diff --git a/.vscode/launch.json b/.vscode/launch.json index b55be648371..402f441eeca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,9 +13,6 @@ "skipFiles": [ "/**" ], - "skipFiles": [ - "/**" - ], "type": "node" }, { @@ -28,11 +25,8 @@ "${fileBasenameNoExtension}.js", "--watch" ], - "args": [ - "${fileBasenameNoExtension}.js", - "--watch" - ], - "console": "integratedTerminal" + "console": "integratedTerminal", + "runtimeExecutable": "/run/user/1000/fnm_multishells/90830_1737588026933/bin/node" // "internalConsoleOptions": "neverOpen" }, { diff --git a/.vscode/settings.json b/.vscode/settings.json index 75a3f5f169c..d81610df90c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,5 +24,6 @@ }, "Prettier-SQL.keywordCase": "upper", "Prettier-SQL.SQLFlavourOverride": "mysql", - "Prettier-SQL.expressionWidth": 80 + "Prettier-SQL.expressionWidth": 80, + "prettier.semi": false } diff --git a/adminSiteClient/AbstractChartEditor.ts b/adminSiteClient/AbstractChartEditor.ts index 7c2bfc676ea..3a3cc43db4b 100644 --- a/adminSiteClient/AbstractChartEditor.ts +++ b/adminSiteClient/AbstractChartEditor.ts @@ -11,9 +11,14 @@ import { import { action, computed, observable, when } from "mobx" import { EditorFeatures } from "./EditorFeatures.js" import { Admin } from "./Admin.js" -import { defaultGrapherConfig, Grapher } from "@ourworldindata/grapher" +import { + defaultGrapherConfig, + getCachingInputTableFetcher, + GrapherState, +} from "@ourworldindata/grapher" import { ChartViewMinimalInformation } from "./ChartEditor.js" import { IndicatorChartInfo } from "./IndicatorChartEditor.js" +import { DATA_API_URL } from "../settings/clientSettings.js" export type EditorTab = | "basic" @@ -48,7 +53,11 @@ export abstract class AbstractChartEditor< > { manager: Manager - @observable.ref grapher = new Grapher() + @observable.ref grapherState = new GrapherState({}) + cachingGrapherDataLoader = getCachingInputTableFetcher( + DATA_API_URL, + undefined + ) @observable.ref currentRequest: Promise | undefined // Whether the current chart state is saved or not @observable.ref tab: EditorTab = "basic" @observable.ref errorMessage?: { title: string; content: string } @@ -58,7 +67,7 @@ export abstract class AbstractChartEditor< // parent config derived from the current chart config @observable.ref parentConfig: GrapherInterface | undefined = undefined - // if inheritance is enabled, the parent config is applied to grapher + // if inheritance is enabled, the parent config is applied to grapherState @observable.ref isInheritanceEnabled: boolean | undefined = undefined constructor(props: { manager: Manager }) { @@ -80,14 +89,14 @@ export abstract class AbstractChartEditor< ) when( - () => this.grapher.hasData && this.grapher.isReady, + () => this.grapherState.hasData && this.grapherState.isReady, () => (this.savedPatchConfig = this.patchConfig) ) } abstract get references(): References | undefined - /** original grapher config used to init the grapher instance */ + /** original grapher config used to init the grapherState instance */ @computed get originalGrapherConfig(): GrapherInterface { const { patchConfig, parentConfig, isInheritanceEnabled } = this.manager if (!isInheritanceEnabled) return patchConfig @@ -96,7 +105,7 @@ export abstract class AbstractChartEditor< /** live-updating config */ @computed get liveConfig(): GrapherInterface { - return this.grapher.object + return this.grapherState.object } @computed get liveConfigWithDefaults(): GrapherInterface { @@ -144,9 +153,9 @@ export abstract class AbstractChartEditor< } @action.bound updateLiveGrapher(config: GrapherInterface): void { - this.grapher.reset() - this.grapher.updateFromObject(config) - this.grapher.updateAuthoredVersion(config) + this.grapherState.reset() + this.grapherState.updateFromObject(config) + this.grapherState.updateAuthoredVersion(config) } // only works for top-level properties @@ -166,21 +175,21 @@ export abstract class AbstractChartEditor< } @computed get invalidFocusedSeriesNames(): SeriesName[] { - const { grapher } = this + const { grapherState } = this // if focusing is not supported, then all focused series are invalid if (!this.features.canHighlightSeries) { - return grapher.focusArray.seriesNames + return grapherState.focusArray.seriesNames } // find invalid focused series - const availableSeriesNames = grapher.chartSeriesNames - const focusedSeriesNames = grapher.focusArray.seriesNames + const availableSeriesNames = grapherState.chartSeriesNames + const focusedSeriesNames = grapherState.focusArray.seriesNames return difference(focusedSeriesNames, availableSeriesNames) } @action.bound removeInvalidFocusedSeriesNames(): void { - this.grapher.focusArray.remove(...this.invalidFocusedSeriesNames) + this.grapherState.focusArray.remove(...this.invalidFocusedSeriesNames) } abstract get isNewGrapher(): boolean diff --git a/adminSiteClient/ChartEditor.ts b/adminSiteClient/ChartEditor.ts index 60950a7e020..73c7479abd1 100644 --- a/adminSiteClient/ChartEditor.ts +++ b/adminSiteClient/ChartEditor.ts @@ -89,9 +89,9 @@ export class ChartEditor extends AbstractChartEditor { @computed get availableTabs(): EditorTab[] { const tabs: EditorTab[] = ["basic", "data", "text", "customize"] - if (this.grapher.hasMapTab) tabs.push("map") - if (this.grapher.isScatter) tabs.push("scatter") - if (this.grapher.isMarimekko) tabs.push("marimekko") + if (this.grapherState.hasMapTab) tabs.push("map") + if (this.grapherState.isScatter) tabs.push("scatter") + if (this.grapherState.isMarimekko) tabs.push("marimekko") tabs.push("revisions") tabs.push("refs") tabs.push("export") @@ -100,14 +100,14 @@ export class ChartEditor extends AbstractChartEditor { } @computed get isNewGrapher() { - return this.grapher.id === undefined + return this.grapherState.id === undefined } @action.bound async updateParentConfig() { const currentParentIndicatorId = this.parentConfig?.dimensions?.[0].variableId const newParentIndicatorId = getParentVariableIdFromChartConfig( - this.grapher.object + this.grapherState.object ) // no-op if the parent indicator hasn't changed @@ -138,12 +138,12 @@ export class ChartEditor extends AbstractChartEditor { async saveGrapher({ onError, }: { onError?: () => void } = {}): Promise { - const { grapher, isNewGrapher, patchConfig } = this + const { grapherState, isNewGrapher, patchConfig } = this // Chart title and slug may be autocalculated from data, in which case they won't be in props // But the server will need to know what we calculated in order to do its job - if (!patchConfig.title) patchConfig.title = grapher.displayTitle - if (!patchConfig.slug) patchConfig.slug = grapher.displaySlug + if (!patchConfig.title) patchConfig.title = grapherState.displayTitle + if (!patchConfig.slug) patchConfig.slug = grapherState.displaySlug // it only makes sense to enable inheritance if the chart has a parent const shouldEnableInheritance = @@ -154,7 +154,7 @@ export class ChartEditor extends AbstractChartEditor { }) const targetUrl = isNewGrapher ? `/api/charts?${query}` - : `/api/charts/${grapher.id}?${query}` + : `/api/charts/${grapherState.id}?${query}` const json = await this.manager.admin.requestJSON( targetUrl, @@ -165,12 +165,12 @@ export class ChartEditor extends AbstractChartEditor { if (json.success) { if (isNewGrapher) { this.newChartId = json.chartId - this.grapher.id = json.chartId + this.grapherState.id = json.chartId this.savedPatchConfig = json.savedPatch this.isInheritanceEnabled = shouldEnableInheritance } else { runInAction(() => { - grapher.version += 1 + grapherState.version += 1 this.logs.unshift(json.newLog) this.savedPatchConfig = json.savedPatch this.isInheritanceEnabled = shouldEnableInheritance @@ -213,13 +213,13 @@ export class ChartEditor extends AbstractChartEditor { async saveAsChartView( name: string ): Promise<{ success: boolean; errorMsg?: string }> { - const { patchConfig, grapher } = this + const { patchConfig, grapherState } = this const chartJson = omit(patchConfig, CHART_VIEW_PROPS_TO_OMIT) const body = { name, - parentChartId: grapher.id, + parentChartId: grapherState.id, config: chartJson, } @@ -239,12 +239,12 @@ export class ChartEditor extends AbstractChartEditor { } publishGrapher(): void { - const url = `${BAKED_GRAPHER_URL}/${this.grapher.displaySlug}` + const url = `${BAKED_GRAPHER_URL}/${this.grapherState.displaySlug}` if (window.confirm(`Publish chart at ${url}?`)) { - this.grapher.isPublished = true + this.grapherState.isPublished = true void this.saveGrapher({ - onError: () => (this.grapher.isPublished = undefined), + onError: () => (this.grapherState.isPublished = undefined), }) } } @@ -255,9 +255,9 @@ export class ChartEditor extends AbstractChartEditor { ? "WARNING: This chart might be referenced from public posts, please double check before unpublishing. Try to remove the chart anyway?" : "Are you sure you want to unpublish this chart?" if (window.confirm(message)) { - this.grapher.isPublished = undefined + this.grapherState.isPublished = undefined void this.saveGrapher({ - onError: () => (this.grapher.isPublished = true), + onError: () => (this.grapherState.isPublished = true), }) } } diff --git a/adminSiteClient/ChartEditorView.tsx b/adminSiteClient/ChartEditorView.tsx index 3c6fb4312ac..ef55b3ff0cf 100644 --- a/adminSiteClient/ChartEditorView.tsx +++ b/adminSiteClient/ChartEditorView.tsx @@ -24,7 +24,7 @@ import { GrapherStaticFormat, DimensionProperty, } from "@ourworldindata/types" -import { Grapher } from "@ourworldindata/grapher" +import { Grapher, GrapherState } from "@ourworldindata/grapher" import { Admin } from "./Admin.js" import { getFullReferencesCount, isChartEditorInstance } from "./ChartEditor.js" import { EditorBasicTab } from "./EditorBasicTab.js" @@ -109,7 +109,9 @@ export class ChartEditorView< }> { @observable.ref database = new EditorDatabase({}) @observable details: DetailDictionary = {} - @observable.ref grapherElement?: React.ReactElement + @computed get grapherState(): GrapherState { + return this.manager.editor.grapherState + } @observable simulateVisionDeficiency?: VisionDeficiency @@ -125,20 +127,16 @@ export class ChartEditorView< @action.bound private updateGrapher(): void { const config = this.manager.editor.originalGrapherConfig - const grapherConfig = { + // 2025-01-04 Daniel Not sure this is the best way to do things + this.grapherState.updateFromObject({ ...config, - // binds the grapher instance to this.grapher - getGrapherInstance: (grapher: Grapher) => { - this.manager.editor.grapher = grapher - }, dataApiUrlForAdmin: this.manager.admin.settings.DATA_API_FOR_ADMIN_UI, // passed this way because clientSettings are baked and need a recompile to be updated bounds: this.bounds, staticFormat: this.staticFormat, - } - this.manager.editor.grapher.renderToStatic = + }) + this.manager.editor.grapherState.renderToStatic = !!this.editor?.showStaticPreview - this.grapherElement = this._isGrapherSet = true } @@ -196,7 +194,7 @@ export class ChartEditorView< @computed private get bounds(): Bounds { return this.isMobilePreview ? new Bounds(0, 0, 380, 525) - : this.manager.editor.grapher.defaultBounds + : this.grapherState.defaultBounds } @computed private get staticFormat(): GrapherStaticFormat { @@ -209,15 +207,15 @@ export class ChartEditorView< // these may point to non-existent details e.g. ["not_a_real_term", "pvotery"] @computed get currentDetailReferences(): DetailReferences { - const { grapher } = this.manager.editor + const { grapherState } = this.manager.editor return { - subtitle: extractDetailsFromSyntax(grapher.currentSubtitle), - note: extractDetailsFromSyntax(grapher.note ?? ""), + subtitle: extractDetailsFromSyntax(grapherState.currentSubtitle), + note: extractDetailsFromSyntax(grapherState.note ?? ""), axisLabelX: extractDetailsFromSyntax( - grapher.xAxisConfig.label ?? "" + grapherState.xAxisConfig.label ?? "" ), axisLabelY: extractDetailsFromSyntax( - grapher.yAxisConfig.label ?? "" + grapherState.yAxisConfig.label ?? "" ), } } @@ -286,7 +284,7 @@ export class ChartEditorView< [DimensionProperty.table]: [], // not used } - this.manager.editor.grapher.dimensionSlots.forEach((slot) => { + this.grapherState.dimensionSlots.forEach((slot) => { slot.dimensions.forEach((dimension, dimensionIndex) => { const details = extractDetailsFromSyntax( dimension.display.name ?? "" @@ -353,7 +351,7 @@ export class ChartEditorView< } renderReady(editor: Editor): React.ReactElement { - const { grapher, availableTabs } = editor + const { grapherState, availableTabs } = editor const chartEditor = isChartEditorInstance(editor) ? editor : undefined @@ -424,10 +422,10 @@ export class ChartEditorView< /> )} {editor.tab === "scatter" && ( - + )} {editor.tab === "marimekko" && ( - + )} {editor.tab === "map" && ( @@ -457,10 +455,9 @@ export class ChartEditorView<
- {this.grapherElement} +
@computed get availableTabs(): EditorTab[] { const tabs: EditorTab[] = ["basic", "data", "text", "customize"] - if (this.grapher.hasMapTab) tabs.push("map") - if (this.grapher.isScatter) tabs.push("scatter") - if (this.grapher.isMarimekko) tabs.push("marimekko") + if (this.grapherState.hasMapTab) tabs.push("map") + if (this.grapherState.isScatter) tabs.push("scatter") + if (this.grapherState.isMarimekko) tabs.push("marimekko") tabs.push("refs") tabs.push("export") tabs.push("debug") diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index 87f69dbcdbe..71667fb093f 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -39,7 +39,7 @@ export class DimensionCard< @observable.ref isExpanded: boolean = false @computed get table(): OwidTable { - return this.props.editor.grapher.table + return this.props.editor.grapherState.table } @action.bound onToggleExpand() { @@ -111,7 +111,7 @@ export class DimensionCard< render() { const { dimension, editor, isDndEnabled } = this.props - const { grapher } = editor + const { grapherState } = editor const { column } = dimension const columnDef = column.def as OwidColumnDef @@ -137,7 +137,7 @@ export class DimensionCard<
- {grapher.isLineChart && ( + {grapherState.isLineChart && ( )} - {grapher.isLineChart && ( + {grapherState.isLineChart && ( { @@ -95,12 +95,12 @@ class DimensionSlotView< this.isSelectingVariables = false - this.updateDimensionsAndRebuildTable(dimensionConfigs) + await this.updateDimensionsAndRebuildTable(dimensionConfigs) this.updateParentConfig() } - @action.bound private onRemoveDimension(variableId: OwidVariableId) { - this.updateDimensionsAndRebuildTable( + @action.bound private async onRemoveDimension(variableId: OwidVariableId) { + await this.updateDimensionsAndRebuildTable( this.props.slot.dimensions.filter( (d) => d.variableId !== variableId ) @@ -108,24 +108,24 @@ class DimensionSlotView< this.updateParentConfig() } - @action.bound private onChangeDimension() { - this.updateDimensionsAndRebuildTable() + @action.bound private async onChangeDimension() { + await this.updateDimensionsAndRebuildTable() this.updateParentConfig() } @action.bound private updateDefaultSelection() { - const { grapher } = this.props.editor - const { selection } = grapher + const { grapherState } = this.props.editor + const { selection } = grapherState const { availableEntityNames, availableEntityNameSet } = selection - if (grapher.isScatter || grapher.isMarimekko) { + if (grapherState.isScatter || grapherState.isMarimekko) { // chart types that display all entities by default shouldn't select any by default selection.clearSelection() } else if ( - grapher.yColumnsFromDimensions.length > 1 && - !grapher.isStackedArea && - !grapher.isStackedBar && - !grapher.isStackedDiscreteBar + grapherState.yColumnsFromDimensions.length > 1 && + !grapherState.isStackedArea && + !grapherState.isStackedBar && + !grapherState.isStackedDiscreteBar ) { // non-stacked charts with multiple y-dimensions should select a single entity by default. // if possible, the currently selected entity is persisted, otherwise "World" is preferred @@ -135,7 +135,7 @@ class DimensionSlotView< : sample(availableEntityNames) if (entity) selection.setSelectedEntities([entity]) } - grapher.addCountryMode = EntitySelectionMode.SingleEntity + grapherState.addCountryMode = EntitySelectionMode.SingleEntity } else { // stacked charts or charts with a single y-dimension should select multiple entities by default. // if possible, the currently selected entities are persisted, otherwise a random sample is selected @@ -146,26 +146,26 @@ class DimensionSlotView< : availableEntityNames ) } - grapher.addCountryMode = EntitySelectionMode.MultipleEntities + grapherState.addCountryMode = EntitySelectionMode.MultipleEntities } } componentDidMount() { - // We want to add the reaction only after the grapher is loaded, + // We want to add the reaction only after the grapherState is loaded, // so we don't update the initial chart (as configured) by accident. when( - () => this.grapher.isReady, + () => this.grapherState.isReady, () => { this.disposers.push( reaction( - () => this.grapher.validChartTypes, + () => this.grapherState.validChartTypes, () => { this.updateDefaultSelection() this.editor.removeInvalidFocusedSeriesNames() } ), reaction( - () => this.grapher.yColumnsFromDimensions.length, + () => this.grapherState.yColumnsFromDimensions.length, () => { this.updateDefaultSelection() this.editor.removeInvalidFocusedSeriesNames() @@ -174,29 +174,43 @@ class DimensionSlotView< ) } ) + if (this.grapherState.dimensions.length > 0) + void this.editor + .cachingGrapherDataLoader( + this.grapherState.dimensions, + this.grapherState.selectedEntityColors + ) + .then((inputTable) => { + if (inputTable) this.grapherState.inputTable = inputTable + }) } componentWillUnmount() { this.disposers.forEach((dispose) => dispose()) } - @action.bound private updateDimensionsAndRebuildTable( + @action.bound private async updateDimensionsAndRebuildTable( updatedDimensions?: OwidChartDimensionInterface[] ) { - const { grapher } = this.props.editor + const { grapherState } = this.props.editor if (updatedDimensions) { - grapher.setDimensionsForProperty( + grapherState.setDimensionsForProperty( this.props.slot.property, updatedDimensions ) } - this.grapher.updateAuthoredVersion({ - dimensions: grapher.dimensions.map((dim) => dim.toObject()), + this.grapherState.updateAuthoredVersion({ + dimensions: grapherState.dimensions.map((dim) => dim.toObject()), }) - grapher.seriesColorMap?.clear() - this.grapher.rebuildInputOwidTable() + grapherState.seriesColorMap?.clear() + const inputTable = await this.props.editor.cachingGrapherDataLoader( + grapherState.dimensions, + grapherState.selectedEntityColors + ) + + if (inputTable) this.grapherState.inputTable = inputTable } @action.bound private updateParentConfig() { @@ -206,7 +220,7 @@ class DimensionSlotView< } } - @action.bound private onDragEnd(result: DropResult) { + @action.bound private async onDragEnd(result: DropResult) { const { source, destination } = result if (!destination) return @@ -216,7 +230,7 @@ class DimensionSlotView< destination.index ) - this.updateDimensionsAndRebuildTable(dimensions) + await this.updateDimensionsAndRebuildTable(dimensions) this.updateParentConfig() } @@ -335,7 +349,7 @@ class VariablesSection< render() { const { props } = this - const { dimensionSlots } = props.editor.grapher + const { dimensionSlots } = props.editor.grapherState return (
@@ -417,34 +431,34 @@ export class EditorBasicTab< } @action.bound onChartTypeChange(value: string) { - const { grapher } = this.props.editor + const { grapherState } = this.props.editor - grapher.chartTypes = + grapherState.chartTypes = value === this.chartTypeOptionNone ? [] : [value as GrapherChartType] - if (grapher.isMarimekko) { - grapher.hideRelativeToggle = false - grapher.stackMode = StackMode.relative + if (grapherState.isMarimekko) { + grapherState.hideRelativeToggle = false + grapherState.stackMode = StackMode.relative } // Give scatterplots a default color and size dimensions - if (grapher.isScatter) { - const hasColor = grapher.dimensions.find( + if (grapherState.isScatter) { + const hasColor = grapherState.dimensions.find( (d) => d.property === DimensionProperty.color ) if (!hasColor) - grapher.addDimension({ + grapherState.addDimension({ variableId: CONTINENTS_INDICATOR_ID, property: DimensionProperty.color, }) - const hasSize = grapher.dimensions.find( + const hasSize = grapherState.dimensions.find( (d) => d.property === DimensionProperty.size ) if (!hasSize) - grapher.addDimension({ + grapherState.addDimension({ variableId: POPULATION_INDICATOR_ID_USED_IN_ADMIN, property: DimensionProperty.size, }) @@ -472,17 +486,17 @@ export class EditorBasicTab< } private addSlopeChart(): void { - const { grapher } = this.props.editor - if (grapher.hasSlopeChart) return - grapher.chartTypes = [ - ...grapher.chartTypes, + const { grapherState } = this.props.editor + if (grapherState.hasSlopeChart) return + grapherState.chartTypes = [ + ...grapherState.chartTypes, GRAPHER_CHART_TYPES.SlopeChart, ] } private removeSlopeChart(): void { - const { grapher } = this.props.editor - grapher.chartTypes = grapher.chartTypes.filter( + const { grapherState } = this.props.editor + grapherState.chartTypes = grapherState.chartTypes.filter( (type) => type !== GRAPHER_CHART_TYPES.SlopeChart ) } @@ -503,9 +517,9 @@ export class EditorBasicTab< async saveTags(tags: DbChartTagJoin[]) { const { editor } = this.props - const { grapher } = editor + const { grapherState } = editor await this.context.admin.requestJSON( - `/api/charts/${grapher.id}/setTags`, + `/api/charts/${grapherState.id}/setTags`, { tags }, "POST" ) @@ -513,7 +527,7 @@ export class EditorBasicTab< render() { const { editor } = this.props - const { grapher } = editor + const { grapherState } = editor const isIndicatorChart = isIndicatorChartEditorInstance(editor) return ( @@ -523,22 +537,24 @@ export class EditorBasicTab<
- (grapher.hasMapTab = shouldHaveMapTab) + (grapherState.hasMapTab = shouldHaveMapTab) } /> - {grapher.isLineChart && ( + {grapherState.isLineChart && ( )} @@ -556,7 +572,7 @@ export class EditorBasicTab< {isChartEditorInstance(editor) && ( { @action.bound onChange(selected: ColorSchemeOption) { @@ -124,31 +124,31 @@ export class ColorSchemeSelector extends React.Component<{ // we are not using the multi-option select we can force the type to be // a single value. - this.props.grapher.baseColorScheme = ( + this.props.grapherState.baseColorScheme = ( selected.value === "default" ? undefined : selected.value ) as ColorSchemeName // clear out saved, pre-computed colors so the color scheme change is immediately visible - this.props.grapher.seriesColorMap?.clear() + this.props.grapherState.seriesColorMap?.clear() } @action.bound onBlur() { - if (this.props.grapher.baseColorScheme === undefined) { - this.props.grapher.baseColorScheme = this.props.defaultValue + if (this.props.grapherState.baseColorScheme === undefined) { + this.props.grapherState.baseColorScheme = this.props.defaultValue // clear out saved, pre-computed colors so the color scheme change is immediately visible - this.props.grapher.seriesColorMap?.clear() + this.props.grapherState.seriesColorMap?.clear() } } @action.bound onInvertColorScheme(value: boolean) { - this.props.grapher.invertColorScheme = value || undefined + this.props.grapherState.invertColorScheme = value || undefined - this.props.grapher.seriesColorMap?.clear() + this.props.grapherState.seriesColorMap?.clear() } render() { - const { grapher } = this.props + const { grapherState } = this.props return ( @@ -156,14 +156,16 @@ export class ColorSchemeSelector extends React.Component<{
@@ -198,11 +200,11 @@ class SortOrderSection< Editor extends AbstractChartEditor, > extends React.Component<{ editor: Editor }> { @computed get sortConfig(): SortConfig { - return this.grapher._sortConfig + return this.grapherState._sortConfig } - @computed get grapher() { - return this.props.editor.grapher + @computed get grapherState() { + return this.props.editor.grapherState } @computed get sortOptions(): SortOrderDropdownOption[] { @@ -210,7 +212,7 @@ class SortOrderSection< let dimensionSortOptions: SortOrderDropdownOption[] = [] if (features.canSortByColumn) { - dimensionSortOptions = this.grapher.yColumnsFromDimensions.map( + dimensionSortOptions = this.grapherState.yColumnsFromDimensions.map( (column): SortOrderDropdownOption => ({ label: column.displayName, display: { @@ -237,12 +239,12 @@ class SortOrderSection< } @action.bound onSortByChange(selected: SortOrderDropdownOption | null) { - this.grapher.sortBy = selected?.value.sortBy - this.grapher.sortColumnSlug = selected?.value.sortColumnSlug + this.grapherState.sortBy = selected?.value.sortBy + this.grapherState.sortColumnSlug = selected?.value.sortColumnSlug } @action.bound onSortOrderChange(sortOrder: string) { - this.grapher.sortOrder = sortOrder as SortOrder + this.grapherState.sortOrder = sortOrder as SortOrder } render() { @@ -301,8 +303,8 @@ class FacetSection extends React.Component<{ }> { base: React.RefObject = React.createRef() - @computed get grapher() { - return this.props.editor.grapher + @computed get grapherState() { + return this.props.editor.grapherState } @computed get facetOptions(): Array<{ @@ -310,14 +312,14 @@ class FacetSection extends React.Component<{ value?: FacetStrategy }> { return [{ label: "auto" }].concat( - this.grapher.availableFacetStrategies.map((s) => { + this.grapherState.availableFacetStrategies.map((s) => { return { label: s.toString(), value: s } }) ) } @computed get facetSelection(): { label: string; value?: FacetStrategy } { - const strategy = this.grapher.selectedFacetStrategy + const strategy = this.grapherState.selectedFacetStrategy if (strategy) { return { label: strategy.toString(), value: strategy } } @@ -331,11 +333,11 @@ class FacetSection extends React.Component<{ value?: FacetStrategy } | null ) { - this.grapher.selectedFacetStrategy = selected?.value + this.grapherState.selectedFacetStrategy = selected?.value } render() { - const yAxisConfig = this.props.editor.grapher.yAxis + const yAxisConfig = this.props.editor.grapherState.yAxis return (
@@ -350,9 +352,10 @@ class FacetSection extends React.Component<{ { - this.grapher.hideFacetControl = value || undefined + this.grapherState.hideFacetControl = + value || undefined }} /> @@ -380,29 +383,29 @@ class TimelineSection< > extends React.Component<{ editor: Editor }> { base: React.RefObject = React.createRef() - @computed get grapher() { - return this.props.editor.grapher + @computed get grapherState() { + return this.props.editor.grapherState } @action.bound onToggleHideTimeline(value: boolean) { - this.grapher.hideTimeline = value || undefined + this.grapherState.hideTimeline = value || undefined } @action.bound onToggleShowYearLabels(value: boolean) { - this.grapher.showYearLabels = value || undefined + this.grapherState.showYearLabels = value || undefined } render() { const { editor } = this.props const { features } = editor - const { grapher } = this + const { grapherState } = this return (
{features.timeDomain && ( )} {features.showYearLabels && ( )} @@ -493,19 +496,19 @@ class ComparisonLineSection< @observable comparisonLines: ComparisonLineConfig[] = [] @action.bound onAddComparisonLine() { - const { grapher } = this.props.editor - if (!grapher.comparisonLines) grapher.comparisonLines = [] - grapher.comparisonLines.push({}) + const { grapherState } = this.props.editor + if (!grapherState.comparisonLines) grapherState.comparisonLines = [] + grapherState.comparisonLines.push({}) } @action.bound onRemoveComparisonLine(index: number) { - const { grapher } = this.props.editor - if (!grapher.comparisonLines) grapher.comparisonLines = [] - grapher.comparisonLines.splice(index, 1) + const { grapherState } = this.props.editor + if (!grapherState.comparisonLines) grapherState.comparisonLines = [] + grapherState.comparisonLines.splice(index, 1) } render() { - const { comparisonLines = [] } = this.props.editor.grapher + const { comparisonLines = [] } = this.props.editor.grapherState return (
@@ -560,11 +563,11 @@ export class EditorCustomizeTab< } render() { - const xAxisConfig = this.props.editor.grapher.xAxis - const yAxisConfig = this.props.editor.grapher.yAxis + const xAxisConfig = this.props.editor.grapherState.xAxis + const yAxisConfig = this.props.editor.grapherState.yAxis const { features, activeParentConfig } = this.props.editor - const { grapher } = this.props.editor + const { grapherState } = this.props.editor return (
@@ -746,7 +749,7 @@ export class EditorCustomizeTab<
)} - {grapher.chartInstanceExceptMap.colorScale && ( + {grapherState.chartInstanceExceptMap.colorScale && ( - (grapher.hideLegend = value || undefined) + (grapherState.hideLegend = + value || undefined) } /> @@ -785,12 +790,12 @@ export class EditorCustomizeTab< {features.canCustomizeVariableType && ( @@ -802,7 +807,7 @@ export class EditorCustomizeTab< } field="facettingLabelByYVariables" - store={grapher} + store={grapherState} helpText={ "When facetting is active, one option is to split " + "by entity/country, the other is by metric. This option " + @@ -818,9 +823,9 @@ export class EditorCustomizeTab< - (grapher.hideRelativeToggle = + (grapherState.hideRelativeToggle = value || false) } /> @@ -832,9 +837,9 @@ export class EditorCustomizeTab< - (grapher.hideTotalValueLabel = + (grapherState.hideTotalValueLabel = value || false) } /> diff --git a/adminSiteClient/EditorDataTab.tsx b/adminSiteClient/EditorDataTab.tsx index 82ba1dc8021..a62b93465f3 100644 --- a/adminSiteClient/EditorDataTab.tsx +++ b/adminSiteClient/EditorDataTab.tsx @@ -14,7 +14,7 @@ import { EntityName, SeriesName, } from "@ourworldindata/types" -import { Grapher } from "@ourworldindata/grapher" +import { GrapherState } from "@ourworldindata/grapher" import { ColorBox, SelectField, Section, FieldsRow } from "./Forms.js" import { faArrowsAltV, @@ -32,7 +32,7 @@ import { import { AbstractChartEditor } from "./AbstractChartEditor.js" interface EntityListItemProps extends React.HTMLProps { - grapher: Grapher + grapherState: GrapherState entityName: EntityName onRemove?: () => void } @@ -48,7 +48,7 @@ class EntityListItem extends React.Component { @observable.ref isChoosingColor: boolean = false @computed get table() { - return this.props.grapher.table + return this.props.grapherState.table } @computed get color() { @@ -56,15 +56,19 @@ class EntityListItem extends React.Component { } @action.bound onColor(color: string | undefined) { - const { grapher } = this.props - grapher.selectedEntityColors[this.props.entityName] = color - grapher.legacyConfigAsAuthored.selectedEntityColors = { - ...grapher.legacyConfigAsAuthored.selectedEntityColors, + const { grapherState } = this.props + grapherState.selectedEntityColors[this.props.entityName] = color + grapherState.legacyConfigAsAuthored.selectedEntityColors = { + ...grapherState.legacyConfigAsAuthored.selectedEntityColors, [this.props.entityName]: color, } - grapher.seriesColorMap?.clear() - grapher.rebuildInputOwidTable() + grapherState.seriesColorMap?.clear() + // TODO: 2025-01-05 Daniel we used to rebuild the table here but that + // was AFAIK only because scatter and marimekko charts need the color + // column to be updated. Move this merge logic into scatter and marimekko + // table transforms instead? + // grapherState.rebuildInputOwidTable() } @action.bound onRemove() { @@ -73,8 +77,8 @@ class EntityListItem extends React.Component { render() { const { props, color } = this - const { entityName, grapher } = props - const rest = omit(props, ["entityName", "onRemove", "grapher"]) + const { entityName, grapherState } = props + const rest = omit(props, ["entityName", "onRemove", "grapherState"]) return (
{ {entityName}
@@ -141,17 +145,17 @@ export class EntitySelectionSection extends React.Component<{ } @action.bound onAddKey(entityName: EntityName) { - this.editor.grapher.selection.selectEntity(entityName) + this.editor.grapherState.selection.selectEntity(entityName) this.editor.removeInvalidFocusedSeriesNames() } @action.bound onRemoveKey(entityName: EntityName) { - this.editor.grapher.selection.deselectEntity(entityName) + this.editor.grapherState.selection.deselectEntity(entityName) this.editor.removeInvalidFocusedSeriesNames() } @action.bound onDragEnd(result: DropResult) { - const { selection } = this.editor.grapher + const { selection } = this.editor.grapherState const { source, destination } = result if (!destination) return @@ -164,10 +168,10 @@ export class EntitySelectionSection extends React.Component<{ } @action.bound setEntitySelectionToParentValue() { - const { grapher, activeParentConfig } = this.editor + const { grapherState, activeParentConfig } = this.editor if (!activeParentConfig || !activeParentConfig.selectedEntityNames) return - grapher.selection.setSelectedEntities( + grapherState.selection.setSelectedEntities( activeParentConfig.selectedEntityNames ) this.editor.removeInvalidFocusedSeriesNames() @@ -175,8 +179,8 @@ export class EntitySelectionSection extends React.Component<{ render() { const { editor } = this - const { grapher } = editor - const { selection } = grapher + const { grapherState } = editor + const { selection } = grapherState const { unselectedEntityNames, selectedEntityNames } = selection const isEntitySelectionInherited = editor.isPropertyInherited( @@ -234,7 +238,9 @@ export class EntitySelectionSection extends React.Component<{ > this.onRemoveKey( @@ -274,18 +280,18 @@ export class FocusSection extends React.Component<{ } @action.bound addToFocusedSeries(seriesName: SeriesName) { - this.editor.grapher.focusArray.add(seriesName) + this.editor.grapherState.focusArray.add(seriesName) } @action.bound removeFromFocusedSeries(seriesName: SeriesName) { - this.editor.grapher.focusArray.remove(seriesName) + this.editor.grapherState.focusArray.remove(seriesName) } @action.bound setFocusedSeriesNamesToParentValue() { - const { grapher, activeParentConfig } = this.editor + const { grapherState, activeParentConfig } = this.editor if (!activeParentConfig || !activeParentConfig.focusedSeriesNames) return - grapher.focusArray.clearAllAndAdd( + grapherState.focusArray.clearAllAndAdd( ...activeParentConfig.focusedSeriesNames ) this.editor.removeInvalidFocusedSeriesNames() @@ -293,16 +299,16 @@ export class FocusSection extends React.Component<{ render() { const { editor } = this - const { grapher } = editor + const { grapherState } = editor const isFocusInherited = editor.isPropertyInherited("focusedSeriesNames") - const focusedSeriesNameSet = grapher.focusArray.seriesNameSet - const focusedSeriesNames = grapher.focusArray.seriesNames + const focusedSeriesNameSet = grapherState.focusArray.seriesNameSet + const focusedSeriesNames = grapherState.focusArray.seriesNames // series available to highlight are those that are currently plotted - const seriesNameSet = new Set(grapher.chartSeriesNames) + const seriesNameSet = new Set(grapherState.chartSeriesNames) const availableSeriesNameSet = differenceOfSets([ seriesNameSet, focusedSeriesNameSet, @@ -365,8 +371,8 @@ export class FocusSection extends React.Component<{ class MissingDataSection< Editor extends AbstractChartEditor, > extends React.Component<{ editor: Editor }> { - @computed get grapher() { - return this.props.editor.grapher + @computed get grapherState() { + return this.props.editor.grapherState } get missingDataStrategyOptions(): { @@ -388,17 +394,17 @@ class MissingDataSection< } @action.bound onSelectMissingDataStrategy(value: string | undefined) { - this.grapher.missingDataStrategy = value as MissingDataStrategy + this.grapherState.missingDataStrategy = value as MissingDataStrategy } render() { - const { grapher } = this + const { grapherState } = this return (
@@ -413,7 +419,7 @@ export class EditorDataTab< > extends React.Component<{ editor: Editor }> { render() { const { editor } = this.props - const { grapher, features } = editor + const { grapherState, features } = editor return (
@@ -426,11 +432,11 @@ export class EditorDataTab< name="add-country-mode" value={EntitySelectionMode.MultipleEntities} checked={ - grapher.addCountryMode === + grapherState.addCountryMode === EntitySelectionMode.MultipleEntities } onChange={() => - (grapher.addCountryMode = + (grapherState.addCountryMode = EntitySelectionMode.MultipleEntities) } /> @@ -445,11 +451,11 @@ export class EditorDataTab< name="add-country-mode" value={EntitySelectionMode.SingleEntity} checked={ - grapher.addCountryMode === + grapherState.addCountryMode === EntitySelectionMode.SingleEntity } onChange={() => - (grapher.addCountryMode = + (grapherState.addCountryMode = EntitySelectionMode.SingleEntity) } /> @@ -464,11 +470,11 @@ export class EditorDataTab< name="add-country-mode" value={EntitySelectionMode.Disabled} checked={ - grapher.addCountryMode === + grapherState.addCountryMode === EntitySelectionMode.Disabled } onChange={() => - (grapher.addCountryMode = + (grapherState.addCountryMode = EntitySelectionMode.Disabled) } /> diff --git a/adminSiteClient/EditorDebugTab.tsx b/adminSiteClient/EditorDebugTab.tsx index ba5ffbc2dde..f09834408d4 100644 --- a/adminSiteClient/EditorDebugTab.tsx +++ b/adminSiteClient/EditorDebugTab.tsx @@ -69,7 +69,7 @@ class EditorDebugTabForChart extends Component<{ @action.bound onToggleInheritance(shouldBeEnabled: boolean) { const { patchConfig, parentConfig } = this.props.editor - // update live grapher + // update live grapherState const newParentConfig = shouldBeEnabled ? parentConfig : undefined const newConfig = mergeGrapherConfigs( newParentConfig ?? {}, @@ -87,11 +87,11 @@ class EditorDebugTabForChart extends Component<{ isInheritanceEnabled, fullConfig, parentVariableId, - grapher, + grapherState, } = this.props.editor const column = parentVariableId - ? grapher.inputTable.get(parentVariableId.toString()) + ? grapherState.inputTable.get(parentVariableId.toString()) : undefined const variableLink = ( @@ -132,7 +132,7 @@ class EditorDebugTabForChart extends Component<{ <> {" "} But the parent indicator does not - yet have an associated Grapher + yet have an associated grapherState config. You can{" "} type OriginalGrapher = Pick< - Grapher, + GrapherState, | "currentTitle" | "shouldAddEntitySuffixToTitle" | "shouldAddTimeSuffixToTitle" @@ -63,7 +63,7 @@ interface EditorExportTabProps { @observer export class EditorExportTab< - Editor extends AbstractChartEditor, + Editor extends AbstractChartEditor > extends Component> { @observable private settings = DEFAULT_SETTINGS private originalSettings: Partial = DEFAULT_SETTINGS @@ -116,52 +116,54 @@ export class EditorExportTab< private saveOriginalSettings() { this.originalSettings = { - hideTitle: this.grapher.hideTitle, + hideTitle: this.grapherState.hideTitle, forceHideAnnotationFieldsInTitle: - this.grapher.forceHideAnnotationFieldsInTitle, - hideSubtitle: this.grapher.hideSubtitle, - hideNote: this.grapher.hideNote, - hideOriginUrl: this.grapher.hideOriginUrl, + this.grapherState.forceHideAnnotationFieldsInTitle, + hideSubtitle: this.grapherState.hideSubtitle, + hideNote: this.grapherState.hideNote, + hideOriginUrl: this.grapherState.hideOriginUrl, shouldIncludeDetailsInStaticExport: - this.grapher.shouldIncludeDetailsInStaticExport, + this.grapherState.shouldIncludeDetailsInStaticExport, } } // a deep clone of Grapher would be simpler and cleaner, but takes too long private grabRelevantPropertiesFromGrapher(): OriginalGrapher { return { - currentTitle: this.grapher.currentTitle, + currentTitle: this.grapherState.currentTitle, shouldAddEntitySuffixToTitle: - this.grapher.shouldAddEntitySuffixToTitle, - shouldAddTimeSuffixToTitle: this.grapher.shouldAddTimeSuffixToTitle, - currentSubtitle: this.grapher.currentSubtitle, - note: this.grapher.note, - originUrl: this.grapher.originUrl, + this.grapherState.shouldAddEntitySuffixToTitle, + shouldAddTimeSuffixToTitle: + this.grapherState.shouldAddTimeSuffixToTitle, + currentSubtitle: this.grapherState.currentSubtitle, + note: this.grapherState.note, + originUrl: this.grapherState.originUrl, shouldIncludeDetailsInStaticExport: - this.grapher.shouldIncludeDetailsInStaticExport, - detailsOrderedByReference: this.grapher.detailsOrderedByReference, + this.grapherState.shouldIncludeDetailsInStaticExport, + detailsOrderedByReference: + this.grapherState.detailsOrderedByReference, } } private resetGrapher() { - Object.assign(this.grapher, this.originalSettings) + Object.assign(this.grapherState, this.originalSettings) } private updateGrapher() { - Object.assign(this.grapher, this.settings) + Object.assign(this.grapherState, this.settings) } - @computed private get grapher(): Grapher { - return this.props.editor.grapher + @computed private get grapherState(): GrapherState { + return this.props.editor.grapherState } @computed private get chartId(): number { // the id is undefined for unsaved charts - return this.grapher.id ?? 0 + return this.grapherState.id ?? 0 } @computed private get baseFilename(): string { - return this.props.editor.grapher.displaySlug + return this.props.editor.grapherState.displaySlug } @action.bound private onDownloadDesktopSVG() { @@ -206,21 +208,21 @@ export class EditorExportTab< } ) { try { - let grapher = this.grapher + let grapherState = this.grapherState if ( - this.grapher.staticFormat !== format || - this.grapher.isSocialMediaExport !== isSocialMediaExport + this.grapherState.staticFormat !== format || + this.grapherState.isSocialMediaExport !== isSocialMediaExport ) { - grapher = new Grapher({ - ...this.grapher, + grapherState = new GrapherState({ + ...this.grapherState, staticFormat: format, selectedEntityNames: - this.grapher.selection.selectedEntityNames, - focusedSeriesNames: this.grapher.focusedSeriesNames, + this.grapherState.selection.selectedEntityNames, + focusedSeriesNames: this.grapherState.focusedSeriesNames, isSocialMediaExport, }) } - const { blob: pngBlob, svgBlob } = await grapher.rasterize() + const { blob: pngBlob, svgBlob } = await grapherState.rasterize() if (filename.endsWith("svg") && svgBlob) { triggerDownloadFromBlob(filename, svgBlob) } else if (filename.endsWith("png") && pngBlob) { @@ -235,10 +237,10 @@ export class EditorExportTab< const chartAnimationUrl = new URL( urljoin(ETL_WIZARD_URL, "chart-animation") ) - if (this.grapher.canonicalUrl) + if (this.grapherState.canonicalUrl) chartAnimationUrl.searchParams.set( "animation_chart_url", - this.grapher.canonicalUrl + this.grapherState.canonicalUrl ) chartAnimationUrl.searchParams.set("animation_skip_button", "True") // chartAnimationUrl.searchParams.set( @@ -306,7 +308,7 @@ export class EditorExportTab< /> )} {this.originalGrapher.originUrl && - !this.grapher.isStaticAndSmall && ( + !this.grapherState.isStaticAndSmall && ( {/* Link to Wizard dataset preview */} - {this.grapher.isPublished && ( + {this.grapherState.isPublished && (
{ export class EditorMapTab< Editor extends AbstractChartEditor, > extends Component<{ editor: Editor }> { - @computed get grapher() { - return this.props.editor.grapher + @computed get grapherState() { + return this.props.editor.grapherState } render() { - const { grapher } = this - const mapConfig = grapher.map - const { mapColumnSlug } = grapher - const mapChart = new MapChart({ manager: this.grapher }) + const { grapherState } = this + const mapConfig = grapherState.map + const { mapColumnSlug } = grapherState + const mapChart = new MapChart({ manager: this.grapherState }) const colorScale = mapChart.colorScale - const isReady = !!mapColumnSlug && grapher.table.has(mapColumnSlug) + const isReady = !!mapColumnSlug && grapherState.table.has(mapColumnSlug) return (
{isReady && ( diff --git a/adminSiteClient/EditorMarimekkoTab.tsx b/adminSiteClient/EditorMarimekkoTab.tsx index 2e1ffd7c300..3b4e123c6bd 100644 --- a/adminSiteClient/EditorMarimekkoTab.tsx +++ b/adminSiteClient/EditorMarimekkoTab.tsx @@ -1,7 +1,7 @@ import { faMinus, faTrash } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { EntityName } from "@ourworldindata/types" -import { Grapher } from "@ourworldindata/grapher" +import { GrapherState } from "@ourworldindata/grapher" import { excludeUndefined } from "@ourworldindata/utils" import lodash from "lodash" import { action, computed, IReactionDisposer, observable, reaction } from "mobx" @@ -10,15 +10,17 @@ import { Component } from "react" import { NumberField, Section, SelectField, Toggle } from "./Forms.js" @observer -export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { +export class EditorMarimekkoTab extends Component<{ + grapherState: GrapherState +}> { @observable xOverrideTimeInputField: number | undefined - constructor(props: { grapher: Grapher }) { + constructor(props: { grapherState: GrapherState }) { super(props) - this.xOverrideTimeInputField = props.grapher.xOverrideTime + this.xOverrideTimeInputField = props.grapherState.xOverrideTime } @computed private get includedEntityNames(): EntityName[] { - const { includedEntities, inputTable } = this.props.grapher + const { includedEntities, inputTable } = this.props.grapherState const { entityIdToNameMap } = inputTable const includedEntityIds = includedEntities ?? [] return excludeUndefined( @@ -27,7 +29,7 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { } @computed private get includedEntityChoices() { - const { inputTable } = this.props.grapher + const { inputTable } = this.props.grapherState return inputTable.availableEntityNames .filter( (entityName) => !this.includedEntityNames.includes(entityName) @@ -36,28 +38,28 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { } @action.bound onIncludeEntity(entity: string) { - const { grapher } = this.props - if (grapher.includedEntities === undefined) { - grapher.includedEntities = [] + const { grapherState } = this.props + if (grapherState.includedEntities === undefined) { + grapherState.includedEntities = [] } - const entityId = grapher.table.entityNameToIdMap.get(entity)! - if (grapher.includedEntities.indexOf(entityId) === -1) - grapher.includedEntities.push(entityId) + const entityId = grapherState.table.entityNameToIdMap.get(entity)! + if (grapherState.includedEntities.indexOf(entityId) === -1) + grapherState.includedEntities.push(entityId) } @action.bound onUnincludeEntity(entity: string) { - const { grapher } = this.props - if (!grapher.includedEntities) return + const { grapherState } = this.props + if (!grapherState.includedEntities) return - const entityId = grapher.table.entityNameToIdMap.get(entity) - grapher.includedEntities = grapher.includedEntities.filter( + const entityId = grapherState.table.entityNameToIdMap.get(entity) + grapherState.includedEntities = grapherState.includedEntities.filter( (e) => e !== entityId ) } @computed private get excludedEntityNames(): EntityName[] { - const { excludedEntities, inputTable } = this.props.grapher + const { excludedEntities, inputTable } = this.props.grapherState const { entityIdToNameMap } = inputTable const excludedEntityIds = excludedEntities ?? [] return excludeUndefined( @@ -66,7 +68,7 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { } @computed private get excludedEntityChoices() { - const { inputTable } = this.props.grapher + const { inputTable } = this.props.grapherState return inputTable.availableEntityNames .filter( (entityName) => !this.excludedEntityNames.includes(entityName) @@ -75,34 +77,34 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { } @action.bound onExcludeEntity(entity: string) { - const { grapher } = this.props - if (grapher.excludedEntities === undefined) { - grapher.excludedEntities = [] + const { grapherState } = this.props + if (grapherState.excludedEntities === undefined) { + grapherState.excludedEntities = [] } - const entityId = grapher.table.entityNameToIdMap.get(entity)! - if (grapher.excludedEntities.indexOf(entityId) === -1) - grapher.excludedEntities.push(entityId) + const entityId = grapherState.table.entityNameToIdMap.get(entity)! + if (grapherState.excludedEntities.indexOf(entityId) === -1) + grapherState.excludedEntities.push(entityId) } @action.bound onUnexcludeEntity(entity: string) { - const { grapher } = this.props - if (!grapher.excludedEntities) return + const { grapherState } = this.props + if (!grapherState.excludedEntities) return - const entityId = grapher.table.entityNameToIdMap.get(entity) - grapher.excludedEntities = grapher.excludedEntities.filter( + const entityId = grapherState.table.entityNameToIdMap.get(entity) + grapherState.excludedEntities = grapherState.excludedEntities.filter( (e) => e !== entityId ) } @action.bound onClearExcludedEntities() { - const { grapher } = this.props - grapher.excludedEntities = [] + const { grapherState } = this.props + grapherState.excludedEntities = [] } @action.bound onClearIncludedEntities() { - const { grapher } = this.props - grapher.includedEntities = [] + const { grapherState } = this.props + grapherState.includedEntities = [] } @action.bound onXOverrideYear(value: number | undefined) { @@ -110,7 +112,7 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { } render() { const { excludedEntityChoices, includedEntityChoices } = this - const { grapher } = this.props + const { grapherState } = this.props return (
@@ -124,10 +126,10 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { - (grapher.matchingEntitiesOnly = + (grapherState.matchingEntitiesOnly = value || undefined) )} /> @@ -215,7 +217,7 @@ export class EditorMarimekkoTab extends Component<{ grapher: Grapher }> { () => this.xOverrideTimeInputField, lodash.debounce( () => - (this.props.grapher.xOverrideTime = + (this.props.grapherState.xOverrideTime = this.xOverrideTimeInputField), 800 ) diff --git a/adminSiteClient/EditorReferencesTab.tsx b/adminSiteClient/EditorReferencesTab.tsx index 8c35a8db006..8d1969963b5 100644 --- a/adminSiteClient/EditorReferencesTab.tsx +++ b/adminSiteClient/EditorReferencesTab.tsx @@ -173,7 +173,7 @@ export class EditorReferencesTabForChart extends Component<{ editor: ChartEditor }> { @computed get isPersisted() { - return this.props.editor.grapher.id + return this.props.editor.grapherState.id } @computed get references() { @@ -313,7 +313,7 @@ class AddRedirectForm extends Component<{ if (!this.isLoading) { this.isLoading = true try { - const chartId = this.props.editor.grapher.id + const chartId = this.props.editor.grapherState.id const result = await this.context.admin.requestJSON( `/api/charts/${chartId}/redirects/new`, { slug: this.slug }, diff --git a/adminSiteClient/EditorScatterTab.tsx b/adminSiteClient/EditorScatterTab.tsx index 582f4489d19..360f9069c78 100644 --- a/adminSiteClient/EditorScatterTab.tsx +++ b/adminSiteClient/EditorScatterTab.tsx @@ -5,7 +5,7 @@ import { ComparisonLineConfig, ScatterPointLabelStrategy, } from "@ourworldindata/types" -import { Grapher } from "@ourworldindata/grapher" +import { GrapherState } from "@ourworldindata/grapher" import { debounce, excludeUndefined } from "@ourworldindata/utils" import { action, computed, observable } from "mobx" import { observer } from "mobx-react" @@ -13,27 +13,29 @@ import { Component } from "react" import { NumberField, Section, SelectField, Toggle } from "./Forms.js" @observer -export class EditorScatterTab extends Component<{ grapher: Grapher }> { +export class EditorScatterTab extends Component<{ + grapherState: GrapherState +}> { @observable comparisonLine: ComparisonLineConfig = { yEquals: undefined } - constructor(props: { grapher: Grapher }) { + constructor(props: { grapherState: GrapherState }) { super(props) } @action.bound onToggleHideTimeline(value: boolean) { - this.props.grapher.hideTimeline = value || undefined + this.props.grapherState.hideTimeline = value || undefined } @action.bound onToggleHideScatterLabels(value: boolean) { - this.props.grapher.hideScatterLabels = value || undefined + this.props.grapherState.hideScatterLabels = value || undefined } @action.bound onXOverrideYear(value: number | undefined) { - this.props.grapher.xOverrideTime = value + this.props.grapherState.xOverrideTime = value } @computed private get includedEntityNames(): EntityName[] { - const { includedEntities, inputTable } = this.props.grapher + const { includedEntities, inputTable } = this.props.grapherState const { entityIdToNameMap } = inputTable const includedEntityIds = includedEntities ?? [] return excludeUndefined( @@ -42,7 +44,7 @@ export class EditorScatterTab extends Component<{ grapher: Grapher }> { } @computed private get excludedEntityNames(): EntityName[] { - const { excludedEntities, inputTable } = this.props.grapher + const { excludedEntities, inputTable } = this.props.grapherState const { entityIdToNameMap } = inputTable const excludedEntityIds = excludedEntities ?? [] return excludeUndefined( @@ -51,7 +53,7 @@ export class EditorScatterTab extends Component<{ grapher: Grapher }> { } @computed private get includedEntityChoices() { - const { inputTable } = this.props.grapher + const { inputTable } = this.props.grapherState return inputTable.availableEntityNames .filter( (entityName) => !this.includedEntityNames.includes(entityName) @@ -60,7 +62,7 @@ export class EditorScatterTab extends Component<{ grapher: Grapher }> { } @computed private get excludedEntityChoices() { - const { inputTable } = this.props.grapher + const { inputTable } = this.props.grapherState return inputTable.availableEntityNames .filter( (entityName) => !this.excludedEntityNames.includes(entityName) @@ -69,94 +71,94 @@ export class EditorScatterTab extends Component<{ grapher: Grapher }> { } @action.bound onExcludeEntity(entity: string) { - const { grapher } = this.props - if (grapher.excludedEntities === undefined) { - grapher.excludedEntities = [] + const { grapherState } = this.props + if (grapherState.excludedEntities === undefined) { + grapherState.excludedEntities = [] } - const entityId = grapher.table.entityNameToIdMap.get(entity)! - if (grapher.excludedEntities.indexOf(entityId) === -1) - grapher.excludedEntities.push(entityId) + const entityId = grapherState.table.entityNameToIdMap.get(entity)! + if (grapherState.excludedEntities.indexOf(entityId) === -1) + grapherState.excludedEntities.push(entityId) } @action.bound onUnexcludeEntity(entity: string) { - const { grapher } = this.props - if (!grapher.excludedEntities) return + const { grapherState } = this.props + if (!grapherState.excludedEntities) return - const entityId = grapher.table.entityNameToIdMap.get(entity) - grapher.excludedEntities = grapher.excludedEntities.filter( + const entityId = grapherState.table.entityNameToIdMap.get(entity) + grapherState.excludedEntities = grapherState.excludedEntities.filter( (e) => e !== entityId ) } @action.bound onIncludeEntity(entity: string) { - const { grapher } = this.props - if (grapher.includedEntities === undefined) { - grapher.includedEntities = [] + const { grapherState } = this.props + if (grapherState.includedEntities === undefined) { + grapherState.includedEntities = [] } - const entityId = grapher.table.entityNameToIdMap.get(entity)! - if (grapher.includedEntities.indexOf(entityId) === -1) - grapher.includedEntities.push(entityId) + const entityId = grapherState.table.entityNameToIdMap.get(entity)! + if (grapherState.includedEntities.indexOf(entityId) === -1) + grapherState.includedEntities.push(entityId) } @action.bound onUnincludeEntity(entity: string) { - const { grapher } = this.props - if (!grapher.includedEntities) return + const { grapherState } = this.props + if (!grapherState.includedEntities) return - const entityId = grapher.table.entityNameToIdMap.get(entity) - grapher.includedEntities = grapher.includedEntities.filter( + const entityId = grapherState.table.entityNameToIdMap.get(entity) + grapherState.includedEntities = grapherState.includedEntities.filter( (e) => e !== entityId ) } @action.bound onClearExcludedEntities() { - const { grapher } = this.props - grapher.excludedEntities = [] + const { grapherState } = this.props + grapherState.excludedEntities = [] } @action.bound onClearIncludedEntities() { - const { grapher } = this.props - grapher.includedEntities = [] + const { grapherState } = this.props + grapherState.includedEntities = [] } @action.bound onToggleConnection(value: boolean) { - const { grapher } = this.props - grapher.hideConnectedScatterLines = value + const { grapherState } = this.props + grapherState.hideConnectedScatterLines = value } @action.bound onChangeScatterPointLabelStrategy(value: string) { - this.props.grapher.scatterPointLabelStrategy = + this.props.grapherState.scatterPointLabelStrategy = value as ScatterPointLabelStrategy } render() { const { includedEntityChoices, excludedEntityChoices } = this - const { grapher } = this.props + const { grapherState } = this.props return (
({ value: entry }) @@ -164,17 +166,17 @@ export class EditorScatterTab extends Component<{ grapher: Grapher }> { />
- (grapher.matchingEntitiesOnly = + (grapherState.matchingEntitiesOnly = value || undefined) )} /> diff --git a/adminSiteClient/EditorTextTab.tsx b/adminSiteClient/EditorTextTab.tsx index 26eb9a35c26..69efb5ea8ce 100644 --- a/adminSiteClient/EditorTextTab.tsx +++ b/adminSiteClient/EditorTextTab.tsx @@ -31,49 +31,51 @@ export class EditorTextTab< Editor extends AbstractChartEditor, > extends Component<{ editor: Editor; errorMessages: ErrorMessages }> { @action.bound onSlug(slug: string) { - this.props.editor.grapher.slug = slugify(slug) + this.props.editor.grapherState.slug = slugify(slug) } @action.bound onChangeLogo(value: string) { if (value === "none") { - this.props.editor.grapher.hideLogo = true + this.props.editor.grapherState.hideLogo = true } else { - this.props.editor.grapher.hideLogo = undefined - this.props.editor.grapher.logo = (value as LogoOption) || undefined + this.props.editor.grapherState.hideLogo = undefined + this.props.editor.grapherState.logo = + (value as LogoOption) || undefined } } @action.bound onAddRelatedQuestion() { - const { grapher } = this.props.editor - if (!grapher.relatedQuestions) grapher.relatedQuestions = [] - grapher.relatedQuestions.push({ + const { grapherState } = this.props.editor + if (!grapherState.relatedQuestions) grapherState.relatedQuestions = [] + grapherState.relatedQuestions.push({ text: "", url: "", }) } @action.bound onRemoveRelatedQuestion(idx: number) { - const { grapher } = this.props.editor - if (!grapher.relatedQuestions) grapher.relatedQuestions = [] - grapher.relatedQuestions.splice(idx, 1) + const { grapherState } = this.props.editor + if (!grapherState.relatedQuestions) grapherState.relatedQuestions = [] + grapherState.relatedQuestions.splice(idx, 1) } @action.bound onToggleTitleAnnotationEntity(value: boolean) { - const { grapher } = this.props.editor - grapher.hideAnnotationFieldsInTitle ??= {} - grapher.hideAnnotationFieldsInTitle.entity = value || undefined + const { grapherState } = this.props.editor + grapherState.hideAnnotationFieldsInTitle ??= {} + grapherState.hideAnnotationFieldsInTitle.entity = value || undefined } @action.bound onToggleTitleAnnotationTime(value: boolean) { - const { grapher } = this.props.editor - grapher.hideAnnotationFieldsInTitle ??= {} - grapher.hideAnnotationFieldsInTitle.time = value || undefined + const { grapherState } = this.props.editor + grapherState.hideAnnotationFieldsInTitle ??= {} + grapherState.hideAnnotationFieldsInTitle.time = value || undefined } @action.bound onToggleTitleAnnotationChangeInPrefix(value: boolean) { - const { grapher } = this.props.editor - grapher.hideAnnotationFieldsInTitle ??= {} - grapher.hideAnnotationFieldsInTitle.changeInPrefix = value || undefined + const { grapherState } = this.props.editor + grapherState.hideAnnotationFieldsInTitle ??= {} + grapherState.hideAnnotationFieldsInTitle.changeInPrefix = + value || undefined } @computed get errorMessages() { @@ -94,25 +96,27 @@ export class EditorTextTab< } @computed get hasCopyAdminURLButton() { - return !!this.props.editor.grapher.id + return !!this.props.editor.grapherState.id } @computed get hasCopyGrapherURLButton() { - return !!this.props.editor.grapher.isPublished + return !!this.props.editor.grapherState.isPublished } render() { const { editor } = this.props - const { grapher, features } = editor - const { relatedQuestions = [] } = grapher + const { grapherState, features } = editor + const { relatedQuestions = [] } = grapherState return (
grapher.displayTitle} - writeFn={(grapher, newVal) => (grapher.title = newVal)} + readFn={(grapherState) => grapherState.displayTitle} + writeFn={(grapherState, newVal) => + (grapherState.title = newVal) + } auto={ editor.couldPropertyBeInherited("title") ? editor.activeParentConfig!.title @@ -120,16 +124,17 @@ export class EditorTextTab< } isAuto={ editor.isPropertyInherited("title") || - grapher.title === undefined + grapherState.title === undefined } - store={grapher} + store={grapherState} softCharacterLimit={100} /> {features.showEntityAnnotationInTitleToggle && ( @@ -138,11 +143,13 @@ export class EditorTextTab< )} @@ -150,7 +157,7 @@ export class EditorTextTab< } {this.showChartSlug && ( - (grapher.slug = - grapher.slug === undefined - ? grapher.displaySlug + (grapherState.slug = + grapherState.slug === undefined + ? grapherState.displaySlug : undefined) } helpText="Human-friendly URL for this chart" @@ -174,9 +181,9 @@ export class EditorTextTab< )} grapher.currentSubtitle} - writeFn={(grapher, newVal) => - (grapher.subtitle = newVal) + readFn={(grapherState) => grapherState.currentSubtitle} + writeFn={(grapherState, newVal) => + (grapherState.subtitle = newVal) } auto={ editor.couldPropertyBeInherited("subtitle") @@ -185,9 +192,9 @@ export class EditorTextTab< } isAuto={ editor.isPropertyInherited("subtitle") || - grapher.subtitle === undefined + grapherState.subtitle === undefined } - store={grapher} + store={grapherState} placeholder="Briefly describe the context of the data. It's best to avoid duplicating any information which can be easily inferred from other visual elements of the chart." textarea softCharacterLimit={280} @@ -202,7 +209,9 @@ export class EditorTextTab< { label: "No logo", value: "none" }, ]} value={ - grapher.hideLogo ? "none" : grapher.logo || "owid" + grapherState.hideLogo + ? "none" + : grapherState.logo || "owid" } onChange={this.onChangeLogo} /> @@ -210,9 +219,9 @@ export class EditorTextTab<
grapher.sourcesLine} - writeFn={(grapher, newVal) => - (grapher.sourceDesc = newVal) + readFn={(grapherState) => grapherState.sourcesLine} + writeFn={(grapherState, newVal) => + (grapherState.sourceDesc = newVal) } auto={ editor.couldPropertyBeInherited("sourceDesc") @@ -221,17 +230,17 @@ export class EditorTextTab< } isAuto={ editor.isPropertyInherited("sourceDesc") || - grapher.sourceDesc === undefined + grapherState.sourceDesc === undefined } - store={grapher} + store={grapherState} helpText="Short comma-separated list of source names" softCharacterLimit={60} /> {isChartEditorInstance(editor) && @@ -254,15 +263,17 @@ export class EditorTextTab< grapher.note ?? ""} - writeFn={(grapher, newVal) => (grapher.note = newVal)} + readFn={(grapherState) => grapherState.note ?? ""} + writeFn={(grapherState, newVal) => + (grapherState.note = newVal) + } auto={ editor.couldPropertyBeInherited("note") ? editor.activeParentConfig?.note : undefined } isAuto={editor.isPropertyInherited("note")} - store={grapher} + store={grapherState} helpText="Any important clarification needed to avoid miscommunication" softCharacterLimit={140} errorMessage={this.errorMessages.note} @@ -320,14 +331,14 @@ export class EditorTextTab< @@ -340,7 +351,7 @@ export class EditorTextTab< copyToClipboard( - `[${grapher.title}](${ADMIN_BASE_URL}/admin/charts/${grapher.id}/edit)` + `[${grapherState.title}](${ADMIN_BASE_URL}/admin/charts/${grapherState.id}/edit)` ) } > @@ -351,7 +362,7 @@ export class EditorTextTab< copyToClipboard( - `[${grapher.title}](${BAKED_GRAPHER_URL}/${grapher.slug})` + `[${grapherState.title}](${BAKED_GRAPHER_URL}/${grapherState.slug})` ) } > diff --git a/adminSiteClient/GrapherConfigGridEditor.tsx b/adminSiteClient/GrapherConfigGridEditor.tsx index b064688980f..10904acb27f 100644 --- a/adminSiteClient/GrapherConfigGridEditor.tsx +++ b/adminSiteClient/GrapherConfigGridEditor.tsx @@ -46,8 +46,10 @@ import { AdminAppContext, AdminAppContextType } from "./AdminAppContext.js" import Handsontable from "handsontable" import { GRAPHER_CHART_TYPES, GRAPHER_MAP_TYPE } from "@ourworldindata/types" import { + fetchInputTableForConfig, Grapher, GrapherProgrammaticInterface, + GrapherState, MapChart, } from "@ourworldindata/grapher" import { BindString, SelectField, Toggle } from "./Forms.js" @@ -106,6 +108,7 @@ import { UnControlled as CodeMirror } from "react-codemirror2" import jsonpointer from "json8-pointer" import { EditorColorScaleSection } from "./EditorColorScaleSection.js" import { Operation } from "../adminShared/SqlFilterSExpression.js" +import { DATA_API_URL } from "../settings/clientSettings.js" // The rule doesn't support class components in the same file. // eslint-disable-next-line react-refresh/only-export-components @@ -157,8 +160,7 @@ class HotColorScaleEditor extends BaseEditorComponent { export class GrapherConfigGridEditor extends React.Component { static contextType = AdminAppContext - @observable.ref grapher = new Grapher() // the grapher instance we keep around and update - @observable.ref grapherElement?: React.ReactElement // the JSX Element of the preview IF we want to display it currently + @observable.ref grapherState = new GrapherState({}) // the grapher instance we keep around and update numTotalRows: number | undefined = undefined @observable selectedRow: number | undefined = undefined @observable selectionEndRow: number | undefined = undefined @@ -309,34 +311,34 @@ export class GrapherConfigGridEditor extends React.Component { const newConfig: GrapherProgrammaticInterface = { ...json, isEmbeddedInAnOwidPage: true, bounds: new Bounds(0, 0, 480, 500), - getGrapherInstance: (grapher: Grapher) => { - this.grapher = grapher - }, dataApiUrlForAdmin: this.context.admin.settings.DATA_API_FOR_ADMIN_UI, // passed this way because clientSettings are baked and need a recompile to be updated } - if (this.grapherElement) { - this.grapher.setAuthoredVersion(newConfig) - this.grapher.reset() - if (!this.keepEntitySelectionOnChartChange) - // this resets the entity selection to what the author set in the chart config - // This is user controlled because when working with existing charts this is usually desired - // but when working on the variable level where this is missing it is often nicer to keep - // the same country selection as you zap through the variables - this.grapher.clearSelection() - this.grapher.updateFromObject(newConfig) - this.grapher.downloadData() - } else this.grapherElement = + this.grapherState.setAuthoredVersion(newConfig) + this.grapherState.reset() + if (!this.keepEntitySelectionOnChartChange) + // this resets the entity selection to what the author set in the chart config + // This is user controlled because when working with existing charts this is usually desired + // but when working on the variable level where this is missing it is often nicer to keep + // the same country selection as you zap through the variables + this.grapherState.clearSelection() + this.grapherState.updateFromObject(newConfig) + const inputTable = await fetchInputTableForConfig( + newConfig.dimensions ?? [], + newConfig.selectedEntityColors, + this.context.admin.settings.DATA_API_FOR_ADMIN_UI || DATA_API_URL, + undefined + ) + if (inputTable) this.grapherState.inputTable = inputTable } - @action private updatePreviewToRow(): void { + @action private async updatePreviewToRow(): Promise { const { selectedRowContent } = this if (selectedRowContent === undefined) return @@ -352,7 +354,7 @@ export class GrapherConfigGridEditor extends React.Component ) if (performCommit) { - const grapherObject = { ...this.grapher.object } + const grapherObject = { ...this.grapherState.object } const newVal = currentColumnFieldDescription.getter( grapherObject as Record ) @@ -444,7 +449,7 @@ export class GrapherConfigGridEditor extends React.Component { if (currentColumnFieldDescription?.pointer.startsWith("/map")) { // TODO: remove this hack once map is more similar to other charts - const mapChart = new MapChart({ manager: this.grapher }) + const mapChart = new MapChart({ + manager: this.grapherState, + }) const colorScale = mapChart.colorScale // TODO: instead of using onChange below that has to be maintained when // the color scale changes I tried to use a reaction here after Daniel G's suggestion @@ -500,9 +510,9 @@ export class GrapherConfigGridEditor extends React.Component ) : undefined } else { - if (grapher.chartInstanceExceptMap.colorScale) { + if (grapherState.chartInstanceExceptMap.colorScale) { const colorScale = - grapher.chartInstanceExceptMap.colorScale + grapherState.chartInstanceExceptMap.colorScale // TODO: instead of using onChange below that has to be maintained when // the color scale changes I tried to use a reaction here after Daniel G's suggestion // but I couldn't get this to work. Worth trying again later. @@ -515,14 +525,14 @@ export class GrapherConfigGridEditor extends React.Component ) @@ -532,7 +542,7 @@ export class GrapherConfigGridEditor extends React.Component ( + grapherState as any as Record )} options={{ //theme: "material", @@ -587,7 +597,7 @@ export class GrapherConfigGridEditor extends React.Component ) - const grapherObject = { ...this.grapher.object } + const grapherObject = { ...this.grapherState.object } newVal = fieldDesc.getter( grapherObject as Record ) @@ -1642,7 +1652,7 @@ export class GrapherConfigGridEditor extends React.Component
Interactive grapher preview
@@ -1654,7 +1664,7 @@ export class GrapherConfigGridEditor extends React.Component - {grapherElement ? grapherElement : null} +
) diff --git a/adminSiteClient/IndicatorChartEditor.ts b/adminSiteClient/IndicatorChartEditor.ts index 5b3d0f7cbba..0ac2a123a18 100644 --- a/adminSiteClient/IndicatorChartEditor.ts +++ b/adminSiteClient/IndicatorChartEditor.ts @@ -38,9 +38,9 @@ export class IndicatorChartEditor extends AbstractChartEditor 0 - const isSavingDisabled = grapher.hasFatalErrors || hasEditingErrors + const isSavingDisabled = grapherState.hasFatalErrors || hasEditingErrors return (
@@ -114,9 +114,9 @@ class SaveButtonsForChart extends Component<{ onClick={this.onSaveChart} disabled={isSavingDisabled} > - {grapher.isPublished + {grapherState.isPublished ? "Update chart" - : grapher.id + : grapherState.id ? "Save draft" : "Create draft"} {" "} @@ -132,7 +132,7 @@ class SaveButtonsForChart extends Component<{ onClick={this.onPublishToggle} disabled={isSavingDisabled} > - {grapher.isPublished ? "Unpublish" : "Publish"} + {grapherState.isPublished ? "Unpublish" : "Publish"}
@@ -187,12 +187,12 @@ class SaveButtonsForIndicatorChart extends Component<{ render() { const { editingErrors } = this const { editor } = this.props - const { grapher } = editor + const { grapherState } = editor const isTrivial = editor.isNewGrapher && !editor.isModified const hasEditingErrors = editingErrors.length > 0 const isSavingDisabled = - grapher.hasFatalErrors || hasEditingErrors || isTrivial + grapherState.hasFatalErrors || hasEditingErrors || isTrivial return (
@@ -236,10 +236,10 @@ class SaveButtonsForChartView extends Component<{ render() { const { editingErrors } = this const { editor } = this.props - const { grapher } = editor + const { grapherState } = editor const hasEditingErrors = editingErrors.length > 0 - const isSavingDisabled = grapher.hasFatalErrors || hasEditingErrors + const isSavingDisabled = grapherState.hasFatalErrors || hasEditingErrors return (
diff --git a/adminSiteClient/VariableEditPage.tsx b/adminSiteClient/VariableEditPage.tsx index eda8574e3ee..8dfdc714e36 100644 --- a/adminSiteClient/VariableEditPage.tsx +++ b/adminSiteClient/VariableEditPage.tsx @@ -36,7 +36,6 @@ import { stringifyUnknownError, startCase, } from "@ourworldindata/utils" -import { GrapherFigureView } from "../site/GrapherFigureView.js" import { ChartList, ChartListItem } from "./ChartList.js" import { OriginList } from "./OriginList.js" import { SourceList } from "./SourceList.js" @@ -47,7 +46,7 @@ import { GrapherInterface, OwidVariableRoundingMode, } from "@ourworldindata/types" -import { Grapher } from "@ourworldindata/grapher" +import { Grapher, GrapherState } from "@ourworldindata/grapher" import { faCircleInfo } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { DATA_API_URL, ETL_API_URL } from "../settings/clientSettings.js" @@ -144,7 +143,7 @@ class VariableEditor extends Component<{ static contextType = AdminAppContext context!: AdminAppContextType - @observable.ref grapher?: Grapher + @observable.ref grapherState?: GrapherState @computed get isModified(): boolean { return ( @@ -335,27 +334,29 @@ class VariableEditor extends Component<{
{/* BUG: when user pres Enter when editing form, chart will switch to `Table` tab */} - {this.grapher && ( + {this.grapherState && (

Preview

Edit as new chart
-
)} @@ -713,11 +714,11 @@ class VariableEditor extends Component<{ dispose!: IReactionDisposer componentDidMount() { - this.grapher = new Grapher(this.grapherConfig) + this.grapherState = new GrapherState(this.grapherConfig) this.dispose = autorun(() => { - if (this.grapher && this.grapherConfig) { - this.grapher.updateFromObject(this.grapherConfig) + if (this.grapherState && this.grapherConfig) { + this.grapherState.updateFromObject(this.grapherConfig) } }) } diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index a028358aaaf..0a2b6875fd6 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -346,7 +346,8 @@ $nav-height: 45px; } } - figure[data-grapher-src] { + figure[data-grapher-src], + figure[data-grapher-component] { line-height: 0; // remove extra space on the bottom } } diff --git a/adminSiteServer/testPageRouter.tsx b/adminSiteServer/testPageRouter.tsx index 8dbd771c3ff..aeb213f15d5 100644 --- a/adminSiteServer/testPageRouter.tsx +++ b/adminSiteServer/testPageRouter.tsx @@ -3,10 +3,7 @@ import { Router } from "express" import { renderToHtmlPage, expectInt } from "../serverUtils/serverUtil.js" -import { - getChartConfigBySlug, - getChartVariableData, -} from "../db/model/Chart.js" +import { getChartConfigBySlug } from "../db/model/Chart.js" import { Head } from "../site/Head.js" import * as db from "../db/db.js" import { @@ -41,6 +38,7 @@ import { ColorSchemes, GrapherProgrammaticInterface, } from "@ourworldindata/grapher" + import { GRAPHER_DYNAMIC_THUMBNAIL_URL } from "../settings/clientSettings.js" const IS_LIVE = ADMIN_BASE_URL === "https://owid.cloud" @@ -802,12 +800,11 @@ getPlainRouteWithROTransaction( "/:slug.svg", async (req, res, trx) => { const grapher = await getChartConfigBySlug(trx, req.params.slug) - const vardata = await getChartVariableData(grapher.config) - const svg = await grapherToSVG(grapher.config, vardata) + // const vardata = await getChartVariableData(grapher.config) + const svg = await grapherToSVG(grapher.config) res.send(svg) } ) - testPageRouter.get("/explorers", async (req, res) => { let explorers = await explorerAdminServer.getAllPublishedExplorers() const viewProps = getViewPropsFromQueryParams(req.query) diff --git a/baker/GrapherImageBaker.tsx b/baker/GrapherImageBaker.tsx index 74547910e80..db7350ec595 100644 --- a/baker/GrapherImageBaker.tsx +++ b/baker/GrapherImageBaker.tsx @@ -4,12 +4,17 @@ import { GrapherInterface, DbRawChartConfig, } from "@ourworldindata/types" -import { Grapher, GrapherProgrammaticInterface } from "@ourworldindata/grapher" -import { MultipleOwidVariableDataDimensionsMap } from "@ourworldindata/utils" +import { + fetchInputTableForConfig, + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "@ourworldindata/grapher" import path from "path" import * as db from "../db/db.js" import { grapherSlugToExportFileKey } from "./GrapherBakingUtils.js" import { BAKED_GRAPHER_URL } from "../settings/clientSettings.js" +import { DATA_API_URL } from "../settings/serverSettings.js" interface SvgFilenameFragments { slug: string @@ -71,13 +76,15 @@ export function initGrapherForSvgExport( queryStr: string = "" ) { const grapher = new Grapher({ - bakedGrapherURL: BAKED_GRAPHER_URL, - ...jsonConfig, - manuallyProvideData: true, - queryStr, + grapherState: new GrapherState({ + bakedGrapherURL: BAKED_GRAPHER_URL, + ...jsonConfig, + manuallyProvideData: true, + queryStr, + }), }) - grapher.isExportingToSvgOrPng = true - grapher.shouldIncludeDetailsInStaticExport = false + grapher.grapherState.isExportingToSvgOrPng = true + grapher.grapherState.shouldIncludeDetailsInStaticExport = false return grapher } @@ -109,12 +116,24 @@ export function buildSvgOutFilepath( } export async function grapherToSVG( - jsonConfig: GrapherInterface, - vardata: MultipleOwidVariableDataDimensionsMap + jsonConfig: GrapherInterface + // vardata: MultipleOwidVariableDataDimensionsMap ): Promise { - const grapher = new Grapher({ ...jsonConfig, manuallyProvideData: true }) - grapher.isExportingToSvgOrPng = true - grapher.shouldIncludeDetailsInStaticExport = false - grapher.receiveOwidData(vardata) + const grapher = new Grapher({ + grapherState: new GrapherState({ + ...jsonConfig, + manuallyProvideData: true, + }), + }) + grapher.grapherState.isExportingToSvgOrPng = true + grapher.grapherState.shouldIncludeDetailsInStaticExport = false + // grapher.receiveOwidData(vardata) + const inputTable = await fetchInputTableForConfig( + jsonConfig.dimensions ?? [], + jsonConfig.selectedEntityColors, + DATA_API_URL, + undefined + ) + if (inputTable) grapher.grapherState.inputTable = inputTable return grapher.staticSVG } diff --git a/baker/updateChartEntities.ts b/baker/updateChartEntities.ts index cbaf6a74107..2e72c09ebe0 100644 --- a/baker/updateChartEntities.ts +++ b/baker/updateChartEntities.ts @@ -4,7 +4,10 @@ * To do this, we need to instantiate a grapher, download its data, and then look at the available entities. */ -import { Grapher } from "@ourworldindata/grapher" +import { + GrapherState, + legacyToOwidTableAndDimensions, +} from "@ourworldindata/grapher" import { ChartsXEntitiesTableName, DbPlainChart, @@ -83,7 +86,10 @@ const getVariableDataUsingCache = async ( const obtainAvailableEntitiesForGrapherConfig = async ( grapherConfig: GrapherInterface ) => { - const grapher = new Grapher({ ...grapherConfig, manuallyProvideData: true }) + const grapher = new GrapherState({ + ...grapherConfig, + manuallyProvideData: true, + }) // Manually fetch data for grapher, so we can employ caching const variableIds = uniq(grapher.dimensions.map((d) => d.variableId)) @@ -93,7 +99,12 @@ const obtainAvailableEntitiesForGrapherConfig = async ( await getVariableDataUsingCache(variableId), ]) ) - grapher.receiveOwidData(variableData) + // TODO: Daniel grapher state refactoring: check if this works as expected + grapher.inputTable = legacyToOwidTableAndDimensions( + variableData, + grapher.dimensions, + grapher.selectedEntityColors + ) // If the grapher has a chart tab, then the available entities there are the "most interesting" ones to us if (grapher.hasChartTab) { diff --git a/devTools/svgTester/utils.ts b/devTools/svgTester/utils.ts index 12d20a1c1d9..cd680578a6e 100644 --- a/devTools/svgTester/utils.ts +++ b/devTools/svgTester/utils.ts @@ -26,6 +26,7 @@ import util from "util" import { getHeapStatistics } from "v8" import { queryStringsByChartType } from "./chart-configurations.js" import * as d3 from "d3" +import { legacyToOwidTableAndDimensions } from "@ourworldindata/grapher" // the owid-grapher-svgs repo is usually cloned as a sibling to the owid-grapher repo export const DEFAULT_CONFIGS_DIR = "../owid-grapher-svgs/configs" @@ -373,7 +374,7 @@ export async function saveGrapherSchemaAndData( const promise1 = writeToFile(config, configPath) const grapher = initGrapherForSvgExport(config) - const variableIds = grapher.dimensions.map((d) => d.variableId) + const variableIds = grapher.grapherState.dimensions.map((d) => d.variableId) const writeVariablePromises = variableIds.map(async (variableId) => { const dataPath = path.join(dataDir, `${variableId}.data.json`) @@ -407,7 +408,7 @@ export async function renderSvg( }, queryStr ) - const { width, height } = grapher.defaultBounds + const { width, height } = grapher.grapherState.defaultBounds const outFilename = buildSvgOutFilename( { slug: configAndData.config.slug!, @@ -419,7 +420,11 @@ export async function renderSvg( { shouldHashQueryStr: false, separator: "?" } ) - grapher.receiveOwidData(configAndData.variableData) + grapher.grapherState.inputTable = legacyToOwidTableAndDimensions( + configAndData.variableData, + grapher.grapherState.dimensions, + grapher.grapherState.selectedEntityColors + ) const durationReceiveData = Date.now() - timeStart const svg = grapher.staticSVG @@ -428,7 +433,7 @@ export async function renderSvg( const svgRecord = { chartId: configAndData.config.id!, slug: configAndData.config.slug!, - chartType: grapher.activeTab, + chartType: grapher.grapherState.activeTab, queryStr, md5: processSvgAndCalculateHash(svg), svgFilename: outFilename, diff --git a/functions/_common/downloadFunctions.ts b/functions/_common/downloadFunctions.ts index 0edb5f36f84..b7f16babdda 100644 --- a/functions/_common/downloadFunctions.ts +++ b/functions/_common/downloadFunctions.ts @@ -1,4 +1,8 @@ -import { Grapher } from "@ourworldindata/grapher" +import { + fetchInputTableForConfig, + Grapher, + GrapherState, +} from "@ourworldindata/grapher" import { OwidColumnDef } from "@ourworldindata/types" import { StatusError } from "itty-router" import { createZip, File } from "littlezipper" @@ -21,10 +25,15 @@ export async function fetchMetadataForGrapher( env ) - await grapher.downloadLegacyDataFromOwidVariableIds() + const inputTable = await fetchInputTableForConfig( + grapher.grapherState.dimensions, + grapher.grapherState.selectedEntityColors, + env.DATA_API_URL + ) + grapher.grapherState.inputTable = inputTable const fullMetadata = assembleMetadata( - grapher, + grapher.grapherState, searchParams ?? new URLSearchParams(""), multiDimAvailableDimensions ) @@ -44,9 +53,14 @@ export async function fetchZipForGrapher( searchParams ?? new URLSearchParams(""), env ) - await grapher.downloadLegacyDataFromOwidVariableIds() + const inputTable = await fetchInputTableForConfig( + grapher.grapherState.dimensions, + grapher.grapherState.selectedEntityColors, + env.DATA_API_URL + ) + grapher.grapherState.inputTable = inputTable ensureDownloadOfDataAllowed(grapher) - const metadata = assembleMetadata(grapher, searchParams) + const metadata = assembleMetadata(grapher.grapherState, searchParams) const readme = assembleReadme(grapher, searchParams) const csv = assembleCsv(grapher, searchParams) console.log("Fetched the parts, creating zip file") @@ -67,12 +81,15 @@ export async function fetchZipForGrapher( }, }) } -function assembleCsv(grapher: Grapher, searchParams: URLSearchParams): string { +function assembleCsv( + grapherState: GrapherState, + searchParams: URLSearchParams +): string { const useShortNames = searchParams.get("useColumnShortNames") === "true" - const fullTable = grapher.inputTable - const filteredTable = grapher.isOnTableTab - ? grapher.tableForDisplay - : grapher.transformedTable + const fullTable = grapherState.inputTable + const filteredTable = grapherState.isOnTableTab + ? grapherState.tableForDisplay + : grapherState.transformedTable const table = searchParams.get("csvType") === "filtered" ? filteredTable : fullTable return table.toPrettyCsv(useShortNames) @@ -89,20 +106,28 @@ export async function fetchCsvForGrapher( searchParams ?? new URLSearchParams(""), env ) - await grapher.downloadLegacyDataFromOwidVariableIds() + const inputTable = await fetchInputTableForConfig( + grapher.grapherState.dimensions, + grapher.grapherState.selectedEntityColors, + env.DATA_API_URL + ) + grapher.grapherState.inputTable = inputTable console.log("checking if download is allowed") - ensureDownloadOfDataAllowed(grapher) + ensureDownloadOfDataAllowed(grapher.grapherState) console.log("data download is allowed") - const csv = assembleCsv(grapher, searchParams ?? new URLSearchParams("")) + const csv = assembleCsv( + grapher.grapherState, + searchParams ?? new URLSearchParams("") + ) return new Response(csv, { headers: { "Content-Type": "text/csv", }, }) } -function ensureDownloadOfDataAllowed(grapher: Grapher) { +function ensureDownloadOfDataAllowed(grapherState: GrapherState) { if ( - grapher.inputTable.columnsAsArray.some( + grapherState.inputTable.columnsAsArray.some( (col) => (col.def as OwidColumnDef).nonRedistributable ) ) { @@ -126,7 +151,12 @@ export async function fetchReadmeForGrapher( env ) - await grapher.downloadLegacyDataFromOwidVariableIds() + const inputTable = await fetchInputTableForConfig( + grapher.grapherState.dimensions, + grapher.grapherState.selectedEntityColors, + env.DATA_API_URL + ) + grapher.grapherState.inputTable = inputTable const readme = assembleReadme( grapher, @@ -145,9 +175,9 @@ function assembleReadme( searchParams: URLSearchParams, multiDimAvailableDimensions?: string[] ): string { - const metadataCols = getColumnsForMetadata(grapher) + const metadataCols = getColumnsForMetadata(grapher.grapherState) return constructReadme( - grapher, + grapher.grapherState, metadataCols, searchParams, multiDimAvailableDimensions diff --git a/functions/_common/env.ts b/functions/_common/env.ts index a594f1a48c1..7cf614a0b8c 100644 --- a/functions/_common/env.ts +++ b/functions/_common/env.ts @@ -9,6 +9,7 @@ export interface Env { CLOUDFLARE_IMAGES_API_KEY: string CLOUDFLARE_IMAGES_URL: string ENV: string + DATA_API_URL: string SENTRY_DSN: string } // We collect the possible extensions here so we can easily take them into account diff --git a/functions/_common/grapherRenderer.ts b/functions/_common/grapherRenderer.ts index 74f1ec4738e..7acb5f25013 100644 --- a/functions/_common/grapherRenderer.ts +++ b/functions/_common/grapherRenderer.ts @@ -12,6 +12,7 @@ import PlayfairSemiBold from "../_common/fonts/PlayfairDisplayLatin-SemiBold.ttf import { Env } from "./env.js" import { ImageOptions, extractOptions } from "./imageOptions.js" import { GrapherIdentifier, initGrapher } from "./grapherTools.js" +import { fetchInputTableForConfig } from "@ourworldindata/grapher" declare global { // eslint-disable-next-line no-var @@ -71,8 +72,17 @@ async function fetchAndRenderGrapherToSvg( grapherLogger.log("initGrapher") const promises = [] - promises.push(grapher.downloadLegacyDataFromOwidVariableIds()) - if (options.details && grapher.detailsOrderedByReference.length) { + promises.push( + fetchInputTableForConfig( + grapher.grapherState.dimensions, + grapher.grapherState.selectedEntityColors, + env.DATA_API_URL + ) + ) + if ( + options.details && + grapher.grapherState.detailsOrderedByReference.length + ) { promises.push( await fetch("https://ourworldindata.org/dods.json") .then((r) => r.json()) @@ -82,13 +92,16 @@ async function fetchAndRenderGrapherToSvg( ) } - await Promise.all(promises) // Run these (potentially) two fetches in parallel + const results = await Promise.all(promises) // Run these (potentially) two fetches in parallel grapherLogger.log("fetchDataAndDods") - const svg = grapher.generateStaticSvg() + const inputTable = results[0] + if (inputTable) grapher.grapherState.inputTable = inputTable + + const svg = grapher.grapherState.generateStaticSvg() grapherLogger.log("generateStaticSvg") - return { svg, backgroundColor: grapher.backgroundColor } + return { svg, backgroundColor: grapher.grapherState.backgroundColor } } export const fetchAndRenderGrapher = async ( diff --git a/functions/_common/grapherTools.ts b/functions/_common/grapherTools.ts index 4f2553a0945..37e550b2fc9 100644 --- a/functions/_common/grapherTools.ts +++ b/functions/_common/grapherTools.ts @@ -1,4 +1,8 @@ -import { generateGrapherImageSrcSet, Grapher } from "@ourworldindata/grapher" +import { + generateGrapherImageSrcSet, + Grapher, + GrapherState, +} from "@ourworldindata/grapher" import { GrapherInterface, MultiDimDataPageConfigEnriched, @@ -213,7 +217,7 @@ export async function initGrapher( } const bounds = new Bounds(0, 0, options.svgWidth, options.svgHeight) - const grapher = new Grapher({ + const grapherState = new GrapherState({ ...grapherConfigResponse.grapherConfig, bakedGrapherURL: grapherBaseUrl, queryStr: "?" + searchParams.toString(), @@ -222,8 +226,9 @@ export async function initGrapher( baseFontSize: options.fontSize, ...options.grapherProps, }) - grapher.isExportingToSvgOrPng = true - grapher.shouldIncludeDetailsInStaticExport = options.details + grapherState.isExportingToSvgOrPng = true + grapherState.shouldIncludeDetailsInStaticExport = options.details + const grapher = new Grapher({ grapherState }) return { grapher, diff --git a/functions/_common/metadataTools.ts b/functions/_common/metadataTools.ts index 9dcd074494b..a5a2c2a7af1 100644 --- a/functions/_common/metadataTools.ts +++ b/functions/_common/metadataTools.ts @@ -1,4 +1,4 @@ -import { Grapher } from "@ourworldindata/grapher" +import { GrapherState } from "@ourworldindata/grapher" import { OwidTableSlugs, OwidOrigin, @@ -34,7 +34,7 @@ type MetadataColumn = { fullMetadata: string } -export const getColumnsForMetadata = (grapher: Grapher) => { +export const getColumnsForMetadata = (grapherState: GrapherState) => { const columnsToIgnore = new Set( [ OwidTableSlugs.entityId, @@ -47,20 +47,20 @@ export const getColumnsForMetadata = (grapher: Grapher) => { ].map((slug) => slug.toString()) ) - const colsToGet = grapher.inputTable.columnSlugs.filter( + const colsToGet = grapherState.inputTable.columnSlugs.filter( (col) => !columnsToIgnore.has(col) ) - return grapher.inputTable.getColumns(colsToGet) + return grapherState.inputTable.getColumns(colsToGet) } export function assembleMetadata( - grapher: Grapher, + grapherState: GrapherState, searchParams: URLSearchParams, multiDimAvailableDimensions?: string[] ) { const useShortNames = searchParams.get("useColumnShortNames") === "true" - const metadataCols = getColumnsForMetadata(grapher) + const metadataCols = getColumnsForMetadata(grapherState) const columns: [string, MetadataColumn][] = metadataCols.map((col) => { const { @@ -179,14 +179,14 @@ export function assembleMetadata( const fullMetadata = { chart: { - title: grapher.title, - subtitle: grapher.subtitle, - note: grapher.note, - xAxisLabel: grapher.xAxis.label, - yAxisLabel: grapher.yAxis.label, - citation: grapher.sourcesLine, - originalChartUrl: grapher.canonicalUrl, - selection: grapher.selectedEntityNames, + title: grapherState.title, + subtitle: grapherState.subtitle, + note: grapherState.note, + xAxisLabel: grapherState.xAxis.label, + yAxisLabel: grapherState.yAxis.label, + citation: grapherState.sourcesLine, + originalChartUrl: grapherState.canonicalUrl, + selection: grapherState.selectedEntityNames, }, columns: Object.fromEntries(columns), // date downloaded should be YYYY-MM-DD diff --git a/functions/_common/readmeTools.ts b/functions/_common/readmeTools.ts index 3d1e3e20062..851af1deba1 100644 --- a/functions/_common/readmeTools.ts +++ b/functions/_common/readmeTools.ts @@ -16,7 +16,7 @@ import { isEmpty, } from "@ourworldindata/utils" import { CoreColumn } from "@ourworldindata/core-table" -import { Grapher } from "@ourworldindata/grapher" +import { GrapherState } from "@ourworldindata/grapher" import { getGrapherFilters } from "./urlTools.js" const markdownNewlineEnding = " " @@ -255,7 +255,7 @@ function* activeFilterSettings( } export function constructReadme( - grapher: Grapher, + grapherState: GrapherState, columns: CoreColumn[], searchParams: URLSearchParams, multiDimAvailableDimensions?: string[] @@ -268,13 +268,13 @@ export function constructReadme( ) .flatMap((col) => [...columnReadmeText(col)]) let readme: string - const urlWithFilters = `${grapher.canonicalUrl}` + const urlWithFilters = `${grapherState.canonicalUrl}` const downloadDate = formatDate(new Date()) // formats the date as "October 10, 2024" if (isSingleColumn) - readme = `# ${grapher.displayTitle} - Data package + readme = `# ${grapherState.displayTitle} - Data package -This data package contains the data that powers the chart ["${grapher.displayTitle}"](${urlWithFilters}) on the Our World in Data website. It was downloaded on ${downloadDate}. +This data package contains the data that powers the chart ["${grapherState.displayTitle}"](${urlWithFilters}) on the Our World in Data website. It was downloaded on ${downloadDate}. ${[...activeFilterSettings(searchParams, multiDimAvailableDimensions)].join("\n")} ## CSV Structure @@ -300,9 +300,9 @@ ${sources.join("\n")} ` else - readme = `# ${grapher.displayTitle} - Data package + readme = `# ${grapherState.displayTitle} - Data package -This data package contains the data that powers the chart ["${grapher.displayTitle}"](${urlWithFilters}) on the Our World in Data website. +This data package contains the data that powers the chart ["${grapherState.displayTitle}"](${urlWithFilters}) on the Our World in Data website. ## CSV Structure 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 8a86d8aa098..7f4a1ba125f 100644 --- a/packages/@ourworldindata/explorer/src/Explorer.tsx +++ b/packages/@ourworldindata/explorer/src/Explorer.tsx @@ -27,6 +27,8 @@ import { SlideShowManager, DEFAULT_GRAPHER_ENTITY_TYPE, GrapherAnalytics, + GrapherState, + fetchInputTableForConfig, FocusArray, } from "@ourworldindata/grapher" import { @@ -194,15 +196,25 @@ export class Explorer GrapherManager { analytics = new GrapherAnalytics() + grapherState: GrapherState + inputTableTransformer = (table: OwidTable) => table 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 @@ -331,7 +343,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() @@ -352,7 +364,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` } } @@ -429,7 +441,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 @@ -441,23 +453,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 { @@ -465,7 +480,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, @@ -475,8 +490,8 @@ export class Explorer @action.bound private setGrapherTable(table: OwidTable) { if (this.grapher) { - this.grapher.inputTable = table - this.grapher.appendNewEntitySelectionOptions() + this.grapher.grapherState.inputTable = + this.inputTableTransformer(table) } } @@ -492,7 +507,7 @@ export class Explorer @action.bound updateGrapherFromExplorer() { switch (this.explorerProgram.chartCreationMode) { case ExplorerChartCreationMode.FromGrapherId: - this.updateGrapherFromExplorerUsingGrapherId() + void this.updateGrapherFromExplorerUsingGrapherId() break case ExplorerChartCreationMode.FromVariableIds: void this.updateGrapherFromExplorerUsingVariableIds() @@ -549,7 +564,7 @@ export class Explorer ) } - @action.bound updateGrapherFromExplorerUsingGrapherId() { + @action.bound async updateGrapherFromExplorerUsingGrapherId() { const grapher = this.grapher if (!grapher) return @@ -573,10 +588,18 @@ export class Explorer config.selectedEntityNames = this.selection.selectedEntityNames } - grapher.setAuthoredVersion(config) - grapher.reset() - grapher.updateFromObject(config) - grapher.downloadData() + grapher?.grapherState.setAuthoredVersion(config) + grapher.grapherState.reset() + grapher?.grapherState.updateFromObject(config) + const inputTable = await fetchInputTableForConfig( + config.dimensions ?? [], + config.selectedEntityColors, + this.props.dataApiUrl, + undefined + ) + if (inputTable) + grapher.grapherState.inputTable = + this.inputTableTransformer(inputTable) } @action.bound async updateGrapherFromExplorerUsingVariableIds() { @@ -695,7 +718,8 @@ export class Explorer config.dimensions = dimensions if (ySlugs && yVariableIds) config.ySlugs = ySlugs + " " + yVariableIds - const inputTableTransformer = (table: OwidTable) => { + // TODO: 2025-01-07 Daniel - do we still need this? + this.inputTableTransformer = (table: OwidTable) => { // add transformed (and intermediate) columns to the grapher table if (uniqueSlugsInGrapherRow.length) { const allColumnSlugs = uniq( @@ -737,17 +761,27 @@ export class Explorer return table } - grapher.setAuthoredVersion(config) - grapher.reset() - grapher.updateFromObject(config) + grapher?.grapherState.setAuthoredVersion(config) + grapher.grapherState.reset() + 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 - grapher.receiveOwidData(new Map()) + // grapher.receiveOwidData(new Map()) + grapher.grapherState.inputTable = BlankOwidTable() } else { - await grapher.downloadLegacyDataFromOwidVariableIds( - inputTableTransformer + // await grapher.downloadLegacyDataFromOwidVariableIds( + // inputTableTransformer + // ) + const inputTable = await fetchInputTableForConfig( + config.dimensions, + config.selectedEntityColors, + this.props.dataApiUrl, + undefined ) + if (inputTable) + grapher.grapherState.inputTable = + this.inputTableTransformer(inputTable) } } @@ -770,9 +804,9 @@ export class Explorer config.selectedEntityNames = this.selection.selectedEntityNames } - grapher.setAuthoredVersion(config) - grapher.reset() - grapher.updateFromObject(config) + grapher?.grapherState.setAuthoredVersion(config) + grapher.grapherState.reset() + grapher?.grapherState.updateFromObject(config) // Clear any error messages, they are likely to be related to dataset loading. this.grapher?.clearErrors() @@ -806,7 +840,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, @@ -974,7 +1008,7 @@ export class Explorer private updateGrapherBounds() { const grapherContainer = this.grapherContainerRef.current if (grapherContainer) - this.grapherBounds = new Bounds( + this.grapherState.externalBounds = new Bounds( 0, 0, grapherContainer.clientWidth, @@ -1028,14 +1062,8 @@ export class Explorer this.mobileCustomizeButton}
@@ -1077,7 +1105,7 @@ export class Explorer } @computed get grapherTable() { - return this.grapher?.tableAfterAuthorTimelineFilter + return this.grapher?.grapherState?.tableAfterAuthorTimelineFilter } @observable entityPickerMetric? = this.initialQueryParams.pickerMetric @@ -1192,6 +1220,6 @@ export class Explorer } @computed get requiredColumnSlugs() { - return this.grapher?.newSlugs ?? [] + return this.grapher?.grapherState?.newSlugs ?? [] } } diff --git a/packages/@ourworldindata/explorer/src/GrapherGrammar.ts b/packages/@ourworldindata/explorer/src/GrapherGrammar.ts index 9c12109ffd0..685c1d68001 100644 --- a/packages/@ourworldindata/explorer/src/GrapherGrammar.ts +++ b/packages/@ourworldindata/explorer/src/GrapherGrammar.ts @@ -25,7 +25,6 @@ import { IndicatorIdOrEtlPathCellDef, GrapherCellDef, } from "./gridLang/GridLangConstants.js" - const toTerminalOptions = (keywords: string[]): CellDef[] => { return keywords.map((keyword) => ({ keyword, diff --git a/packages/@ourworldindata/grapher/src/chart/DimensionSlot.ts b/packages/@ourworldindata/grapher/src/chart/DimensionSlot.ts index 15013b4e7fe..a01dd9e3e5e 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 { 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 new file mode 100644 index 00000000000..bc62fa44962 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/core/FetchingGrapher.tsx @@ -0,0 +1,180 @@ +import { + AssetMap, + GrapherInterface, + MultipleOwidVariableDataDimensionsMap, + OwidChartDimensionInterface, + OwidChartDimensionInterfaceWithMandatorySlug, + OwidVariableDataMetadataDimensions, +} from "@ourworldindata/types" +import React from "react" +import { + Grapher, + GrapherProgrammaticInterface, + GrapherState, +} from "./Grapher.js" +import { loadVariableDataAndMetadata } from "./loadVariable.js" +import { legacyToOwidTableAndDimensionsWithMandatorySlug } from "./LegacyToOwidTable.js" +import { OwidTable } from "@ourworldindata/core-table" +import { isEqual } from "@ourworldindata/utils" + +export interface FetchingGrapherProps { + config?: GrapherProgrammaticInterface + configUrl?: string + dataApiUrl: string + assetMap: AssetMap | undefined +} +export function FetchingGrapher( + props: FetchingGrapherProps +): JSX.Element | null { + // if config is not provided, fetch it from configUrl + + const [downloadedConfig, setdownloadedConfig] = React.useState< + GrapherInterface | undefined + >(undefined) + + const grapherState = React.useRef( + new GrapherState({ + ...props.config, + dataApiUrl: props.dataApiUrl, + }) + ) + + // update grapherState when the config from props changes + React.useEffect(() => { + if (props.config?.bounds) + grapherState.current.externalBounds = props.config.bounds + }, [props.config?.bounds]) + + React.useEffect(() => { + async function fetchConfigAndLoadData(): Promise { + if (props.configUrl) { + const fetchedConfig = await fetch(props.configUrl).then((res) => + res.json() + ) + setdownloadedConfig(fetchedConfig) + grapherState.current.updateFromObject(fetchedConfig) + } + } + void fetchConfigAndLoadData() + }, [props.configUrl]) + + React.useEffect(() => { + async function fetchData(): Promise { + const inputTable = await fetchInputTableForConfig( + downloadedConfig?.dimensions ?? props.config?.dimensions ?? [], + downloadedConfig?.selectedEntityColors ?? + props.config?.selectedEntityColors, + props.dataApiUrl, + props.assetMap + ) + if (inputTable) grapherState.current.inputTable = inputTable + } + void fetchData() + }, [ + props.config?.dimensions, + props.dataApiUrl, + downloadedConfig?.dimensions, + downloadedConfig?.selectedEntityColors, + props.config?.selectedEntityColors, + props.assetMap, + ]) + + return +} + +export async function fetchInputTableForConfig( + dimensions: OwidChartDimensionInterface[], + selectedEntityColors: + | { [entityName: string]: string | undefined } + | undefined, + dataApiUrl: string, + assetMap: AssetMap | undefined +): Promise { + if (dimensions.length === 0) return undefined + const variables = dimensions.map((d) => d.variableId) + const variablesDataMap = await loadVariablesDataSite( + variables, + dataApiUrl, + assetMap + ) + const inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + variablesDataMap, + dimensions, + selectedEntityColors + ) + + return inputTable +} + +export function getCachingInputTableFetcher( + dataApiUrl: string, + assetMap: AssetMap | undefined +): ( + dimensions: OwidChartDimensionInterfaceWithMandatorySlug[], + selectedEntityColors: + | { [entityName: string]: string | undefined } + | undefined +) => Promise { + const cache: Map = new Map() + let previousDimensions: OwidChartDimensionInterface[] = [] + + return async ( + dimensions: OwidChartDimensionInterfaceWithMandatorySlug[], + selectedEntityColors: + | { [entityName: string]: string | undefined } + | undefined + ) => { + // Check if dimensions have changed + + if (isEqual(previousDimensions, dimensions)) { + return undefined // No changes in dimensions + } + previousDimensions = dimensions + + if (dimensions.length === 0) return undefined + + const variables = dimensions.map((d) => d.variableId) + const variablesToFetch = variables.filter((v) => !cache.has(v)) + + if (variablesToFetch.length > 0) { + const fetchedData = await Promise.all( + variablesToFetch.map((variableId) => + loadVariableDataAndMetadata( + variableId, + dataApiUrl, + assetMap + ) + ) + ) + fetchedData.forEach((data) => cache.set(data.metadata.id, data)) + } + + const variablesDataMap = new Map( + variables.map((v) => [v, cache.get(v)!]) + ) + + const inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + variablesDataMap, + dimensions, + selectedEntityColors + ) + + return inputTable + } +} + +async function loadVariablesDataSite( + variableIds: number[], + dataApiUrl: string, + assetMap: AssetMap | undefined +): Promise { + const loadVariableDataPromises = variableIds.map((variableId) => + loadVariableDataAndMetadata(variableId, dataApiUrl, assetMap) + ) + const variablesData: OwidVariableDataMetadataDimensions[] = + await Promise.all(loadVariableDataPromises) + const variablesDataMap = new Map( + variablesData.map((data) => [data.metadata.id, data]) + ) + return variablesDataMap +} diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts index dba594860e5..f7a2558e450 100755 --- a/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts +++ b/packages/@ourworldindata/grapher/src/core/Grapher.jsdom.test.ts @@ -1,5 +1,5 @@ #! /usr/bin/env jest -import { Grapher, GrapherProgrammaticInterface } from "../core/Grapher" +import { GrapherProgrammaticInterface, GrapherState } from "../core/Grapher" import { GRAPHER_CHART_TYPES, EntitySelectionMode, @@ -36,10 +36,11 @@ import { OwidDistinctLinesColorScheme, } from "../color/CustomSchemes" import { latestGrapherConfigSchema } from "./GrapherConstants.js" +import { legacyToOwidTableAndDimensionsWithMandatorySlug } from "./LegacyToOwidTable.js" const TestGrapherConfig = (): { table: OwidTable - selection: any[] + selectedEntityNames: any[] dimensions: { slug: SampleColumnSlugs property: DimensionProperty @@ -49,7 +50,7 @@ const TestGrapherConfig = (): { const table = SynthesizeGDPTable({ entityCount: 10 }) return { table, - selection: table.sampleEntityName(5), + selectedEntityNames: table.sampleEntityName(5), dimensions: [ { slug: SampleColumnSlugs.GDP, @@ -61,7 +62,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 +70,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 +78,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 +87,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,18 +149,28 @@ const legacyConfig: Omit & } it("can apply legacy chart dimension settings", () => { - const grapher = new Grapher(legacyConfig) + const grapher = new GrapherState(legacyConfig) + grapher.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyConfig.owidDataset!, + legacyConfig.dimensions!, + legacyConfig.selectedEntityColors + ) const col = grapher.yColumnsFromDimensions[0]! expect(col.unit).toEqual(unit) expect(col.displayName).toEqual(name) }) it("correctly identifies changes to passed-in selection", () => { - const selection = new SelectionArray() - const grapher = new Grapher({ + const selection = new SelectionArray(legacyConfig.selectedEntityNames) + const grapher = new GrapherState({ ...legacyConfig, manager: { selection }, }) + grapher.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyConfig.owidDataset!, + legacyConfig.dimensions!, + legacyConfig.selectedEntityColors + ) expect(grapher.changedParams).toEqual({}) expect(selection.selectedEntityNames).toEqual(["Iceland", "Afghanistan"]) @@ -173,7 +186,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 +195,12 @@ 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) + grapher.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + config.owidDataset!, + config.dimensions!, + config.selectedEntityColors + ) expect(grapher.queryStr).toBe("") grapher.selection.selectAll() expect(grapher.queryStr).toContain("AFG") @@ -194,7 +212,12 @@ 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) + grapher2.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + config2.owidDataset!, + config2.dimensions!, + config2.selectedEntityColors + ) expect(grapher2.queryStr).toBe("") grapher2.selection.selectAll() expect(grapher2.queryStr).toContain("AFG") @@ -202,7 +225,12 @@ 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.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyConfig.owidDataset!, + legacyConfig.dimensions!, + legacyConfig.selectedEntityColors + ) grapher.chartTypes = [GRAPHER_CHART_TYPES.LineChart] expect(grapher.hasTimeline).toBeTruthy() grapher.chartTypes = [GRAPHER_CHART_TYPES.SlopeChart] @@ -216,7 +244,12 @@ describe("hasTimeline", () => { }) it("map tab has timeline even if chart doesn't", () => { - const grapher = new Grapher(legacyConfig) + const grapher = new GrapherState(legacyConfig) + grapher.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyConfig.owidDataset!, + legacyConfig.dimensions!, + legacyConfig.selectedEntityColors + ) grapher.hideTimeline = true grapher.chartTypes = [GRAPHER_CHART_TYPES.LineChart] expect(grapher.hasTimeline).toBeFalsy() @@ -227,67 +260,74 @@ describe("hasTimeline", () => { }) }) -const getGrapher = (): Grapher => - new Grapher({ +const getGrapher = (): GrapherState => { + const dataset = new Map([ + [ + 142609, + { + data: { + years: [-1, 0, 1, 2], + entities: [1, 2, 1, 2], + values: [51, 52, 53, 54], + }, + metadata: { + id: 142609, + display: { zeroDay: "2020-01-21", yearIsDay: true }, + dimensions: { + entities: { + values: [ + { + name: "United Kingdom", + code: "GBR", + id: 1, + }, + { name: "Ireland", code: "IRL", id: 2 }, + ], + }, + years: { + values: [ + { + id: -1, + }, + { + id: 0, + }, + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }, + }, + }, + ], + ]) + const state = new GrapherState({ dimensions: [ { variableId: 142609, property: DimensionProperty.y, }, ], - owidDataset: new Map([ - [ - 142609, - { - data: { - years: [-1, 0, 1, 2], - entities: [1, 2, 1, 2], - values: [51, 52, 53, 54], - }, - metadata: { - id: 142609, - display: { zeroDay: "2020-01-21", yearIsDay: true }, - dimensions: { - entities: { - values: [ - { - name: "United Kingdom", - code: "GBR", - id: 1, - }, - { name: "Ireland", code: "IRL", id: 2 }, - ], - }, - years: { - values: [ - { - id: -1, - }, - { - id: 0, - }, - { - id: 1, - }, - { - id: 2, - }, - ], - }, - }, - }, - }, - ], - ]), minTime: -5000, maxTime: 5000, }) + state.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + dataset, + state.dimensions, + {} + ) + return state +} 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 +337,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 +347,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,9 +362,9 @@ describe("currentTitle", () => { { entityCount: 2, timeRange: [2000, 2010] }, 1 ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, - selectedEntityNames: table.availableEntityNames, + selectedEntityNames: [...table.availableEntityNames], dimensions: [ { slug: SampleColumnSlugs.GDP, @@ -357,7 +397,7 @@ describe("currentTitle", () => { { entityCount: 2, timeRange: [2000, 2010] }, 1 ) - const grapher = new Grapher({ + const grapher = new GrapherState({ table, ySlugs: "GDP", }) @@ -369,10 +409,10 @@ 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, + selectedEntityNames: [...table.availableEntityNames], maxTime: 2005, ySlugs: "GDP", }) @@ -382,7 +422,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 +434,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 +495,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 +504,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 +531,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 +543,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 +563,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 +576,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 +587,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 +608,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,9 +627,9 @@ 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, + selectedEntityNames: [...table.availableEntityNames], dimensions: [ { slug: SampleColumnSlugs.GDP, @@ -716,7 +756,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 +764,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 +903,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 +1026,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 +1063,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 +1095,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 +1124,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 +1160,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 965b35869d6..460adce936d 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -28,9 +28,6 @@ import { differenceObj, QueryParams, MultipleOwidVariableDataDimensionsMap, - OwidVariableDataMetadataDimensions, - OwidVariableMixedData, - OwidVariableWithSourceAndDimension, Bounds, DEFAULT_BOUNDS, minTimeBoundFromJSONOrNegativeInfinity, @@ -114,7 +111,6 @@ import { GrapherTabOption, SeriesName, ChartViewInfo, - OwidChartDimensionInterfaceWithMandatorySlug, AssetMap, } from "@ourworldindata/types" import { @@ -140,13 +136,8 @@ import { latestGrapherConfigSchema, GRAPHER_SQUARE_SIZE, } from "../core/GrapherConstants" -import { loadVariableDataAndMetadata } from "./loadVariable" import Cookies from "js-cookie" -import { - ChartDimension, - getDimensionColumnSlug, - LegacyDimensionsManager, -} from "../chart/ChartDimension" +import { ChartDimension } from "../chart/ChartDimension" import { TooltipManager } from "../tooltip/TooltipProps" import { DimensionSlot } from "../chart/DimensionSlot" @@ -154,41 +145,29 @@ import { getFocusedSeriesNamesParam, getSelectedEntityNamesParam, } from "./EntityUrlBuilder" -import { AxisConfig, AxisManager } from "../axis/AxisConfig" +import { AxisConfig } from "../axis/AxisConfig" import { ColorScaleConfig } from "../color/ColorScaleConfig" import { MapConfig } from "../mapCharts/MapConfig" import { FullScreen } from "../fullScreen/FullScreen" import { isOnTheMap } from "../mapCharts/EntitiesOnTheMap" -import { ChartManager } from "../chart/ChartManager" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons" -import { SettingsMenu, SettingsMenuManager } from "../controls/SettingsMenu" +import { SettingsMenu } from "../controls/SettingsMenu" import { TooltipContainer } from "../tooltip/Tooltip" -import { - EntitySelectorModal, - EntitySelectorModalManager, -} from "../modal/EntitySelectorModal" -import { DownloadModal, DownloadModalManager } from "../modal/DownloadModal" +import { EntitySelectorModal } from "../modal/EntitySelectorModal" +import { DownloadModal } from "../modal/DownloadModal" import ReactDOM from "react-dom" import { observer } from "mobx-react" import "d3-transition" -import { SourcesModal, SourcesModalManager } from "../modal/SourcesModal" -import { DataTableManager } from "../dataTable/DataTable" -import { MapChartManager } from "../mapCharts/MapChartConstants" +import { SourcesModal } from "../modal/SourcesModal" import { MapChart } from "../mapCharts/MapChart" -import { DiscreteBarChartManager } from "../barCharts/DiscreteBarChartConstants" import { Command, CommandPalette } from "../controls/CommandPalette" -import { ShareMenuManager } from "../controls/ShareMenu" -import { EmbedModalManager, EmbedModal } from "../modal/EmbedModal" +import { EmbedModal } from "../modal/EmbedModal" import { CaptionedChart, - CaptionedChartManager, StaticCaptionedChart, } from "../captionedChart/CaptionedChart" -import { - TimelineController, - TimelineManager, -} from "../timeline/TimelineController" +import { TimelineController } from "../timeline/TimelineController" import Mousetrap from "mousetrap" import { SlideShowController } from "../slideshowController/SlideShowController" import { @@ -196,8 +175,7 @@ import { DefaultChartClass, } from "../chart/ChartTypeMap" import { Entity, SelectionArray } from "../selection/SelectionArray" -import { legacyToOwidTableAndDimensions } from "./LegacyToOwidTable" -import { ScatterPlotManager } from "../scatterCharts/ScatterPlotChartConstants" +import { legacyToOwidTableAndDimensionsWithMandatorySlug } from "./LegacyToOwidTable" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, @@ -208,14 +186,11 @@ import { import classnames from "classnames" import { GrapherAnalytics } from "./GrapherAnalytics" import { legacyToCurrentGrapherQueryParams } from "./GrapherUrlMigrations" -import { ChartInterface, ChartTableTransformer } from "../chart/ChartInterface" -import { MarimekkoChartManager } from "../stackedCharts/MarimekkoChartConstants" -import { FacetChartManager } from "../facetChart/FacetChartConstants" +import { ChartInterface } from "../chart/ChartInterface" import { StaticChartRasterizer, type GrapherExport, } from "../captionedChart/StaticChartRasterizer.js" -import { SlopeChartManager } from "../slopeCharts/SlopeChart" import { SidePanel } from "../sidePanel/SidePanel" import { EntitySelector, @@ -231,6 +206,7 @@ import { GRAPHER_LIGHT_TEXT, } from "../color/ColorConstants" import { FacetChart } from "../facetChart/FacetChart" +import { FetchingGrapher } from "./FetchingGrapher.js" declare global { interface Window { @@ -239,56 +215,6 @@ declare global { } } -async function loadVariablesDataAdmin( - variableFetchBaseUrl: string | undefined, - variableIds: number[] -): Promise { - const dataFetchPath = (variableId: number): string => - variableFetchBaseUrl - ? `${variableFetchBaseUrl}/v1/variableById/data/${variableId}` - : `/api/data/variables/data/${variableId}.json` - const metadataFetchPath = (variableId: number): string => - variableFetchBaseUrl - ? `${variableFetchBaseUrl}/v1/variableById/metadata/${variableId}` - : `/api/data/variables/metadata/${variableId}.json` - - const loadVariableDataPromises = variableIds.map(async (variableId) => { - const dataPromise = window.admin.getJSON( - dataFetchPath(variableId) - ) as Promise - const metadataPromise = window.admin.getJSON( - metadataFetchPath(variableId) - ) as Promise - const [data, metadata] = await Promise.all([ - dataPromise, - metadataPromise, - ]) - return { data, metadata: { ...metadata, id: variableId } } - }) - const variablesData: OwidVariableDataMetadataDimensions[] = - await Promise.all(loadVariableDataPromises) - const variablesDataMap = new Map( - variablesData.map((data) => [data.metadata.id, data]) - ) - return variablesDataMap -} - -async function loadVariablesDataSite( - variableIds: number[], - dataApiUrl: string, - assetMap?: AssetMap -): Promise { - const loadVariableDataPromises = variableIds.map((variableId) => - loadVariableDataAndMetadata(variableId, dataApiUrl, assetMap) - ) - const variablesData: OwidVariableDataMetadataDimensions[] = - await Promise.all(loadVariableDataPromises) - const variablesDataMap = new Map( - variablesData.map((data) => [data.metadata.id, data]) - ) - return variablesDataMap -} - const DEFAULT_MS_PER_TICK = 100 // Exactly the same as GrapherInterface, but contains options that developers want but authors won't be touching. @@ -355,278 +281,64 @@ export interface GrapherManager { editUrl?: string } -@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 -{ - @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.ref variantName?: string = undefined - @observable.ref internalNotes?: string = undefined - @observable.ref originUrl?: 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 addCountryMode = EntitySelectionMode.MultipleEntities - @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 facettingLabelByYVariables = "metric" - @observable.ref hideTimeline?: boolean = undefined - @observable.ref hideScatterLabels?: 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.ref chartTab?: GrapherChartType - @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 - 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.ref missingDataStrategy?: MissingDataStrategy = undefined - @observable.ref showSelectionOnlyInDataTable?: boolean = undefined - - @observable.ref xAxis = new AxisConfig(undefined, this) - @observable.ref yAxis = new AxisConfig(undefined, this) - @observable colorScale = new ColorScaleConfig() - @observable map = new MapConfig() - @observable.ref dimensions: ChartDimension[] = [] - - @observable ySlugs?: ColumnSlugs = undefined - @observable xSlug?: ColumnSlug = undefined - @observable colorSlug?: ColumnSlug = undefined - @observable sizeSlug?: ColumnSlug = undefined - @observable tableSlugs?: ColumnSlugs = undefined - - @observable selectedEntityColors: { - [entityName: string]: string | undefined - } = {} - - @observable selectedEntityNames: EntityName[] = [] - @observable focusedSeriesNames: SeriesName[] = [] - @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 comparisonLines?: ComparisonLineConfig[] = undefined // todo: Persistables? - @observable relatedQuestions?: RelatedQuestionsConfig[] = undefined // todo: Persistables? - - /** - * 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 - - @observable.ref hideFacetControl?: boolean = undefined - - // the desired faceting strategy, which might not be possible if we change the data - @observable selectedFacetStrategy?: FacetStrategy = undefined - - @observable sortBy?: SortBy = SortBy.total - @observable sortOrder?: SortOrder = SortOrder.desc - @observable sortColumnSlug?: string - - @observable.ref _isInFullScreenMode = false - - @observable.ref windowInnerWidth?: number - @observable.ref windowInnerHeight?: number - - owidDataset?: MultipleOwidVariableDataDimensionsMap = undefined // This is used for passing data for testing - - manuallyProvideData? = false // This will be removed. - - // 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 - @observable bakedGrapherURL = this.props.bakedGrapherURL - adminBaseUrl = this.props.adminBaseUrl - dataApiUrl = - this.props.dataApiUrl ?? "https://api.ourworldindata.org/v1/indicators/" - - @observable.ref externalQueryParams: QueryParams - - private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL - private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL - - @observable.ref inputTable: OwidTable - - @observable.ref legacyConfigAsAuthored: Partial = {} - - // stored on Grapher so state is preserved when switching to full-screen mode - @observable entitySelectorState: Partial = {} - - @computed get dataApiUrlForAdmin(): string | undefined { - return this.props.dataApiUrlForAdmin - } - - @computed get dataTableSlugs(): ColumnSlug[] { - return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs - } - - isEmbeddedInAnOwidPage?: boolean = this.props.isEmbeddedInAnOwidPage - isEmbeddedInADataPage?: boolean = this.props.isEmbeddedInADataPage - - chartViewInfo?: Pick< - ChartViewInfo, - "name" | "parentChartSlug" | "queryParamsForParentChart" - > = undefined - - runtimeAssetMap?: AssetMap = this.props.runtimeAssetMap - - selection = - this.manager?.selection ?? - new SelectionArray( - this.props.selectedEntityNames ?? [], - this.props.table?.availableEntities ?? [] - ) - - focusArray = this.manager?.focusArray ?? new FocusArray() - - /** - * 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 updateAuthoredVersion( - config: Partial - ): void { - this.legacyConfigAsAuthored = { - ...this.legacyConfigAsAuthored, - ...config, - } - } - - constructor( - propsWithGrapherInstanceGetter: GrapherProgrammaticInterface = {} - ) { - super(propsWithGrapherInstanceGetter) - - const { getGrapherInstance, ...props } = propsWithGrapherInstanceGetter - - this.inputTable = props.table ?? BlankOwidTable(`initialGrapherTable`) - - if (props) this.setAuthoredVersion(props) - +// 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 (props.manager?.selection?.hasSelection) { - this.updateFromObject(omit(props, "selectedEntityNames")) + if (options.manager?.selection?.hasSelection) { + this.updateFromObject(omit(options, "selectedEntityNames")) } else { - this.updateFromObject(props) + this.updateFromObject(options) } + if (options.dataApiUrl) this.dataApiUrl = options.dataApiUrl - if (!props.table) this.downloadData() + if (options.staticFormat) this._staticFormat = options.staticFormat + this.staticBounds = + options.staticBounds ?? this.getStaticBounds(this._staticFormat) - this.populateFromQueryParams( - legacyToCurrentGrapherQueryParams(props.queryStr ?? "") - ) this.externalQueryParams = omit( - Url.fromQueryStr(props.queryStr ?? "").queryParams, + 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) + this.runtimeAssetMap = options.runtimeAssetMap + + this.populateFromQueryParams( + legacyToCurrentGrapherQueryParams( + this.initialOptions.queryStr ?? "" + ) + ) if (this.isEditor) { this.ensureValidConfigWhenEditing() } - - if (getGrapherInstance) getGrapherInstance(this) // todo: possibly replace with more idiomatic ref - } - - toObject(): GrapherInterface { - const obj: GrapherInterface = objectWithPersistablesToObject( - this, - grapherKeysToSerialize - ) - - obj.selectedEntityNames = this.selection.selectedEntityNames - obj.focusedSeriesNames = this.focusArray.seriesNames - - deleteRuntimeAndUnchangedProps(obj, defaultObject) - - // always include the schema, even if it's the default - obj.$schema = this.$schema || latestGrapherConfigSchema - - // 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 - - if (obj.timelineMinTime) - obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any - if (obj.timelineMaxTime) - obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any - - // todo: remove dimensions concept - // if (this.legacyConfigAsAuthored?.dimensions) - // obj.dimensions = this.legacyConfigAsAuthored.dimensions - - return obj - } - - @action.bound downloadData(): void { - if (this.manuallyProvideData) { - // ignore - } else if (this.owidDataset) { - this._receiveOwidDataAndApplySelection(this.owidDataset) - } else void this.downloadLegacyDataFromOwidVariableIds() } @action.bound updateFromObject(obj?: GrapherProgrammaticInterface): void { @@ -748,252 +460,433 @@ export class Grapher } } - @action.bound private setTimeFromTimeQueryParam(time: string): void { - this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map( - (time) => findClosestTime(this.times, time) ?? time - ) as TimeBounds - } + toObject(): GrapherInterface { + const obj: GrapherInterface = objectWithPersistablesToObject( + this, + grapherKeysToSerialize + ) - @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 - } + obj.selectedEntityNames = this.selection.selectedEntityNames + obj.focusedSeriesNames = this.focusArray.seriesNames - @computed get activeChartType(): GrapherChartType | undefined { - if (!this.isOnChartTab) return undefined - return this.activeTab as GrapherChartType - } + deleteRuntimeAndUnchangedProps(obj, defaultObject) - @computed get chartType(): GrapherChartType | undefined { - return this.validChartTypes[0] - } + // always include the schema, even if it's the default + obj.$schema = this.$schema || latestGrapherConfigSchema - @computed get hasChartTab(): boolean { - return this.validChartTypes.length > 0 - } + // 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 isOnChartTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.chart - } + if (obj.timelineMinTime) + obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any + if (obj.timelineMaxTime) + obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any - @computed get isOnMapTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.map - } - - @computed get isOnTableTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.table - } + // todo: remove dimensions concept + // if (this.legacyConfigAsAuthored?.dimensions) + // obj.dimensions = this.legacyConfigAsAuthored.dimensions - @computed get isOnChartOrMapTab(): boolean { - return this.isOnChartTab || this.isOnMapTab + return obj } - @computed get yAxisConfig(): Readonly { - return this.yAxis.toObject() + // 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) } + disposers: (() => void)[] = [] + private mapQueryParamToGrapherTab(tab: string): GrapherTabName | undefined { + const { + chartType: defaultChartType, + validChartTypeSet, + hasMapTab, + } = this - @computed get xAxisConfig(): Readonly { - return this.xAxis.toObject() - } + if (tab === GRAPHER_TAB_QUERY_PARAMS.table) { + return GRAPHER_TAB_NAMES.Table + } + if (tab === GRAPHER_TAB_QUERY_PARAMS.map) { + return GRAPHER_TAB_NAMES.WorldMap + } - @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 + 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 + } } - return !this.hideLegend - } + const chartTypeName = mapQueryParamToChartTypeName(tab) - @computed private get showsAllEntitiesInChart(): boolean { - return this.isScatter || this.isMarimekko - } + if (!chartTypeName) return undefined - @computed private get settingsMenu(): SettingsMenu { - return new SettingsMenu({ manager: this, top: 0, bottom: 0, right: 0 }) + 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 + } } - /** - * If the table filter toggle isn't offered, then we default to - * to showing only the selected entities – unless there is a view - * that displays all data points, like a map or a scatter plot. - */ - @computed get forceShowSelectionOnlyInDataTable(): boolean { - return ( - !this.settingsMenu.showTableFilterToggle && - this.hasChartTab && - !this.showsAllEntitiesInChart && - !this.hasMapTab - ) + @action.bound setTimeFromTimeQueryParam(time: string): void { + this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map( + (time) => findClosestTime(this.times, time) ?? time + ) as TimeBounds } - // table that is used for display in the table tab - @computed get tableForDisplay(): OwidTable { - let table = this.table - - if (!this.isReady || !this.isOnTableTab) return table - - if (this.chartInstance.transformTableForDisplay) { - table = this.chartInstance.transformTableForDisplay(table) - } + @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) + ) - if ( - this.forceShowSelectionOnlyInDataTable || - this.showSelectionOnlyInDataTable - ) { - table = table.filterByEntityNames( - this.selection.selectedEntityNames - ) - } + this.dimensionSlots.forEach((slot) => { + if (!slot.allowMultiple) + validDimensions = uniqWith( + validDimensions, + ( + a: OwidChartDimensionInterface, + b: OwidChartDimensionInterface + ) => + a.property === slot.property && + a.property === b.property + ) + }) - return table + return validDimensions } - @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.isReady) return table - - // 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) - } + // 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) - return table + 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] } /** - * 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. + * todo: factor this out and make more RAII. * - * 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. + * 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". */ - @computed get tableAfterColorAndSizeToleranceApplication(): OwidTable { - let table = this.inputTable - - if (this.isScatter && this.sizeColumnSlug) { - const tolerance = - table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity - table = table.interpolateColumnWithTolerance( - this.sizeColumnSlug, - tolerance - ) - } - - if ((this.isScatter || this.isMarimekko) && this.colorColumnSlug) { - const tolerance = - table.get(this.colorColumnSlug)?.display?.tolerance ?? Infinity - table = table.interpolateColumnWithTolerance( - this.colorColumnSlug, - tolerance - ) - } - - return table + @action.bound setAuthoredVersion( + config: Partial + ): void { + this.legacyConfigAsAuthored = config } - // 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 ( - this.timelineMinTime === undefined && - this.timelineMaxTime === undefined - ) - return table - return table.filterByTimeRange( - this.timelineMinTime ?? -Infinity, - this.timelineMaxTime ?? Infinity + initialOptions: GrapherProgrammaticInterface + @action.bound setDimensionsFromConfigs( + configs: OwidChartDimensionInterface[] + ): void { + this.dimensions = configs.map( + (config) => new ChartDimension(config, this) ) } - // Convenience method for debugging - windowQueryParams(str = location.search): QueryParams { - return strToQueryParams(str) - } - - @computed - get tableAfterAuthorTimelineAndActiveChartTransform(): OwidTable { - const table = this.tableAfterAuthorTimelineFilter - if (!this.isReady || !this.isOnChartOrMapTab) return table - - const startMark = performance.now() + // #region SortConfig props + @observable sortBy?: SortBy = SortBy.total + @observable sortOrder?: SortOrder = SortOrder.desc + @observable sortColumnSlug?: string + // #endregion - const transformedTable = this.chartInstance.transformTable(table) + // #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 - this.createPerformanceMeasurement( - "chartInstance.transformTable", - startMark - ) - return transformedTable - } + // 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 - @computed get chartInstance(): ChartInterface { - // Note: when timeline handles on a LineChart are collapsed into a single handle, the - // LineChart turns into a DiscreteBar. + @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 this.isOnMapTab - ? new MapChart({ manager: this }) - : this.chartInstanceExceptMap + @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 + + runtimeAssetMap?: AssetMap + + 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 } - // When Map becomes a first-class chart instance, we should drop this - @computed get chartInstanceExceptMap(): ChartInterface { - const chartTypeName = - this.typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart + @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 - const ChartClass = - ChartComponentClassMap.get(chartTypeName) ?? DefaultChartClass - return new ChartClass({ manager: this }) + @computed get baseFontSize(): number { + if (this.isStaticAndSmall) { + return this.computeBaseFontSizeFromHeight(this.staticBounds) + } + if (this.isStatic) return 18 + return this._baseFontSize } + @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 - @computed get chartSeriesNames(): SeriesName[] { - if (!this.isReady) return [] + // 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 - // 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) - ) + // 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 + + @observable.ref isSocialMediaExport = false + // getGrapherInstance defined in interface but not on Grapher (as a property - it is set in the constructor) + + enableKeyboardShortcuts?: boolean + + bindUrlToWindow?: boolean + + isEmbeddedInAnOwidPage?: boolean = false + isEmbeddedInADataPage?: boolean = false + + chartViewInfo?: Pick< + ChartViewInfo, + "name" | "parentChartSlug" | "queryParamsForParentChart" + > = undefined + + readonly manager: GrapherManager | undefined = undefined + // instanceRef defined in interface but not on Grapher + + // Autocomputed url params to reflect difference between current grapher state + // and original config state + @computed.struct get changedParams(): Partial { + return differenceObj(this.allParams, this.authorsVersion.allParams) + } + + // computed properties that are needed by the above + + @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.isReady) return table + + // 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 table + } + + /** + * 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 + + if (this.isScatter && this.sizeColumnSlug) { + const tolerance = + table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity + table = table.interpolateColumnWithTolerance( + this.sizeColumnSlug, + tolerance ) } - return this.chartInstance.series.map((series) => series.seriesName) + if ((this.isScatter || this.isMarimekko) && this.colorColumnSlug) { + const tolerance = + table.get(this.colorColumnSlug)?.display?.tolerance ?? Infinity + table = table.interpolateColumnWithTolerance( + this.colorColumnSlug, + tolerance + ) + } + + return table } - @computed get table(): OwidTable { - return this.tableAfterAuthorTimelineFilter + // 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 ( + this.timelineMinTime === undefined && + this.timelineMaxTime === undefined + ) + return table + return table.filterByTimeRange( + this.timelineMinTime ?? -Infinity, + this.timelineMaxTime ?? Infinity + ) + } + + @computed + get tableAfterAuthorTimelineAndActiveChartTransform(): OwidTable { + const table = this.tableAfterAuthorTimelineFilter + if (!this.isReady || !this.isOnChartOrMapTab) return table + + const startMark = performance.now() + + const transformedTable = this.chartInstance.transformTable(table) + + this.createPerformanceMeasurement( + "chartInstance.transformTable", + startMark + ) + return transformedTable } @computed @@ -1029,92 +922,155 @@ export class Grapher return table.filterByTimeRange(startTime, endTime) } - @computed get transformedTable(): OwidTable { - return this.tableAfterAllTransformsAndFilters + private computeBaseFontSizeFromHeight(bounds: Bounds): number { + const squareBounds = this.getStaticBounds(GrapherStaticFormat.square) + const factor = squareBounds.height / 21 + return Math.max(10, bounds.height / factor) } - @observable.ref renderToStatic = false - @observable.ref isExportingToSvgOrPng = false - @observable.ref isSocialMediaExport = false + 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 + } - tooltip?: TooltipManager["tooltip"] = observable.box(undefined, { - deep: false, - }) + 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 + } + } - @observable.ref isPlaying = false - @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished - @observable.ref animationStartTime?: Time - @observable.ref areHandlesOnSameTimeBeforeAnimation?: boolean + // 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: "", + }) + } - @observable.ref isEntitySelectorModalOrDrawerOpen = false + @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 + } - @observable.ref isSourcesModalOpen = false - @observable.ref isDownloadModalOpen = false - @observable.ref isEmbedModalOpen = false + @computed get isLineChartThatTurnedIntoDiscreteBarActive(): boolean { + return ( + this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar + ) + } - @computed get isStatic(): boolean { - return this.renderToStatic || this.isExportingToSvgOrPng + @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 } - private get isStaging(): boolean { - if (typeof location === "undefined") return false - return location.host.includes("staging") + @computed get hasLineChart(): boolean { + return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.LineChart) + } + @computed get hasSlopeChart(): boolean { + return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.SlopeChart) } - private get isLocalhost(): boolean { - if (typeof location === "undefined") return false - return location.host.includes("localhost") + @computed get supportsMultipleYColumns(): boolean { + return !this.isScatter } - @computed get editUrl(): string | undefined { - if (this.showAdminControls) { - return `${this.adminBaseUrl}/admin/${ - this.manager?.editUrl ?? `charts/${this.id}/edit` - }` - } - return undefined + @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 } - /** - * 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" - ) + @computed get chartType(): GrapherChartType | undefined { + return this.validChartTypes[0] } + @observable.ref chartTab?: GrapherChartType + @observable.ref _inputTable: OwidTable = new OwidTable() - @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. + get inputTable(): OwidTable { + return this._inputTable + } - 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 + set inputTable(table: OwidTable) { + this._inputTable = table + this.appendNewEntitySelectionOptions() - return !!Cookies.get(CookieKey.isAdmin) - } catch { - return false - } + if (this.manager?.selection?.hasSelection) { + // Selection is managed externally, do nothing. + } else if (this.selection.hasSelection) { + // User has changed the selection, use theris + } else this.applyOriginalSelectionAsAuthored() } - - @computed get showAdminControls(): boolean { - return ( - this.isUserLoggedInAsAdmin || - this.isDev || - this.isLocalhost || - this.isStaging + @action.bound appendNewEntitySelectionOptions(): void { + const { selection } = this + const currentEntities = selection.availableEntityNameSet + const missingEntities = this.availableEntities.filter( + (entity) => !currentEntities.has(entity.entityName) ) + selection.addAvailableEntityNames(missingEntities) + } + @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 } - // Exclusively used for the performance.measurement API, so that DevTools can show some context - private createPerformanceMeasurement( - name: string, - startMark: number - ): void { + createPerformanceMeasurement(name: string, startMark: number): void { const endMark = performance.now() const detail = { devtools: { @@ -1138,176 +1094,63 @@ export class Grapher // 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) + } - @action.bound - async downloadLegacyDataFromOwidVariableIds( - inputTableTransformer?: ChartTableTransformer - ): Promise { - if (this.variableIds.length === 0) - // No data to download - return - - let variablesDataMap: MultipleOwidVariableDataDimensionsMap - - const startMark = performance.now() - if (this.useAdminAPI) { - // TODO grapher model: switch this to downloading multiple data and metadata files - variablesDataMap = await loadVariablesDataAdmin( - this.dataApiUrlForAdmin, - this.variableIds - ) - } else { - variablesDataMap = await loadVariablesDataSite( - this.variableIds, - this.dataApiUrl, - this.runtimeAssetMap - ) - } - this.createPerformanceMeasurement("downloadVariablesData", startMark) + // When Map becomes a first-class chart instance, we should drop this + @computed get chartInstanceExceptMap(): ChartInterface { + const chartTypeName = + this.typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart - this._receiveOwidDataAndApplySelection( - variablesDataMap, - inputTableTransformer - ) + const ChartClass = + ChartComponentClassMap.get(chartTypeName) ?? DefaultChartClass + return new ChartClass({ manager: this }) } - @action.bound receiveOwidData( - json: MultipleOwidVariableDataDimensionsMap - ): void { - // TODO grapher model: switch this to downloading multiple data and metadata files - this._receiveOwidDataAndApplySelection(json) + @computed + get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { + return this.isLineChartThatTurnedIntoDiscreteBarActive + ? GRAPHER_CHART_TYPES.DiscreteBar + : (this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart) } - @action.bound private _setInputTable( - json: MultipleOwidVariableDataDimensionsMap, - legacyConfig: Partial, - inputTableTransformer?: ChartTableTransformer - ): void { - // TODO grapher model: switch this to downloading multiple data and metadata files - - const startMark = performance.now() - const dimensions: OwidChartDimensionInterfaceWithMandatorySlug[] = - legacyConfig.dimensions?.map((dimension) => ({ - ...dimension, - slug: - dimension.slug ?? - getDimensionColumnSlug( - dimension.variableId, - dimension.targetYear - ), - })) ?? [] - const tableWithColors = legacyToOwidTableAndDimensions( - json, - dimensions, - legacyConfig.selectedEntityColors - ) - this.createPerformanceMeasurement( - "legacyToOwidTableAndDimensions", - startMark + @computed get isLineChart(): boolean { + return ( + this.chartType === GRAPHER_CHART_TYPES.LineChart || !this.chartType ) + } - if (inputTableTransformer) - this.inputTable = inputTableTransformer(tableWithColors) - else this.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.appendNewEntitySelectionOptions() - - if (this.manager?.selection?.hasSelection) { - // Selection is managed externally, do nothing. - } else if (this.selection.hasSelection) { - // User has changed the selection, use theris - } else this.applyOriginalSelectionAsAuthored() + @computed private get isSingleTimeSelectionActive(): boolean { + return ( + this.onlySingleTimeSelectionPossible || + this.isSingleTimeScatterAnimationActive + ) } - @action rebuildInputOwidTable( - inputTableTransformer?: ChartTableTransformer - ): void { - // TODO grapher model: switch this to downloading multiple data and metadata files - if (!this.legacyVariableDataJson) return - this._setInputTable( - this.legacyVariableDataJson, - this.legacyConfigAsAuthored, - inputTableTransformer + @computed private get onlySingleTimeSelectionPossible(): boolean { + return ( + this.isDiscreteBar || + this.isStackedDiscreteBar || + this.isOnMapTab || + this.isMarimekko ) } - @observable - private legacyVariableDataJson?: MultipleOwidVariableDataDimensionsMap + // #endregion GrapherProgrammaticInterface properties - @action.bound private _receiveOwidDataAndApplySelection( - json: MultipleOwidVariableDataDimensionsMap, - inputTableTransformer?: ChartTableTransformer - ): void { - this.legacyVariableDataJson = json + // #region Start TimelineManager propertes - this.rebuildInputOwidTable(inputTableTransformer) + @computed get disablePlay(): boolean { + return false } - @action.bound appendNewEntitySelectionOptions(): void { - const { selection } = this - const currentEntities = selection.availableEntityNameSet - const missingEntities = this.availableEntities.filter( - (entity) => !currentEntities.has(entity.entityName) - ) - selection.addAvailableEntityNames(missingEntities) + formatTimeFn(time: Time): string { + return this.inputTable.timeColumn.formatTime(time) } - @action.bound private applyOriginalSelectionAsAuthored(): void { - if (this.selectedEntityNames?.length) - this.selection.setSelectedEntities(this.selectedEntityNames) - } - - @action.bound private applyOriginalFocusAsAuthored(): void { - if (this.focusedSeriesNames?.length) - this.focusArray.clearAllAndAdd(...this.focusedSeriesNames) - } - - @computed get hasData(): boolean { - return this.dimensions.length > 0 || this.newSlugs.length > 0 - } - - // Ready to go iff we have retrieved data for every variable associated with the chart - @computed get isReady(): boolean { - return this.whatAreWeWaitingFor === "" - } - - @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(",")}.` - } - - // 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]) - } - - @computed private get loadingDimensions(): ChartDimension[] { - return this.dimensions.filter( - (dim) => !this.inputTable.has(dim.columnSlug) - ) - } - - @computed get isInIFrame(): boolean { - return isInIFrame() - } + @observable.ref isPlaying = false + @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished @computed get times(): Time[] { const columnSlugs = this.isOnMapTab @@ -1322,209 +1165,23 @@ export class Grapher columnSlugs ) } - - /** - * Plots time on the x-axis. - */ - @computed private get hasTimeDimension(): boolean { - return this.isStackedBar || this.isStackedArea || this.isLineChart - } - - @computed private get hasTimeDimensionButTimelineIsHidden(): boolean { - return this.hasTimeDimension && !!this.hideTimeline - } - @computed get startHandleTimeBound(): TimeBound { if (this.isSingleTimeSelectionActive) return this.endHandleTimeBound return this.timelineHandleTimeBounds[0] } - - 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, - ] - } - @computed get endHandleTimeBound(): TimeBound { return this.timelineHandleTimeBounds[1] } - @action.bound resetHandleTimeBounds(): void { - this.startHandleTimeBound = this.timelineMinTime ?? -Infinity - this.endHandleTimeBound = this.timelineMaxTime ?? Infinity - } - - // Keeps a running cache of series colors at the Grapher level. - seriesColorMap: SeriesColorMap = new Map() - - @computed get startTime(): Time | undefined { - return findClosestTime(this.times, this.startHandleTimeBound) - } - - @computed get endTime(): Time | undefined { - return findClosestTime(this.times, this.endHandleTimeBound) - } - - @computed get isSingleTimeScatterAnimationActive(): boolean { - return ( - this.isTimelineAnimationActive && - this.isOnScatterTab && - !this.isRelativeMode && - !!this.areHandlesOnSameTimeBeforeAnimation - ) - } - - @computed private get onlySingleTimeSelectionPossible(): boolean { - return ( - this.isDiscreteBar || - this.isStackedDiscreteBar || - this.isOnMapTab || - this.isMarimekko - ) - } - - @computed private get isSingleTimeSelectionActive(): boolean { - return ( - this.onlySingleTimeSelectionPossible || - this.isSingleTimeScatterAnimationActive - ) - } - - @computed get shouldLinkToOwid(): boolean { - if ( - this.isEmbeddedInAnOwidPage || - this.isExportingToSvgOrPng || - !this.isInIFrame - ) - return false - - return true - } - - @computed.struct private get variableIds(): number[] { - return uniq(this.dimensions.map((d) => d.variableId)) - } - - @computed get hasOWIDLogo(): boolean { - return ( - !this.hideLogo && (this.logo === undefined || this.logo === "owid") - ) - } - - // todo: did this name get botched in a merge? - @computed get hasFatalErrors(): boolean { - const { relatedQuestions = [] } = this - return relatedQuestions.some( - (question) => !!getErrorMessageRelatedQuestionUrl(question) - ) - } - - 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 { - this.disposers.push( - reaction( - () => this.variableIds, - () => this.downloadLegacyDataFromOwidVariableIds() - ) - ) - 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) - } - - @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) - ) - - this.dimensionSlots.forEach((slot) => { - if (!slot.allowMultiple) - validDimensions = uniqWith( - validDimensions, - ( - a: OwidChartDimensionInterface, - b: OwidChartDimensionInterface - ) => - a.property === slot.property && - a.property === b.property - ) - }) - - return validDimensions + @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?.() } - // 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 - } + // required properties @computed get timelineHandleTimeBounds(): TimeBounds { if (this.isOnMapTab) { @@ -1560,55 +1217,173 @@ export class Grapher } } - // 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) - - 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] + @computed private get hasTimeDimensionButTimelineIsHidden(): boolean { + return this.hasTimeDimension && !!this.hideTimeline } - @computed.struct get filledDimensions(): ChartDimension[] { - return this.isReady ? this.dimensions : [] + /** + * Plots time on the x-axis. + */ + @computed private get hasTimeDimension(): boolean { + return this.isStackedBar || this.isStackedArea || this.isLineChart } - @action.bound addDimension(config: OwidChartDimensionInterface): void { - this.dimensions.push(new ChartDimension(config, this)) + // #endregion TimelineManager properties + + // #region ChartManager properties + base: React.RefObject = React.createRef() + + @computed get fontSize(): number { + return this.baseFontSize } + // table defined in interface but not on Grapher - @action.bound setDimensionsForProperty( - property: DimensionProperty, - newConfigs: OwidChartDimensionInterface[] - ): void { - let newDimensions: ChartDimension[] = [] - this.dimensionSlots.forEach((slot) => { - if (slot.property === property) - newDimensions = newDimensions.concat( - newConfigs.map((config) => new ChartDimension(config, this)) - ) - else newDimensions = newDimensions.concat(slot.dimensions) - }) - this.dimensions = newDimensions + @computed get transformedTable(): OwidTable { + return this.tableAfterAllTransformsAndFilters } - @action.bound setDimensionsFromConfigs( - configs: OwidChartDimensionInterface[] - ): void { - this.dimensions = configs.map( - (config) => new ChartDimension(config, this) + @observable.ref isExportingToSvgOrPng = false + + // 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 + } + + 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 + + @computed get yAxisConfig(): Readonly { + return this.yAxis.toObject() + } + + @computed get xAxisConfig(): Readonly { + return this.xAxis.toObject() + } + + @computed get yColumnSlugs(): string[] { + return this.ySlugs + ? this.ySlugs.split(" ") + : this.dimensions + .filter((dim) => dim.property === DimensionProperty.y) + .map((dim) => dim.columnSlug) + } + + @computed get yColumnSlug(): string | undefined { + return this.ySlugs + ? this.ySlugs.split(" ")[0] + : this.getSlugForProperty(DimensionProperty.y) + } + + @computed get xColumnSlug(): string | undefined { + return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x) + } + + @computed get sizeColumnSlug(): string | undefined { + return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size) + } + + @computed get colorColumnSlug(): string | undefined { + return ( + this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color) ) } - @computed get displaySlug(): string { - return this.slug ?? slugify(this.displayTitle) + 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) } - @observable shouldIncludeDetailsInStaticExport = true + @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, + } + } + + @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 + } + + @computed get isStatic(): boolean { + return this.renderToStatic || this.isExportingToSvgOrPng + } + + @computed get isSemiNarrow(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 550 + } + + @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 + } + + @computed get shouldPinTooltipToBottom(): boolean { + return this.isNarrow && this.isTouchDevice + } // Used for superscript numbers in static exports @computed get detailsOrderedByReference(): string[] { @@ -1650,80 +1425,31 @@ export class Grapher : "none" } - // Used for static exports. Defined at this level because they need to - // be accessed by CaptionedChart and DownloadModal - @computed get detailRenderers(): MarkdownTextWrap[] { - if (typeof window === "undefined") return [] - return this.detailsOrderedByReference.map((term, i) => { - let text = `**${i + 1}.** ` - const detail: EnrichedDetail | undefined = window.details?.[term] - if (detail) { - const plainText = detail.text.map(({ value }) => - spansToUnformattedPlainText(value) - ) - plainText[0] = `**${plainText[0]}**:` - - text += `${plainText.join(" ")}` - } - - // can't use the computed property here because Grapher might not currently be in static mode - const baseFontSize = this.areStaticBoundsSmall - ? this.computeBaseFontSizeFromHeight(this.staticBounds) - : 18 - - return new MarkdownTextWrap({ - text, - fontSize: (11 / BASE_FONT_SIZE) * baseFontSize, - // leave room for padding on the left and right - maxWidth: - this.staticBounds.width - 2 * this.framePaddingHorizontal, - lineHeight: 1.2, - style: { fill: GRAPHER_LIGHT_TEXT }, - }) - }) - } - - @computed get hasProjectedData(): boolean { - return this.inputTable.numericColumnSlugs.some( - (slug) => this.inputTable.get(slug).isProjection - ) - } - - @computed get validChartTypes(): GrapherChartType[] { - const { chartTypes } = this - - // all single-chart Graphers are valid - if (chartTypes.length <= 1) return chartTypes - - // find valid combination in a pre-defined list - const validChartTypes = findValidChartTypeCombination(chartTypes) - - // if the given combination is not valid, then ignore all but the first chart type - if (!validChartTypes) return chartTypes.slice(0, 1) - - // 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] - } + // required derived properties - return validChartTypes + getSlugForProperty(property: DimensionProperty): string | undefined { + return this.dimensions.find((dim) => dim.property === property) + ?.columnSlug } - @computed get validChartTypeSet(): Set { - return new Set(this.validChartTypes) + @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 } - @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 isExportingForSocialMedia(): boolean { + return ( + this.isExportingToSvgOrPng && + this.isStaticAndSmall && + this.isSocialMediaExport + ) } - @computed get hasMultipleChartTypes(): boolean { - return this.validChartTypes.length > 1 + @computed get isTouchDevice(): boolean { + return isTouchDevice() } @computed get currentSubtitle(): string { @@ -1734,331 +1460,297 @@ export class Grapher return "" } - @computed get shouldAddEntitySuffixToTitle(): boolean { - const selectedEntityNames = this.selection.selectedEntityNames - const showEntityAnnotation = !this.hideAnnotationFieldsInTitle?.entity + @observable shouldIncludeDetailsInStaticExport = true + @computed get yColumnsFromDimensions(): CoreColumn[] { + return this.filledDimensions + .filter((dim) => dim.property === DimensionProperty.y) + .map((dim) => dim.column) + } - const seriesStrategy = - this.chartInstance.seriesStrategy || - autoDetectSeriesStrategy(this, true) + // #endregion ChartManager properties - return !!( - !this.forceHideAnnotationFieldsInTitle?.entity && - this.tab === GRAPHER_TAB_OPTIONS.chart && - (seriesStrategy !== SeriesStrategy.entity || !this.showLegend) && - selectedEntityNames.length === 1 && - (showEntityAnnotation || - this.canChangeEntity || - this.canSelectMultipleEntities) - ) - } + // #region AxisManager + // fontSize defined previously + // detailsOrderedByReference defined previously + // #endregion - @computed get shouldAddTimeSuffixToTitle(): boolean { - const showTimeAnnotation = !this.hideAnnotationFieldsInTitle?.time - return ( - !this.forceHideAnnotationFieldsInTitle?.time && - this.isReady && - (showTimeAnnotation || - (this.hasTimeline && - // chart types that refer to the current time only in the timeline - (this.isLineChartThatTurnedIntoDiscreteBar || - this.isOnDiscreteBarTab || - this.isOnStackedDiscreteBarTab || - this.isOnMarimekkoTab || - this.isOnMapTab))) + // CaptionedChartManager interface ommited (only used for testing) + + // #region SourcesModalManager props + + // 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 + + // sort y-columns by their display name + const sortedYColumnSlugs = sortBy( + yColumnSlugs, + (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title ) + + const columnSlugs = excludeUndefined([ + ...sortedYColumnSlugs, + xColumnSlug, + sizeColumnSlug, + colorColumnSlug, + ]) + + return this.inputTable + .getColumns(uniq(columnSlugs)) + .filter( + (column) => !!column.source.name || !isEmpty(column.def.origins) + ) } - @computed get shouldAddChangeInPrefixToTitle(): boolean { - const showChangeInPrefix = - !this.hideAnnotationFieldsInTitle?.changeInPrefix + @computed get showAdminControls(): boolean { return ( - !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && - (this.isOnLineChartTab || this.isOnSlopeChartTab) && - this.isRelativeMode && - showChangeInPrefix + this.isUserLoggedInAsAdmin || + this.isDev || + this.isLocalhost || + this.isStaging ) } + // isSourcesModalOpen defined previously - @computed get currentTitle(): string { - let text = this.displayTitle.trim() - if (text.length === 0) return text + @computed get frameBounds(): Bounds { + return this.useIdealBounds + ? new Bounds(0, 0, this.idealWidth, this.idealHeight) + : new Bounds(0, 0, this.availableWidth, this.availableHeight) + } - // 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}` - } + // isEmbeddedInAnOwidPage defined previously + // isNarrow defined previously + // fontSize defined previously - if (this.shouldAddEntitySuffixToTitle) { - const selectedEntityNames = this.selection.selectedEntityNames - const entityStr = selectedEntityNames[0] - if (entityStr?.length) text = appendAnnotationField(text, entityStr) + @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(",")}.` + } - if (this.shouldAddChangeInPrefixToTitle) - text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) - - if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) - text = appendAnnotationField(text, this.timeTitleSuffix) + // 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]) + } - return text.trim() + @computed private get loadingDimensions(): ChartDimension[] { + return this.dimensions.filter( + (dim) => !this.inputTable.has(dim.columnSlug) + ) } - /** - * 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 + @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. - switch (this.tab) { - // the map tab has its own `hideTimeline` option - case GRAPHER_TAB_OPTIONS.map: - return !this.map.hideTimeline + 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 - // use the chart-level `hideTimeline` option - case GRAPHER_TAB_OPTIONS.chart: - return !this.hideTimeline + return !!Cookies.get(CookieKey.isAdmin) + } catch { + return false + } + } + @computed get isDev(): boolean { + return this.initialOptions.env === "dev" + } - // 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 + private get isStaging(): boolean { + if (typeof location === "undefined") return false + return location.host.includes("staging") + } - default: - return false - } + private get isLocalhost(): boolean { + if (typeof location === "undefined") return false + return location.host.includes("localhost") } - @computed private get areHandlesOnSameTime(): boolean { - const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues - const [start, end] = this.timelineHandleTimeBounds.map((time) => - findClosestTime(times, time) + @computed private get useIdealBounds(): boolean { + const { + isEditor, + isExportingToSvgOrPng, + externalBounds, + widthForDeviceOrientation, + heightForDeviceOrientation, + isInIFrame, + isInFullScreenMode, + windowInnerWidth, + windowInnerHeight, + } = this + + // 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 + } + + // For these, defer to the bounds that are set externally + if ( + this.isEmbeddedInADataPage || + this.isEmbeddedInAnOwidPage || + this.manager || + isInIFrame ) - return start === end - } + return false - @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 the user is using interactive version and then goes to export chart, use current bounds to maintain WYSIWYG + if (isExportingToSvgOrPng) return false + + // 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 ( - !mapColumnSlug || - !this.dimensions.some((dim) => dim.columnSlug === mapColumnSlug) + externalBounds.height < heightForDeviceOrientation || + externalBounds.width < widthForDeviceOrientation ) - return this.yColumnSlug! - return mapColumnSlug - } + return false - getColumnForProperty(property: DimensionProperty): CoreColumn | undefined { - return this.dimensions.find((dim) => dim.property === property)?.column + return true } - getSlugForProperty(property: DimensionProperty): string | undefined { - return this.dimensions.find((dim) => dim.property === property) - ?.columnSlug + @computed private get widthForDeviceOrientation(): number { + return this.isPortrait ? 400 : 680 } - @computed get yColumnsFromDimensions(): CoreColumn[] { - return this.filledDimensions - .filter((dim) => dim.property === DimensionProperty.y) - .map((dim) => dim.column) + @computed private get heightForDeviceOrientation(): number { + return this.isPortrait ? 640 : 480 } - @computed get yColumnSlugs(): string[] { - return this.ySlugs - ? this.ySlugs.split(" ") - : this.dimensions - .filter((dim) => dim.property === DimensionProperty.y) - .map((dim) => dim.columnSlug) + isEditor = + typeof window !== "undefined" && (window as any).isEditor === true + + @observable _externalBounds: Bounds | undefined = undefined + /** externalBounds should be set to the available plotting area for a + Grapher that resizes itself to fit. When this area changes, + externalBounds should be updated. Updating externalBounds can + trigger a bunch of somewhat expensive recalculations so it might + be worth debouncing updates (e.g. when drag-resizing) */ + @computed get externalBounds(): Bounds { + const { _externalBounds, initialOptions } = this + return _externalBounds ?? initialOptions.bounds ?? DEFAULT_BOUNDS } - @computed get yColumnSlug(): string | undefined { - return this.ySlugs - ? this.ySlugs.split(" ")[0] - : this.getSlugForProperty(DimensionProperty.y) + set externalBounds(bounds: Bounds) { + this._externalBounds = bounds } - @computed get xColumnSlug(): string | undefined { - return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x) + @computed get isInIFrame(): boolean { + return isInIFrame() } - @computed get sizeColumnSlug(): string | undefined { - return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size) + @computed get isInFullScreenMode(): boolean { + return this._isInFullScreenMode } - @computed get colorColumnSlug(): string | undefined { + @observable.ref windowInnerWidth?: number + @observable.ref windowInnerHeight?: number + + @computed get isPortrait(): boolean { return ( - this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color) + this.externalBounds.width < this.externalBounds.height && + this.externalBounds.width < DEFAULT_GRAPHER_WIDTH ) } - @computed get yScaleType(): ScaleType | undefined { - return this.yAxis.scaleType + @observable.ref _isInFullScreenMode = false + @computed private get idealWidth(): number { + return Math.floor(this.widthForDeviceOrientation * this.scaleToFitIdeal) } - @computed get xScaleType(): ScaleType | undefined { - return this.xAxis.scaleType + @computed private get idealHeight(): number { + return Math.floor( + this.heightForDeviceOrientation * this.scaleToFitIdeal + ) } + @computed private get availableWidth(): number { + const { + externalBounds, + isInFullScreenMode, + windowInnerWidth, + fullScreenPadding, + } = this - @computed private get timeTitleSuffix(): string | undefined { - const timeColumn = this.table.timeColumn - if (timeColumn.isMissing) return undefined // Do not show year until data is loaded - const { startTime, endTime } = this - if (startTime === undefined || endTime === undefined) return undefined - - const time = - startTime === endTime - ? timeColumn.formatValue(startTime) - : timeColumn.formatValue(startTime) + - " to " + - timeColumn.formatValue(endTime) - - return time - } - - @computed get sourcesLine(): string { - return this.sourceDesc ?? this.defaultSourcesLine - } - - // Columns that are used as a dimension in the currently active view - @computed get activeColumnSlugs(): string[] { - const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = - this - - // sort y columns by their display name - const sortedYColumnSlugs = sortBy( - yColumnSlugs, - (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + return Math.floor( + isInFullScreenMode + ? windowInnerWidth! - 2 * fullScreenPadding + : externalBounds.width ) - - return excludeUndefined([ - ...sortedYColumnSlugs, - xColumnSlug, - sizeColumnSlug, - colorColumnSlug, - ]) } - @computed get columnsWithSourcesExtensive(): CoreColumn[] { - const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = - this + @computed private get availableHeight(): number { + const { + externalBounds, + isInFullScreenMode, + windowInnerHeight, + fullScreenPadding, + } = this - // sort y-columns by their display name - const sortedYColumnSlugs = sortBy( - yColumnSlugs, - (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + return Math.floor( + isInFullScreenMode + ? windowInnerHeight! - 2 * fullScreenPadding + : externalBounds.height ) - - const columnSlugs = excludeUndefined([ - ...sortedYColumnSlugs, - xColumnSlug, - sizeColumnSlug, - colorColumnSlug, - ]) - - return this.inputTable - .getColumns(uniq(columnSlugs)) - .filter( - (column) => !!column.source.name || !isEmpty(column.def.origins) - ) } - getColumnSlugsForCondensedSources(): string[] { - const { xColumnSlug, sizeColumnSlug, colorColumnSlug, isMarimekko } = - this - const columnSlugs: string[] = [] - - // exclude "Countries Continent" if it's used as the color dimension in a scatter plot, slope chart etc. - if ( - colorColumnSlug !== undefined && - !isContinentsVariableId(colorColumnSlug) + // 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 ) - columnSlugs.push(colorColumnSlug) - - if (xColumnSlug !== undefined) { - const xColumn = this.inputTable.get(xColumnSlug) - .def as OwidColumnDef - // exclude population variable if it's used as the x dimension in a marimekko - if ( - !isMarimekko || - !isPopulationVariableETLPath(xColumn?.catalogPath ?? "") - ) - columnSlugs.push(xColumnSlug) - } - - // exclude population variable if it's used as the size dimension in a scatter plot - if (sizeColumnSlug !== undefined) { - const sizeColumn = this.inputTable.get(sizeColumnSlug) - .def as OwidColumnDef - if (!isPopulationVariableETLPath(sizeColumn?.catalogPath ?? "")) - columnSlugs.push(sizeColumnSlug) - } - return columnSlugs } - @computed get columnsWithSourcesCondensed(): CoreColumn[] { - const { yColumnSlugs } = this - - const columnSlugs = [...yColumnSlugs] - columnSlugs.push(...this.getColumnSlugsForCondensedSources()) - - return this.inputTable - .getColumns(uniq(columnSlugs)) - .filter( - (column) => !!column.source.name || !isEmpty(column.def.origins) - ) + @computed private get fullScreenPadding(): number { + const { windowInnerWidth } = this + if (!windowInnerWidth) return 0 + return windowInnerWidth < 940 ? 0 : 40 } - @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] - } - } - ) - - const uniqueAttributions = uniq(compact(attributions)) - - if (uniqueAttributions.length > 3) - return `${uniqueAttributions[0]} and other sources` + // #endregion - return uniqueAttributions.join("; ") + // #region DownloadModalManager + @computed get displaySlug(): string { + return this.slug ?? slugify(this.displayTitle) } - @computed private get axisDimensions(): ChartDimension[] { - return this.filledDimensions.filter( - (dim) => - dim.property === DimensionProperty.y || - dim.property === DimensionProperty.x - ) - } + rasterize(): Promise { + const { width, height } = this.staticBoundsWithDetails + const staticSVG = this.generateStaticSvg() - // todo: remove when we remove dimensions - @computed get yColumnsFromDimensionsOrSlugsOrAuto(): CoreColumn[] { - return this.yColumnsFromDimensions.length - ? this.yColumnsFromDimensions - : this.table.getColumns(autoDetectYColumnSlugs(this)) + 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 @@ -2089,1770 +1781,2203 @@ export class Grapher .join(", ") } - @computed get displayTitle(): string { - if (this.title) return this.title - if (this.isReady) return this.defaultTitle - return "" - } - - // Returns an object ready to be serialized to JSON - @computed get object(): GrapherInterface { - return this.toObject() + @computed private get axisDimensions(): ChartDimension[] { + return this.filledDimensions.filter( + (dim) => + dim.property === DimensionProperty.y || + dim.property === DimensionProperty.x + ) } - @computed - get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { - return this.isLineChartThatTurnedIntoDiscreteBarActive - ? GRAPHER_CHART_TYPES.DiscreteBar - : (this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart) + @computed get hasMultipleYColumns(): boolean { + return this.yColumnSlugs.length > 1 } - @computed get isLineChart(): boolean { - return ( - this.chartType === GRAPHER_CHART_TYPES.LineChart || !this.chartType + generateStaticSvg(): string { + const _isExportingToSvgOrPng = this.isExportingToSvgOrPng + this.isExportingToSvgOrPng = true + const staticSvg = ReactDOMServer.renderToStaticMarkup( + ) - } - @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 + this.isExportingToSvgOrPng = _isExportingToSvgOrPng + return staticSvg } - @computed get isLineChartThatTurnedIntoDiscreteBar(): boolean { - if (!this.isLineChart) return false + // staticBounds defined previously - let { minTime, maxTime } = this + @computed get staticBoundsWithDetails(): Bounds { + const includeDetails = + this.shouldIncludeDetailsInStaticExport && + !isEmpty(this.detailRenderers) - // 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 + let height = this.staticBounds.height + if (includeDetails) { + height += + 2 * this.framePaddingVertical + + sumTextWrapHeights( + this.detailRenderers, + STATIC_EXPORT_DETAIL_SPACING + ) } - // This is the easy case: minTime and maxTime are the same, no need to do - // more fancy checks - if (minTime === maxTime) return true + return new Bounds(0, 0, this.staticBounds.width, height) + } - // 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 isLineChartThatTurnedIntoDiscreteBarActive(): boolean { - return ( - this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar - ) + @computed get staticFormat(): GrapherStaticFormat { + return this._staticFormat } - @computed get isOnLineChartTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.LineChart - } - @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 baseUrl(): string | undefined { + return this.isPublished + ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}` + : undefined } + // queryStr defined previously + // table defined previously + // transformedTable defined previously - @computed get hasLineChart(): boolean { - return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.LineChart) - } - @computed get hasSlopeChart(): boolean { - return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.SlopeChart) + // 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 get supportsMultipleYColumns(): boolean { - return !this.isScatter - } + @computed get captionedChartBounds(): Bounds { + // if there's no panel, the chart takes up the whole frame + if (!this.isEntitySelectorPanelActive) return this.frameBounds - @computed private get xDimension(): ChartDimension | undefined { - return this.filledDimensions.find( - (d) => d.property === DimensionProperty.x + 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 ) } - // 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 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 - // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class? - set xOverrideTime(value: number | undefined) { - this.xDimension!.targetYear = value - } + // sort y columns by their display name + const sortedYColumnSlugs = sortBy( + yColumnSlugs, + (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + ) - @computed get defaultBounds(): Bounds { - return new Bounds(0, 0, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT) + return excludeUndefined([ + ...sortedYColumnSlugs, + xColumnSlug, + sizeColumnSlug, + colorColumnSlug, + ]) } - @computed get hasYDimension(): boolean { - return this.dimensions.some((d) => d.property === DimensionProperty.y) + @computed get isEntitySelectorPanelActive(): boolean { + // console.log("isEntitySelectorPanelActive", { + // hideEntityControls: this.hideEntityControls, + // canChangeAddOrHighlightEntities: + // this.canChangeAddOrHighlightEntities, + // isOnChartTab: this.isOnChartTab, + // showEntitySelectorAs: this.showEntitySelectorAs, + // }) + return ( + !this.hideEntityControls && + this.canChangeAddOrHighlightEntities && + this.isOnChartTab && + this.showEntitySelectorAs === GrapherWindowType.panel + ) } - @observable.ref private _staticFormat = GrapherStaticFormat.landscape + private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL + private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL - @computed get staticFormat(): GrapherStaticFormat { - if (this.props.staticFormat) return this.props.staticFormat - return this._staticFormat + @computed get showEntitySelectorAs(): GrapherWindowType { + // console.log("showEntitySelectorAs", { + // isEmbeddedInAnOwidPage: this.isEmbeddedInAnOwidPage, + // isEmbeddedInADataPage: this.isEmbeddedInADataPage, + // isSemiNarrow: this.isSemiNarrow, + // isInFullScreenMode: this.isInFullScreenMode, + // frameBounds: this.frameBounds, + // isIFrame: this.isInIFrame, + // }) + 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 } - set staticFormat(format: GrapherStaticFormat) { - this._staticFormat = format + // 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 + } } - 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 + @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 + } } } - @computed get staticBounds(): Bounds { - if (this.props.staticBounds) return this.props.staticBounds - return this.getStaticBounds(this.staticFormat) + // Used for static exports. Defined at this level because they need to + // be accessed by CaptionedChart and DownloadModal + @computed get detailRenderers(): MarkdownTextWrap[] { + if (typeof window === "undefined") return [] + return this.detailsOrderedByReference.map((term, i) => { + let text = `**${i + 1}.** ` + const detail: EnrichedDetail | undefined = window.details?.[term] + if (detail) { + const plainText = detail.text.map(({ value }) => + spansToUnformattedPlainText(value) + ) + plainText[0] = `**${plainText[0]}**:` + + text += `${plainText.join(" ")}` + } + + // can't use the computed property here because Grapher might not currently be in static mode + const baseFontSize = this.areStaticBoundsSmall + ? this.computeBaseFontSizeFromHeight(this.staticBounds) + : 18 + + return new MarkdownTextWrap({ + text, + fontSize: (11 / BASE_FONT_SIZE) * baseFontSize, + // leave room for padding on the left and right + maxWidth: + this.staticBounds.width - 2 * this.framePaddingHorizontal, + lineHeight: 1.2, + style: { + fill: GRAPHER_LIGHT_TEXT, + }, + }) + }) } - generateStaticSvg(): string { - const _isExportingToSvgOrPng = this.isExportingToSvgOrPng - this.isExportingToSvgOrPng = true - const staticSvg = ReactDOMServer.renderToStaticMarkup( - + @computed private get areHandlesOnSameTime(): boolean { + const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues + const [start, end] = this.timelineHandleTimeBounds.map((time) => + findClosestTime(times, time) ) - this.isExportingToSvgOrPng = _isExportingToSvgOrPng - return staticSvg + return start === end } - get staticSVG(): string { - return this.generateStaticSvg() + set startHandleTimeBound(newValue: TimeBound) { + if (this.isSingleTimeSelectionActive) + this.timelineHandleTimeBounds = [newValue, newValue] + else + this.timelineHandleTimeBounds = [ + newValue, + this.timelineHandleTimeBounds[1], + ] } - @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 new Bounds(0, 0, this.staticBounds.width, height) + set endHandleTimeBound(newValue: TimeBound) { + if (this.isSingleTimeSelectionActive) + this.timelineHandleTimeBounds = [newValue, newValue] + else + this.timelineHandleTimeBounds = [ + this.timelineHandleTimeBounds[0], + newValue, + ] } - rasterize(): Promise { - const { width, height } = this.staticBoundsWithDetails - const staticSVG = this.generateStaticSvg() + // #endregion - return new StaticChartRasterizer(staticSVG, width, height).render() - } + // #region DiscreteBarChartManager props - @computed get disableIntroAnimation(): boolean { - return this.isStatic - } + // showYearLabels defined previously + // endTime defined previously - @computed get mapConfig(): MapConfig { - return this.map + @computed get isOnLineChartTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.LineChart } + // #endregion - @computed get cacheTag(): string { - return this.version.toString() - } + // LegacyDimensionsManager omitted (only defines table) - @computed get mapIsClickable(): boolean { - return ( - this.hasChartTab && - (this.hasLineChart || this.isScatter) && - !isMobile() - ) - } + // #region ShareMenuManager props + // slug 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 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() } - // 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 + // 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) ) - return false - return this.stackMode === StackMode.relative } - @computed get canToggleRelativeMode(): boolean { - const { - isOnLineChartTab, - isOnSlopeChartTab, - hideRelativeToggle, - areHandlesOnSameTime, - yScaleType, - hasSingleEntityInFacets, - hasSingleMetricInFacets, - xColumnSlug, - isOnMarimekkoTab, - isStackedChartSplitByMetric, - } = this + @computed get editUrl(): string | undefined { + if (this.showAdminControls) { + return `${this.adminBaseUrl}/admin/${ + this.manager?.editUrl ?? `charts/${this.id}/edit` + }` + } + return undefined + } + // isEmbedModalOpen defined previously - if (isOnLineChartTab || isOnSlopeChartTab) - return ( - !hideRelativeToggle && - !areHandlesOnSameTime && - yScaleType !== ScaleType.log - ) + // required derived properties - // actually trying to exclude relative mode with just one metric or entity - if ( - hasSingleEntityInFacets || - hasSingleMetricInFacets || - isStackedChartSplitByMetric - ) - return false + @computed get shouldAddEntitySuffixToTitle(): boolean { + const selectedEntityNames = this.selection.selectedEntityNames + const showEntityAnnotation = !this.hideAnnotationFieldsInTitle?.entity - if (isOnMarimekkoTab && xColumnSlug === undefined) return false - return !hideRelativeToggle - } + const seriesStrategy = + this.chartInstance.seriesStrategy || + autoDetectSeriesStrategy(this, true) - // 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)) + return !!( + !this.forceHideAnnotationFieldsInTitle?.entity && + this.tab === GRAPHER_TAB_OPTIONS.chart && + (seriesStrategy !== SeriesStrategy.entity || !this.showLegend) && + selectedEntityNames.length === 1 && + (showEntityAnnotation || + this.canChangeEntity || + this.canSelectMultipleEntities) + ) } - static renderGrapherIntoContainer( - config: GrapherProgrammaticInterface, - containerNode: Element - ): React.RefObject { - const grapherInstanceRef = React.createRef() + @computed get shouldAddChangeInPrefixToTitle(): boolean { + const showChangeInPrefix = + !this.hideAnnotationFieldsInTitle?.changeInPrefix + return ( + !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && + (this.isOnLineChartTab || this.isOnSlopeChartTab) && + this.isRelativeMode && + showChangeInPrefix + ) + } - const setBoundsFromContainerAndRender = ( - entries: ResizeObserverEntry[] - ): void => { - const entry = entries?.[0] // We always observe exactly one element - if (!entry) - throw new Error( - "Couldn't resize grapher, expected exactly one ResizeObserverEntry" - ) + @computed get canonicalUrlIfIsChartView(): string | undefined { + if (!this.chartViewInfo) return undefined - // Don't bother rendering if the container is hidden - // see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent - if ((entry.target as HTMLElement).offsetParent === null) return + const { parentChartSlug, queryParamsForParentChart } = + this.chartViewInfo - const props: GrapherProgrammaticInterface = { - ...config, - bounds: Bounds.fromRect(entry.contentRect), - } - ReactDOM.render( - - - , - containerNode - ) + const combinedQueryParams = { + ...queryParamsForParentChart, + ...this.changedParams, } - if (typeof window !== "undefined" && "ResizeObserver" in window) { - const resizeObserver = new ResizeObserver( - // Use a leading debounce to render immediately upon first load, and also immediately upon orientation change on mobile - debounce(setBoundsFromContainerAndRender, 400, { - leading: true, - }) - ) - resizeObserver.observe(containerNode) - } else if ( - typeof window === "object" && - typeof document === "object" && - !navigator.userAgent.includes("jsdom") - ) { - // only show the warning when we're in something that roughly resembles a browser - console.warn( - "ResizeObserver not available; grapher will not be able to render" - ) - } + return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr( + combinedQueryParams + )}` + } + @computed get shouldAddTimeSuffixToTitle(): boolean { + const showTimeAnnotation = !this.hideAnnotationFieldsInTitle?.time + return ( + !this.forceHideAnnotationFieldsInTitle?.time && + this.isReady && + (showTimeAnnotation || + (this.hasTimeline && + // chart types that refer to the current time only in the timeline + (this.isLineChartThatTurnedIntoDiscreteBar || + this.isOnDiscreteBarTab || + this.isOnStackedDiscreteBarTab || + this.isOnMarimekkoTab || + this.isOnMapTab))) + ) + } - return grapherInstanceRef + @computed private get timeTitleSuffix(): string | undefined { + const timeColumn = this.table.timeColumn + if (timeColumn.isMissing) return undefined // Do not show year until data is loaded + const { startTime, endTime } = this + if (startTime === undefined || endTime === undefined) return undefined + + const time = + startTime === endTime + ? timeColumn.formatValue(startTime) + : timeColumn.formatValue(startTime) + + " to " + + timeColumn.formatValue(endTime) + + return time } - static renderSingleGrapherOnGrapherPage( - jsonConfig: GrapherInterface, - { runtimeAssetMap }: { runtimeAssetMap?: AssetMap } = {} - ): void { - const container = document.getElementsByTagName("figure")[0] - try { - Grapher.renderGrapherIntoContainer( - { - ...jsonConfig, - bindUrlToWindow: true, - enableKeyboardShortcuts: true, - queryStr: window.location.search, - runtimeAssetMap, - }, - container - ) - } catch (err) { - container.innerHTML = `

Unable to load interactive visualization

` - container.setAttribute("id", "fallback") - throw err + // #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 isMobile(): boolean { - return isMobile() + @computed get embedDialogAdditionalElements(): + | React.ReactElement + | undefined { + return this.manager?.embedDialogAdditionalElements } + // isEmbedModalOpen defined previously + // frameBounds defined previously + // #endregion - @computed get isTouchDevice(): boolean { - return isTouchDevice() + // TooltipManager omitted (only defines tooltip) + + // #region DataTableManager props + // table defined previously + // table that is used for display in the table tab + @computed private get showsAllEntitiesInChart(): boolean { + return this.isScatter || this.isMarimekko } - @computed private get externalBounds(): Bounds { - return this.props.bounds ?? DEFAULT_BOUNDS + @computed private get settingsMenu(): SettingsMenu { + return new SettingsMenu({ manager: this, top: 0, bottom: 0, right: 0 }) } - @computed private get isPortrait(): boolean { + /** + * If the table filter toggle isn't offered, then we default to + * to showing only the selected entities – unless there is a view + * that displays all data points, like a map or a scatter plot. + */ + @computed get forceShowSelectionOnlyInDataTable(): boolean { return ( - this.externalBounds.width < this.externalBounds.height && - this.externalBounds.width < DEFAULT_GRAPHER_WIDTH + !this.settingsMenu.showTableFilterToggle && + this.hasChartTab && + !this.showsAllEntitiesInChart && + !this.hasMapTab ) } - @computed private get widthForDeviceOrientation(): number { - return this.isPortrait ? 400 : 680 - } + @computed get tableForDisplay(): OwidTable { + let table = this.table - @computed private get heightForDeviceOrientation(): number { - return this.isPortrait ? 640 : 480 - } + if (!this.isReady || !this.isOnTableTab) return table - @computed private get useIdealBounds(): boolean { - const { - isEditor, - isExportingToSvgOrPng, - externalBounds, - widthForDeviceOrientation, - heightForDeviceOrientation, - isInIFrame, - isInFullScreenMode, - windowInnerWidth, - windowInnerHeight, - } = this + if (this.chartInstance.transformTableForDisplay) { + table = this.chartInstance.transformTableForDisplay(table) + } - // 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 + if ( + this.forceShowSelectionOnlyInDataTable || + this.showSelectionOnlyInDataTable + ) { + table = table.filterByEntityNames( + this.selection.selectedEntityNames ) - return true - return false } - // For these, defer to the bounds that are set externally - if ( - this.isEmbeddedInADataPage || - this.isEmbeddedInAnOwidPage || - this.props.manager || - isInIFrame - ) - return false + return table + } + // entityType defined previously + // endTime defined previously + // startTime defined previously - // If the user is using interactive version and then goes to export chart, use current bounds to maintain WYSIWYG - if (isExportingToSvgOrPng) return false + @computed get dataTableSlugs(): ColumnSlug[] { + return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs + } - // 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 + @observable.ref showSelectionOnlyInDataTable?: boolean = undefined - // If the available space is very small, we use all of the space given to us - if ( - externalBounds.height < heightForDeviceOrientation || - externalBounds.width < widthForDeviceOrientation - ) - return false + @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 true + // 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 - // 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 canChangeAddOrHighlightEntities(): boolean { + return ( + this.canChangeEntity || + this.canAddEntities || + this.canHighlightEntities ) } - - @computed private get fullScreenPadding(): number { - const { windowInnerWidth } = this - if (!windowInnerWidth) return 0 - return windowInnerWidth < 940 ? 0 : 40 + // hasMapTab defined previously + @computed get hasChartTab(): boolean { + return this.validChartTypes.length > 0 } + @computed get validChartTypes(): GrapherChartType[] { + const { chartTypes } = this - @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 - } + // all single-chart Graphers are valid + if (chartTypes.length <= 1) return chartTypes - @computed private get availableWidth(): number { - const { - externalBounds, - isInFullScreenMode, - windowInnerWidth, - fullScreenPadding, - } = this + // find valid combination in a pre-defined list + const validChartTypes = findValidChartTypeCombination(chartTypes) - return Math.floor( - isInFullScreenMode - ? windowInnerWidth! - 2 * fullScreenPadding - : externalBounds.width - ) - } + // if the given combination is not valid, then ignore all but the first chart type + if (!validChartTypes) return chartTypes.slice(0, 1) - @computed private get availableHeight(): number { - const { - externalBounds, - isInFullScreenMode, - windowInnerHeight, - fullScreenPadding, - } = this + // 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 Math.floor( - isInFullScreenMode - ? windowInnerHeight! - 2 * fullScreenPadding - : externalBounds.height - ) + return validChartTypes } - @computed private get idealWidth(): number { - return Math.floor(this.widthForDeviceOrientation * this.scaleToFitIdeal) + @computed get validChartTypeSet(): Set { + return new Set(this.validChartTypes) } - @computed private get idealHeight(): number { - return Math.floor( - this.heightForDeviceOrientation * this.scaleToFitIdeal - ) + @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 frameBounds(): Bounds { - return this.useIdealBounds - ? new Bounds(0, 0, this.idealWidth, this.idealHeight) - : new Bounds(0, 0, this.availableWidth, this.availableHeight) + @computed get hasMultipleChartTypes(): boolean { + return this.validChartTypes.length > 1 } - @computed get captionedChartBounds(): Bounds { - // if there's no panel, the chart takes up the whole frame - if (!this.isEntitySelectorPanelActive) return this.frameBounds + // required derived properties - 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 get canAddEntities(): boolean { + return ( + this.hasChartTab && + this.canSelectMultipleEntities && + (this.isOnLineChartTab || + this.isOnSlopeChartTab || + this.isOnStackedAreaTab || + this.isOnStackedBarTab || + this.isOnDiscreteBarTab || + this.isOnStackedDiscreteBarTab) ) } - @computed get sidePanelBounds(): Bounds | undefined { - if (!this.isEntitySelectorPanelActive) return - - 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 + @computed get hasProjectedData(): boolean { + return this.inputTable.numericColumnSlugs.some( + (slug) => this.inputTable.get(slug).isProjection ) } - base: React.RefObject = React.createRef() + // #endregion DataTableManager props - @computed get containerElement(): HTMLDivElement | undefined { - return this.base.current || undefined + // #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 } + // tableAfterAuthorTimelineAndActiveChartTransform defined below (together with other table transforms) - private hasLoggedGAViewEvent = false - @observable private hasBeenVisible = false - @observable private uncaughtError?: Error + /** + * 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 - @action.bound setError(err: Error): void { - this.uncaughtError = err - } + switch (this.tab) { + // the map tab has its own `hideTimeline` option + case GRAPHER_TAB_OPTIONS.map: + return !this.map.hideTimeline - @action.bound clearErrors(): void { - this.uncaughtError = undefined - } + // use the chart-level `hideTimeline` option + case GRAPHER_TAB_OPTIONS.chart: + return !this.hideTimeline - private get commandPalette(): React.ReactElement | null { - return this.props.enableKeyboardShortcuts ? ( - - ) : null - } + // 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 - formatTimeFn(time: Time): string { - return this.inputTable.timeColumn.formatTime(time) + default: + return false + } } - @action.bound private toggleTabCommand(): void { - this.setTab(next(this.availableTabs, this.activeTab)) + @computed get isModalOpen(): boolean { + return ( + this.isEntitySelectorModalOpen || + this.isSourcesModalOpen || + this.isEmbedModalOpen || + this.isDownloadModalOpen + ) } - @action.bound private togglePlayingCommand(): void { - void this.timelineController.togglePlay() + @computed get isSingleTimeScatterAnimationActive(): boolean { + return ( + this.isTimelineAnimationActive && + this.isOnScatterTab && + !this.isRelativeMode && + !!this.areHandlesOnSameTimeBeforeAnimation + ) } - @computed get availableEntities(): Entity[] { - return this.tableForSelection.availableEntities + @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 } - private get keyboardShortcuts(): Command[] { - const temporaryFacetTestCommands = range(0, 10).map((num) => { - return { - combo: `${num}`, - fn: (): void => this.randomSelection(num), - } - }) - const shortcuts = [ - ...temporaryFacetTestCommands, - { - combo: "t", - fn: (): void => this.toggleTabCommand(), - title: "Toggle tab", - category: "Navigation", - }, - { - combo: "?", - fn: (): void => CommandPalette.togglePalette(), - title: `Toggle Help`, - category: "Navigation", - }, - { - combo: "a", - fn: (): void => { - if (this.selection.hasSelection) { - this.selection.clearSelection() - this.focusArray.clear() - } else { - this.selection.selectAll() - } - }, - title: this.selection.hasSelection - ? `Select None` - : `Select All`, - category: "Selection", - }, - { - combo: "f", - fn: (): void => { - this.hideFacetControl = !this.hideFacetControl - }, - title: `Toggle Faceting`, - category: "Chart", - }, - { - combo: "p", - fn: (): void => this.togglePlayingCommand(), - title: this.isPlaying ? `Pause` : `Play`, - category: "Timeline", - }, - { - combo: "l", - fn: (): void => this.toggleYScaleTypeCommand(), - title: "Toggle Y log/linear", - category: "Chart", - }, - { - combo: "w", - fn: (): void => this.toggleFullScreenMode(), - title: `Toggle full-screen mode`, - category: "Chart", - }, - { - combo: "s", - fn: (): void => { - this.isSourcesModalOpen = !this.isSourcesModalOpen - }, - title: `Toggle sources modal`, - category: "Chart", - }, - { - combo: "d", - fn: (): void => { - this.isDownloadModalOpen = !this.isDownloadModalOpen - }, - title: "Toggle download modal", - category: "Chart", - }, - { - combo: "esc", - fn: (): void => this.clearErrors(), - }, - { - combo: "z", - fn: (): void => this.toggleTimelineCommand(), - title: "Latest/Earliest/All period", - category: "Timeline", - }, - { - combo: "shift+o", - fn: (): void => this.clearQueryParams(), - title: "Reset to original", - category: "Navigation", - }, - ] - - if (this.slideShow) { - const slideShow = this.slideShow - shortcuts.push({ - combo: "right", - fn: () => slideShow.playNext(), - title: "Next chart", - category: "Browse", - }) - shortcuts.push({ - combo: "left", - fn: () => slideShow.playPrevious(), - title: "Previous chart", - category: "Browse", - }) - } + // required derived properties - return shortcuts + @computed get xDimension(): ChartDimension | undefined { + return this.filledDimensions.find( + (d) => d.property === DimensionProperty.x + ) } - @observable slideShow?: SlideShowController - - @action.bound private toggleTimelineCommand(): void { - // Todo: add tests for this - this.setTimeFromTimeQueryParam( - next(["latest", "earliest", ".."], this.timeParam!) + @computed get isEntitySelectorModalOpen(): boolean { + return ( + this.isEntitySelectorModalOrDrawerOpen && + this.showEntitySelectorAs === GrapherWindowType.modal ) } - @action.bound private toggleYScaleTypeCommand(): void { - this.yAxis.scaleType = next( - [ScaleType.linear, ScaleType.log], - this.yAxis.scaleType + @computed get isEntitySelectorDrawerOpen(): boolean { + return ( + this.isEntitySelectorModalOrDrawerOpen && + this.showEntitySelectorAs === GrapherWindowType.drawer ) } - @computed get _sortConfig(): Readonly { - return { - sortBy: this.sortBy ?? SortBy.total, - sortOrder: this.sortOrder ?? SortOrder.desc, - sortColumnSlug: this.sortColumnSlug, - } - } + @observable.ref isSourcesModalOpen = false + @observable.ref isDownloadModalOpen = false + @observable.ref isEmbedModalOpen = false - @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 - } + // #endregion ScatterPlotManager props - @computed get hasMultipleYColumns(): boolean { - return this.yColumnSlugs.length > 1 - } + // #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 private get hasSingleMetricInFacets(): boolean { - const { - isOnStackedDiscreteBarTab, - isOnStackedAreaTab, - isOnStackedBarTab, - selectedFacetStrategy, - hasMultipleYColumns, - } = this + // #region FacetChartManager - if (isOnStackedDiscreteBarTab) { - return ( - selectedFacetStrategy === FacetStrategy.entity || - selectedFacetStrategy === FacetStrategy.metric - ) - } + @computed get canSelectMultipleEntities(): boolean { + if (this.numSelectableEntityNames < 2) return false + if (this.addCountryMode === EntitySelectionMode.MultipleEntities) + return true - if (isOnStackedAreaTab || isOnStackedBarTab) { - return ( - selectedFacetStrategy === FacetStrategy.entity && - !hasMultipleYColumns - ) - } + // if the chart is currently faceted by entity, then use multi-entity + // selection, even if the author specified single-entity selection + if ( + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.facetStrategy === FacetStrategy.entity + ) + return true return false } - @computed private get hasSingleEntityInFacets(): boolean { - const { - isOnStackedAreaTab, - isOnStackedBarTab, - selectedFacetStrategy, - selection, - } = this + // #endregion - if (isOnStackedAreaTab || isOnStackedBarTab) { - return ( - selectedFacetStrategy === FacetStrategy.metric && - selection.numSelectedEntities === 1 - ) - } + // #region EntitySelectorModalManager - return false - } + @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 - // TODO: remove once #2136 is fixed - // issue #2136 describes a serious bug that relates to relative mode and - // affects all stacked area/bar charts that are split by metric. for now, - // we simply turn off relative mode in such cases. once the bug is properly - // addressed, this computed property and its references can be removed - @computed - private get isStackedChartSplitByMetric(): boolean { + @observable.ref isEntitySelectorModalOrDrawerOpen = false + + @computed get canChangeEntity(): boolean { return ( - (this.isOnStackedAreaTab || this.isOnStackedBarTab) && - this.selectedFacetStrategy === FacetStrategy.metric + this.hasChartTab && + !this.isOnScatterTab && + !this.canSelectMultipleEntities && + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.numSelectableEntityNames > 1 ) } - @computed get availableFacetStrategies(): FacetStrategy[] { - return this.chartInstance.availableFacetStrategies?.length - ? this.chartInstance.availableFacetStrategies - : [FacetStrategy.none] + @computed get canHighlightEntities(): boolean { + return ( + this.hasChartTab && + this.addCountryMode !== EntitySelectionMode.Disabled && + this.numSelectableEntityNames > 1 && + !this.canAddEntities && + !this.canChangeEntity + ) } - // 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 + focusArray = new FocusArray() - if ( - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.selection.selectedEntityNames.length > 1 - ) { - return FacetStrategy.entity - } + // frameBounds defined previously - return firstOfNonEmptyArray(this.availableFacetStrategies) - } + // required derived properties - set facetStrategy(facet: FacetStrategy) { - this.selectedFacetStrategy = facet + // 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 get isFaceted(): boolean { - const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none - return this.isOnChartTab && hasFacetStrategy - } + // #endregion - @action.bound randomSelection(num: number): void { - // Continent, Population, GDP PC, GDP, PopDens, UN, Language, etc. - this.clearErrors() - const currentSelection = this.selection.selectedEntityNames.length - const newNum = num ? num : currentSelection ? currentSelection * 2 : 10 - this.selection.setSelectedEntities( - sampleFrom(this.selection.availableEntityNames, newNum, Date.now()) - ) - } + // #region SettingsMenuManager - @computed get isInFullScreenMode(): boolean { - return this._isInFullScreenMode + // 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" } - set isInFullScreenMode(newValue: boolean) { - // prevent scrolling when in full-screen mode - if (newValue) { - document.documentElement.classList.add("no-scroll") - } else { - document.documentElement.classList.remove("no-scroll") - } + // showNoDataArea defined previously - // dismiss the share menu - this.isShareMenuActive = false + // facetStrategy defined previously + // yAxis defined previously + // zoomToSelection defined previously + // showSelectedEntitiesOnly defined previously + // entityTypePlural defined previously - this._isInFullScreenMode = newValue + @computed get availableFacetStrategies(): FacetStrategy[] { + return this.chartInstance.availableFacetStrategies?.length + ? this.chartInstance.availableFacetStrategies + : [FacetStrategy.none] } - @action.bound toggleFullScreenMode(): void { - this.isInFullScreenMode = !this.isInFullScreenMode - } + // 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 - @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 - this.isShareMenuActive = false - } else { - this.isInFullScreenMode = false - } + @computed get activeChartType(): GrapherChartType | undefined { + if (!this.isOnChartTab) return undefined + return this.activeTab as GrapherChartType } - @computed get isModalOpen(): boolean { - return ( - this.isEntitySelectorModalOpen || - this.isSourcesModalOpen || - this.isEmbedModalOpen || - this.isDownloadModalOpen + // 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 } - private renderError(): React.ReactElement { - return ( -
+ // selection defined previously + // canChangeAddOrHighlightEntities defined previously + + @computed.struct get filledDimensions(): ChartDimension[] { + return this.isReady ? this.dimensions : [] + } + + // 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 + + 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 + + if (isOnMarimekkoTab && xColumnSlug === undefined) return false + return !hideRelativeToggle } - 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, - }) + @computed get isOnChartTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.chart + } + + @computed get isOnMapTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.map + } - const activeBounds = this.renderToStatic - ? this.staticBounds - : this.frameBounds + @computed get isOnTableTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.table + } - const containerStyle = { - width: activeBounds.width, - height: activeBounds.height, - fontSize: this.isExportingToSvgOrPng - ? 18 - : Math.min(16, this.fontSize), // cap font size at 16px - } + // yAxis defined previously + // xAxis defined previously + // compareEndPointsOnly defined previously - return ( -
- {this.commandPalette} - {this.uncaughtError ? this.renderError() : this.renderReady()} -
+ // 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 + } + + 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 - 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 + @computed private get hasSingleMetricInFacets(): boolean { + const { + isOnStackedDiscreteBarTab, + isOnStackedAreaTab, + isOnStackedBarTab, + selectedFacetStrategy, + hasMultipleYColumns, + } = this - if (this.isInFullScreenMode) { + if (isOnStackedDiscreteBarTab) { return ( - - {this.renderGrapherComponent()} - + selectedFacetStrategy === FacetStrategy.entity || + selectedFacetStrategy === FacetStrategy.metric ) } - return this.renderGrapherComponent() + if (isOnStackedAreaTab || isOnStackedBarTab) { + return ( + selectedFacetStrategy === FacetStrategy.entity && + !hasMultipleYColumns + ) + } + + return false } - private renderReady(): React.ReactElement | null { - if (!this.hasBeenVisible) return null + @computed private get hasSingleEntityInFacets(): boolean { + const { + isOnStackedAreaTab, + isOnStackedBarTab, + selectedFacetStrategy, + selection, + } = this - if (this.renderToStatic) { - return + if (isOnStackedAreaTab || isOnStackedBarTab) { + return ( + selectedFacetStrategy === FacetStrategy.metric && + selection.numSelectedEntities === 1 + ) } + return false + } + + // TODO: remove once #2136 is fixed + // issue #2136 describes a serious bug that relates to relative mode and + // affects all stacked area/bar charts that are split by metric. for now, + // we simply turn off relative mode in such cases. once the bug is properly + // addressed, this computed property and its references can be removed + @computed + private get isStackedChartSplitByMetric(): boolean { return ( - <> - {/* captioned chart and entity selector */} -
- - {this.sidePanelBounds && ( - - - - )} -
+ (this.isOnStackedAreaTab || this.isOnStackedBarTab) && + this.selectedFacetStrategy === FacetStrategy.metric + ) + } - {/* modals */} - {this.isSourcesModalOpen && } - {this.isDownloadModalOpen && } - {this.isEmbedModalOpen && } - {this.isEntitySelectorModalOpen && ( - - )} + @computed get yScaleType(): ScaleType | undefined { + return this.yAxis.scaleType + } - {/* entity selector in a slide-in drawer */} - { - this.isEntitySelectorModalOrDrawerOpen = - !this.isEntitySelectorModalOrDrawerOpen - }} - > - - + // #endregion - {/* tooltip: either pin to the bottom or render into the chart area */} - {this.shouldPinTooltipToBottom ? ( - - - - ) : ( - - )} - + // #region MapChartManager props + + @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 } - // Chart should only render SVG when it's on the screen - @action.bound private setUpIntersectionObserver(): void { - if (typeof window !== "undefined" && "IntersectionObserver" in window) { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - this.hasBeenVisible = true + @computed get mapIsClickable(): boolean { + return ( + this.hasChartTab && + (this.hasLineChart || this.isScatter) && + !isMobile() + ) + } - if (!this.hasLoggedGAViewEvent) { - this.hasLoggedGAViewEvent = true + // tab defined previously + // type defined in interface but not on Grapher - if (this.chartViewInfo) { - this.analytics.logGrapherView( - this.chartViewInfo.parentChartSlug, - { - chartViewName: - this.chartViewInfo.name, - } - ) - this.hasLoggedGAViewEvent = true - } else if (this.slug) { - this.analytics.logGrapherView(this.slug) - this.hasLoggedGAViewEvent = true - } - } - } + @computed get isLineChartThatTurnedIntoDiscreteBar(): boolean { + if (!this.isLineChart) return false - // dismiss tooltip when less than 2/3 of the chart is visible - const tooltip = this.tooltip?.get() - const isNotVisible = !entry.isIntersecting - const isPartiallyVisible = - entry.isIntersecting && - entry.intersectionRatio < 0.66 - if (tooltip && (isNotVisible || isPartiallyVisible)) { - tooltip.dismiss?.() - } - }) - }, - { threshold: [0, 0.66] } - ) - observer.observe(this.containerElement!) - this.disposers.push(() => observer.disconnect()) - } else { - // IntersectionObserver not available; we may be in a Node environment, just render - this.hasBeenVisible = true + 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 } - } - @observable private _baseFontSize = BASE_FONT_SIZE + // 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 baseFontSize(): number { - if (this.isStaticAndSmall) { - return this.computeBaseFontSizeFromHeight(this.staticBounds) - } - if (this.isStatic) return 18 - return this._baseFontSize + // 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 } - set baseFontSize(val: number) { - this._baseFontSize = val - } + // hasTimeline defined previously - // the header and footer don't rely on the base font size unless explicitly specified - @computed get useBaseFontSize(): boolean { - return this.props.baseFontSize !== undefined + @action.bound resetHandleTimeBounds(): void { + this.startHandleTimeBound = this.timelineMinTime ?? -Infinity + this.endHandleTimeBound = this.timelineMaxTime ?? Infinity } - private computeBaseFontSizeFromHeight(bounds: Bounds): number { - const squareBounds = this.getStaticBounds(GrapherStaticFormat.square) - const factor = squareBounds.height / 21 - return Math.max(10, bounds.height / factor) + @computed get mapConfig(): MapConfig { + return this.map } - 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 - } + // endTime defined previously + // title defined previously + // #endregion - @action.bound private setBaseFontSize(): void { - this.baseFontSize = this.computeBaseFontSizeFromWidth( - this.captionedChartBounds - ) - } + // #region SlopeChartManager props + // canSelectMultipleEntities defined previously + // hasTimeline defined previously + // hideNoDataSection defined in interface but not on Grapher + // #endregion - @computed get fontSize(): number { - return this.props.baseFontSize ?? this.baseFontSize - } + // 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 - @computed get isNarrow(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 420 - } + if (!this.hasMultipleChartTypes) return GRAPHER_TAB_QUERY_PARAMS.chart - @computed get isSemiNarrow(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 550 + return mapChartTypeNameToQueryParam(tab) } + @computed get timeParam(): string | undefined { + const { timeColumn } = this.table + const formatTime = (t: Time): string => + timeBoundToTimeBoundString( + t, + timeColumn instanceof ColumnTypeMap.Day + ) - // 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 - } + if (this.isOnMapTab) { + return this.map.time !== undefined && + this.hasUserChangedMapTimeHandle + ? formatTime(this.map.time) + : undefined + } - // 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 - } + if (!this.hasUserChangedTimeHandles) return undefined - @computed get isStaticAndSmall(): boolean { - if (!this.isStatic) return false - return this.areStaticBoundsSmall + const [startTime, endTime] = + this.timelineHandleTimeBounds.map(formatTime) + return startTime === endTime ? startTime : `${startTime}..${endTime}` } - @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 private get hasUserChangedMapTimeHandle(): boolean { + return this.map.time !== this.authorsVersion.map.time } - @computed get isExportingForSocialMedia(): boolean { + @computed private get hasUserChangedTimeHandles(): boolean { + const authorsVersion = this.legacyConfigAsAuthored return ( - this.isExportingToSvgOrPng && - this.isStaticAndSmall && - this.isSocialMediaExport + 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 backgroundColor(): Color { - return this.isExportingForSocialMedia - ? GRAPHER_BACKGROUND_BEIGE - : GRAPHER_BACKGROUND_DEFAULT + return isArrayDifferentFromReference( + currentSelectedEntityNames, + originalSelectedEntityNames + ) } + @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean { + const authoredConfig = this.legacyConfigAsAuthored + const currentFocusedSeriesNames = this.focusArray.seriesNames + const originalFocusedSeriesNames = + authoredConfig.focusedSeriesNames ?? [] - @computed get shouldPinTooltipToBottom(): boolean { - return this.isNarrow && this.isTouchDevice + return isArrayDifferentFromReference( + currentFocusedSeriesNames, + originalFocusedSeriesNames + ) } - // Binds chart properties to global window title and URL. This should only - // ever be invoked from top-level JavaScript. - private bindToWindow(): void { - // 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)) - const debouncedPushParams = debounce(pushParams, 100) - - reaction( - () => this.changedParams, - () => (this.debounceMode ? debouncedPushParams() : pushParams()) - ) + // 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() + } + @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) + } - autorun(() => (document.title = this.currentTitle)) + @action.bound applyOriginalSelectionAsAuthored(): void { + if (this.selectedEntityNames?.length) + this.selection.setSelectedEntities(this.selectedEntityNames) + } + @computed get availableEntities(): Entity[] { + return this.tableForSelection.availableEntities + } + // The below properties are here so the admin can access them + @computed get hasData(): boolean { + return this.dimensions.length > 0 || this.newSlugs.length > 0 + } + // Returns an object ready to be serialized to JSON + @computed get object(): GrapherInterface { + return this.toObject() } - @action.bound private setUpWindowResizeEventHandler(): void { - const updateWindowDimensions = (): void => { - this.windowInnerWidth = window.innerWidth - this.windowInnerHeight = window.innerHeight + // 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 grapherState = new GrapherState({}) + for (const key of grapherKeysToSerialize) { + // @ts-expect-error grapherKeysToSerialize is not properly typed + this[key] = grapherState[key] } - const onResize = debounce(updateWindowDimensions, 400, { - leading: true, - }) - if (typeof window !== "undefined") { - updateWindowDimensions() - window.addEventListener("resize", onResize) - this.disposers.push(() => { - window.removeEventListener("resize", onResize) - }) + this.ySlugs = grapherState.ySlugs + this.xSlug = grapherState.xSlug + this.colorSlug = grapherState.colorSlug + this.sizeSlug = grapherState.sizeSlug + + this.selection.clearSelection() + this.focusArray.clear() + } + @action.bound updateAuthoredVersion( + config: Partial + ): void { + this.legacyConfigAsAuthored = { + ...this.legacyConfigAsAuthored, + ...config, } } + @computed get chartSeriesNames(): SeriesName[] { + if (!this.isReady) return [] - componentDidMount(): void { - this.setBaseFontSize() - this.setUpIntersectionObserver() - this.setUpWindowResizeEventHandler() - 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( - reaction( - () => this.isReady, - () => { - if (this.isReady) { - document.dispatchEvent( - new CustomEvent(GRAPHER_LOADED_EVENT_NAME, { - detail: { grapher: this }, - }) - ) - } - } - ), - reaction( - () => this.facetStrategy, - () => this.focusArray.clear() + // 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) + ) ) - ) - if (this.props.bindUrlToWindow) this.bindToWindow() - if (this.props.enableKeyboardShortcuts) this.bindKeyboardShortcuts() + } + + return this.chartInstance.series.map((series) => series.seriesName) } - private _shortcutsBound = false - private bindKeyboardShortcuts(): void { - if (this._shortcutsBound) return - this.keyboardShortcuts.forEach((shortcut) => { - Mousetrap.bind(shortcut.combo, () => { - shortcut.fn() - this.analytics.logKeyboardShortcut( - shortcut.title || "", - shortcut.combo + @computed get isFaceted(): boolean { + const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none + return this.isOnChartTab && hasFacetStrategy + } + // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class? + set xOverrideTime(value: number | undefined) { + this.xDimension!.targetYear = value + } + @action.bound setDimensionsForProperty( + property: DimensionProperty, + newConfigs: OwidChartDimensionInterface[] + ): void { + let newDimensions: ChartDimension[] = [] + this.dimensionSlots.forEach((slot) => { + if (slot.property === property) + newDimensions = newDimensions.concat( + newConfigs.map((config) => new ChartDimension(config, this)) ) - return false - }) + else newDimensions = newDimensions.concat(slot.dimensions) }) - this._shortcutsBound = true + this.dimensions = newDimensions } - - private unbindKeyboardShortcuts(): void { - if (!this._shortcutsBound) return - this.keyboardShortcuts.forEach((shortcut) => { - Mousetrap.unbind(shortcut.combo) - }) - this._shortcutsBound = false + @action.bound addDimension(config: OwidChartDimensionInterface): void { + this.dimensions.push(new ChartDimension(config, this)) } - componentWillUnmount(): void { - this.unbindKeyboardShortcuts() - this.dispose() + seriesColorMap: SeriesColorMap = new Map() + @computed get sourcesLine(): string { + return this.sourceDesc ?? this.defaultSourcesLine } + @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] + } + } + ) - componentDidUpdate(): void { - this.setBaseFontSize() + const uniqueAttributions = uniq(compact(attributions)) + + if (uniqueAttributions.length > 3) + return `${uniqueAttributions[0]} and other sources` + + return uniqueAttributions.join("; ") } - componentDidCatch(error: Error): void { - this.setError(error) - this.analytics.logGrapherViewError(error) + @computed get columnsWithSourcesCondensed(): CoreColumn[] { + const { yColumnSlugs } = this + + const columnSlugs = [...yColumnSlugs] + columnSlugs.push(...this.getColumnSlugsForCondensedSources()) + + return this.inputTable + .getColumns(uniq(columnSlugs)) + .filter( + (column) => !!column.source.name || !isEmpty(column.def.origins) + ) } - @observable isShareMenuActive = false + getColumnSlugsForCondensedSources(): string[] { + const { xColumnSlug, sizeColumnSlug, colorColumnSlug, isMarimekko } = + this + const columnSlugs: string[] = [] - @computed get hasRelatedQuestion(): boolean { + // exclude "Countries Continent" if it's used as the color dimension in a scatter plot, slope chart etc. if ( - this.hideRelatedQuestion || - !this.relatedQuestions || - !this.relatedQuestions.length + colorColumnSlug !== undefined && + !isContinentsVariableId(colorColumnSlug) ) - return false - const question = this.relatedQuestions[0] - return !!question && !!question.text && !!question.url + columnSlugs.push(colorColumnSlug) + + if (xColumnSlug !== undefined) { + const xColumn = this.inputTable.get(xColumnSlug) + .def as OwidColumnDef + // exclude population variable if it's used as the x dimension in a marimekko + if ( + !isMarimekko || + !isPopulationVariableETLPath(xColumn?.catalogPath ?? "") + ) + columnSlugs.push(xColumnSlug) + } + + // exclude population variable if it's used as the size dimension in a scatter plot + if (sizeColumnSlug !== undefined) { + const sizeColumn = this.inputTable.get(sizeColumnSlug) + .def as OwidColumnDef + if (!isPopulationVariableETLPath(sizeColumn?.catalogPath ?? "")) + columnSlugs.push(sizeColumnSlug) + } + return columnSlugs + } + // 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 + } + // todo: did this name get botched in a merge? + @computed get hasFatalErrors(): boolean { + const { relatedQuestions = [] } = this + return relatedQuestions.some( + (question) => !!getErrorMessageRelatedQuestionUrl(question) + ) + } + set facetStrategy(facet: FacetStrategy) { + this.selectedFacetStrategy = facet } + set staticFormat(format: GrapherStaticFormat) { + this._staticFormat = format + } + set baseFontSize(val: number) { + this._baseFontSize = val + } + set isInFullScreenMode(newValue: boolean) { + // prevent scrolling when in full-screen mode + if (newValue) { + document.documentElement.classList.add("no-scroll") + } else { + document.documentElement.classList.remove("no-scroll") + } - @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] + // dismiss the share menu + this.isShareMenuActive = false + + this._isInFullScreenMode = newValue + } + + @observable isShareMenuActive = false + + // Computed props that are not required by any inteface but can be optional in interfaces + // and could then lead to changed behavior vs previously if now only GrapherState is passed into + // a component. + + /** + * 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 ( - hasRelatedQuestion && - !!relatedQuestion && - getWindowUrl().pathname !== - Url.fromURL(relatedQuestion.url).pathname + window.admin !== undefined && + // Ensure that we're not accidentally matching on a DOM element with an ID of "admin" + typeof window.admin.isSuperuser === "boolean" ) } - @computed get showRelatedQuestion(): boolean { - return ( - !!this.relatedQuestions && - !!this.hasRelatedQuestion && - !!this.isRelatedQuestionTargetDifferentFromCurrentPage + @computed get shouldLinkToOwid(): boolean { + if ( + this.isEmbeddedInAnOwidPage || + this.isExportingToSvgOrPng || + !this.isInIFrame ) + return false + + return true } - @action.bound clearSelection(): void { - this.selection.clearSelection() - this.applyOriginalSelectionAsAuthored() + @computed.struct private get variableIds(): number[] { + return uniq(this.dimensions.map((d) => d.variableId)) } - @action.bound clearFocus(): void { - this.focusArray.clear() - this.applyOriginalFocusAsAuthored() + @computed get hasOWIDLogo(): boolean { + return ( + !this.hideLogo && (this.logo === undefined || this.logo === "owid") + ) } - @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() + @computed get xScaleType(): ScaleType | undefined { + return this.xAxis.scaleType } - // 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() - for (const key of grapherKeysToSerialize) { - // @ts-expect-error grapherKeysToSerialize is not properly typed - this[key] = grapher[key] - } + @computed get hasYDimension(): boolean { + return this.dimensions.some((d) => d.property === DimensionProperty.y) + } - this.ySlugs = grapher.ySlugs - this.xSlug = grapher.xSlug - this.colorSlug = grapher.colorSlug - this.sizeSlug = grapher.sizeSlug + @computed get cacheTag(): string { + return this.version.toString() + } - this.selection.clearSelection() - this.focusArray.clear() + // 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)) } - debounceMode = false + @computed get isMobile(): boolean { + return isMobile() + } - private mapQueryParamToGrapherTab(tab: string): GrapherTabName | undefined { - const { - chartType: defaultChartType, - validChartTypeSet, - hasMapTab, - } = this + @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 + } - if (tab === GRAPHER_TAB_QUERY_PARAMS.table) { - return GRAPHER_TAB_NAMES.Table + @computed get sidePanelBounds(): Bounds | undefined { + if (!this.isEntitySelectorPanelActive) return + + 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 + ) + } + @computed get containerElement(): HTMLDivElement | undefined { + return this.base.current || undefined + } + + // the header and footer don't rely on the base font size unless explicitly specified + @computed get useBaseFontSize(): boolean { + return this.initialOptions.baseFontSize !== undefined || this.isStatic + } + + @computed get hasRelatedQuestion(): boolean { + 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 { relatedQuestions = [], hasRelatedQuestion } = this + const relatedQuestion = relatedQuestions[0] + return ( + hasRelatedQuestion && + !!relatedQuestion && + getWindowUrl().pathname !== + Url.fromURL(relatedQuestion.url).pathname + ) + } + + @computed get showRelatedQuestion(): boolean { + return ( + !!this.relatedQuestions && + !!this.hasRelatedQuestion && + !!this.isRelatedQuestionTargetDifferentFromCurrentPage + ) + } + + @computed get isOnCanonicalUrl(): boolean { + if (!this.canonicalUrl) return false + return ( + getWindowUrl().pathname === Url.fromURL(this.canonicalUrl).pathname + ) + } + + @computed get showEntitySelectionToggle(): boolean { + return ( + !this.hideEntityControls && + this.canChangeAddOrHighlightEntities && + this.isOnChartTab && + (this.showEntitySelectorAs === GrapherWindowType.modal || + this.showEntitySelectorAs === GrapherWindowType.drawer) + ) + } +} + +export interface GrapherProps { + grapherState: GrapherState +} + +@observer +export class Grapher extends React.Component { + @computed get grapherState(): GrapherState { + return this.props.grapherState + } + + // #region Observable props not in any interface + + analytics = new GrapherAnalytics( + this.props.grapherState.initialOptions.env ?? "" + ) + + // stored on Grapher so state is preserved when switching to full-screen mode + + @observable + private legacyVariableDataJson?: MultipleOwidVariableDataDimensionsMap + private hasLoggedGAViewEvent = false + @observable private hasBeenVisible = false + @observable private uncaughtError?: Error + @observable slideShow?: SlideShowController + + 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 + // TODO: 2025-01-17 Daniel this is now obsolete - the selection update happens in the inputTable setter + @action.bound private _setInputTable( + json: MultipleOwidVariableDataDimensionsMap, + legacyConfig: Partial + ): void { + // TODO grapher model: switch this to downloading multiple data and metadata files + + const startMark = performance.now() + const tableWithColors = legacyToOwidTableAndDimensionsWithMandatorySlug( + json, + legacyConfig.dimensions ?? [], + legacyConfig.selectedEntityColors + ) + this.grapherState.createPerformanceMeasurement( + "legacyToOwidTableAndDimensions", + startMark + ) + + this.grapherState.inputTable = tableWithColors + } + + @action rebuildInputOwidTable(): void { + // TODO grapher model: switch this to downloading multiple data and metadata files + if (!this.legacyVariableDataJson) return + this._setInputTable( + this.legacyVariableDataJson, + this.grapherState.legacyConfigAsAuthored + ) + } + + // Keeps a running cache of series colors at the Grapher level. + + @bind dispose(): void { + this.grapherState.disposers.forEach((dispose) => dispose()) + } + + getColumnForProperty(property: DimensionProperty): CoreColumn | undefined { + return this.grapherState.dimensions.find( + (dim) => dim.property === property + )?.column + } + + get staticSVG(): string { + return this.grapherState.generateStaticSvg() + } + + static renderGrapherIntoContainer( + config: GrapherProgrammaticInterface, + containerNode: Element, + assetMap: AssetMap | undefined + ): void { + const setBoundsFromContainerAndRender = ( + entries: ResizeObserverEntry[] + ): void => { + const entry = entries?.[0] // We always observe exactly one element + if (!entry) + throw new Error( + "Couldn't resize grapher, expected exactly one ResizeObserverEntry" + ) + + // Don't bother rendering if the container is hidden + // see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent + if ((entry.target as HTMLElement).offsetParent === null) return + + const grapherConfigWithBounds = { + ...config, + bounds: Bounds.fromRect(entry.contentRect), + } + + ReactDOM.render( + + + , + containerNode + ) } - if (tab === GRAPHER_TAB_QUERY_PARAMS.map) { - return GRAPHER_TAB_NAMES.WorldMap + + if (typeof window !== "undefined" && "ResizeObserver" in window) { + const resizeObserver = new ResizeObserver( + // Use a leading debounce to render immediately upon first load, and also immediately upon orientation change on mobile + debounce(setBoundsFromContainerAndRender, 400, { + leading: true, + }) + ) + resizeObserver.observe(containerNode) + } else if ( + typeof window === "object" && + typeof document === "object" && + !navigator.userAgent.includes("jsdom") + ) { + // only show the warning when we're in something that roughly resembles a browser + console.warn( + "ResizeObserver not available; grapher will not be able to render" + ) } + } - 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 - } + static renderSingleGrapherOnGrapherPage( + jsonConfig: GrapherProgrammaticInterface, + { runtimeAssetMap }: { runtimeAssetMap?: AssetMap } = {} + ): void { + const container = document.getElementsByTagName("figure")[0] + try { + Grapher.renderGrapherIntoContainer( + { + ...jsonConfig, + bindUrlToWindow: true, + enableKeyboardShortcuts: true, + queryStr: window.location.search, + runtimeAssetMap, + }, + container, + runtimeAssetMap + ) + } catch (err) { + container.innerHTML = `

Unable to load interactive visualization

` + container.setAttribute("id", "fallback") + throw err } + } - const chartTypeName = mapQueryParamToChartTypeName(tab) + @action.bound setError(err: Error): void { + this.uncaughtError = err + } - if (!chartTypeName) return undefined + @action.bound clearErrors(): void { + this.uncaughtError = 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 - } + private get commandPalette(): React.ReactElement | null { + return this.props.grapherState.enableKeyboardShortcuts ? ( + + ) : null } - 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 + @action.bound private toggleTabCommand(): void { + this.grapherState.setTab( + next(this.grapherState.availableTabs, this.grapherState.activeTab) + ) + } - if (!this.hasMultipleChartTypes) return GRAPHER_TAB_QUERY_PARAMS.chart + @action.bound private togglePlayingCommand(): void { + void this.grapherState.timelineController.togglePlay() + } + + private get keyboardShortcuts(): Command[] { + const temporaryFacetTestCommands = range(0, 10).map((num) => { + return { + combo: `${num}`, + fn: (): void => this.randomSelection(num), + } + }) + const shortcuts = [ + ...temporaryFacetTestCommands, + { + combo: "t", + fn: (): void => this.toggleTabCommand(), + title: "Toggle tab", + category: "Navigation", + }, + { + combo: "?", + fn: (): void => CommandPalette.togglePalette(), + title: `Toggle Help`, + category: "Navigation", + }, + { + combo: "a", + fn: (): void => { + if (this.grapherState.selection.hasSelection) { + this.grapherState.selection.clearSelection() + this.grapherState.focusArray.clear() + } else { + this.grapherState.selection.selectAll() + } + }, + title: this.grapherState.selection.hasSelection + ? `Select None` + : `Select All`, + category: "Selection", + }, + { + combo: "f", + fn: (): void => { + this.grapherState.hideFacetControl = + !this.grapherState.hideFacetControl + }, + title: `Toggle Faceting`, + category: "Chart", + }, + { + combo: "p", + fn: (): void => this.togglePlayingCommand(), + title: this.grapherState.isPlaying ? `Pause` : `Play`, + category: "Timeline", + }, + { + combo: "l", + fn: (): void => this.toggleYScaleTypeCommand(), + title: "Toggle Y log/linear", + category: "Chart", + }, + { + combo: "w", + fn: (): void => this.toggleFullScreenMode(), + title: `Toggle full-screen mode`, + category: "Chart", + }, + { + combo: "s", + fn: (): void => { + this.grapherState.isSourcesModalOpen = + !this.grapherState.isSourcesModalOpen + }, + title: `Toggle sources modal`, + category: "Chart", + }, + { + combo: "d", + fn: (): void => { + this.grapherState.isDownloadModalOpen = + !this.grapherState.isDownloadModalOpen + }, + title: "Toggle download modal", + category: "Chart", + }, + { + combo: "esc", + fn: (): void => this.clearErrors(), + }, + { + combo: "z", + fn: (): void => this.toggleTimelineCommand(), + title: "Latest/Earliest/All period", + category: "Timeline", + }, + { + combo: "shift+o", + fn: (): void => this.grapherState.clearQueryParams(), + title: "Reset to original", + category: "Navigation", + }, + ] - return mapChartTypeNameToQueryParam(tab) - } + if (this.slideShow) { + const slideShow = this.slideShow + shortcuts.push({ + combo: "right", + fn: () => slideShow.playNext(), + title: "Next chart", + category: "Browse", + }) + shortcuts.push({ + combo: "left", + fn: () => slideShow.playPrevious(), + title: "Previous chart", + category: "Browse", + }) + } - @computed.struct get allParams(): GrapherQueryParams { - return grapherObjectToQueryParams(this) + return shortcuts } - @computed get areSelectedEntitiesDifferentThanAuthors(): boolean { - const authoredConfig = this.legacyConfigAsAuthored - const currentSelectedEntityNames = this.selection.selectedEntityNames - const originalSelectedEntityNames = - authoredConfig.selectedEntityNames ?? [] - - return isArrayDifferentFromReference( - currentSelectedEntityNames, - originalSelectedEntityNames + @action.bound private toggleTimelineCommand(): void { + // Todo: add tests for this + this.grapherState.setTimeFromTimeQueryParam( + next(["latest", "earliest", ".."], this.grapherState.timeParam!) ) } - @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean { - const authoredConfig = this.legacyConfigAsAuthored - const currentFocusedSeriesNames = this.focusArray.seriesNames - const originalFocusedSeriesNames = - authoredConfig.focusedSeriesNames ?? [] - - return isArrayDifferentFromReference( - currentFocusedSeriesNames, - originalFocusedSeriesNames + @action.bound private toggleYScaleTypeCommand(): void { + this.grapherState.yAxis.scaleType = next( + [ScaleType.linear, ScaleType.log], + this.grapherState.yAxis.scaleType ) } - // 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 queryStr(): string { - return queryParamsToStr({ - ...this.changedParams, - ...this.externalQueryParams, - }) - } - - @computed get baseUrl(): string | undefined { - return this.isPublished - ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}` - : undefined + @action.bound randomSelection(num: number): void { + // Continent, Population, GDP PC, GDP, PopDens, UN, Language, etc. + this.clearErrors() + const currentSelection = + this.grapherState.selection.selectedEntityNames.length + const newNum = num ? num : currentSelection ? currentSelection * 2 : 10 + this.grapherState.selection.setSelectedEntities( + sampleFrom( + this.grapherState.selection.availableEntityNames, + newNum, + Date.now() + ) + ) } - @computed private get manager(): GrapherManager | undefined { - return this.props.manager + @action.bound toggleFullScreenMode(): void { + this.grapherState.isInFullScreenMode = + !this.grapherState.isInFullScreenMode } - @computed get canonicalUrlIfIsChartView(): string | undefined { - if (!this.chartViewInfo) return undefined - - const { parentChartSlug, queryParamsForParentChart } = - this.chartViewInfo - - const combinedQueryParams = { - ...queryParamsForParentChart, - ...this.changedParams, + @action.bound dismissFullScreen(): void { + // if a modal is open, dismiss it instead of exiting full-screen mode + if ( + this.grapherState.isModalOpen || + this.grapherState.isShareMenuActive + ) { + this.grapherState.isEntitySelectorModalOrDrawerOpen = false + this.grapherState.isSourcesModalOpen = false + this.grapherState.isEmbedModalOpen = false + this.grapherState.isDownloadModalOpen = false + this.grapherState.isShareMenuActive = false + } else { + this.grapherState.isInFullScreenMode = false } - - return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr( - combinedQueryParams - )}` } - // Get the full url representing the canonical location of this grapher state - @computed get canonicalUrl(): string | undefined { + private renderError(): React.ReactElement { return ( - this.manager?.canonicalUrl ?? - this.canonicalUrlIfIsChartView ?? - (this.baseUrl ? this.baseUrl + this.queryStr : undefined) +
+

+ + {ThereWasAProblemLoadingThisChart} +

+

+ We have been notified of this error, please check back later + whether it's been fixed. If the error persists, get in touch + with us at{" "} + + info@ourworldindata.org + + . +

+ {this.uncaughtError && this.uncaughtError.message && ( +
+                        Error: {this.uncaughtError.message}
+                    
+ )} +
) } - @computed get isOnCanonicalUrl(): boolean { - if (!this.canonicalUrl) return false - return ( - getWindowUrl().pathname === Url.fromURL(this.canonicalUrl).pathname - ) - } + private renderGrapherComponent(): React.ReactElement { + const containerClasses = classnames({ + GrapherComponent: true, + 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, + }) - @computed get embedUrl(): string | undefined { - const url = this.manager?.embedDialogUrl ?? this.canonicalUrl - if (!url) return + const activeBounds = this.grapherState.renderToStatic + ? this.grapherState.staticBounds + : this.grapherState.frameBounds - // 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 }) + const containerStyle = { + width: activeBounds.width, + height: activeBounds.height, + fontSize: this.grapherState.isExportingToSvgOrPng + ? 18 + : Math.min(16, this.grapherState.fontSize), // cap font size at 16px } - return urlObj.fullUrl - } - - @computed get embedDialogAdditionalElements(): - | React.ReactElement - | undefined { - return this.manager?.embedDialogAdditionalElements - } - @computed private get hasUserChangedTimeHandles(): boolean { - const authorsVersion = this.authorsVersion return ( - this.minTime !== authorsVersion.minTime || - this.maxTime !== authorsVersion.maxTime +
+ {this.commandPalette} + {this.uncaughtError ? this.renderError() : this.renderReady()} +
) } - @computed private get hasUserChangedMapTimeHandle(): boolean { - return this.map.time !== this.authorsVersion.map.time - } + render(): React.ReactElement | undefined { + // TODO how to handle errors in exports? + // TODO remove this? should have a simple toStaticSVG for exporting + if (this.grapherState.isExportingToSvgOrPng) + return - @computed get timeParam(): string | undefined { - const { timeColumn } = this.table - const formatTime = (t: Time): string => - timeBoundToTimeBoundString( - t, - timeColumn instanceof ColumnTypeMap.Day + if (this.grapherState.isInFullScreenMode) { + return ( + + {this.renderGrapherComponent()} + ) - - if (this.isOnMapTab) { - return this.map.time !== undefined && - this.hasUserChangedMapTimeHandle - ? formatTime(this.map.time) - : undefined } - if (!this.hasUserChangedTimeHandles) return undefined - - const [startTime, endTime] = - this.timelineHandleTimeBounds.map(formatTime) - return startTime === endTime ? startTime : `${startTime}..${endTime}` + return this.renderGrapherComponent() } - msPerTick = DEFAULT_MS_PER_TICK + private renderReady(): React.ReactElement | null { + if (!this.hasBeenVisible) return null - timelineController = new TimelineController(this) + if (this.grapherState.renderToStatic) { + return + } - @action.bound onTimelineClick(): void { - const tooltip = this.tooltip?.get() - if (tooltip) tooltip.dismiss?.() + return ( + <> + {/* captioned chart and entity selector */} +
+ + {this.grapherState.sidePanelBounds && ( + + + + )} +
+ + {/* modals */} + {this.grapherState.isSourcesModalOpen && ( + + )} + {this.grapherState.isDownloadModalOpen && ( + + )} + {this.grapherState.isEmbedModalOpen && ( + + )} + {this.grapherState.isEntitySelectorModalOpen && ( + + )} + + {/* entity selector in a slide-in drawer */} + { + this.grapherState.isEntitySelectorModalOrDrawerOpen = + !this.grapherState.isEntitySelectorModalOrDrawerOpen + }} + > + + + + {/* tooltip: either pin to the bottom or render into the chart area */} + {this.grapherState.shouldPinTooltipToBottom ? ( + + + + ) : ( + + )} + + ) } - // todo: restore this behavior?? - onStartPlayOrDrag(): void { - this.debounceMode = true - } + // Chart should only render SVG when it's on the screen + @action.bound private setUpIntersectionObserver(): void { + if (typeof window !== "undefined" && "IntersectionObserver" in window) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.hasBeenVisible = true - onStopPlayOrDrag(): void { - this.debounceMode = false - } + if (!this.hasLoggedGAViewEvent) { + this.hasLoggedGAViewEvent = true - @computed get disablePlay(): boolean { - return false - } + if (this.grapherState.chartViewInfo) { + this.analytics.logGrapherView( + this.grapherState.chartViewInfo + .parentChartSlug, + { + chartViewName: + this.grapherState.chartViewInfo + .name, + } + ) + this.hasLoggedGAViewEvent = true + } else if (this.grapherState.slug) { + this.analytics.logGrapherView( + this.grapherState.slug + ) + this.hasLoggedGAViewEvent = true + } + } - @computed get animationEndTime(): Time { - const { timeColumn } = this.tableAfterAuthorTimelineFilter - if (this.timelineMaxTime) { - return ( - findClosestTime(timeColumn.uniqValues, this.timelineMaxTime) ?? - timeColumn.maxTime + // dismiss tooltip when less than 2/3 of the chart is visible + const tooltip = this.grapherState.tooltip?.get() + const isNotVisible = !entry.isIntersecting + const isPartiallyVisible = + entry.isIntersecting && + entry.intersectionRatio < 0.66 + if ( + tooltip && + (isNotVisible || isPartiallyVisible) + ) { + tooltip.dismiss?.() + } + } + }) + }, + { threshold: [0, 0.66] } ) + observer.observe(this.grapherState.containerElement!) + this.grapherState.disposers.push(() => observer.disconnect()) + } else { + // IntersectionObserver not available; we may be in a Node environment, just render + this.hasBeenVisible = true } - return timeColumn.maxTime } - formatTime(value: Time): string { - const timeColumn = this.table.timeColumn - return isMobile() - ? timeColumn.formatValueForMobile(value) - : timeColumn.formatValue(value) + @action.bound private setBaseFontSize(): void { + this.grapherState.baseFontSize = + this.grapherState.computeBaseFontSizeFromWidth( + this.grapherState.captionedChartBounds + ) } - @computed get canSelectMultipleEntities(): boolean { - if (this.numSelectableEntityNames < 2) return false - if (this.addCountryMode === EntitySelectionMode.MultipleEntities) - return true + // Binds chart properties to global window title and URL. This should only + // ever be invoked from top-level JavaScript. + private bindToWindow(): void { + // 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.grapherState.changedParams)) + const debouncedPushParams = debounce(pushParams, 100) - // if the chart is currently faceted by entity, then use multi-entity - // selection, even if the author specified single-entity selection - if ( - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.facetStrategy === FacetStrategy.entity + reaction( + () => this.grapherState.changedParams, + () => (this.debounceMode ? debouncedPushParams() : pushParams()) ) - return true - return false + autorun(() => (document.title = this.grapherState.currentTitle)) } - @computed get canChangeEntity(): boolean { - return ( - this.hasChartTab && - !this.isOnScatterTab && - !this.canSelectMultipleEntities && - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.numSelectableEntityNames > 1 - ) - } + @action.bound private setUpWindowResizeEventHandler(): void { + const updateWindowDimensions = (): void => { + this.grapherState.windowInnerWidth = window.innerWidth + this.grapherState.windowInnerHeight = window.innerHeight + } + const onResize = debounce(updateWindowDimensions, 400, { + leading: true, + }) - @computed get canAddEntities(): boolean { - return ( - this.hasChartTab && - this.canSelectMultipleEntities && - (this.isOnLineChartTab || - this.isOnSlopeChartTab || - this.isOnStackedAreaTab || - this.isOnStackedBarTab || - this.isOnDiscreteBarTab || - this.isOnStackedDiscreteBarTab) - ) + if (typeof window !== "undefined") { + updateWindowDimensions() + window.addEventListener("resize", onResize) + this.grapherState.disposers.push(() => { + window.removeEventListener("resize", onResize) + }) + } } - @computed get canHighlightEntities(): boolean { - return ( - this.hasChartTab && - this.addCountryMode !== EntitySelectionMode.Disabled && - this.numSelectableEntityNames > 1 && - !this.canAddEntities && - !this.canChangeEntity + componentDidMount(): void { + this.setBaseFontSize() + this.setUpIntersectionObserver() + this.setUpWindowResizeEventHandler() + 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.grapherState.disposers.push( + reaction( + () => this.grapherState.isReady, + () => { + if (this.grapherState.isReady) { + document.dispatchEvent( + new CustomEvent(GRAPHER_LOADED_EVENT_NAME, { + detail: { grapher: this }, + }) + ) + } + } + ), + reaction( + () => this.grapherState.facetStrategy, + () => this.grapherState.focusArray.clear() + ) ) + if (this.grapherState.bindUrlToWindow) this.bindToWindow() + if (this.grapherState.enableKeyboardShortcuts) + this.bindKeyboardShortcuts() } - @computed get canChangeAddOrHighlightEntities(): boolean { - return ( - this.canChangeEntity || - this.canAddEntities || - this.canHighlightEntities - ) + private _shortcutsBound = false + private bindKeyboardShortcuts(): void { + if (this._shortcutsBound) return + this.keyboardShortcuts.forEach((shortcut) => { + Mousetrap.bind(shortcut.combo, () => { + shortcut.fn() + this.analytics.logKeyboardShortcut( + shortcut.title || "", + shortcut.combo + ) + return false + }) + }) + this._shortcutsBound = true } - @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 + private unbindKeyboardShortcuts(): void { + if (!this._shortcutsBound) return + this.keyboardShortcuts.forEach((shortcut) => { + Mousetrap.unbind(shortcut.combo) + }) + this._shortcutsBound = false } - @computed get isEntitySelectorPanelActive(): boolean { - return ( - !this.hideEntityControls && - this.canChangeAddOrHighlightEntities && - this.isOnChartTab && - this.showEntitySelectorAs === GrapherWindowType.panel - ) + componentWillUnmount(): void { + this.unbindKeyboardShortcuts() + this.dispose() } - @computed get showEntitySelectionToggle(): boolean { - return ( - !this.hideEntityControls && - this.canChangeAddOrHighlightEntities && - this.isOnChartTab && - (this.showEntitySelectorAs === GrapherWindowType.modal || - this.showEntitySelectorAs === GrapherWindowType.drawer) - ) + componentDidUpdate(): void { + this.setBaseFontSize() } - @computed get isEntitySelectorModalOpen(): boolean { - return ( - this.isEntitySelectorModalOrDrawerOpen && - this.showEntitySelectorAs === GrapherWindowType.modal - ) + componentDidCatch(error: Error): void { + this.setError(error) + this.analytics.logGrapherViewError(error) } - @computed get isEntitySelectorDrawerOpen(): boolean { - return ( - this.isEntitySelectorModalOrDrawerOpen && - this.showEntitySelectorAs === GrapherWindowType.drawer - ) - } + debounceMode = false - // 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 + // todo: restore this behavior?? + onStartPlayOrDrag(): void { + this.debounceMode = true } - @computed get entitiesAreCountryLike(): boolean { - return !!this.entityType.match(/\bcountry\b/i) + onStopPlayOrDrag(): void { + this.debounceMode = false } - @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, + formatTime(value: Time): string { + const timeColumn = this.grapherState.table.timeColumn + return isMobile() + ? timeColumn.formatValueForMobile(value) + : timeColumn.formatValue(value) } - @observable hasTableTab = true - @observable hideChartTabs = false - @observable hideShareButton = false - @observable hideExploreTheDataButton = true - @observable hideRelatedQuestion = false } const defaultObject = objectWithPersistablesToObject( - new Grapher(), + new GrapherState({}), grapherKeysToSerialize ) diff --git a/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts b/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts index 813c76ffeb3..45ac7df19ad 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts @@ -1,5 +1,3 @@ -import { findDOMParent } from "@ourworldindata/utils" - const DEBUG = false // Add type information for dataLayer global provided by Google Tag Manager @@ -177,55 +175,52 @@ export class GrapherAnalytics { startClickTracking(): void { // we use a data-track-note attr on elements to indicate that clicks on them should be tracked, and what to send - const dataTrackAttr = "data-track-note" - + // const _dataTrackAttr = "data-track-note" // we set a data-grapher-url attr on grapher charts to indicate the URL of the chart. // this is helpful for tracking clicks on charts that are embedded in articles, where we would like to know // which chart the user is interacting with - const dataGrapherUrlAttr = "data-grapher-url" - document.addEventListener( - "click", - async (ev) => { - const targetElement = ev.target as HTMLElement - const trackedElement = findDOMParent( - targetElement, - (el: HTMLElement) => el.getAttribute(dataTrackAttr) !== null - ) - if (!trackedElement) return - - const grapherUrlRaw = trackedElement - .closest(`[${dataGrapherUrlAttr}]`) - ?.getAttribute(dataGrapherUrlAttr) - - if (grapherUrlRaw) { - let grapherUrlObj: - | { - grapherUrl: string - chartViewName: string - } - | undefined - try { - grapherUrlObj = JSON.parse(grapherUrlRaw) - } catch (e) { - console.warn("failed to parse grapherUrl", e) - } - - this.logGrapherClick( - trackedElement.getAttribute(dataTrackAttr) || undefined, - { - label: trackedElement.innerText, - grapherUrl: grapherUrlObj?.grapherUrl, - chartViewName: grapherUrlObj?.chartViewName, - } - ) - } else - this.logSiteClick( - trackedElement.getAttribute(dataTrackAttr) || undefined, - trackedElement.innerText - ) - }, - { capture: true, passive: true } - ) + // const _dataGrapherUrlAttr = "data-grapher-url" + // TODO: 2025-01-05 Daniel - re-enable this before deploying + // document.addEventListener( + // "click", + // async (ev) => { + // const targetElement = ev.target as HTMLElement + // const trackedElement = findDOMParent( + // targetElement, + // (el: HTMLElement) => el.getAttribute(dataTrackAttr) !== null + // ) + // if (!trackedElement) return + // const grapherUrlRaw = trackedElement + // .closest(`[${dataGrapherUrlAttr}]`) + // ?.getAttribute(dataGrapherUrlAttr) + // if (grapherUrlRaw) { + // let grapherUrlObj: + // | { + // grapherUrl: string + // chartViewName: string + // } + // | undefined + // try { + // grapherUrlObj = JSON.parse(grapherUrlRaw) + // } catch (e) { + // console.warn("failed to parse grapherUrl", e) + // } + // this.logGrapherClick( + // trackedElement.getAttribute(dataTrackAttr) || undefined, + // { + // label: trackedElement.innerText, + // grapherUrl: grapherUrlObj?.grapherUrl, + // chartViewName: grapherUrlObj?.chartViewName, + // } + // ) + // } else + // this.logSiteClick( + // trackedElement.getAttribute(dataTrackAttr) || undefined, + // trackedElement.innerText + // ) + // }, + // { capture: true, passive: true } + // ) } protected logToGA(event: GAEvent): void { diff --git a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts index de8d7f2ce19..c972d32404e 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherUrl.ts @@ -9,7 +9,7 @@ import { generateSelectedEntityNamesParam, } from "./EntityUrlBuilder.js" import { match } from "ts-pattern" -import { Grapher } from "./Grapher.js" +import { 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 +78,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..64102080f1a 100755 --- a/packages/@ourworldindata/grapher/src/core/GrapherWithChartTypes.jsdom.test.tsx +++ b/packages/@ourworldindata/grapher/src/core/GrapherWithChartTypes.jsdom.test.tsx @@ -5,14 +5,24 @@ import { SynthesizeGDPTable, SampleColumnSlugs, } from "@ourworldindata/core-table" -import { Grapher, GrapherProgrammaticInterface } from "../core/Grapher" +import { GrapherProgrammaticInterface, GrapherState } from "../core/Grapher" import { MapChart } from "../mapCharts/MapChart" import { legacyMapGrapher } from "../mapCharts/MapChart.sample" import { GRAPHER_CHART_TYPES } from "@ourworldindata/types" +import { legacyToOwidTableAndDimensionsWithMandatorySlug } from "./LegacyToOwidTable.js" describe("grapher and map charts", () => { describe("map time tolerance plus query string works with a map chart", () => { - const grapher = new Grapher(legacyMapGrapher) + const inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyMapGrapher.owidDataset!, + legacyMapGrapher.dimensions!, + legacyMapGrapher.selectedEntityColors + ) + const grapher = new GrapherState({ + ...legacyMapGrapher, + table: inputTable, + }) + expect(grapher.mapColumnSlug).toBe("3512") expect(grapher.inputTable.minTime).toBe(2000) expect(grapher.inputTable.maxTime).toBe(2010) @@ -26,7 +36,12 @@ describe("grapher and map charts", () => { }) it("can change time and see more points", () => { - const manager = new Grapher(legacyMapGrapher) + const manager = new GrapherState(legacyMapGrapher) + manager.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyMapGrapher.owidDataset!, + legacyMapGrapher.dimensions!, + legacyMapGrapher.selectedEntityColors + ) const chart = new MapChart({ manager }) expect(Object.keys(chart.series).length).toEqual(1) @@ -49,7 +64,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/core/LegacyToOwidTable.test.ts b/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.test.ts index a6bd3265a07..1f950c2a220 100755 --- a/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.test.ts +++ b/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.test.ts @@ -6,40 +6,17 @@ import { OwidTableSlugs, StandardOwidColumnDefs, LegacyGrapherInterface, - OwidChartDimensionInterface, } from "@ourworldindata/types" +import { ColumnTypeMap, ErrorValueTypes } from "@ourworldindata/core-table" import { - ColumnTypeMap, - ErrorValueTypes, - OwidTable, -} from "@ourworldindata/core-table" -import { legacyToOwidTableAndDimensions } from "./LegacyToOwidTable" + legacyToOwidTableAndDimensions, + legacyToOwidTableAndDimensionsWithMandatorySlug, +} from "./LegacyToOwidTable" import { MultipleOwidVariableDataDimensionsMap, OwidVariableDataMetadataDimensions, DimensionProperty, } from "@ourworldindata/utils" -import { getDimensionColumnSlug } from "../chart/ChartDimension.js" - -export const legacyToOwidTableAndDimensionsWithMandatorySlug = ( - json: MultipleOwidVariableDataDimensionsMap, - dimensions: OwidChartDimensionInterface[], - selectedEntityColors: - | { [entityName: string]: string | undefined } - | undefined -): OwidTable => { - const dimensionsWithSlug = dimensions?.map((dimension) => ({ - ...dimension, - slug: - dimension.slug ?? - getDimensionColumnSlug(dimension.variableId, dimension.targetYear), - })) - return legacyToOwidTableAndDimensions( - json, - dimensionsWithSlug, - selectedEntityColors - ) -} describe(legacyToOwidTableAndDimensions, () => { const legacyVariableEntry: OwidVariableDataMetadataDimensions = { diff --git a/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.ts b/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.ts index cde6503843f..5d7e5fed52b 100644 --- a/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.ts +++ b/packages/@ourworldindata/grapher/src/core/LegacyToOwidTable.ts @@ -10,6 +10,7 @@ import { OwidVariableDataMetadataDimensions, ErrorValue, OwidChartDimensionInterfaceWithMandatorySlug, + OwidChartDimensionInterface, EntityName, } from "@ourworldindata/types" import { @@ -40,6 +41,27 @@ import { isEmpty, } from "@ourworldindata/utils" import { isContinentsVariableId } from "./GrapherConstants" +import { getDimensionColumnSlug } from "../chart/ChartDimension.js" + +export const legacyToOwidTableAndDimensionsWithMandatorySlug = ( + json: MultipleOwidVariableDataDimensionsMap, + dimensions: OwidChartDimensionInterface[], + selectedEntityColors: + | { [entityName: string]: string | undefined } + | undefined +): OwidTable => { + const dimensionsWithSlug = dimensions?.map((dimension) => ({ + ...dimension, + slug: + dimension.slug ?? + getDimensionColumnSlug(dimension.variableId, dimension.targetYear), + })) + return legacyToOwidTableAndDimensions( + json, + dimensionsWithSlug, + selectedEntityColors + ) +} export const legacyToOwidTableAndDimensions = ( json: MultipleOwidVariableDataDimensionsMap, @@ -90,7 +112,8 @@ export const legacyToOwidTableAndDimensions = ( const valueColumnColor = dimension.display?.color // Ensure the column slug is unique by copying it from the dimensions // (there can be two columns of the same variable with different targetTimes) - valueColumnDef.slug = dimension.slug + if (dimension.slug) valueColumnDef.slug = dimension.slug + else throw new Error("Dimension slug was undefined") // Because database columns can contain mixed types, we want to avoid // parsing for Grapher data until we fix that. valueColumnDef.skipParsing = true diff --git a/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts b/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts index 6326d2752b8..b808c8557d0 100644 --- a/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts +++ b/packages/@ourworldindata/grapher/src/dataTable/DataTable.sample.ts @@ -1,15 +1,16 @@ import { DimensionProperty } from "@ourworldindata/utils" -import { Grapher } from "../core/Grapher" +import { GrapherState } from "../core/Grapher" import { GRAPHER_TAB_OPTIONS, GrapherInterface } from "@ourworldindata/types" import { TestMetadata, createOwidTestDataset, fakeEntities, } from "../testData/OwidTestData" +import { legacyToOwidTableAndDimensionsWithMandatorySlug } from "../core/LegacyToOwidTable.js" export const childMortalityGrapher = ( props: Partial = {} -): Grapher => { +): GrapherState => { const childMortalityId = 104402 const childMortalityMetadata: TestMetadata = { id: childMortalityId, @@ -36,23 +37,30 @@ export const childMortalityGrapher = ( property: DimensionProperty.y, }, ] - return new Grapher({ + const owidDataset = createOwidTestDataset([ + { + metadata: childMortalityMetadata, + data: childMortalityData, + }, + ]) + const state = new GrapherState({ hasMapTab: true, tab: GRAPHER_TAB_OPTIONS.map, dimensions, ...props, - owidDataset: createOwidTestDataset([ - { - metadata: childMortalityMetadata, - data: childMortalityData, - }, - ]), + owidDataset, }) + state.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + owidDataset, + dimensions, + {} + ) + return state } export const GrapherWithIncompleteData = ( props: Partial = {} -): Grapher => { +): GrapherState => { const indicatorId = 3512 const metadata = { id: indicatorId, shortUnit: "%" } const data = [ @@ -84,18 +92,23 @@ export const GrapherWithIncompleteData = ( }, }, ] - return new Grapher({ + const inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + createOwidTestDataset([{ metadata, data }]), + dimensions, + {} + ) + return new GrapherState({ tab: GRAPHER_TAB_OPTIONS.table, selectedEntityNames: ["Iceland", "France", "Afghanistan"], dimensions, ...props, - owidDataset: createOwidTestDataset([{ metadata, data }]), + table: inputTable, }) } export const GrapherWithAggregates = ( props: Partial = {} -): Grapher => { +): GrapherState => { const childMortalityId = 104402 const childMortalityMetadata: TestMetadata = { id: childMortalityId, @@ -125,20 +138,25 @@ export const GrapherWithAggregates = ( property: DimensionProperty.y, }, ] - return new Grapher({ + const inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + createOwidTestDataset([ + { metadata: childMortalityMetadata, data: childMortalityData }, + ]), + dimensions, + {} + ) + return new GrapherState({ tab: GRAPHER_TAB_OPTIONS.table, dimensions, selectedEntityNames: ["Afghanistan", "Iceland", "World"], ...props, - owidDataset: createOwidTestDataset([ - { metadata: childMortalityMetadata, data: childMortalityData }, - ]), + table: inputTable, }) } export const GrapherWithMultipleVariablesAndMultipleYears = ( props: Partial = {} -): Grapher => { +): GrapherState => { const abovePovertyLineId = 514050 const belowPovertyLineId = 472265 @@ -169,14 +187,18 @@ export const GrapherWithMultipleVariablesAndMultipleYears = ( { year: 2019, entity: fakeEntities.World, value: 10 }, ], } - - return new Grapher({ - tab: GRAPHER_TAB_OPTIONS.table, - dimensions, - ...props, - owidDataset: createOwidTestDataset([ + const inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + createOwidTestDataset([ abovePovertyLineDataset, belowPovertyLineDataset, ]), + dimensions, + {} + ) + return new GrapherState({ + tab: GRAPHER_TAB_OPTIONS.table, + dimensions, + ...props, + table: inputTable, }) } diff --git a/packages/@ourworldindata/grapher/src/index.ts b/packages/@ourworldindata/grapher/src/index.ts index af37b72a2e9..70e17162543 100644 --- a/packages/@ourworldindata/grapher/src/index.ts +++ b/packages/@ourworldindata/grapher/src/index.ts @@ -7,6 +7,11 @@ export { type ColorScaleBin, } from "./color/ColorScaleBin" export { ChartDimension } from "./chart/ChartDimension" +export { + FetchingGrapher, + fetchInputTableForConfig, + getCachingInputTableFetcher, +} from "./core/FetchingGrapher" export { GRAPHER_EMBEDDED_FIGURE_ATTR, GRAPHER_EMBEDDED_FIGURE_CONFIG_ATTR, @@ -54,6 +59,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 a07da5438fa..62050a9437f 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -1,4 +1,3 @@ -import React from "react" import { Bounds, DEFAULT_BOUNDS, @@ -71,6 +70,7 @@ import { import { NoDataModal } from "../noDataModal/NoDataModal" import { ColorScaleConfig } from "../color/ColorScaleConfig" import { SelectionArray } from "../selection/SelectionArray" +import { Component, createRef } from "react" const DEFAULT_STROKE_COLOR = "#333" const CHOROPLETH_MAP_CLASSNAME = "ChoroplethMap" @@ -162,7 +162,7 @@ const renderFeaturesFor = ( @observer export class MapChart - extends React.Component + extends Component implements ChartInterface, HorizontalColorLegendManager, ColorScaleManager { @observable focusEntity?: MapEntity @@ -234,7 +234,7 @@ export class MapChart return this.seriesMap } - base: React.RefObject = React.createRef() + base: React.RefObject = createRef() @action.bound onMapMouseOver(feature: GeoFeature): void { const series = feature.id === undefined @@ -673,10 +673,10 @@ export class MapChart declare type SVGMouseEvent = React.MouseEvent @observer -class ChoroplethMap extends React.Component<{ +class ChoroplethMap extends Component<{ manager: ChoroplethMapManager }> { - base: React.RefObject = React.createRef() + base: React.RefObject = createRef() private focusStrokeColor = "#111" diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx index ad85afe950a..28f8e02c949 100755 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.jsdom.test.tsx @@ -1,12 +1,19 @@ #! /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" +import { legacyToOwidTableAndDimensionsWithMandatorySlug } from "../core/LegacyToOwidTable.js" Enzyme.configure({ adapter: new Adapter() }) -const grapherWrapper = Enzyme.mount() +const state = new GrapherState({ ...legacyMapGrapher }) +state.inputTable = legacyToOwidTableAndDimensionsWithMandatorySlug( + legacyMapGrapher.owidDataset!, + legacyMapGrapher.dimensions!, + legacyMapGrapher.selectedEntityColors +) +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..3c0373099bd 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 { 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..ec5a1177cb8 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 { 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..1777e7d3e6c 100644 --- a/packages/@ourworldindata/grapher/src/testData/OwidTestData.sample.ts +++ b/packages/@ourworldindata/grapher/src/testData/OwidTestData.sample.ts @@ -1,5 +1,5 @@ import { DimensionProperty } from "@ourworldindata/utils" -import { Grapher, GrapherProgrammaticInterface } from "../core/Grapher" +import { GrapherProgrammaticInterface, GrapherState } from "../core/Grapher" import { TestMetadata, createOwidTestDataset, @@ -13,7 +13,7 @@ Grapher properties: */ export const LifeExpectancyGrapher = ( props: Partial = {} -): Grapher => { +): GrapherState => { const lifeExpectancyId = 815383 const lifeExpectancyMetadata: TestMetadata = { id: lifeExpectancyId, @@ -55,7 +55,7 @@ export const LifeExpectancyGrapher = ( property: DimensionProperty.y, }, ] - return new Grapher({ + return new GrapherState({ ...props, dimensions, owidDataset: createOwidTestDataset([ diff --git a/site/DataPage.scss b/site/DataPage.scss index b3b3382d25e..c3960e0ce5f 100644 --- a/site/DataPage.scss +++ b/site/DataPage.scss @@ -8,7 +8,8 @@ html.IsInIframe body.DataPage { } // GrapherWithFallback - figure[data-grapher-src] { + figure[data-grapher-src], + figure[data-grapher-component] { display: flex; align-items: center; justify-content: center; diff --git a/site/DataPageContent.scss b/site/DataPageContent.scss index a9897345ee7..89149e0d5f8 100644 --- a/site/DataPageContent.scss +++ b/site/DataPageContent.scss @@ -76,7 +76,8 @@ } @include grid(12); - figure[data-grapher-src] { + figure[data-grapher-src], + figure[data-grapher-component] { grid-column: span 12; margin: 0; @@ -168,7 +169,8 @@ margin-bottom: 0; } - figure[data-grapher-src] { + figure[data-grapher-src], + figure[data-grapher-component] { height: $grapher-height; } } diff --git a/site/DataPageV2Content.tsx b/site/DataPageV2Content.tsx index bc94615fc0c..914752c1e2e 100644 --- a/site/DataPageV2Content.tsx +++ b/site/DataPageV2Content.tsx @@ -1,10 +1,9 @@ -import { useState, useEffect, useMemo } from "react" -import { Grapher, GrapherProgrammaticInterface } from "@ourworldindata/grapher" +import { useMemo } from "react" +import { GrapherProgrammaticInterface } from "@ourworldindata/grapher" import { REUSE_THIS_WORK_SECTION_ID, DATAPAGE_SOURCES_AND_PROCESSING_SECTION_ID, } from "@ourworldindata/components" -import { GrapherWithFallback } from "./GrapherWithFallback.js" import { RelatedCharts } from "./blocks/RelatedCharts.js" import { DataPageV2ContentFields, @@ -12,14 +11,20 @@ import { joinTitleFragments, ImageMetadata, } from "@ourworldindata/utils" -import { DocumentContext } from "./gdocs/DocumentContext.js" -import { AttachmentsContext } from "./gdocs/AttachmentsContext.js" import StickyNav from "./blocks/StickyNav.js" +import { + ADMIN_BASE_URL, + BAKED_GRAPHER_URL, +} from "../settings/clientSettings.js" import AboutThisData from "./AboutThisData.js" import DataPageResearchAndWriting from "./DataPageResearchAndWriting.js" import MetadataSection from "./MetadataSection.js" import TopicTags from "./TopicTags.js" import { processRelatedResearch } from "./dataPage.js" +import { GrapherWithFallback } from "./GrapherWithFallback.js" +import { AttachmentsContext } from "./gdocs/AttachmentsContext.js" +import { DocumentContext } from "./gdocs/DocumentContext.js" +import { GrapherFigureView } from "./GrapherFigureView.js" declare global { interface Window { @@ -41,8 +46,6 @@ export const DataPageV2Content = ({ grapherConfig: GrapherInterface imageMetadata: Record }) => { - const [grapher, setGrapher] = useState(undefined) - const titleFragments = joinTitleFragments( datapageData.attributionShort, datapageData.titleVariant @@ -54,14 +57,11 @@ export const DataPageV2Content = ({ ...grapherConfig, isEmbeddedInADataPage: true, bindUrlToWindow: true, + adminBaseUrl: ADMIN_BASE_URL, + bakedGrapherURL: BAKED_GRAPHER_URL, }), [grapherConfig] ) - - useEffect(() => { - setGrapher(new Grapher(mergedGrapherConfig)) - }, [mergedGrapherConfig]) - const stickyNavLinks = [ { text: "Explore the Data", @@ -98,11 +98,7 @@ export const DataPageV2Content = ({ >
- +
@@ -131,16 +127,14 @@ export const DataPageV2Content = ({
- + {grapherConfig.slug && ( + + )} + queryStr?: string } -// Wrapper for Grapher that uses css on figure element to determine the bounds -export const GrapherFigureView = ({ - grapher, - extraProps, -}: { - grapher: Grapher - extraProps?: Partial -}) => { +export function GrapherFigureView(props: GrapherFigureViewProps): JSX.Element { + const slug = props.slug + const base = useRef(null) const bounds = useElementBounds(base) @@ -28,28 +28,30 @@ export const GrapherFigureView = ({ (typeof window !== "undefined" && window._OWID_RUNTIME_ASSET_MAP) || undefined - const grapherProps: GrapherProgrammaticInterface = { - ...grapher.toObject(), - isEmbeddedInADataPage: grapher.isEmbeddedInADataPage, - bindUrlToWindow: grapher.props.bindUrlToWindow, - queryStr: grapher.props.bindUrlToWindow - ? window.location.search - : undefined, - runtimeAssetMap, + const config: GrapherProgrammaticInterface = { + ...props.config, + bakedGrapherURL: BAKED_GRAPHER_URL, + adminBaseUrl: ADMIN_BASE_URL, bounds, - dataApiUrl: DATA_API_URL, + queryStr: + props.queryStr ?? + (typeof window !== "undefined" ? window.location.search : ""), enableKeyboardShortcuts: true, - ...extraProps, + runtimeAssetMap, } + return ( - // They key= in here makes it so that the chart is re-loaded when the slug changes. -
+
{bounds && ( - )}
diff --git a/site/GrapherFigureViewForMultiembedder.tsx b/site/GrapherFigureViewForMultiembedder.tsx new file mode 100644 index 00000000000..42a47ec376f --- /dev/null +++ b/site/GrapherFigureViewForMultiembedder.tsx @@ -0,0 +1,12 @@ +import { GrapherProgrammaticInterface } from "@ourworldindata/grapher" +import { BAKED_GRAPHER_URL } from "../settings/clientSettings.js" + +// Wrapper for Grapher that uses css on figure element to determine the bounds +export const GrapherFigureViewForMultiembedder = ({ + slug, +}: { + slug: string + extraProps?: Partial +}) => { + return
+} diff --git a/site/GrapherPage.tsx b/site/GrapherPage.tsx index e1b694a0edf..a92d07c2766 100644 --- a/site/GrapherPage.tsx +++ b/site/GrapherPage.tsx @@ -80,7 +80,6 @@ const runtimeAssetMap = (typeof window !== "undefined" && window._OWID_RUNTIME_A window.Grapher.renderSingleGrapherOnGrapherPage(jsonConfig, { runtimeAssetMap: runtimeAssetMap })` const variableIds = uniq(grapher.dimensions!.map((d) => d.variableId)) - return ( { + config: Partial + queryStr?: string + fetchConfigForSlug?: boolean +} + +// TODO: change this so it's possible to hand a full grapher config down (and maybe an extra config?) + +export function GrapherWithFallback( + props: GrapherWithFallbackProps +): JSX.Element { + const { slug, className, id, config, queryStr, fetchConfigForSlug } = props + const fetchConfig = fetchConfigForSlug ?? true + + const [isClient, setIsClient] = useState(false) + const { ref, inView } = useInView({ + rootMargin: "400px", + // Only trigger once + triggerOnce: true, + }) + useEffect(() => { + setIsClient(true) + }, []) + + // Render fallback svg when javascript disabled or while + // grapher is loading + const imageFallback = ( +
+ +
+ ) + return (
- <> - {grapher ? ( - - ) : ( - // Render fallback svg when javascript disabled or while - // grapher is loading -
- {slug && ( - - )} -
- )} - + {!isClient ? ( + imageFallback + ) : inView ? ( + + ) : ( + // Optional loading placeholder while waiting to come into view + imageFallback + )}
) } diff --git a/site/collections/DynamicCollection.tsx b/site/collections/DynamicCollection.tsx index 978f6f7d398..eb41e3f79fb 100644 --- a/site/collections/DynamicCollection.tsx +++ b/site/collections/DynamicCollection.tsx @@ -34,7 +34,8 @@ export function embedDynamicCollectionGrapher( const interval = setInterval(() => { if (grapherRef.current) { const originalSlug = - grapherRef.current.slug + grapherRef.current.queryStr + grapherRef.current.grapherState.slug + + grapherRef.current.grapherState.queryStr const index = figure.getAttribute("data-grapher-index") @@ -60,9 +61,9 @@ export class DynamicCollection extends React.Component { @computed get allGrapherSlugsAndQueryStrings() { if (!this.graphers) return [] - // If the grapher hasn't mounted yet, we use the original slugAndQueryString - // This allows us to update the URL if users interact with graphers that have mounted - // while still keeping the unmounted graphers in the URL in the right place + // // If the grapher hasn't mounted yet, we use the original slugAndQueryString + // // This allows us to update the URL if users interact with graphers that have mounted + // // while still keeping the unmounted graphers in the URL in the right place const slugsAndQueryStrings = new Array(this.graphers.size) for (const [originalSlugAndUrl, { index, grapher }] of this.graphers) { @@ -72,12 +73,13 @@ export class DynamicCollection extends React.Component { slugsAndQueryStrings[index] = encodeURIComponent(withoutIndex) } else { slugsAndQueryStrings[index] = encodeURIComponent( - `${grapher.slug}${grapher.queryStr}` + `${grapher.grapherState.slug}${grapher.grapherState.queryStr}` ) } } return slugsAndQueryStrings + return [] } componentDidMount() { diff --git a/site/css/chart.scss b/site/css/chart.scss index 5c8cb575934..1c9556f6ab3 100644 --- a/site/css/chart.scss +++ b/site/css/chart.scss @@ -1,6 +1,7 @@ @use "sass:math"; .StandaloneGrapherOrExplorerPage main figure[data-grapher-src], +.StandaloneGrapherOrExplorerPage main figure[data-grapher-component], #fallback { display: flex; align-items: center; @@ -104,7 +105,8 @@ html.IsInIframe .StandaloneGrapherOrExplorerPage { min-height: inherit; } - main figure[data-grapher-src] { + main figure[data-grapher-src], + main figure[data-grapher-component] { height: 100vh; min-height: auto; max-height: none; diff --git a/site/css/content.scss b/site/css/content.scss index 3c65870aeb4..afca8a8a127 100644 --- a/site/css/content.scss +++ b/site/css/content.scss @@ -30,7 +30,8 @@ @include figure-margin; } -.article-content figure[data-grapher-src] { +.article-content figure[data-grapher-src], +.article-content figure[data-grapher-component] { @include figure-grapher-reset; > a { @@ -51,11 +52,13 @@ } } -.article-content figure[data-grapher-src].grapherPreview { +.article-content figure[data-grapher-src].grapherPreview, +.article-content figure[data-grapher-component].grapherPreview { padding: 1em 0; } -.article-content figure[data-grapher-src]:not(.grapherPreview) { +.article-content figure[data-grapher-src]:not(.grapherPreview), +.article-content figure[data-grapher-component]:not(.grapherPreview) { height: $grapher-height; } diff --git a/site/gdocs/components/AllCharts.scss b/site/gdocs/components/AllCharts.scss index b72825c469f..0358570292e 100644 --- a/site/gdocs/components/AllCharts.scss +++ b/site/gdocs/components/AllCharts.scss @@ -24,7 +24,8 @@ margin-top: 40px; } - figure[data-grapher-src]:not(.grapherPreview) { + figure[data-grapher-src]:not(.grapherPreview), + figure[data-grapher-component]:not(.grapherPreview) { height: $grapher-height; } } diff --git a/site/gdocs/components/Chart.tsx b/site/gdocs/components/Chart.tsx index 5e85e97bd51..613b31f6f05 100644 --- a/site/gdocs/components/Chart.tsx +++ b/site/gdocs/components/Chart.tsx @@ -19,6 +19,7 @@ import { useLinkedChart } from "../utils.js" import SpanElements from "./SpanElements.js" import cx from "classnames" import GrapherImage from "../../GrapherImage.js" +import { GrapherWithFallback } from "../../GrapherWithFallback.js" export default function Chart({ d, @@ -42,6 +43,8 @@ export default function Chart({ const url = Url.fromURL(d.url) const resolvedUrl = linkedChart.resolvedUrl + const resolvedUrlParsed = Url.fromURL(resolvedUrl) + const slug = resolvedUrlParsed.slug! const isExplorer = linkedChart.configType === ChartConfigType.Explorer const isMultiDim = linkedChart.configType === ChartConfigType.MultiDim const hasControls = url.queryParams.hideControls !== "true" @@ -105,35 +108,46 @@ export default function Chart({ style={{ gridRow: d.row, gridColumn: d.column }} ref={refChartContainer} > -
- {isExplorer || isMultiDim ? ( -
- ) : ( - - - - )} -
+ {isExplorer || isMultiDim ? ( +
+ {isExplorer || isMultiDim ? ( +
+ ) : ( + resolvedUrl && ( + + + + ) + )} +
+ ) : ( + // TODO: 2025-01-05 Daniel - this is a crude first version, this entire control has to be touched again + + )} {d.caption ? (
diff --git a/site/multiDim/MultiDimDataPageContent.tsx b/site/multiDim/MultiDimDataPageContent.tsx index 57eb7d1e3e9..84964fee937 100644 --- a/site/multiDim/MultiDimDataPageContent.tsx +++ b/site/multiDim/MultiDimDataPageContent.tsx @@ -3,6 +3,7 @@ import { Grapher, GrapherAnalytics, GrapherProgrammaticInterface, + GrapherState, getVariableMetadataRoute, } from "@ourworldindata/grapher" import { @@ -119,7 +120,9 @@ const cachedGetGrapherConfigByUuid = memoize( isPreviewing: boolean ): Promise => { return fetchWithRetry( - `/grapher/by-uuid/${grapherConfigUuid}.config.json${isPreviewing ? "?nocache" : ""}` + `/grapher/by-uuid/${grapherConfigUuid}.config.json${ + isPreviewing ? "?nocache" : "" + }` ).then((resp) => resp.json()) } ) @@ -259,17 +262,17 @@ export const MultiDimDataPageContent = ({ // This is the ACTUAL grapher instance being used, because GrapherFigureView/GrapherWithFallback are doing weird things and are not actually using the grapher instance we pass into it // and therefore we can not access the grapher state (e.g. tab, selection) from the grapher instance we pass into it // TODO we should probably fix that? seems sensible? change GrapherFigureView around a bit to use the actual grapher inst? or pass a GrapherProgrammaticInterface to it instead? - const [grapherInst, setGrapherInst] = useState(null) + const [grapherState, setGrapherState] = useState(null) // De-mobx grapher.changedParams by transforming it into React state const grapherChangedParams = useMobxStateToReactState( - useCallback(() => grapherInst?.changedParams, [grapherInst]), - !!grapherInst + useCallback(() => grapherState?.changedParams, [grapherState]), + !!grapherState ) const grapherCurrentTitle = useMobxStateToReactState( - useCallback(() => grapherInst?.currentTitle, [grapherInst]), - !!grapherInst + useCallback(() => grapherState?.currentTitle, [grapherState]), + !!grapherState ) useEffect(() => { @@ -327,6 +330,13 @@ export const MultiDimDataPageContent = ({ currentView?.indicators, ]) + // TODO: 2025-01-05 Daniel - this is an inefficient way of doing things + // switch to fetching grapher instead but make it work with updating the config + useEffect(() => { + if (!grapherConfigIsReady) return + setGrapherState(new GrapherState(grapherConfigComputed)) + }, [grapherConfigComputed, grapherConfigIsReady]) + const hasTopicTags = !!config.config.topicTags?.length const relatedResearch = useMemo( @@ -399,14 +409,16 @@ export const MultiDimDataPageContent = ({ className="GrapherWithFallback full-width-on-mobile" >
- + {grapherState && ( // TODO: we should always render something here + + )}
{varDatapageData && ( diff --git a/site/multiembedder/MultiEmbedder.tsx b/site/multiembedder/MultiEmbedder.tsx index 6e1b8917c78..a96b1c76868 100644 --- a/site/multiembedder/MultiEmbedder.tsx +++ b/site/multiembedder/MultiEmbedder.tsx @@ -40,7 +40,7 @@ import { GRAPHER_DYNAMIC_CONFIG_URL, MULTI_DIM_DYNAMIC_CONFIG_URL, } from "../../settings/clientSettings.js" -import { embedDynamicCollectionGrapher } from "../collections/DynamicCollection.js" +// import { embedDynamicCollectionGrapher } from "../collections/DynamicCollection.js" import { match } from "ts-pattern" type EmbedType = "grapher" | "explorer" | "multiDim" | "chartView" @@ -210,12 +210,17 @@ class MultiEmbedder { if (config.manager?.selection) this.graphersAndExplorersToUpdate.add(config.manager.selection) - const grapherRef = Grapher.renderGrapherIntoContainer(config, figure) + const runtimeAssetMap = + (typeof window !== "undefined" && window._OWID_RUNTIME_ASSET_MAP) || + undefined + + Grapher.renderGrapherIntoContainer(config, figure, runtimeAssetMap) // Special handling for shared collections - if (window.location.pathname.startsWith("/collection/custom")) { - embedDynamicCollectionGrapher(grapherRef, figure) - } + // TODO: re-enable this + // if (window.location.pathname.startsWith("/collection/custom")) { + // embedDynamicCollectionGrapher(grapherRef, figure) + // } } async renderGrapherIntoFigure(figure: Element) { const embedUrlRaw = figure.getAttribute(GRAPHER_EMBEDDED_FIGURE_ATTR) diff --git a/wrangler.toml b/wrangler.toml index ab04b0ebcf5..ebdb0344935 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -15,7 +15,7 @@ ENV = "development" GRAPHER_CONFIG_R2_BUCKET_URL = "https://grapher-configs-staging.owid.io" GRAPHER_CONFIG_R2_BUCKET_FALLBACK_URL = "https://grapher-configs.owid.io" GRAPHER_CONFIG_R2_BUCKET_FALLBACK_PATH = "v1" - +DATA_API_URL = "https://api.ourworldindata.org/v1/indicators/" # Overrides for CF preview deployments [env.preview.vars]