From cf5a498d63d04f71fcaa65943cf3983e76c579e4 Mon Sep 17 00:00:00 2001 From: Daniel Bachler Date: Sat, 28 Dec 2024 13:52:47 +0100 Subject: [PATCH] Sort grapher properties by interface order and move computed props up --- .../grapher/src/core/Grapher.tsx | 4967 +++++++++-------- site/DataPageV2Content.tsx | 2 +- 2 files changed, 2586 insertions(+), 2383 deletions(-) diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index de521392b9..9ff63d143b 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -303,6 +303,8 @@ export interface GrapherManager { editUrl?: string } +export class GrapherState {} + @observer export class Grapher extends React.Component @@ -327,6 +329,13 @@ export class Grapher MapChartManager, SlopeChartManager { + // #region SortConfig props + @observable sortBy?: SortBy = SortBy.total + @observable sortOrder?: SortOrder = SortOrder.desc + @observable sortColumnSlug?: string + // #endregion + + // #region GrapherInterface props @observable.ref $schema = latestGrapherConfigSchema @observable.ref chartTypes: GrapherChartType[] = [ GRAPHER_CHART_TYPES.LineChart, @@ -340,17 +349,16 @@ export class Grapher @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 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 @@ -359,107 +367,137 @@ export class Grapher @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 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.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 selectedEntityNames: EntityName[] = [] + @observable selectedEntityColors: { + [entityName: string]: string | undefined + } = {} + @observable focusedSeriesNames: SeriesName[] = [] + @observable.ref missingDataStrategy?: MissingDataStrategy = undefined @observable.ref hideFacetControl?: boolean = undefined - + @observable.ref facettingLabelByYVariables = "metric" // the desired faceting strategy, which might not be possible if we change the data @observable selectedFacetStrategy?: FacetStrategy = undefined - @observable sortBy?: SortBy = SortBy.total - @observable sortOrder?: SortOrder = SortOrder.desc - @observable sortColumnSlug?: string + @observable.ref xAxis = new AxisConfig(undefined, this) + @observable.ref yAxis = new AxisConfig(undefined, this) + @observable colorScale = new ColorScaleConfig() + @observable map = new MapConfig() - @observable.ref _isInFullScreenMode = false + @observable ySlugs?: ColumnSlugs = undefined + @observable xSlug?: ColumnSlug = undefined + @observable sizeSlug?: ColumnSlug = undefined + @observable colorSlug?: ColumnSlug = undefined + @observable tableSlugs?: ColumnSlugs = undefined - @observable.ref windowInnerWidth?: number - @observable.ref windowInnerHeight?: number + // #endregion GrapherInterface properties - owidDataset?: MultipleOwidVariableDataDimensionsMap = undefined // This is used for passing data for testing + // #region GrapherProgrammaticInterface props + owidDataset?: MultipleOwidVariableDataDimensionsMap = undefined // This is used for passing data for testing manuallyProvideData? = false // This will be removed. + @computed get queryStr(): string { + return queryParamsToStr({ + ...this.changedParams, + ...this.externalQueryParams, + }) + } + // bounds defined in interface but not on Grapher + @computed get table(): OwidTable { + return this.tableAfterAuthorTimelineFilter + } - // 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/" + // env defined in interface but not on Grapher + @computed get dataApiUrlForAdmin(): string | undefined { + return this.props.dataApiUrlForAdmin + } + /** + * Used to highlight an entity at a particular time in a line chart. + * The sparkline in map tooltips makes use of this. + */ + @observable.ref entityYearHighlight?: EntityYearHighlight = undefined - @observable.ref externalQueryParams: QueryParams - - private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL - private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL + @computed get baseFontSize(): number { + if (this.isStaticAndSmall) { + return this.computeBaseFontSizeFromHeight(this.staticBounds) + } + if (this.isStatic) return 18 + return this._baseFontSize + } + @observable private _baseFontSize = BASE_FONT_SIZE + @computed get staticBounds(): Bounds { + if (this.props.staticBounds) return this.props.staticBounds + return this.getStaticBounds(this.staticFormat) + } + @observable.ref private _staticFormat = GrapherStaticFormat.landscape + @observable hideTitle = false + @observable hideSubtitle = false + @observable hideNote = false + @observable hideOriginUrl = false - @observable.ref inputTable: OwidTable + // 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 - @observable.ref legacyConfigAsAuthored: Partial = {} + // 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 - // stored on Grapher so state is preserved when switching to full-screen mode - @observable entitySelectorState: Partial = {} + @observable.ref isSocialMediaExport = false + // getGrapherInstance defined in interface but not on Grapher (as a property - it is set in the constructor) - @computed get dataApiUrlForAdmin(): string | undefined { - return this.props.dataApiUrlForAdmin - } + enableKeyboardShortcuts?: boolean - @computed get dataTableSlugs(): ColumnSlug[] { - return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs - } + bindUrlToWindow?: boolean isEmbeddedInAnOwidPage?: boolean = this.props.isEmbeddedInAnOwidPage isEmbeddedInADataPage?: boolean = this.props.isEmbeddedInADataPage @@ -469,502 +507,463 @@ export class Grapher "parentChartSlug" | "queryParamsForParentChart" > = undefined - 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 + @computed private get manager(): GrapherManager | undefined { + return this.props.manager } + // instanceRef defined in interface but not on Grapher - @action.bound updateAuthoredVersion( - config: Partial - ): void { - this.legacyConfigAsAuthored = { - ...this.legacyConfigAsAuthored, - ...config, - } - } + // #endregion GrapherProgrammaticInterface properties - constructor( - propsWithGrapherInstanceGetter: GrapherProgrammaticInterface = {} - ) { - super(propsWithGrapherInstanceGetter) + // #region Start TimelineManager propertes - const { getGrapherInstance, ...props } = propsWithGrapherInstanceGetter + @computed get disablePlay(): boolean { + return false + } - this.inputTable = props.table ?? BlankOwidTable(`initialGrapherTable`) + formatTimeFn(time: Time): string { + return this.inputTable.timeColumn.formatTime(time) + } - if (props) this.setAuthoredVersion(props) + @observable.ref isPlaying = false + @observable.ref isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished - // prefer the manager's selection over the config's selectedEntityNames - // if both are passed in and the manager's selection is not empty. - // this is necessary for the global entity selector to work correctly. - if (props.manager?.selection?.hasSelection) { - this.updateFromObject(omit(props, "selectedEntityNames")) - } else { - this.updateFromObject(props) - } + @computed get times(): Time[] { + const columnSlugs = this.isOnMapTab + ? [this.mapColumnSlug] + : this.yColumnSlugs - this.populateFromQueryParams( - legacyToCurrentGrapherQueryParams(props.queryStr ?? "") - ) - this.externalQueryParams = omit( - Url.fromQueryStr(props.queryStr ?? "").queryParams, - GRAPHER_QUERY_PARAM_KEYS + // Generate the times only after the chart transform has been applied, so that we don't show + // times on the timeline for which data may not exist, e.g. when the selected entity + // doesn't contain data for all years in the table. + // -@danielgavrilov, 2020-10-22 + return this.tableAfterAuthorTimelineAndActiveChartTransform.getTimesUniqSortedAscForColumns( + columnSlugs ) + } + @computed get startHandleTimeBound(): TimeBound { + if (this.isSingleTimeSelectionActive) return this.endHandleTimeBound + return this.timelineHandleTimeBounds[0] + } + @computed get endHandleTimeBound(): TimeBound { + return this.timelineHandleTimeBounds[1] + } - if (this.isEditor) { - this.ensureValidConfigWhenEditing() - } - - if (getGrapherInstance) getGrapherInstance(this) // todo: possibly replace with more idiomatic ref + @observable.ref areHandlesOnSameTimeBeforeAnimation?: boolean + msPerTick = DEFAULT_MS_PER_TICK + // missing from TimelineManager: onPlay + @action.bound onTimelineClick(): void { + const tooltip = this.tooltip?.get() + if (tooltip) tooltip.dismiss?.() } + // #endregion TimelineManager properties - toObject(): GrapherInterface { - const obj: GrapherInterface = objectWithPersistablesToObject( - this, - grapherKeysToSerialize - ) + // #region ChartManager properties + base: React.RefObject = React.createRef() - obj.selectedEntityNames = this.selection.selectedEntityNames - obj.focusedSeriesNames = this.focusArray.seriesNames + @computed get fontSize(): number { + return this.props.baseFontSize ?? this.baseFontSize + } + // table defined in interface but not on Grapher - deleteRuntimeAndUnchangedProps(obj, defaultObject) + @computed get transformedTable(): OwidTable { + return this.tableAfterAllTransformsAndFilters + } - // always include the schema, even if it's the default - obj.$schema = this.$schema || latestGrapherConfigSchema + @observable.ref isExportingToSvgOrPng = false - // 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 + // 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 + } - if (obj.timelineMinTime) - obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any - if (obj.timelineMaxTime) - obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any + return !this.hideLegend + } - // todo: remove dimensions concept - // if (this.legacyConfigAsAuthored?.dimensions) - // obj.dimensions = this.legacyConfigAsAuthored.dimensions + 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 - return obj + @computed get yAxisConfig(): Readonly { + return this.yAxis.toObject() } - @action.bound updateFromObject(obj?: GrapherProgrammaticInterface): void { - if (!obj) return - - updatePersistables(this, obj) + @computed get xAxisConfig(): Readonly { + return this.xAxis.toObject() + } - // Regression fix: some legacies have this set to Null. Todo: clean DB. - if (obj.originUrl === null) this.originUrl = "" + @computed get yColumnSlugs(): string[] { + return this.ySlugs + ? this.ySlugs.split(" ") + : this.dimensions + .filter((dim) => dim.property === DimensionProperty.y) + .map((dim) => dim.columnSlug) + } - // update selection - if (obj.selectedEntityNames) - this.selection.setSelectedEntities(obj.selectedEntityNames) + @computed get yColumnSlug(): string | undefined { + return this.ySlugs + ? this.ySlugs.split(" ")[0] + : this.getSlugForProperty(DimensionProperty.y) + } - // update focus - if (obj.focusedSeriesNames) - this.focusArray.clearAllAndAdd(...obj.focusedSeriesNames) + @computed get xColumnSlug(): string | undefined { + return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x) + } - // JSON doesn't support Infinity, so we use strings instead. - this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) - this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) + @computed get sizeColumnSlug(): string | undefined { + return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size) + } - this.timelineMinTime = minTimeBoundFromJSONOrNegativeInfinity( - obj.timelineMinTime - ) - this.timelineMaxTime = maxTimeBoundFromJSONOrPositiveInfinity( - obj.timelineMaxTime + @computed get colorColumnSlug(): string | undefined { + return ( + this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color) ) - - // Todo: remove once we are more RAII. - if (obj?.dimensions?.length) - this.setDimensionsFromConfigs(obj.dimensions) } - @action.bound populateFromQueryParams(params: GrapherQueryParams): void { - // Set tab if specified - if (params.tab) { - const tab = this.mapQueryParamToGrapherTab(params.tab) - if (tab) this.setTab(tab) - else console.error("Unexpected tab: " + params.tab) - } - - // Set overlay if specified - const overlay = params.overlay - if (overlay) { - if (overlay === "sources") { - this.isSourcesModalOpen = true - } else if (overlay === "download") { - this.isDownloadModalOpen = true - } else { - console.error("Unexpected overlay: " + overlay) - } - } - - // Stack mode for bar and stacked area charts - this.stackMode = (params.stackMode ?? this.stackMode) as StackMode - - this.zoomToSelection = - params.zoomToSelection === "true" ? true : this.zoomToSelection - - // Axis scale mode - const xScaleType = params.xScale - if (xScaleType) { - if (xScaleType === ScaleType.linear || xScaleType === ScaleType.log) - this.xAxis.scaleType = xScaleType - else console.error("Unexpected xScale: " + xScaleType) - } - - const yScaleType = params.yScale - if (yScaleType) { - if (yScaleType === ScaleType.linear || yScaleType === ScaleType.log) - this.yAxis.scaleType = yScaleType - else console.error("Unexpected xScale: " + yScaleType) - } - - const time = params.time - if (time !== undefined && time !== "") - this.setTimeFromTimeQueryParam(time) - - const endpointsOnly = params.endpointsOnly - if (endpointsOnly !== undefined) - this.compareEndPointsOnly = endpointsOnly === "1" ? true : undefined - - const region = params.region - if (region !== undefined) - this.map.projection = region as MapProjectionName - - // selection - const selection = getSelectedEntityNamesParam( - Url.fromQueryParams(params) + selection = + this.manager?.selection ?? + new SelectionArray( + this.props.selectedEntityNames ?? [], + this.props.table?.availableEntities ?? [] ) - if (this.addCountryMode !== EntitySelectionMode.Disabled && selection) - this.selection.setSelectedEntities(selection) - - // focus - const focusedSeriesNames = getFocusedSeriesNamesParam(params.focus) - if (focusedSeriesNames) { - this.focusArray.clearAllAndAdd(...focusedSeriesNames) - } - - // faceting - if (params.facet && params.facet in FacetStrategy) { - this.selectedFacetStrategy = params.facet as FacetStrategy - } - if (params.uniformYAxis === "0") { - this.yAxis.facetDomain = FacetAxisDomain.independent - } else if (params.uniformYAxis === "1") { - this.yAxis.facetDomain = FacetAxisDomain.shared - } - - // only relevant for the table - if (params.showSelectionOnlyInTable) { - this.showSelectionOnlyInDataTable = - params.showSelectionOnlyInTable === "1" ? true : undefined - } - - if (params.showNoDataArea) { - this.showNoDataArea = params.showNoDataArea === "1" - } + // 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) } - @action.bound private setTimeFromTimeQueryParam(time: string): void { - this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map( - (time) => findClosestTime(this.times, time) ?? time - ) as TimeBounds + @computed get endTime(): Time | undefined { + return findClosestTime(this.times, this.endHandleTimeBound) } - - @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 + // 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 activeChartType(): GrapherChartType | undefined { - if (!this.isOnChartTab) return undefined - return this.activeTab as GrapherChartType + @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 } - - @computed get chartType(): GrapherChartType | undefined { - return this.validChartTypes[0] + // showNoDataArea defined previously + // externalLegendHoverBin defined in interface but not on Grapher + @computed get disableIntroAnimation(): boolean { + return this.isStatic } - - @computed get hasChartTab(): boolean { - return this.validChartTypes.length > 0 + // missingDataStrategy defined previously + @computed get isNarrow(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 420 } - @computed get isOnChartTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.chart + @computed get isStatic(): boolean { + return this.renderToStatic || this.isExportingToSvgOrPng } - @computed get isOnMapTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.map + @computed get isSemiNarrow(): boolean { + if (this.isStatic) return false + return this.frameBounds.width <= 550 } - @computed get isOnTableTab(): boolean { - return this.tab === GRAPHER_TAB_OPTIONS.table + @computed get isStaticAndSmall(): boolean { + if (!this.isStatic) return false + return this.areStaticBoundsSmall } - - @computed get isOnChartOrMapTab(): boolean { - return this.isOnChartTab || this.isOnMapTab + // isExportingForSocialMedia defined previously + @computed get backgroundColor(): Color { + return this.isExportingForSocialMedia + ? GRAPHER_BACKGROUND_BEIGE + : GRAPHER_BACKGROUND_DEFAULT } - @computed get yAxisConfig(): Readonly { - return this.yAxis.toObject() + @computed get shouldPinTooltipToBottom(): boolean { + return this.isNarrow && this.isTouchDevice } - @computed get xAxisConfig(): Readonly { - return this.xAxis.toObject() - } + // Used for superscript numbers in static exports + @computed get detailsOrderedByReference(): string[] { + if (typeof window === "undefined") return [] - @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 - } + // extract details from supporting text + const subtitleDetails = !this.hideSubtitle + ? extractDetailsFromSyntax(this.currentSubtitle) + : [] + const noteDetails = !this.hideNote + ? extractDetailsFromSyntax(this.note ?? "") + : [] - return !this.hideLegend + // extract details from axis labels + const yAxisDetails = extractDetailsFromSyntax( + this.yAxisConfig.label || "" + ) + const xAxisDetails = extractDetailsFromSyntax( + this.xAxisConfig.label || "" + ) + + // text fragments are ordered by appearance + const uniqueDetails = uniq([ + ...subtitleDetails, + ...yAxisDetails, + ...xAxisDetails, + ...noteDetails, + ]) + + return uniqueDetails } - // table that is used for display in the table tab - @computed get tableForDisplay(): OwidTable { - const table = this.table - if (!this.isReady || !this.isOnTableTab) return table - return this.chartInstance.transformTableForDisplay - ? this.chartInstance.transformTableForDisplay(table) - : table + @computed get detailsMarkerInSvg(): DetailsMarker { + const { isStatic, shouldIncludeDetailsInStaticExport } = this + return !isStatic + ? "underline" + : shouldIncludeDetailsInStaticExport + ? "superscript" + : "none" } + // #endregion ChartManager properties - @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 + // #region AxisManager + // fontSize defined previously + // detailsOrderedByReference defined previously + // #endregion - if (!this.isReady) return table + // CaptionedChartManager interface ommited (only used for testing) - // 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) - } + // #region SourcesModalManager props - return table + // 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 - /** - * 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 + // sort y-columns by their display name + const sortedYColumnSlugs = sortBy( + yColumnSlugs, + (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + ) - if (this.isScatter && this.sizeColumnSlug) { - const tolerance = - table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity - table = table.interpolateColumnWithTolerance( - this.sizeColumnSlug, - tolerance - ) - } + const columnSlugs = excludeUndefined([ + ...sortedYColumnSlugs, + xColumnSlug, + sizeColumnSlug, + colorColumnSlug, + ]) - if ((this.isScatter || this.isMarimekko) && this.colorColumnSlug) { - const tolerance = - table.get(this.colorColumnSlug)?.display?.tolerance ?? Infinity - table = table.interpolateColumnWithTolerance( - this.colorColumnSlug, - tolerance + return this.inputTable + .getColumns(uniq(columnSlugs)) + .filter( + (column) => !!column.source.name || !isEmpty(column.def.origins) ) - } - - return table } - // 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 showAdminControls(): boolean { + return ( + this.isUserLoggedInAsAdmin || + this.isDev || + this.isLocalhost || + this.isStaging ) } + // isSourcesModalOpen defined previously - // 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() - - const transformedTable = this.chartInstance.transformTable(table) - - this.createPerformanceMeasurement( - "chartInstance.transformTable", - startMark - ) - return transformedTable + @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 chartInstance(): ChartInterface { - // Note: when timeline handles on a LineChart are collapsed into a single handle, the - // LineChart turns into a DiscreteBar. + // isEmbeddedInAnOwidPage defined previously + // isNarrow defined previously + // fontSize defined previously + // #endregion - return this.isOnMapTab - ? new MapChart({ manager: this }) - : this.chartInstanceExceptMap + // #region DownloadModalManager + @computed get displaySlug(): string { + return this.slug ?? slugify(this.displayTitle) } - // When Map becomes a first-class chart instance, we should drop this - @computed get chartInstanceExceptMap(): ChartInterface { - const chartTypeName = - this.typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart + rasterize(): Promise { + const { width, height } = this.staticBoundsWithDetails + const staticSVG = this.generateStaticSvg() - const ChartClass = - ChartComponentClassMap.get(chartTypeName) ?? DefaultChartClass - return new ChartClass({ manager: this }) + return new StaticChartRasterizer(staticSVG, width, height).render() } + // staticBounds defined previously - @computed get chartSeriesNames(): SeriesName[] { - if (!this.isReady) return [] + @computed get staticBoundsWithDetails(): Bounds { + const includeDetails = + this.shouldIncludeDetailsInStaticExport && + !isEmpty(this.detailRenderers) - // 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) + let height = this.staticBounds.height + if (includeDetails) { + height += + 2 * this.framePaddingVertical + + sumTextWrapHeights( + this.detailRenderers, + STATIC_EXPORT_DETAIL_SPACING ) - ) } - return this.chartInstance.series.map((series) => series.seriesName) + return new Bounds(0, 0, this.staticBounds.width, height) } - @computed get table(): OwidTable { - return this.tableAfterAuthorTimelineFilter + @computed get staticFormat(): GrapherStaticFormat { + if (this.props.staticFormat) return this.props.staticFormat + return this._staticFormat } - @computed - private get tableAfterAllTransformsAndFilters(): OwidTable { - const { startTime, endTime } = this - const table = this.tableAfterAuthorTimelineAndActiveChartTransform + @computed get baseUrl(): string | undefined { + return this.isPublished + ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}` + : undefined + } + // queryStr defined previously + // table defined previously + // transformedTable defined previously - if (startTime === undefined || endTime === undefined) return table + // 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 - if (this.isOnMapTab) - return table.filterByTargetTimes( - [endTime], - this.map.timeTolerance ?? - table.get(this.mapColumnSlug).tolerance - ) + @computed get captionedChartBounds(): Bounds { + // if there's no panel, the chart takes up the whole frame + if (!this.isEntitySelectorPanelActive) return this.frameBounds - if ( - this.isDiscreteBar || - this.isLineChartThatTurnedIntoDiscreteBar || - this.isMarimekko + 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 ) - return table.filterByTargetTimes( - [endTime], - table.get(this.yColumnSlugs[0]).tolerance - ) - - if (this.isOnSlopeChartTab) - return table.filterByTargetTimes( - [startTime, endTime], - table.get(this.yColumnSlugs[0]).tolerance - ) - - return table.filterByTimeRange(startTime, endTime) } - @computed get transformedTable(): OwidTable { - return this.tableAfterAllTransformsAndFilters + @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 - @observable.ref renderToStatic = false - @observable.ref isExportingToSvgOrPng = false - @observable.ref isSocialMediaExport = false - - tooltip?: TooltipManager["tooltip"] = observable.box(undefined, { - deep: false, - }) + // sort y columns by their display name + const sortedYColumnSlugs = sortBy( + yColumnSlugs, + (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + ) - @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 + return excludeUndefined([ + ...sortedYColumnSlugs, + xColumnSlug, + sizeColumnSlug, + colorColumnSlug, + ]) + } - @observable.ref isEntitySelectorModalOrDrawerOpen = false + // #endregion - @observable.ref isSourcesModalOpen = false - @observable.ref isDownloadModalOpen = false - @observable.ref isEmbedModalOpen = false + // #region DiscreteBarChartManager props - @computed get isStatic(): boolean { - return this.renderToStatic || this.isExportingToSvgOrPng + // showYearLabels defined previously + // endTime defined previously + + @computed get isOnLineChartTab(): boolean { + return this.activeChartType === GRAPHER_CHART_TYPES.LineChart } + // #endregion - private get isStaging(): boolean { - if (typeof location === "undefined") return false - return location.host.includes("staging") + // LegacyDimensionsManager omitted (only defines table) + + // #region ShareMenuManager props + // slug defined previously + + @computed get currentTitle(): string { + let text = this.displayTitle.trim() + if (text.length === 0) return text + + // helper function to add an annotation fragment to the title + // only adds a comma if the text does not end with a question mark + const appendAnnotationField = ( + text: string, + annotation: string + ): string => { + const separator = text.endsWith("?") ? "" : "," + return `${text}${separator} ${annotation}` + } + + if (this.shouldAddEntitySuffixToTitle) { + const selectedEntityNames = this.selection.selectedEntityNames + const entityStr = selectedEntityNames[0] + if (entityStr?.length) text = appendAnnotationField(text, entityStr) + } + + if (this.shouldAddChangeInPrefixToTitle) + text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) + + if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) + text = appendAnnotationField(text, this.timeTitleSuffix) + + return text.trim() } - private get isLocalhost(): boolean { - if (typeof location === "undefined") return false - return location.host.includes("localhost") + // Get the full url representing the canonical location of this grapher state + @computed get canonicalUrl(): string | undefined { + return ( + this.manager?.canonicalUrl ?? + this.canonicalUrlIfIsChartView ?? + (this.baseUrl ? this.baseUrl + this.queryStr : undefined) + ) } @computed get editUrl(): string | undefined { @@ -975,867 +974,1059 @@ export class Grapher } return undefined } + // isEmbedModalOpen defined previously + // #endregion - /** - * 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 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. - - 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 + // #region EmbedModalManager props + // canonicalUrl defined previously + @computed get embedUrl(): string | undefined { + const url = this.manager?.embedDialogUrl ?? this.canonicalUrl + if (!url) return - return !!Cookies.get(CookieKey.isAdmin) - } catch { - return false + // 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 showAdminControls(): boolean { - return ( - this.isUserLoggedInAsAdmin || - this.isDev || - this.isLocalhost || - this.isStaging - ) + @computed get embedDialogAdditionalElements(): + | React.ReactElement + | undefined { + return this.manager?.embedDialogAdditionalElements } + // isEmbedModalOpen defined previously + // frameBounds defined previously + // #endregion - // Exclusively used for the performance.measurement API, so that DevTools can show some context - private createPerformanceMeasurement( - name: string, - startMark: number - ): void { - const endMark = performance.now() - const detail = { - devtools: { - track: "Grapher", - properties: [ - // might be missing for charts within explorers or mdims - ["slug", this.slug ?? "missing-slug"], - ["chartTypes", this.validChartTypes], - ["tab", this.tab], - ], - }, - } + // TooltipManager omitted (only defines tooltip) - try { - performance.measure(name, { - start: startMark, - end: endMark, - detail, - }) - } catch { - // In old browsers, the above may throw an error - just ignore it - } + // #region DataTableManager props + // table defined previously + // table that is used for display in the table tab + @computed get tableForDisplay(): OwidTable { + const table = this.table + if (!this.isReady || !this.isOnTableTab) return table + return this.chartInstance.transformTableForDisplay + ? this.chartInstance.transformTableForDisplay(table) + : table } - @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 dimensions = legacyConfig.dimensions?.map((dimension) => ({ - ...dimension, - slug: - dimension.slug ?? - getDimensionColumnSlug( - dimension.variableId, - dimension.targetYear - ), - })) - const tableWithColors = legacyToOwidTableAndDimensions( - json, - dimensions ?? [], - legacyConfig.selectedEntityColors - ) - this.createPerformanceMeasurement( - "legacyToOwidTableAndDimensions", - startMark - ) + // entityType defined previously + // endTime defined previously + // startTime defined previously - this.inputTable = tableWithColors + @computed get dataTableSlugs(): ColumnSlug[] { + return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs + } - this.appendNewEntitySelectionOptions() + @observable.ref showSelectionOnlyInDataTable?: boolean = undefined - 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 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 } - @action rebuildInputOwidTable(): void { - // TODO grapher model: switch this to downloading multiple data and metadata files - if (!this.legacyVariableDataJson) return - this._setInputTable( - this.legacyVariableDataJson, - this.legacyConfigAsAuthored - ) + // 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 - @observable - private legacyVariableDataJson?: MultipleOwidVariableDataDimensionsMap - @action.bound appendNewEntitySelectionOptions(): void { - const { selection } = this - const currentEntities = selection.availableEntityNameSet - const missingEntities = this.availableEntities.filter( - (entity) => !currentEntities.has(entity.entityName) + @computed get canChangeAddOrHighlightEntities(): boolean { + return ( + this.canChangeEntity || + this.canAddEntities || + this.canHighlightEntities ) - selection.addAvailableEntityNames(missingEntities) } - - @action.bound private applyOriginalSelectionAsAuthored(): void { - if (this.selectedEntityNames?.length) - this.selection.setSelectedEntities(this.selectedEntityNames) + // hasMapTab defined previously + @computed get hasChartTab(): boolean { + return this.validChartTypes.length > 0 } + // #endregion DataTableManager props - @action.bound private applyOriginalFocusAsAuthored(): void { - if (this.focusedSeriesNames?.length) - this.focusArray.clearAllAndAdd(...this.focusedSeriesNames) - } + // #region ScatterPlotManager props + // hideConnectedScatterLines defined previously + // scatterPointLabelStrategy defined previously + // addCountryMode defined previously - @computed get hasData(): boolean { - return this.dimensions.length > 0 || this.newSlugs.length > 0 + // 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) - // Ready to go iff we have retrieved data for every variable associated with the chart - @computed get isReady(): boolean { - return this.whatAreWeWaitingFor === "" + /** + * Uses some explicit and implicit information to decide whether a timeline is shown. + */ + @computed get hasTimeline(): boolean { + // we don't have more than one distinct time point in our data, so it doesn't make sense to show a timeline + if (this.times.length <= 1) return false + + switch (this.tab) { + // the map tab has its own `hideTimeline` option + case GRAPHER_TAB_OPTIONS.map: + return !this.map.hideTimeline + + // use the chart-level `hideTimeline` option + case GRAPHER_TAB_OPTIONS.chart: + return !this.hideTimeline + + // use the chart-level `hideTimeline` option for the table, with some exceptions + case GRAPHER_TAB_OPTIONS.table: + // always show the timeline for charts that plot time on the x-axis + if (this.hasTimeDimension) return true + return !this.hideTimeline + + default: + return false + } } - @computed get whatAreWeWaitingFor(): string { - const { newSlugs, inputTable, dimensions } = this - if (newSlugs.length || dimensions.length === 0) { - const missingColumns = newSlugs.filter( - (slug) => !inputTable.has(slug) + @computed get isModalOpen(): boolean { + return ( + this.isEntitySelectorModalOpen || + this.isSourcesModalOpen || + this.isEmbedModalOpen || + this.isDownloadModalOpen + ) + } + + @computed get isSingleTimeScatterAnimationActive(): boolean { + return ( + this.isTimelineAnimationActive && + this.isOnScatterTab && + !this.isRelativeMode && + !!this.areHandlesOnSameTimeBeforeAnimation + ) + } + + @observable.ref animationStartTime?: Time + @computed get animationEndTime(): Time { + const { timeColumn } = this.tableAfterAuthorTimelineFilter + if (this.timelineMaxTime) { + return ( + findClosestTime(timeColumn.uniqValues, this.timelineMaxTime) ?? + timeColumn.maxTime ) - 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(",")}.` + return timeColumn.maxTime } + // #endregion ScatterPlotManager props - // 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]) - } + // #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 loadingDimensions(): ChartDimension[] { - return this.dimensions.filter( - (dim) => !this.inputTable.has(dim.columnSlug) + // #region FacetChartManager + + @computed get canSelectMultipleEntities(): boolean { + if (this.numSelectableEntityNames < 2) return false + if (this.addCountryMode === EntitySelectionMode.MultipleEntities) + return true + + if ( + // we force multi-entity selection mode when the chart is faceted + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.facetStrategy !== FacetStrategy.none && + // unless the author explicitly configured the chart to be split + // by metric and hid the facet control + !( + this.facetStrategy === FacetStrategy.metric && + this.hideFacetControl + ) ) - } + return true - @computed get isInIFrame(): boolean { - return isInIFrame() + return false } - @computed get times(): Time[] { - const columnSlugs = this.isOnMapTab - ? [this.mapColumnSlug] - : this.yColumnSlugs + // #endregion - // Generate the times only after the chart transform has been applied, so that we don't show - // times on the timeline for which data may not exist, e.g. when the selected entity - // doesn't contain data for all years in the table. - // -@danielgavrilov, 2020-10-22 - return this.tableAfterAuthorTimelineAndActiveChartTransform.getTimesUniqSortedAscForColumns( - columnSlugs + // #region EntitySelectorModalManager + + @observable entitySelectorState: Partial = {} + // tableForSeleciton defined below (together with other table transforms) + // selection defined previously + // entityType defined previously + // entityTypePlural defined previously + // activeColumnSlugs defined previously + // dataApiUrl defined previously + + @observable.ref isEntitySelectorModalOrDrawerOpen = false + + @computed get canChangeEntity(): boolean { + return ( + this.hasChartTab && + !this.isOnScatterTab && + !this.canSelectMultipleEntities && + this.addCountryMode === EntitySelectionMode.SingleEntity && + this.numSelectableEntityNames > 1 ) } - /** - * Plots time on the x-axis. - */ - @computed private get hasTimeDimension(): boolean { - return this.isStackedBar || this.isStackedArea || this.isLineChart + @computed get canHighlightEntities(): boolean { + return ( + this.hasChartTab && + this.addCountryMode !== EntitySelectionMode.Disabled && + this.numSelectableEntityNames > 1 && + !this.canAddEntities && + !this.canChangeEntity + ) } - @computed private get hasTimeDimensionButTimelineIsHidden(): boolean { - return this.hasTimeDimension && !!this.hideTimeline - } + focusArray = new FocusArray() - @computed get startHandleTimeBound(): TimeBound { - if (this.isSingleTimeSelectionActive) return this.endHandleTimeBound - return this.timelineHandleTimeBounds[0] - } + // frameBounds defined previously + // #endregion - set startHandleTimeBound(newValue: TimeBound) { - if (this.isSingleTimeSelectionActive) - this.timelineHandleTimeBounds = [newValue, newValue] - else - this.timelineHandleTimeBounds = [ - newValue, - this.timelineHandleTimeBounds[1], - ] - } + // #region SettingsMenuManager - set endHandleTimeBound(newValue: TimeBound) { - if (this.isSingleTimeSelectionActive) - this.timelineHandleTimeBounds = [newValue, newValue] - else - this.timelineHandleTimeBounds = [ - this.timelineHandleTimeBounds[0], - newValue, - ] - } + // stackMode defined previously - @computed get endHandleTimeBound(): TimeBound { - return this.timelineHandleTimeBounds[1] + @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" } - @action.bound resetHandleTimeBounds(): void { - this.startHandleTimeBound = this.timelineMinTime ?? -Infinity - this.endHandleTimeBound = this.timelineMaxTime ?? Infinity - } + // showNoDataArea defined previously - // Keeps a running cache of series colors at the Grapher level. - seriesColorMap: SeriesColorMap = new Map() + // facetStrategy defined previously + // yAxis defined previously + // zoomToSelection defined previously + // showSelectedEntitiesOnly defined previously + // entityTypePlural defined previously - @computed get startTime(): Time | undefined { - return findClosestTime(this.times, this.startHandleTimeBound) + @computed get availableFacetStrategies(): FacetStrategy[] { + return this.chartInstance.availableFacetStrategies?.length + ? this.chartInstance.availableFacetStrategies + : [FacetStrategy.none] } - @computed get endTime(): Time | undefined { - return findClosestTime(this.times, this.endHandleTimeBound) - } + // entityType defined previously + // facettingLabelByYVariables defined previously + // hideFacetControl defined previously + // hideRelativeToggle defined previously + // hideEntityControls defined previously + // hideZoomToggle defined previously + // hideNoDataAreaToggle defined previously + // hideFacetYDomainToggle defined previously + // hideXScaleToggle defined previously + // hideYScaleToggle defined previously + // hideTableFilterToggle defined previously - @computed get isSingleTimeScatterAnimationActive(): boolean { - return ( - this.isTimelineAnimationActive && - this.isOnScatterTab && - !this.isRelativeMode && - !!this.areHandlesOnSameTimeBeforeAnimation - ) + @computed get activeChartType(): GrapherChartType | undefined { + if (!this.isOnChartTab) return undefined + return this.activeTab as GrapherChartType } - @computed private get onlySingleTimeSelectionPossible(): boolean { - return ( - this.isDiscreteBar || - this.isStackedDiscreteBar || - this.isOnMapTab || - this.isMarimekko + // 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 } - @computed private get isSingleTimeSelectionActive(): boolean { - return ( - this.onlySingleTimeSelectionPossible || - this.isSingleTimeScatterAnimationActive - ) + // selection defined previously + // canChangeAddOrHighlightEntities defined previously + + @computed.struct get filledDimensions(): ChartDimension[] { + return this.isReady ? this.dimensions : [] } - @computed get shouldLinkToOwid(): boolean { + // 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 ( - this.isEmbeddedInAnOwidPage || - this.isExportingToSvgOrPng || - !this.isInIFrame + hasSingleEntityInFacets || + hasSingleMetricInFacets || + isStackedChartSplitByMetric ) return false - return true + if (isOnMarimekkoTab && xColumnSlug === undefined) return false + return !hideRelativeToggle } - @computed.struct private get variableIds(): number[] { - return uniq(this.dimensions.map((d) => d.variableId)) + @computed get isOnChartTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.chart } - @computed get hasOWIDLogo(): boolean { - return ( - !this.hideLogo && (this.logo === undefined || this.logo === "owid") - ) + @computed get isOnMapTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.map } - // todo: did this name get botched in a merge? - @computed get hasFatalErrors(): boolean { - const { relatedQuestions = [] } = this - return relatedQuestions.some( - (question) => !!getErrorMessageRelatedQuestionUrl(question) - ) + @computed get isOnTableTab(): boolean { + return this.tab === GRAPHER_TAB_OPTIONS.table } - disposers: (() => void)[] = [] + // yAxis defined previously + // xAxis defined previously + // compareEndPointsOnly defined previously - @bind dispose(): void { - this.disposers.forEach((dispose) => dispose()) + // 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 + + // #endregion + + // #region MapChartManager props - @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 + @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 ( - oldTab === GRAPHER_TAB_NAMES.LineChart && - newTab === GRAPHER_TAB_NAMES.SlopeChart && - this.areHandlesOnSameTime - ) { - if (this.startHandleTimeBound !== -Infinity) { - this.startHandleTimeBound = -Infinity - } else { - this.endHandleTimeBound = Infinity - } - } - } - - // todo: can we remove this? - // I believe these states can only occur during editing. - @action.bound private ensureValidConfigWhenEditing(): void { - const disposers = [ - autorun(() => { - if (!this.availableTabs.includes(this.activeTab)) - runInAction(() => this.setTab(this.availableTabs[0])) - }), - autorun(() => { - const validDimensions = this.validDimensions - if (!isEqual(this.dimensions, validDimensions)) - this.dimensions = validDimensions - }), - ] - this.disposers.push(...disposers) - } - - @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) + !mapColumnSlug || + !this.dimensions.some((dim) => dim.columnSlug === mapColumnSlug) ) - - this.dimensionSlots.forEach((slot) => { - if (!slot.allowMultiple) - validDimensions = uniqWith( - validDimensions, - ( - a: OwidChartDimensionInterface, - b: OwidChartDimensionInterface - ) => - a.property === slot.property && - a.property === b.property - ) - }) - - return validDimensions + return this.yColumnSlug! + return mapColumnSlug } - // todo: do we need this? - @computed get originUrlWithProtocol(): string { - if (!this.originUrl) return "" - let url = this.originUrl - if (!url.startsWith("http")) url = `https://${url}` - return url + @computed get mapIsClickable(): boolean { + return ( + this.hasChartTab && + (this.hasLineChart || this.isScatter) && + !isMobile() + ) } - @computed get timelineHandleTimeBounds(): TimeBounds { - if (this.isOnMapTab) { - const time = maxTimeBoundFromJSONOrPositiveInfinity(this.map.time) - return [time, time] - } + // tab defined previously + // type defined in interface but not on Grapher - // If the timeline is hidden on the chart tab but displayed on the table tab - // (which is the case for charts that plot time on the x-axis), - // we always want to use the authored `minTime` and `maxTime` for the chart, - // irrespective of the time range the user might have selected on the table tab - if (this.isOnChartTab && this.hasTimeDimensionButTimelineIsHidden) { - const { minTime, maxTime } = this.authorsVersion - return [ - minTimeBoundFromJSONOrNegativeInfinity(minTime), - maxTimeBoundFromJSONOrPositiveInfinity(maxTime), - ] - } + @computed get isLineChartThatTurnedIntoDiscreteBar(): boolean { + if (!this.isLineChart) return false - return [ - // Handle `undefined` values in minTime/maxTime - minTimeBoundFromJSONOrNegativeInfinity(this.minTime), - maxTimeBoundFromJSONOrPositiveInfinity(this.maxTime), - ] - } + let { minTime, maxTime } = this - set timelineHandleTimeBounds(value: TimeBounds) { - if (this.isOnMapTab) { - this.map.time = value[1] - } else { - this.minTime = value[0] - this.maxTime = value[1] + // 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 } - } - // 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) + // This is the easy case: minTime and maxTime are the same, no need to do + // more fancy checks + if (minTime === maxTime) return true - 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] + // 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.struct get filledDimensions(): ChartDimension[] { - return this.isReady ? this.dimensions : [] - } + // hasTimeline defined previously - @action.bound addDimension(config: OwidChartDimensionInterface): void { - this.dimensions.push(new ChartDimension(config, this)) + @action.bound resetHandleTimeBounds(): void { + this.startHandleTimeBound = this.timelineMinTime ?? -Infinity + this.endHandleTimeBound = this.timelineMaxTime ?? Infinity } - @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 mapConfig(): MapConfig { + return this.map } - @action.bound setDimensionsFromConfigs( - configs: OwidChartDimensionInterface[] - ): void { - this.dimensions = configs.map( - (config) => new ChartDimension(config, this) - ) - } + // endTime defined previously + // title defined previously + // #endregion - @computed get displaySlug(): string { - return this.slug ?? slugify(this.displayTitle) - } + // #region SlopeChartManager props + // canSelectMultipleEntities defined previously + // hasTimeline defined previously + // hideNoDataSection defined in interface but not on Grapher + // #endregion - @observable shouldIncludeDetailsInStaticExport = true + // #region Observable props not in any interface - // Used for superscript numbers in static exports - @computed get detailsOrderedByReference(): string[] { - if (typeof window === "undefined") return [] + @observable.ref _isInFullScreenMode = false - // extract details from supporting text - const subtitleDetails = !this.hideSubtitle - ? extractDetailsFromSyntax(this.currentSubtitle) - : [] - const noteDetails = !this.hideNote - ? extractDetailsFromSyntax(this.note ?? "") - : [] + @observable.ref windowInnerWidth?: number + @observable.ref windowInnerHeight?: number + @observable.ref chartTab?: GrapherChartType - // extract details from axis labels - const yAxisDetails = extractDetailsFromSyntax( - this.yAxisConfig.label || "" - ) - const xAxisDetails = extractDetailsFromSyntax( - this.xAxisConfig.label || "" - ) + // 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 - // text fragments are ordered by appearance - const uniqueDetails = uniq([ - ...subtitleDetails, - ...yAxisDetails, - ...xAxisDetails, - ...noteDetails, - ]) + seriesColorMap: SeriesColorMap = new Map() + @observable.ref externalQueryParams: QueryParams - return uniqueDetails - } + private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL + private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL - @computed get detailsMarkerInSvg(): DetailsMarker { - const { isStatic, shouldIncludeDetailsInStaticExport } = this - return !isStatic - ? "underline" - : shouldIncludeDetailsInStaticExport - ? "superscript" - : "none" - } + @observable.ref inputTable: OwidTable - // 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]}**:` + @observable.ref legacyConfigAsAuthored: Partial = {} - text += `${plainText.join(" ")}` - } + // stored on Grapher so state is preserved when switching to full-screen mode - // 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 + @observable.ref renderToStatic = false - 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: this.secondaryColorInStaticCharts, - }, - }) - }) + @observable.ref isSourcesModalOpen = false + @observable.ref isDownloadModalOpen = false + @observable.ref isEmbedModalOpen = false + + @observable + private legacyVariableDataJson?: MultipleOwidVariableDataDimensionsMap + @observable shouldIncludeDetailsInStaticExport = true + private hasLoggedGAViewEvent = false + @observable private hasBeenVisible = false + @observable private uncaughtError?: Error + @observable slideShow?: SlideShowController + @observable isShareMenuActive = false + + timelineController = new TimelineController(this) + + // #endregion + + @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 } - @computed get hasProjectedData(): boolean { - return this.inputTable.numericColumnSlugs.some( - (slug) => this.inputTable.get(slug).isProjection - ) + @computed get chartType(): GrapherChartType | undefined { + return this.validChartTypes[0] } + @computed get tableForSelection(): OwidTable { + // This table specifies which entities can be selected in the charts EntitySelectorModal. + // It should contain all entities that can be selected, and none more. + // Depending on the chart type, the criteria for being able to select an entity are + // different; e.g. for scatterplots, the entity needs to (1) not be excluded and + // (2) needs to have data for the x and y dimension. + let table = this.isScatter + ? this.tableAfterAuthorTimelineAndActiveChartTransform + : this.inputTable - @computed get validChartTypes(): GrapherChartType[] { - const { chartTypes } = this + if (!this.isReady) return table - // all single-chart Graphers are valid - if (chartTypes.length <= 1) return chartTypes + // 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) + } - // find valid combination in a pre-defined list - const validChartTypes = findValidChartTypeCombination(chartTypes) + return table + } - // if the given combination is not valid, then ignore all but the first chart type - if (!validChartTypes) return chartTypes.slice(0, 1) + /** + * 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 - // 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] + if (this.isScatter && this.sizeColumnSlug) { + const tolerance = + table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity + table = table.interpolateColumnWithTolerance( + this.sizeColumnSlug, + tolerance + ) } - return validChartTypes - } + if ((this.isScatter || this.isMarimekko) && this.colorColumnSlug) { + const tolerance = + table.get(this.colorColumnSlug)?.display?.tolerance ?? Infinity + table = table.interpolateColumnWithTolerance( + this.colorColumnSlug, + tolerance + ) + } - @computed get validChartTypeSet(): Set { - return new Set(this.validChartTypes) + return table } - @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 - } + // If an author sets a timeline filter run it early in the pipeline so to the charts it's as if the filtered times do not exist + @computed get tableAfterAuthorTimelineFilter(): OwidTable { + const table = this.tableAfterColorAndSizeToleranceApplication - @computed get hasMultipleChartTypes(): boolean { - return this.validChartTypes.length > 1 + if ( + this.timelineMinTime === undefined && + this.timelineMaxTime === undefined + ) + return table + return table.filterByTimeRange( + this.timelineMinTime ?? -Infinity, + this.timelineMaxTime ?? Infinity + ) } - @computed get currentSubtitle(): string { - const subtitle = this.subtitle - if (subtitle !== undefined) return subtitle - const yColumns = this.yColumnsFromDimensions - if (yColumns.length === 1) return yColumns[0].def.descriptionShort ?? "" - return "" - } + @computed + get tableAfterAuthorTimelineAndActiveChartTransform(): OwidTable { + const table = this.tableAfterAuthorTimelineFilter + if (!this.isReady || !this.isOnChartOrMapTab) return table - @computed get shouldAddEntitySuffixToTitle(): boolean { - const selectedEntityNames = this.selection.selectedEntityNames - const showEntityAnnotation = !this.hideAnnotationFieldsInTitle?.entity + const startMark = performance.now() - const seriesStrategy = - this.chartInstance.seriesStrategy || - autoDetectSeriesStrategy(this, true) + const transformedTable = this.chartInstance.transformTable(table) - return !!( - !this.forceHideAnnotationFieldsInTitle?.entity && - this.tab === GRAPHER_TAB_OPTIONS.chart && - (seriesStrategy !== SeriesStrategy.entity || !this.showLegend) && - selectedEntityNames.length === 1 && - (showEntityAnnotation || - this.canChangeEntity || - this.canSelectMultipleEntities) + this.createPerformanceMeasurement( + "chartInstance.transformTable", + startMark ) + return transformedTable } - @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))) - ) + @computed get chartInstance(): ChartInterface { + // Note: when timeline handles on a LineChart are collapsed into a single handle, the + // LineChart turns into a DiscreteBar. + + return this.isOnMapTab + ? new MapChart({ manager: this }) + : this.chartInstanceExceptMap } - @computed get shouldAddChangeInPrefixToTitle(): boolean { - const showChangeInPrefix = - !this.hideAnnotationFieldsInTitle?.changeInPrefix - return ( - !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && - (this.isOnLineChartTab || this.isOnSlopeChartTab) && - this.isRelativeMode && - showChangeInPrefix - ) + // When Map becomes a first-class chart instance, we should drop this + @computed get chartInstanceExceptMap(): ChartInterface { + const chartTypeName = + this.typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart + + const ChartClass = + ChartComponentClassMap.get(chartTypeName) ?? DefaultChartClass + return new ChartClass({ manager: this }) } - @computed get currentTitle(): string { - let text = this.displayTitle.trim() - if (text.length === 0) return text + @computed get chartSeriesNames(): SeriesName[] { + if (!this.isReady) return [] - // 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}` + // 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.shouldAddEntitySuffixToTitle) { - const selectedEntityNames = this.selection.selectedEntityNames - const entityStr = selectedEntityNames[0] - if (entityStr?.length) text = appendAnnotationField(text, entityStr) - } + return this.chartInstance.series.map((series) => series.seriesName) + } - if (this.shouldAddChangeInPrefixToTitle) - text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) + @computed + private get tableAfterAllTransformsAndFilters(): OwidTable { + const { startTime, endTime } = this + const table = this.tableAfterAuthorTimelineAndActiveChartTransform - if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) - text = appendAnnotationField(text, this.timeTitleSuffix) + if (startTime === undefined || endTime === undefined) return table - return text.trim() - } + if (this.isOnMapTab) + return table.filterByTargetTimes( + [endTime], + this.map.timeTolerance ?? + table.get(this.mapColumnSlug).tolerance + ) - /** - * Uses some explicit and implicit information to decide whether a timeline is shown. - */ - @computed get hasTimeline(): boolean { - // we don't have more than one distinct time point in our data, so it doesn't make sense to show a timeline - if (this.times.length <= 1) return false + if ( + this.isDiscreteBar || + this.isLineChartThatTurnedIntoDiscreteBar || + this.isMarimekko + ) + return table.filterByTargetTimes( + [endTime], + table.get(this.yColumnSlugs[0]).tolerance + ) - switch (this.tab) { - // the map tab has its own `hideTimeline` option - case GRAPHER_TAB_OPTIONS.map: - return !this.map.hideTimeline + if (this.isOnSlopeChartTab) + return table.filterByTargetTimes( + [startTime, endTime], + table.get(this.yColumnSlugs[0]).tolerance + ) - // use the chart-level `hideTimeline` option - case GRAPHER_TAB_OPTIONS.chart: - return !this.hideTimeline - - // use the chart-level `hideTimeline` option for the table, with some exceptions - case GRAPHER_TAB_OPTIONS.table: - // always show the timeline for charts that plot time on the x-axis - if (this.hasTimeDimension) return true - return !this.hideTimeline + return table.filterByTimeRange(startTime, endTime) + } - default: - return false - } + private get isStaging(): boolean { + if (typeof location === "undefined") return false + return location.host.includes("staging") } - @computed private get areHandlesOnSameTime(): boolean { - const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues - const [start, end] = this.timelineHandleTimeBounds.map((time) => - findClosestTime(times, time) - ) - return start === end + private get isLocalhost(): boolean { + if (typeof location === "undefined") return false + return location.host.includes("localhost") } - @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) + /** + * 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" ) - return this.yColumnSlug! - return mapColumnSlug } - getColumnForProperty(property: DimensionProperty): CoreColumn | undefined { - return this.dimensions.find((dim) => dim.property === property)?.column - } + @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. - getSlugForProperty(property: DimensionProperty): string | undefined { - return this.dimensions.find((dim) => dim.property === property) - ?.columnSlug - } + try { + // Cookie access can be restricted by iframe sandboxing, in which case the below code will throw an error + // see https://github.com/owid/owid-grapher/pull/2452 - @computed get yColumnsFromDimensions(): CoreColumn[] { - return this.filledDimensions - .filter((dim) => dim.property === DimensionProperty.y) - .map((dim) => dim.column) + return !!Cookies.get(CookieKey.isAdmin) + } catch { + return false + } } - @computed get yColumnSlugs(): string[] { - return this.ySlugs - ? this.ySlugs.split(" ") - : this.dimensions - .filter((dim) => dim.property === DimensionProperty.y) - .map((dim) => dim.columnSlug) + @action.bound private applyOriginalFocusAsAuthored(): void { + if (this.focusedSeriesNames?.length) + this.focusArray.clearAllAndAdd(...this.focusedSeriesNames) } - @computed get yColumnSlug(): string | undefined { - return this.ySlugs - ? this.ySlugs.split(" ")[0] - : this.getSlugForProperty(DimensionProperty.y) + @computed get hasData(): boolean { + return this.dimensions.length > 0 || this.newSlugs.length > 0 } - @computed get xColumnSlug(): string | undefined { - return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x) + @computed get whatAreWeWaitingFor(): string { + const { newSlugs, inputTable, dimensions } = this + if (newSlugs.length || dimensions.length === 0) { + const missingColumns = newSlugs.filter( + (slug) => !inputTable.has(slug) + ) + return missingColumns.length + ? `Waiting for columns ${missingColumns.join(",")} in table '${ + inputTable.tableSlug + }'. ${inputTable.tableDescription}` + : "" + } + if (dimensions.length > 0 && this.loadingDimensions.length === 0) + return "" + return `Waiting for dimensions ${this.loadingDimensions.join(",")}.` } - @computed get sizeColumnSlug(): string | undefined { - return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size) + // 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 get colorColumnSlug(): string | undefined { - return ( - this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color) + @computed private get loadingDimensions(): ChartDimension[] { + return this.dimensions.filter( + (dim) => !this.inputTable.has(dim.columnSlug) ) } - @computed get yScaleType(): ScaleType | undefined { - return this.yAxis.scaleType + @computed get isInIFrame(): boolean { + return isInIFrame() } - @computed get xScaleType(): ScaleType | undefined { - return this.xAxis.scaleType + /** + * Plots time on the x-axis. + */ + @computed private get hasTimeDimension(): boolean { + return this.isStackedBar || this.isStackedArea || this.isLineChart } - @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 + @computed private get hasTimeDimensionButTimelineIsHidden(): boolean { + return this.hasTimeDimension && !!this.hideTimeline + } - const time = - startTime === endTime - ? timeColumn.formatValue(startTime) - : timeColumn.formatValue(startTime) + - " to " + - timeColumn.formatValue(endTime) + @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) + ) - return time + this.dimensionSlots.forEach((slot) => { + if (!slot.allowMultiple) + validDimensions = uniqWith( + validDimensions, + ( + a: OwidChartDimensionInterface, + b: OwidChartDimensionInterface + ) => + a.property === slot.property && + a.property === b.property + ) + }) + + return validDimensions } - @computed get sourcesLine(): string { - return this.sourceDesc ?? this.defaultSourcesLine + // 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 } - // Columns that are used as a dimension in the currently active view - @computed get activeColumnSlugs(): string[] { - const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = - this + @computed get timelineHandleTimeBounds(): TimeBounds { + if (this.isOnMapTab) { + const time = maxTimeBoundFromJSONOrPositiveInfinity(this.map.time) + return [time, time] + } - // sort y columns by their display name - const sortedYColumnSlugs = sortBy( - yColumnSlugs, - (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title - ) + // If the timeline is hidden on the chart tab but displayed on the table tab + // (which is the case for charts that plot time on the x-axis), + // we always want to use the authored `minTime` and `maxTime` for the chart, + // irrespective of the time range the user might have selected on the table tab + if (this.isOnChartTab && this.hasTimeDimensionButTimelineIsHidden) { + const { minTime, maxTime } = this.authorsVersion + return [ + minTimeBoundFromJSONOrNegativeInfinity(minTime), + maxTimeBoundFromJSONOrPositiveInfinity(maxTime), + ] + } - return excludeUndefined([ - ...sortedYColumnSlugs, - xColumnSlug, - sizeColumnSlug, - colorColumnSlug, - ]) + return [ + // Handle `undefined` values in minTime/maxTime + minTimeBoundFromJSONOrNegativeInfinity(this.minTime), + maxTimeBoundFromJSONOrPositiveInfinity(this.maxTime), + ] } - @computed get columnsWithSourcesExtensive(): CoreColumn[] { - const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } = - this + @computed private get onlySingleTimeSelectionPossible(): boolean { + return ( + this.isDiscreteBar || + this.isStackedDiscreteBar || + this.isOnMapTab || + this.isMarimekko + ) + } - // sort y-columns by their display name - const sortedYColumnSlugs = sortBy( - yColumnSlugs, - (slug) => this.inputTable.get(slug).titlePublicOrDisplayName.title + @computed private get isSingleTimeSelectionActive(): boolean { + return ( + this.onlySingleTimeSelectionPossible || + this.isSingleTimeScatterAnimationActive ) + } - const columnSlugs = excludeUndefined([ - ...sortedYColumnSlugs, - xColumnSlug, - sizeColumnSlug, - colorColumnSlug, - ]) + @computed get shouldLinkToOwid(): boolean { + if ( + this.isEmbeddedInAnOwidPage || + this.isExportingToSvgOrPng || + !this.isInIFrame + ) + return false - return this.inputTable - .getColumns(uniq(columnSlugs)) - .filter( - (column) => !!column.source.name || !isEmpty(column.def.origins) - ) + return true } - getColumnSlugsForCondensedSources(): string[] { - const { xColumnSlug, sizeColumnSlug, colorColumnSlug, isMarimekko } = - this - const columnSlugs: string[] = [] + @computed.struct private get variableIds(): number[] { + return uniq(this.dimensions.map((d) => d.variableId)) + } - // exclude "Countries Continent" if it's used as the color dimension in a scatter plot, slope chart etc. - if ( - colorColumnSlug !== undefined && - !isContinentsVariableId(colorColumnSlug) + @computed get hasOWIDLogo(): boolean { + return ( + !this.hideLogo && (this.logo === undefined || this.logo === "owid") ) - 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) - } + // todo: did this name get botched in a merge? + @computed get hasFatalErrors(): boolean { + const { relatedQuestions = [] } = this + return relatedQuestions.some( + (question) => !!getErrorMessageRelatedQuestionUrl(question) + ) + } - // 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) + // 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] + } + + // 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: this.secondaryColorInStaticCharts, + }, + }) + }) + } + + @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] } - return columnSlugs + + return validChartTypes + } + + @computed get validChartTypeSet(): Set { + return new Set(this.validChartTypes) + } + + @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 hasMultipleChartTypes(): boolean { + return this.validChartTypes.length > 1 + } + + @computed get currentSubtitle(): string { + const subtitle = this.subtitle + if (subtitle !== undefined) return subtitle + const yColumns = this.yColumnsFromDimensions + if (yColumns.length === 1) return yColumns[0].def.descriptionShort ?? "" + return "" + } + + @computed get shouldAddEntitySuffixToTitle(): boolean { + const selectedEntityNames = this.selection.selectedEntityNames + const showEntityAnnotation = !this.hideAnnotationFieldsInTitle?.entity + + const seriesStrategy = + this.chartInstance.seriesStrategy || + autoDetectSeriesStrategy(this, true) + + return !!( + !this.forceHideAnnotationFieldsInTitle?.entity && + this.tab === GRAPHER_TAB_OPTIONS.chart && + (seriesStrategy !== SeriesStrategy.entity || !this.showLegend) && + selectedEntityNames.length === 1 && + (showEntityAnnotation || + this.canChangeEntity || + this.canSelectMultipleEntities) + ) + } + + @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))) + ) + } + + @computed get shouldAddChangeInPrefixToTitle(): boolean { + const showChangeInPrefix = + !this.hideAnnotationFieldsInTitle?.changeInPrefix + return ( + !this.forceHideAnnotationFieldsInTitle?.changeInPrefix && + (this.isOnLineChartTab || this.isOnSlopeChartTab) && + this.isRelativeMode && + showChangeInPrefix + ) + } + + @computed private get areHandlesOnSameTime(): boolean { + const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues + const [start, end] = this.timelineHandleTimeBounds.map((time) => + findClosestTime(times, time) + ) + return start === end + } + + @computed get yColumnsFromDimensions(): CoreColumn[] { + return this.filledDimensions + .filter((dim) => dim.property === DimensionProperty.y) + .map((dim) => dim.column) + } + + @computed get yScaleType(): ScaleType | undefined { + return this.yAxis.scaleType + } + + @computed get xScaleType(): ScaleType | undefined { + return this.xAxis.scaleType + } + + @computed private get timeTitleSuffix(): string | undefined { + const timeColumn = this.table.timeColumn + if (timeColumn.isMissing) return undefined // Do not show year until data is loaded + 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 } @computed get columnsWithSourcesCondensed(): CoreColumn[] { @@ -1888,13 +2079,6 @@ export class Grapher ) } - // todo: remove when we remove dimensions - @computed get yColumnsFromDimensionsOrSlugsOrAuto(): CoreColumn[] { - return this.yColumnsFromDimensions.length - ? this.yColumnsFromDimensions - : this.table.getColumns(autoDetectYColumnSlugs(this)) - } - @computed private get defaultTitle(): string { const yColumns = this.yColumnsFromDimensionsOrSlugsOrAuto @@ -1940,7 +2124,7 @@ export class Grapher get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { return this.isLineChartThatTurnedIntoDiscreteBarActive ? GRAPHER_CHART_TYPES.DiscreteBar - : (this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart) + : this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart } @computed get isLineChart(): boolean { @@ -1970,42 +2154,12 @@ export class Grapher return this.chartType === GRAPHER_CHART_TYPES.StackedDiscreteBar } - @computed get isLineChartThatTurnedIntoDiscreteBar(): boolean { - if (!this.isLineChart) return false - - 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 - } - - // This is the easy case: minTime and maxTime are the same, no need to do - // more fancy checks - if (minTime === maxTime) return true - - // We can have cases where minTime = Infinity and/or maxTime = -Infinity, - // but still only a single year is selected. - // To check for that we need to look at the times array. - const times = this.tableAfterAuthorTimelineFilter.timeColumn.uniqValues - const closestMinTime = findClosestTime(times, minTime ?? -Infinity) - const closestMaxTime = findClosestTime(times, maxTime ?? Infinity) - return closestMinTime !== undefined && closestMinTime === closestMaxTime - } - @computed get isLineChartThatTurnedIntoDiscreteBarActive(): boolean { return ( this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar ) } - @computed get isOnLineChartTab(): boolean { - return this.activeChartType === GRAPHER_CHART_TYPES.LineChart - } @computed get isOnScatterTab(): boolean { return this.activeChartType === GRAPHER_CHART_TYPES.ScatterPlot } @@ -2045,19 +2199,6 @@ export class Grapher ) } - // 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 - } - - // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class? - set xOverrideTime(value: number | undefined) { - this.xDimension!.targetYear = value - } - @computed get defaultBounds(): Bounds { return new Bounds(0, 0, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT) } @@ -2066,150 +2207,10 @@ export class Grapher return this.dimensions.some((d) => d.property === DimensionProperty.y) } - @observable.ref private _staticFormat = GrapherStaticFormat.landscape - - @computed get staticFormat(): GrapherStaticFormat { - if (this.props.staticFormat) return this.props.staticFormat - return this._staticFormat - } - - set staticFormat(format: GrapherStaticFormat) { - this._staticFormat = format - } - - getStaticBounds(format: GrapherStaticFormat): Bounds { - switch (format) { - case GrapherStaticFormat.landscape: - return this.defaultBounds - case GrapherStaticFormat.square: - return new Bounds( - 0, - 0, - GRAPHER_SQUARE_SIZE, - GRAPHER_SQUARE_SIZE - ) - default: - return this.defaultBounds - } - } - - @computed get staticBounds(): Bounds { - if (this.props.staticBounds) return this.props.staticBounds - return this.getStaticBounds(this.staticFormat) - } - - generateStaticSvg(): string { - const _isExportingToSvgOrPng = this.isExportingToSvgOrPng - this.isExportingToSvgOrPng = true - const staticSvg = ReactDOMServer.renderToStaticMarkup( - - ) - this.isExportingToSvgOrPng = _isExportingToSvgOrPng - return staticSvg - } - - get staticSVG(): string { - return this.generateStaticSvg() - } - - @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) - } - - rasterize(): Promise { - const { width, height } = this.staticBoundsWithDetails - const staticSVG = this.generateStaticSvg() - - return new StaticChartRasterizer(staticSVG, width, height).render() - } - - @computed get disableIntroAnimation(): boolean { - return this.isStatic - } - - @computed get mapConfig(): MapConfig { - return this.map - } - @computed get cacheTag(): string { return this.version.toString() } - @computed get mapIsClickable(): boolean { - return ( - this.hasChartTab && - (this.hasLineChart || this.isScatter) && - !isMobile() - ) - } - - @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" - } - - // 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 - } - - @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 - } - // Filter data to what can be display on the map (across all times) @computed get mappableData(): OwidVariableRow[] { return this.inputTable @@ -2217,88 +2218,6 @@ export class Grapher .owidRows.filter((row) => isOnTheMap(row.entityName)) } - static renderGrapherIntoContainer( - config: GrapherProgrammaticInterface, - containerNode: Element - ): React.RefObject { - const grapherInstanceRef = React.createRef() - - let ErrorBoundary = React.Fragment as React.ComponentType // use React.Fragment as a sort of default error boundary if Bugsnag is not available - if (Bugsnag && (Bugsnag as any)._client) { - ErrorBoundary = - Bugsnag.getPlugin("react")?.createErrorBoundary(React) ?? - React.Fragment - } - - 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 props: GrapherProgrammaticInterface = { - ...config, - bounds: Bounds.fromRect(entry.contentRect), - } - ReactDOM.render( - - - , - containerNode - ) - } - - 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" - ) - Bugsnag?.notify("ResizeObserver not available") - } - - return grapherInstanceRef - } - - static renderSingleGrapherOnGrapherPage( - jsonConfig: GrapherInterface - ): void { - const container = document.getElementsByTagName("figure")[0] - try { - Grapher.renderGrapherIntoContainer( - { - ...jsonConfig, - bindUrlToWindow: true, - enableKeyboardShortcuts: true, - queryStr: window.location.search, - }, - container - ) - } catch (err) { - container.innerHTML = `

Unable to load interactive visualization

` - container.setAttribute("id", "fallback") - throw err - } - } - @computed get isMobile(): boolean { return isMobile() } @@ -2438,25 +2357,6 @@ export class Grapher ) } - @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 captionedChartBounds(): Bounds { - // if there's no panel, the chart takes up the whole frame - if (!this.isEntitySelectorPanelActive) return this.frameBounds - - return new Bounds( - 0, - 0, - // the chart takes up 9 columns in 12-column grid - (9 / 12) * this.frameBounds.width, - this.frameBounds.height - 2 // 2px accounts for the border - ) - } - @computed get sidePanelBounds(): Bounds | undefined { if (!this.isEntitySelectorPanelActive) return @@ -2467,205 +2367,15 @@ export class Grapher this.captionedChartBounds.height ) } - - base: React.RefObject = React.createRef() - @computed get containerElement(): HTMLDivElement | undefined { return this.base.current || undefined } - private hasLoggedGAViewEvent = false - @observable private hasBeenVisible = false - @observable private uncaughtError?: Error - - @action.bound setError(err: Error): void { - this.uncaughtError = err - } - - @action.bound clearErrors(): void { - this.uncaughtError = undefined - } - - private get commandPalette(): React.ReactElement | null { - return this.props.enableKeyboardShortcuts ? ( - - ) : null - } - - formatTimeFn(time: Time): string { - return this.inputTable.timeColumn.formatTime(time) - } - - @action.bound private toggleTabCommand(): void { - this.setTab(next(this.availableTabs, this.activeTab)) - } - - @action.bound private togglePlayingCommand(): void { - void this.timelineController.togglePlay() - } - - @computed get availableEntities(): Entity[] { - return this.tableForSelection.availableEntities - } - - 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", - }) - } - - return shortcuts - } - - @observable slideShow?: SlideShowController - - @action.bound private toggleTimelineCommand(): void { - // Todo: add tests for this - this.setTimeFromTimeQueryParam( - next(["latest", "earliest", ".."], this.timeParam!) - ) - } - - @action.bound private toggleYScaleTypeCommand(): void { - this.yAxis.scaleType = next( - [ScaleType.linear, ScaleType.log], - this.yAxis.scaleType - ) - } - - @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 - } - - @computed get hasMultipleYColumns(): boolean { - return this.yColumnSlugs.length > 1 + @computed get availableEntities(): Entity[] { + return this.tableForSelection.availableEntities + } + @computed get hasMultipleYColumns(): boolean { + return this.yColumnSlugs.length > 1 } @computed private get hasSingleMetricInFacets(): boolean { @@ -2725,965 +2435,1458 @@ export class Grapher ) } - @computed get availableFacetStrategies(): FacetStrategy[] { - return this.chartInstance.availableFacetStrategies?.length - ? this.chartInstance.availableFacetStrategies - : [FacetStrategy.none] - } - - // 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) - } - - set facetStrategy(facet: FacetStrategy) { - this.selectedFacetStrategy = facet - } - @computed get isFaceted(): boolean { const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none return this.isOnChartTab && hasFacetStrategy } - @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()) - ) - } - @computed get isInFullScreenMode(): boolean { return this._isInFullScreenMode } - 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") - } + // the header and footer don't rely on the base font size unless explicitly specified + @computed get useBaseFontSize(): boolean { + return this.props.baseFontSize !== undefined || this.isStatic + } - // dismiss the share menu - this.isShareMenuActive = 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 + } - this._isInFullScreenMode = newValue + @computed get secondaryColorInStaticCharts(): Color { + return this.isStaticAndSmall ? GRAPHER_LIGHT_TEXT : GRAPHER_DARK_TEXT } - @action.bound toggleFullScreenMode(): void { - this.isInFullScreenMode = !this.isInFullScreenMode + @computed get isExportingForSocialMedia(): boolean { + return ( + this.isExportingToSvgOrPng && + this.isStaticAndSmall && + this.isSocialMediaExport + ) } - @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 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 isModalOpen(): boolean { + @computed get isRelatedQuestionTargetDifferentFromCurrentPage(): boolean { + // comparing paths rather than full URLs for this to work as + // expected on local and staging where the origin (e.g. + // hans.owid.cloud) doesn't match the production origin that has + // been entered in the related question URL field: + // "ourworldindata.org" and yet should still yield a match. + // - Note that this won't work on production previews (where the + // path is /admin/posts/preview/ID) + const { hasRelatedQuestion, relatedQuestions = [] } = this + const relatedQuestion = relatedQuestions[0] return ( - this.isEntitySelectorModalOpen || - this.isSourcesModalOpen || - this.isEmbedModalOpen || - this.isDownloadModalOpen + hasRelatedQuestion && + !!relatedQuestion && + getWindowUrl().pathname !== + Url.fromURL(relatedQuestion.url).pathname ) } - private renderError(): React.ReactElement { + @computed get showRelatedQuestion(): boolean { return ( -
-

- - {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}
-                    
- )} -
+ !!this.relatedQuestions && + !!this.hasRelatedQuestion && + !!this.isRelatedQuestionTargetDifferentFromCurrentPage ) } - 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, - }) - - const activeBounds = this.renderToStatic - ? this.staticBounds - : this.frameBounds + @computed.struct get allParams(): GrapherQueryParams { + return grapherObjectToQueryParams(this) + } - const containerStyle = { - width: activeBounds.width, - height: activeBounds.height, - fontSize: this.isExportingToSvgOrPng - ? 18 - : Math.min(16, this.fontSize), // cap font size at 16px - } + @computed get areSelectedEntitiesDifferentThanAuthors(): boolean { + const authoredConfig = this.legacyConfigAsAuthored + const currentSelectedEntityNames = this.selection.selectedEntityNames + const originalSelectedEntityNames = + authoredConfig.selectedEntityNames ?? [] - return ( -
- {this.commandPalette} - {this.uncaughtError ? this.renderError() : this.renderReady()} -
+ return isArrayDifferentFromReference( + currentSelectedEntityNames, + originalSelectedEntityNames ) } - 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 get areFocusedSeriesNamesDifferentThanAuthors(): boolean { + const authoredConfig = this.legacyConfigAsAuthored + const currentFocusedSeriesNames = this.focusArray.seriesNames + const originalFocusedSeriesNames = + authoredConfig.focusedSeriesNames ?? [] - if (this.isInFullScreenMode) { - return ( - - {this.renderGrapherComponent()} - - ) - } + return isArrayDifferentFromReference( + currentFocusedSeriesNames, + originalFocusedSeriesNames + ) + } - return this.renderGrapherComponent() + // 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) } - private renderReady(): React.ReactElement | null { - if (!this.hasBeenVisible) return null + // 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: "", + }) + } - if (this.renderToStatic) { - return - } + @computed get canonicalUrlIfIsChartView(): string | undefined { + if (!this.chartViewInfo) return undefined - return ( - <> - {/* captioned chart and entity selector */} -
- - {this.sidePanelBounds && ( - - - - )} -
+ const { parentChartSlug, queryParamsForParentChart } = + this.chartViewInfo - {/* modals */} - {this.isSourcesModalOpen && } - {this.isDownloadModalOpen && } - {this.isEmbedModalOpen && } - {this.isEntitySelectorModalOpen && ( - - )} + const combinedQueryParams = { + ...queryParamsForParentChart, + ...this.changedParams, + } - {/* entity selector in a slide-in drawer */} - { - this.isEntitySelectorModalOrDrawerOpen = - !this.isEntitySelectorModalOrDrawerOpen - }} - > - - + return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr( + combinedQueryParams + )}` + } - {/* tooltip: either pin to the bottom or render into the chart area */} - {this.shouldPinTooltipToBottom ? ( - - - - ) : ( - - )} - + @computed get isOnCanonicalUrl(): boolean { + if (!this.canonicalUrl) return false + return ( + getWindowUrl().pathname === Url.fromURL(this.canonicalUrl).pathname ) } - // 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 private get hasUserChangedTimeHandles(): boolean { + const authorsVersion = this.authorsVersion + return ( + this.minTime !== authorsVersion.minTime || + this.maxTime !== authorsVersion.maxTime + ) + } - if (this.slug && !this.hasLoggedGAViewEvent) { - this.analytics.logGrapherView(this.slug) - this.hasLoggedGAViewEvent = true - } - } + @computed private get hasUserChangedMapTimeHandle(): boolean { + return this.map.time !== this.authorsVersion.map.time + } - // 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] } + @computed get timeParam(): string | undefined { + const { timeColumn } = this.table + const formatTime = (t: Time): string => + timeBoundToTimeBoundString( + t, + timeColumn instanceof ColumnTypeMap.Day ) - 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 + + if (this.isOnMapTab) { + return this.map.time !== undefined && + this.hasUserChangedMapTimeHandle + ? formatTime(this.map.time) + : undefined } - } - @observable private _baseFontSize = BASE_FONT_SIZE + if (!this.hasUserChangedTimeHandles) return undefined - @computed get baseFontSize(): number { - if (this.isStaticAndSmall) { - return this.computeBaseFontSizeFromHeight(this.staticBounds) - } - if (this.isStatic) return 18 - return this._baseFontSize + const [startTime, endTime] = + this.timelineHandleTimeBounds.map(formatTime) + return startTime === endTime ? startTime : `${startTime}..${endTime}` } - set baseFontSize(val: number) { - this._baseFontSize = val + @computed get canAddEntities(): boolean { + return ( + this.hasChartTab && + this.canSelectMultipleEntities && + (this.isOnLineChartTab || + this.isOnSlopeChartTab || + this.isOnStackedAreaTab || + this.isOnStackedBarTab || + this.isOnDiscreteBarTab || + this.isOnStackedDiscreteBarTab) + ) } - // the header and footer don't rely on the base font size unless explicitly specified - @computed get useBaseFontSize(): boolean { - return this.props.baseFontSize !== undefined || this.isStatic - } + @computed get 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 - private computeBaseFontSizeFromHeight(bounds: Bounds): number { - const squareBounds = this.getStaticBounds(GrapherStaticFormat.square) - const factor = squareBounds.height / 21 - return Math.max(10, bounds.height / factor) + return this.isSemiNarrow + ? GrapherWindowType.modal + : GrapherWindowType.drawer } - 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 + @computed get isEntitySelectorPanelActive(): boolean { + return ( + !this.hideEntityControls && + this.canChangeAddOrHighlightEntities && + this.isOnChartTab && + this.showEntitySelectorAs === GrapherWindowType.panel + ) } - @action.bound private setBaseFontSize(): void { - this.baseFontSize = this.computeBaseFontSizeFromWidth( - this.captionedChartBounds + @computed get showEntitySelectionToggle(): boolean { + return ( + !this.hideEntityControls && + this.canChangeAddOrHighlightEntities && + this.isOnChartTab && + (this.showEntitySelectorAs === GrapherWindowType.modal || + this.showEntitySelectorAs === GrapherWindowType.drawer) ) } - @computed get fontSize(): number { - return this.props.baseFontSize ?? this.baseFontSize + @computed get isEntitySelectorModalOpen(): boolean { + return ( + this.isEntitySelectorModalOrDrawerOpen && + this.showEntitySelectorAs === GrapherWindowType.modal + ) } - @computed get isNarrow(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 420 + @computed get isEntitySelectorDrawerOpen(): boolean { + return ( + this.isEntitySelectorModalOrDrawerOpen && + this.showEntitySelectorAs === GrapherWindowType.drawer + ) } - @computed get isSemiNarrow(): boolean { - if (this.isStatic) return false - return this.frameBounds.width <= 550 + // 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 } - // 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 + /** + * 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 } - // 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 + @action.bound updateAuthoredVersion( + config: Partial + ): void { + this.legacyConfigAsAuthored = { + ...this.legacyConfigAsAuthored, + ...config, + } } - @computed get isStaticAndSmall(): boolean { - if (!this.isStatic) return false - return this.areStaticBoundsSmall - } + constructor( + propsWithGrapherInstanceGetter: GrapherProgrammaticInterface = {} + ) { + super(propsWithGrapherInstanceGetter) - @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 - } + const { getGrapherInstance, ...props } = propsWithGrapherInstanceGetter - @computed get secondaryColorInStaticCharts(): Color { - return this.isStaticAndSmall ? GRAPHER_LIGHT_TEXT : GRAPHER_DARK_TEXT - } + this.inputTable = props.table ?? BlankOwidTable(`initialGrapherTable`) - @computed get isExportingForSocialMedia(): boolean { - return ( - this.isExportingToSvgOrPng && - this.isStaticAndSmall && - this.isSocialMediaExport + if (props) this.setAuthoredVersion(props) + + // prefer the manager's selection over the config's selectedEntityNames + // if both are passed in and the manager's selection is not empty. + // this is necessary for the global entity selector to work correctly. + if (props.manager?.selection?.hasSelection) { + this.updateFromObject(omit(props, "selectedEntityNames")) + } else { + this.updateFromObject(props) + } + + this.populateFromQueryParams( + legacyToCurrentGrapherQueryParams(props.queryStr ?? "") + ) + this.externalQueryParams = omit( + Url.fromQueryStr(props.queryStr ?? "").queryParams, + GRAPHER_QUERY_PARAM_KEYS ) - } - @computed get backgroundColor(): Color { - return this.isExportingForSocialMedia - ? GRAPHER_BACKGROUND_BEIGE - : GRAPHER_BACKGROUND_DEFAULT - } + if (this.isEditor) { + this.ensureValidConfigWhenEditing() + } - @computed get shouldPinTooltipToBottom(): boolean { - return this.isNarrow && this.isTouchDevice + if (getGrapherInstance) getGrapherInstance(this) // todo: possibly replace with more idiomatic ref } - // 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()) + toObject(): GrapherInterface { + const obj: GrapherInterface = objectWithPersistablesToObject( + this, + grapherKeysToSerialize ) - autorun(() => (document.title = this.currentTitle)) - } + obj.selectedEntityNames = this.selection.selectedEntityNames + obj.focusedSeriesNames = this.focusArray.seriesNames - @action.bound private setUpWindowResizeEventHandler(): void { - const updateWindowDimensions = (): void => { - this.windowInnerWidth = window.innerWidth - this.windowInnerHeight = window.innerHeight - } - const onResize = debounce(updateWindowDimensions, 400, { - leading: true, - }) + deleteRuntimeAndUnchangedProps(obj, defaultObject) - if (typeof window !== "undefined") { - updateWindowDimensions() - window.addEventListener("resize", onResize) - this.disposers.push(() => { - window.removeEventListener("resize", onResize) - }) - } + // 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 } - 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() + @action.bound updateFromObject(obj?: GrapherProgrammaticInterface): void { + if (!obj) return + + updatePersistables(this, obj) + + // Regression fix: some legacies have this set to Null. Todo: clean DB. + if (obj.originUrl === null) this.originUrl = "" + + // update selection + if (obj.selectedEntityNames) + this.selection.setSelectedEntities(obj.selectedEntityNames) + + // update focus + if (obj.focusedSeriesNames) + this.focusArray.clearAllAndAdd(...obj.focusedSeriesNames) + + // JSON doesn't support Infinity, so we use strings instead. + this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime) + this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime) + + this.timelineMinTime = minTimeBoundFromJSONOrNegativeInfinity( + obj.timelineMinTime + ) + this.timelineMaxTime = maxTimeBoundFromJSONOrPositiveInfinity( + obj.timelineMaxTime + ) + + // Todo: remove once we are more RAII. + if (obj?.dimensions?.length) + this.setDimensionsFromConfigs(obj.dimensions) + } + + @action.bound populateFromQueryParams(params: GrapherQueryParams): void { + // Set tab if specified + if (params.tab) { + const tab = this.mapQueryParamToGrapherTab(params.tab) + if (tab) this.setTab(tab) + else console.error("Unexpected tab: " + params.tab) + } + + // Set overlay if specified + const overlay = params.overlay + if (overlay) { + if (overlay === "sources") { + this.isSourcesModalOpen = true + } else if (overlay === "download") { + this.isDownloadModalOpen = true + } else { + console.error("Unexpected overlay: " + overlay) + } + } + + // Stack mode for bar and stacked area charts + this.stackMode = (params.stackMode ?? this.stackMode) as StackMode + + this.zoomToSelection = + params.zoomToSelection === "true" ? true : this.zoomToSelection + + // Axis scale mode + const xScaleType = params.xScale + if (xScaleType) { + if (xScaleType === ScaleType.linear || xScaleType === ScaleType.log) + this.xAxis.scaleType = xScaleType + else console.error("Unexpected xScale: " + xScaleType) + } + + const yScaleType = params.yScale + if (yScaleType) { + if (yScaleType === ScaleType.linear || yScaleType === ScaleType.log) + this.yAxis.scaleType = yScaleType + else console.error("Unexpected xScale: " + yScaleType) + } + + const time = params.time + if (time !== undefined && time !== "") + this.setTimeFromTimeQueryParam(time) + + const endpointsOnly = params.endpointsOnly + if (endpointsOnly !== undefined) + this.compareEndPointsOnly = endpointsOnly === "1" ? true : undefined + + const region = params.region + if (region !== undefined) + this.map.projection = region as MapProjectionName + + // selection + const selection = getSelectedEntityNamesParam( + Url.fromQueryParams(params) + ) + if (this.addCountryMode !== EntitySelectionMode.Disabled && selection) + this.selection.setSelectedEntities(selection) + + // focus + const focusedSeriesNames = getFocusedSeriesNamesParam(params.focus) + if (focusedSeriesNames) { + this.focusArray.clearAllAndAdd(...focusedSeriesNames) + } + + // faceting + if (params.facet && params.facet in FacetStrategy) { + this.selectedFacetStrategy = params.facet as FacetStrategy + } + if (params.uniformYAxis === "0") { + this.yAxis.facetDomain = FacetAxisDomain.independent + } else if (params.uniformYAxis === "1") { + this.yAxis.facetDomain = FacetAxisDomain.shared + } + + // only relevant for the table + if (params.showSelectionOnlyInTable) { + this.showSelectionOnlyInDataTable = + params.showSelectionOnlyInTable === "1" ? true : undefined + } + + if (params.showNoDataArea) { + this.showNoDataArea = params.showNoDataArea === "1" + } + } + + @action.bound private setTimeFromTimeQueryParam(time: string): void { + this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map( + (time) => findClosestTime(this.times, time) ?? time + ) as TimeBounds + } + + // 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 + private createPerformanceMeasurement( + name: string, + startMark: number + ): void { + const endMark = performance.now() + const detail = { + devtools: { + track: "Grapher", + properties: [ + // might be missing for charts within explorers or mdims + ["slug", this.slug ?? "missing-slug"], + ["chartTypes", this.validChartTypes], + ["tab", this.tab], + ], + }, + } + + try { + performance.measure(name, { + start: startMark, + end: endMark, + detail, + }) + } catch { + // In old browsers, the above may throw an error - just ignore it + } + } + @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 table = legacyToOwidTableAndDimensions( + json, + legacyConfig.dimensions ?? [] + ) + const tableWithColors = legacyConfig.selectedEntityColors + ? addSelectedEntityColorsToTable( + table, + legacyConfig.selectedEntityColors + ) + : table + const dimensions = legacyConfig.dimensions + ? computeActualDimensions(legacyConfig.dimensions) + : [] + this.createPerformanceMeasurement( + "legacyToOwidTableAndDimensions", + startMark + ) + + 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() + } + + @action rebuildInputOwidTable(): void { + // TODO grapher model: switch this to downloading multiple data and metadata files + if (!this.legacyVariableDataJson) return + this._setInputTable( + this.legacyVariableDataJson, + this.legacyConfigAsAuthored + ) + } + + @action.bound appendNewEntitySelectionOptions(): void { + const { selection } = this + const currentEntities = selection.availableEntityNameSet + const missingEntities = this.availableEntities.filter( + (entity) => !currentEntities.has(entity.entityName) + ) + selection.addAvailableEntityNames(missingEntities) + } + + @action.bound private applyOriginalSelectionAsAuthored(): void { + if (this.selectedEntityNames?.length) + this.selection.setSelectedEntities(this.selectedEntityNames) + } + + set startHandleTimeBound(newValue: TimeBound) { + if (this.isSingleTimeSelectionActive) + this.timelineHandleTimeBounds = [newValue, newValue] + else + this.timelineHandleTimeBounds = [ + newValue, + this.timelineHandleTimeBounds[1], + ] + } + + set endHandleTimeBound(newValue: TimeBound) { + if (this.isSingleTimeSelectionActive) + this.timelineHandleTimeBounds = [newValue, newValue] + else + this.timelineHandleTimeBounds = [ + this.timelineHandleTimeBounds[0], + newValue, + ] + } + + // Keeps a running cache of series colors at the Grapher level. + + disposers: (() => void)[] = [] + + @bind dispose(): void { + this.disposers.forEach((dispose) => dispose()) + } + + @action.bound setTab(newTab: GrapherTabName): void { + if (newTab === GRAPHER_TAB_NAMES.Table) { + this.tab = GRAPHER_TAB_OPTIONS.table + this.chartTab = undefined + } else if (newTab === GRAPHER_TAB_NAMES.WorldMap) { + this.tab = GRAPHER_TAB_OPTIONS.map + this.chartTab = undefined + } else { + this.tab = GRAPHER_TAB_OPTIONS.chart + this.chartTab = newTab + } + } + + @action.bound onTabChange( + oldTab: GrapherTabName, + newTab: GrapherTabName + ): void { + // if switching from a line to a slope chart and the handles are + // on the same time, then automatically adjust the handles so that + // the slope chart view is meaningful + if ( + oldTab === GRAPHER_TAB_NAMES.LineChart && + newTab === GRAPHER_TAB_NAMES.SlopeChart && + this.areHandlesOnSameTime + ) { + if (this.startHandleTimeBound !== -Infinity) { + this.startHandleTimeBound = -Infinity + } else { + this.endHandleTimeBound = Infinity + } + } + } + + // todo: can we remove this? + // I believe these states can only occur during editing. + @action.bound private ensureValidConfigWhenEditing(): void { + const disposers = [ + autorun(() => { + if (!this.availableTabs.includes(this.activeTab)) + runInAction(() => this.setTab(this.availableTabs[0])) + }), + autorun(() => { + const validDimensions = this.validDimensions + if (!isEqual(this.dimensions, validDimensions)) + this.dimensions = validDimensions + }), + ] + this.disposers.push(...disposers) + } + set timelineHandleTimeBounds(value: TimeBounds) { + if (this.isOnMapTab) { + this.map.time = value[1] + } else { + this.minTime = value[0] + this.maxTime = value[1] + } + } + + @action.bound addDimension(config: OwidChartDimensionInterface): void { + this.dimensions.push(new ChartDimension(config, this)) + } + + @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 + } + + @action.bound setDimensionsFromConfigs( + configs: OwidChartDimensionInterface[] + ): void { + this.dimensions = configs.map( + (config) => new ChartDimension(config, this) + ) + } + + getColumnForProperty(property: DimensionProperty): CoreColumn | undefined { + return this.dimensions.find((dim) => dim.property === property)?.column + } + + getSlugForProperty(property: DimensionProperty): string | undefined { + return this.dimensions.find((dim) => dim.property === property) + ?.columnSlug + } + + 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) + ) + 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: this is only relevant for scatter plots and Marimekko. move to scatter plot class? + set xOverrideTime(value: number | undefined) { + this.xDimension!.targetYear = value + } + + set staticFormat(format: GrapherStaticFormat) { + this._staticFormat = format + } + + getStaticBounds(format: GrapherStaticFormat): Bounds { + switch (format) { + case GrapherStaticFormat.landscape: + return this.defaultBounds + case GrapherStaticFormat.square: + return new Bounds( + 0, + 0, + GRAPHER_SQUARE_SIZE, + GRAPHER_SQUARE_SIZE + ) + default: + return this.defaultBounds + } + } + + generateStaticSvg(): string { + const _isExportingToSvgOrPng = this.isExportingToSvgOrPng + this.isExportingToSvgOrPng = true + const staticSvg = ReactDOMServer.renderToStaticMarkup( + ) - if (this.props.bindUrlToWindow) this.bindToWindow() - if (this.props.enableKeyboardShortcuts) this.bindKeyboardShortcuts() + this.isExportingToSvgOrPng = _isExportingToSvgOrPng + return staticSvg } - 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 + get staticSVG(): string { + return this.generateStaticSvg() + } + + static renderGrapherIntoContainer( + config: GrapherProgrammaticInterface, + containerNode: Element + ): React.RefObject { + const grapherInstanceRef = React.createRef() + + let ErrorBoundary = React.Fragment as React.ComponentType // use React.Fragment as a sort of default error boundary if Bugsnag is not available + if (Bugsnag && (Bugsnag as any)._client) { + ErrorBoundary = + Bugsnag.getPlugin("react")?.createErrorBoundary(React) ?? + React.Fragment + } + + 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" ) - return false - }) - }) - this._shortcutsBound = true + + // 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 props: GrapherProgrammaticInterface = { + ...config, + bounds: Bounds.fromRect(entry.contentRect), + } + ReactDOM.render( + + + , + containerNode + ) + } + + 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" + ) + Bugsnag?.notify("ResizeObserver not available") + } + + return grapherInstanceRef } - private unbindKeyboardShortcuts(): void { - if (!this._shortcutsBound) return - this.keyboardShortcuts.forEach((shortcut) => { - Mousetrap.unbind(shortcut.combo) - }) - this._shortcutsBound = false + static renderSingleGrapherOnGrapherPage( + jsonConfig: GrapherInterface + ): void { + const container = document.getElementsByTagName("figure")[0] + try { + Grapher.renderGrapherIntoContainer( + { + ...jsonConfig, + bindUrlToWindow: true, + enableKeyboardShortcuts: true, + queryStr: window.location.search, + }, + container + ) + } catch (err) { + container.innerHTML = `

Unable to load interactive visualization

` + container.setAttribute("id", "fallback") + throw err + } } - componentWillUnmount(): void { - this.unbindKeyboardShortcuts() - this.dispose() + @action.bound setError(err: Error): void { + this.uncaughtError = err } - componentDidUpdate(): void { - this.setBaseFontSize() + @action.bound clearErrors(): void { + this.uncaughtError = undefined } - componentDidCatch(error: Error): void { - this.setError(error) - this.analytics.logGrapherViewError(error) + private get commandPalette(): React.ReactElement | null { + return this.props.enableKeyboardShortcuts ? ( + + ) : null } - @observable isShareMenuActive = false + @action.bound private toggleTabCommand(): void { + this.setTab(next(this.availableTabs, this.activeTab)) + } - @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 + @action.bound private togglePlayingCommand(): void { + void this.timelineController.togglePlay() } - @computed get isRelatedQuestionTargetDifferentFromCurrentPage(): boolean { - // comparing paths rather than full URLs for this to work as - // expected on local and staging where the origin (e.g. - // hans.owid.cloud) doesn't match the production origin that has - // been entered in the related question URL field: - // "ourworldindata.org" and yet should still yield a match. - // - Note that this won't work on production previews (where the - // path is /admin/posts/preview/ID) - const { hasRelatedQuestion, relatedQuestions = [] } = this - const relatedQuestion = relatedQuestions[0] - return ( - hasRelatedQuestion && - !!relatedQuestion && - getWindowUrl().pathname !== - Url.fromURL(relatedQuestion.url).pathname - ) + 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", + }) + } + + return shortcuts } - @computed get showRelatedQuestion(): boolean { - return ( - !!this.relatedQuestions && - !!this.hasRelatedQuestion && - !!this.isRelatedQuestionTargetDifferentFromCurrentPage + @action.bound private toggleTimelineCommand(): void { + // Todo: add tests for this + this.setTimeFromTimeQueryParam( + next(["latest", "earliest", ".."], this.timeParam!) ) } - @action.bound clearSelection(): void { - this.selection.clearSelection() - this.applyOriginalSelectionAsAuthored() + @action.bound private toggleYScaleTypeCommand(): void { + this.yAxis.scaleType = next( + [ScaleType.linear, ScaleType.log], + this.yAxis.scaleType + ) } - @action.bound clearFocus(): void { - this.focusArray.clear() - this.applyOriginalFocusAsAuthored() + set facetStrategy(facet: FacetStrategy) { + this.selectedFacetStrategy = facet } - @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 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()) + ) } - // 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] + 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") } - this.ySlugs = grapher.ySlugs - this.xSlug = grapher.xSlug - this.colorSlug = grapher.colorSlug - this.sizeSlug = grapher.sizeSlug + // dismiss the share menu + this.isShareMenuActive = false - this.selection.clearSelection() - this.focusArray.clear() + this._isInFullScreenMode = newValue } - debounceMode = false - - private mapQueryParamToGrapherTab(tab: string): GrapherTabName | undefined { - const { - chartType: defaultChartType, - validChartTypeSet, - hasMapTab, - } = this + @action.bound toggleFullScreenMode(): void { + this.isInFullScreenMode = !this.isInFullScreenMode + } - if (tab === GRAPHER_TAB_QUERY_PARAMS.table) { - return GRAPHER_TAB_NAMES.Table - } - if (tab === GRAPHER_TAB_QUERY_PARAMS.map) { - return GRAPHER_TAB_NAMES.WorldMap + @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 } + } - 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 - } - } + private renderError(): React.ReactElement { + return ( +
+

+ + {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}
+                    
+ )} +
+ ) + } - const chartTypeName = mapQueryParamToChartTypeName(tab) + 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, + }) - if (!chartTypeName) return undefined + const activeBounds = this.renderToStatic + ? this.staticBounds + : this.frameBounds - 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 + const containerStyle = { + width: activeBounds.width, + height: activeBounds.height, + fontSize: this.isExportingToSvgOrPng + ? 18 + : Math.min(16, this.fontSize), // cap font size at 16px } + + return ( +
+ {this.commandPalette} + {this.uncaughtError ? this.renderError() : this.renderReady()} +
+ ) } - 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 + render(): React.ReactElement | undefined { + // TODO how to handle errors in exports? + // TODO remove this? should have a simple toStaticSVG for exporting + if (this.isExportingToSvgOrPng) return - if (!this.hasMultipleChartTypes) return GRAPHER_TAB_QUERY_PARAMS.chart + if (this.isInFullScreenMode) { + return ( + + {this.renderGrapherComponent()} + + ) + } - return mapChartTypeNameToQueryParam(tab) + return this.renderGrapherComponent() } - @computed.struct get allParams(): GrapherQueryParams { - return grapherObjectToQueryParams(this) - } + private renderReady(): React.ReactElement | null { + if (!this.hasBeenVisible) return null - @computed get areSelectedEntitiesDifferentThanAuthors(): boolean { - const authoredConfig = this.legacyConfigAsAuthored - const currentSelectedEntityNames = this.selection.selectedEntityNames - const originalSelectedEntityNames = - authoredConfig.selectedEntityNames ?? [] + if (this.renderToStatic) { + return + } - return isArrayDifferentFromReference( - currentSelectedEntityNames, - originalSelectedEntityNames - ) - } + return ( + <> + {/* captioned chart and entity selector */} +
+ + {this.sidePanelBounds && ( + + + + )} +
- @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean { - const authoredConfig = this.legacyConfigAsAuthored - const currentFocusedSeriesNames = this.focusArray.seriesNames - const originalFocusedSeriesNames = - authoredConfig.focusedSeriesNames ?? [] + {/* modals */} + {this.isSourcesModalOpen && } + {this.isDownloadModalOpen && } + {this.isEmbedModalOpen && } + {this.isEntitySelectorModalOpen && ( + + )} - return isArrayDifferentFromReference( - currentFocusedSeriesNames, - originalFocusedSeriesNames + {/* entity selector in a slide-in drawer */} + { + this.isEntitySelectorModalOrDrawerOpen = + !this.isEntitySelectorModalOrDrawerOpen + }} + > + + + + {/* tooltip: either pin to the bottom or render into the chart area */} + {this.shouldPinTooltipToBottom ? ( + + + + ) : ( + + )} + ) } - // 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) - } + // Chart should only render SVG when it's on the screen + @action.bound private setUpIntersectionObserver(): void { + if (typeof window !== "undefined" && "IntersectionObserver" in window) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.hasBeenVisible = true - // If 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: "", - }) - } + if (this.slug && !this.hasLoggedGAViewEvent) { + this.analytics.logGrapherView(this.slug) + this.hasLoggedGAViewEvent = true + } + } - @computed get queryStr(): string { - return queryParamsToStr({ - ...this.changedParams, - ...this.externalQueryParams, - }) + // 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 + } } - @computed get baseUrl(): string | undefined { - return this.isPublished - ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}` - : undefined + set baseFontSize(val: number) { + this._baseFontSize = val } - @computed private get manager(): GrapherManager | undefined { - return this.props.manager + 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 canonicalUrlIfIsChartView(): string | undefined { - if (!this.chartViewInfo) return undefined - - const { parentChartSlug, queryParamsForParentChart } = - this.chartViewInfo - - const combinedQueryParams = { - ...queryParamsForParentChart, - ...this.changedParams, - } - - return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr( - combinedQueryParams - )}` + 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 } - // 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) + @action.bound private setBaseFontSize(): void { + this.baseFontSize = this.computeBaseFontSizeFromWidth( + this.captionedChartBounds ) } - @computed get isOnCanonicalUrl(): boolean { - if (!this.canonicalUrl) return false - return ( - getWindowUrl().pathname === Url.fromURL(this.canonicalUrl).pathname + // 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()) ) - } - @computed get embedUrl(): string | undefined { - const url = this.manager?.embedDialogUrl ?? this.canonicalUrl - if (!url) return + autorun(() => (document.title = this.currentTitle)) + } - // 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 }) + @action.bound private setUpWindowResizeEventHandler(): void { + const updateWindowDimensions = (): void => { + this.windowInnerWidth = window.innerWidth + this.windowInnerHeight = window.innerHeight } - return urlObj.fullUrl - } + const onResize = debounce(updateWindowDimensions, 400, { + leading: true, + }) - @computed get embedDialogAdditionalElements(): - | React.ReactElement - | undefined { - return this.manager?.embedDialogAdditionalElements + if (typeof window !== "undefined") { + updateWindowDimensions() + window.addEventListener("resize", onResize) + this.disposers.push(() => { + window.removeEventListener("resize", onResize) + }) + } } - @computed private get hasUserChangedTimeHandles(): boolean { - const authorsVersion = this.authorsVersion - return ( - this.minTime !== authorsVersion.minTime || - this.maxTime !== authorsVersion.maxTime + 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() + ) ) + if (this.props.bindUrlToWindow) this.bindToWindow() + if (this.props.enableKeyboardShortcuts) this.bindKeyboardShortcuts() } - @computed private get hasUserChangedMapTimeHandle(): boolean { - return this.map.time !== this.authorsVersion.map.time + 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 timeParam(): string | undefined { - const { timeColumn } = this.table - const formatTime = (t: Time): string => - timeBoundToTimeBoundString( - t, - timeColumn instanceof ColumnTypeMap.Day - ) - - 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}` + private unbindKeyboardShortcuts(): void { + if (!this._shortcutsBound) return + this.keyboardShortcuts.forEach((shortcut) => { + Mousetrap.unbind(shortcut.combo) + }) + this._shortcutsBound = false } - msPerTick = DEFAULT_MS_PER_TICK - - timelineController = new TimelineController(this) - - @action.bound onTimelineClick(): void { - const tooltip = this.tooltip?.get() - if (tooltip) tooltip.dismiss?.() + componentWillUnmount(): void { + this.unbindKeyboardShortcuts() + this.dispose() } - // todo: restore this behavior?? - onStartPlayOrDrag(): void { - this.debounceMode = true + componentDidUpdate(): void { + this.setBaseFontSize() } - onStopPlayOrDrag(): void { - this.debounceMode = false + componentDidCatch(error: Error): void { + this.setError(error) + this.analytics.logGrapherViewError(error) } - @computed get disablePlay(): boolean { - return false + @action.bound clearSelection(): void { + this.selection.clearSelection() + this.applyOriginalSelectionAsAuthored() } - @computed get animationEndTime(): Time { - const { timeColumn } = this.tableAfterAuthorTimelineFilter - if (this.timelineMaxTime) { - return ( - findClosestTime(timeColumn.uniqValues, this.timelineMaxTime) ?? - timeColumn.maxTime - ) - } - return timeColumn.maxTime + @action.bound clearFocus(): void { + this.focusArray.clear() + this.applyOriginalFocusAsAuthored() } - formatTime(value: Time): string { - const timeColumn = this.table.timeColumn - return isMobile() - ? timeColumn.formatValueForMobile(value) - : timeColumn.formatValue(value) + @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 canSelectMultipleEntities(): boolean { - if (this.numSelectableEntityNames < 2) return false - if (this.addCountryMode === EntitySelectionMode.MultipleEntities) - return true + // 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] + } - if ( - // we force multi-entity selection mode when the chart is faceted - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.facetStrategy !== FacetStrategy.none && - // unless the author explicitly configured the chart to be split - // by metric and hid the facet control - !( - this.facetStrategy === FacetStrategy.metric && - this.hideFacetControl - ) - ) - return true + this.ySlugs = grapher.ySlugs + this.xSlug = grapher.xSlug + this.colorSlug = grapher.colorSlug + this.sizeSlug = grapher.sizeSlug - return false + this.selection.clearSelection() + this.focusArray.clear() } - @computed get canChangeEntity(): boolean { - return ( - this.hasChartTab && - !this.isOnScatterTab && - !this.canSelectMultipleEntities && - this.addCountryMode === EntitySelectionMode.SingleEntity && - this.numSelectableEntityNames > 1 - ) - } + debounceMode = false - @computed get canAddEntities(): boolean { - return ( - this.hasChartTab && - this.canSelectMultipleEntities && - (this.isOnLineChartTab || - this.isOnSlopeChartTab || - this.isOnStackedAreaTab || - this.isOnStackedBarTab || - this.isOnDiscreteBarTab || - this.isOnStackedDiscreteBarTab) - ) - } + private mapQueryParamToGrapherTab(tab: string): GrapherTabName | undefined { + const { + chartType: defaultChartType, + validChartTypeSet, + hasMapTab, + } = this - @computed get canHighlightEntities(): boolean { - return ( - this.hasChartTab && - this.addCountryMode !== EntitySelectionMode.Disabled && - this.numSelectableEntityNames > 1 && - !this.canAddEntities && - !this.canChangeEntity - ) - } + 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 canChangeAddOrHighlightEntities(): boolean { - return ( - this.canChangeEntity || - this.canAddEntities || - this.canHighlightEntities - ) - } + 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 + } + } - @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 + const chartTypeName = mapQueryParamToChartTypeName(tab) - return this.isSemiNarrow - ? GrapherWindowType.modal - : GrapherWindowType.drawer - } + if (!chartTypeName) return undefined - @computed get isEntitySelectorPanelActive(): boolean { - return ( - !this.hideEntityControls && - this.canChangeAddOrHighlightEntities && - this.isOnChartTab && - this.showEntitySelectorAs === GrapherWindowType.panel - ) + 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 + } } - @computed get showEntitySelectionToggle(): boolean { - return ( - !this.hideEntityControls && - this.canChangeAddOrHighlightEntities && - this.isOnChartTab && - (this.showEntitySelectorAs === GrapherWindowType.modal || - this.showEntitySelectorAs === GrapherWindowType.drawer) - ) - } + 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 isEntitySelectorModalOpen(): boolean { - return ( - this.isEntitySelectorModalOrDrawerOpen && - this.showEntitySelectorAs === GrapherWindowType.modal - ) - } + if (!this.hasMultipleChartTypes) return GRAPHER_TAB_QUERY_PARAMS.chart - @computed get isEntitySelectorDrawerOpen(): boolean { - return ( - this.isEntitySelectorModalOrDrawerOpen && - this.showEntitySelectorAs === GrapherWindowType.drawer - ) + return mapChartTypeNameToQueryParam(tab) } - // 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.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( diff --git a/site/DataPageV2Content.tsx b/site/DataPageV2Content.tsx index c26a470b18..14461460ac 100644 --- a/site/DataPageV2Content.tsx +++ b/site/DataPageV2Content.tsx @@ -21,7 +21,7 @@ import { import { DocumentContext } from "./gdocs/DocumentContext.js" import { AttachmentsContext } from "./gdocs/AttachmentsContext.js" import StickyNav from "./blocks/StickyNav.js" -import { DebugProvider } from "./gdocs/DebugContext.js" +import { DebugProvider } from "./gdocs/DebugProvider.js" import { ADMIN_BASE_URL, BAKED_BASE_URL,