From adf026496074c2311ab164c6e6a8e55a1118a3ec Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Wed, 30 Oct 2024 14:00:39 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20(explorer)=20merge=20grapher=20c?= =?UTF-8?q?onfigs=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- baker/siteRenderers.tsx | 6 +- explorer/Explorer.tsx | 62 +++++------------- explorer/ExplorerProgram.test.ts | 27 ++++++-- explorer/ExplorerProgram.ts | 65 ++++++++++++++----- explorer/GrapherGrammar.ts | 50 +++++++++++++- gridLang/GridLangConstants.ts | 8 ++- .../types/src/grapherTypes/GrapherTypes.ts | 1 + 7 files changed, 146 insertions(+), 73 deletions(-) diff --git a/baker/siteRenderers.tsx b/baker/siteRenderers.tsx index 787ded3f9a7..0b6f77e1a69 100644 --- a/baker/siteRenderers.tsx +++ b/baker/siteRenderers.tsx @@ -826,12 +826,12 @@ const getExplorerTitleByUrl = async ( if (url.queryStr) { explorer.initDecisionMatrix(url.queryParams as ExplorerFullQueryParams) return ( - explorer.grapherConfig.title ?? - (explorer.grapherConfig.grapherId + explorer.explorerGrapherConfig.title ?? + (explorer.explorerGrapherConfig.grapherId ? ( await getEnrichedChartById( knex, - explorer.grapherConfig.grapherId + explorer.explorerGrapherConfig.grapherId ) )?.config?.title : undefined) diff --git a/explorer/Explorer.tsx b/explorer/Explorer.tsx index 952d9f3ef20..afe23784de8 100644 --- a/explorer/Explorer.tsx +++ b/explorer/Explorer.tsx @@ -40,6 +40,7 @@ import { isInIFrame, keyBy, keyMap, + mergeGrapherConfigs, omitUndefinedValues, parseIntOrUndefined, PromiseCache, @@ -372,7 +373,7 @@ export class Explorer reaction( () => [ this.entityPickerMetric, - this.explorerProgram.grapherConfig.tableSlug, + this.explorerProgram.explorerGrapherConfig.tableSlug, ], () => this.updateEntityPickerTable() ) @@ -473,34 +474,6 @@ export class Explorer } } - @action.bound private updateGrapherFromExplorerCommon() { - const grapher = this.grapher - if (!grapher) return - const { - yScaleToggle, - yAxisMin, - facetYDomain, - relatedQuestionText, - relatedQuestionUrl, - mapTargetTime, - } = this.explorerProgram.grapherConfig - - grapher.yAxis.canChangeScaleType = yScaleToggle - grapher.yAxis.min = yAxisMin - if (facetYDomain) { - grapher.yAxis.facetDomain = facetYDomain - } - if (relatedQuestionText && relatedQuestionUrl) { - grapher.relatedQuestions = [ - { text: relatedQuestionText, url: relatedQuestionUrl }, - ] - } - if (mapTargetTime) { - grapher.map.time = mapTargetTime - } - if (!grapher.id) grapher.id = 0 - } - @computed private get columnDefsWithoutTableSlugByIdOrSlug(): Record< number | string, OwidColumnDef @@ -551,12 +524,14 @@ export class Explorer const grapher = this.grapher if (!grapher) return - const { grapherId } = this.explorerProgram.grapherConfig + const { grapherId } = this.explorerProgram.explorerGrapherConfig const grapherConfig = this.grapherConfigs.get(grapherId!) ?? {} const config: GrapherProgrammaticInterface = { - ...grapherConfig, - ...this.explorerProgram.grapherConfigOnlyGrapherProps, + ...mergeGrapherConfigs( + grapherConfig, + this.explorerProgram.grapherConfig + ), bakedGrapherURL: BAKED_GRAPHER_URL, dataApiUrl: DATA_API_URL, hideEntityControls: this.showExplorerControls, @@ -570,9 +545,7 @@ export class Explorer grapher.setAuthoredVersion(config) grapher.reset() - this.updateGrapherFromExplorerCommon() grapher.updateFromObject(config) - grapher.slug = undefined grapher.downloadData() } @@ -588,7 +561,7 @@ export class Explorer xSlug, colorSlug, sizeSlug, - } = this.explorerProgram.grapherConfig + } = this.explorerProgram.explorerGrapherConfig const yVariableIdsList = yVariableIds .split(" ") @@ -600,8 +573,10 @@ export class Explorer {} const config: GrapherProgrammaticInterface = { - ...partialGrapherConfig, - ...this.explorerProgram.grapherConfigOnlyGrapherProps, + ...mergeGrapherConfigs( + partialGrapherConfig, + this.explorerProgram.grapherConfig + ), bakedGrapherURL: BAKED_GRAPHER_URL, dataApiUrl: DATA_API_URL, hideEntityControls: this.showExplorerControls, @@ -687,7 +662,7 @@ export class Explorer } config.dimensions = dimensions - if (config.ySlugs && yVariableIds) config.ySlugs += " " + yVariableIds + if (ySlugs && yVariableIds) config.ySlugs = ySlugs + " " + yVariableIds const inputTableTransformer = (table: OwidTable) => { // add transformed (and intermediate) columns to the grapher table @@ -733,7 +708,6 @@ export class Explorer grapher.setAuthoredVersion(config) grapher.reset() - this.updateGrapherFromExplorerCommon() grapher.updateFromObject(config) if (dimensions.length === 0) { // If dimensions are empty, explicitly set the table to an empty table @@ -749,10 +723,10 @@ export class Explorer @action.bound private updateGrapherFromExplorerUsingColumnSlugs() { const grapher = this.grapher if (!grapher) return - const { tableSlug } = this.explorerProgram.grapherConfig + const { tableSlug } = this.explorerProgram.explorerGrapherConfig const config: GrapherProgrammaticInterface = { - ...this.explorerProgram.grapherConfigOnlyGrapherProps, + ...this.explorerProgram.grapherConfig, bakedGrapherURL: BAKED_GRAPHER_URL, dataApiUrl: DATA_API_URL, hideEntityControls: this.showExplorerControls, @@ -766,7 +740,6 @@ export class Explorer grapher.setAuthoredVersion(config) grapher.reset() - this.updateGrapherFromExplorerCommon() grapher.updateFromObject(config) // Clear any error messages, they are likely to be related to dataset loading. @@ -1099,7 +1072,7 @@ export class Explorer // so that when we start sorting by entity name we can infer that the column is a string column immediately. const tableSlugToLoad = this.entityPickerMetric ? this.getTableSlugOfColumnSlug(this.entityPickerMetric) - : this.explorerProgram.grapherConfig.tableSlug + : this.explorerProgram.explorerGrapherConfig.tableSlug this.entityPickerTableIsLoading = true void this.futureEntityPickerTable.set( @@ -1135,7 +1108,8 @@ export class Explorer // In most cases, column slugs will be duplicated in the tables, e.g. entityName. // Prefer the current Grapher table if it contains the column slug. - const grapherTableSlug = this.explorerProgram.grapherConfig.tableSlug + const grapherTableSlug = + this.explorerProgram.explorerGrapherConfig.tableSlug if ( this.tableSlugHasColumnSlug( grapherTableSlug, diff --git a/explorer/ExplorerProgram.test.ts b/explorer/ExplorerProgram.test.ts index 63435c07d4c..b10e63818ff 100755 --- a/explorer/ExplorerProgram.test.ts +++ b/explorer/ExplorerProgram.test.ts @@ -108,8 +108,8 @@ columns\ttable1\ttable2\ttable3 describe("grapherconfig", () => { it("can return a grapher config", () => { expect( - new ExplorerProgram("test", `yScaleToggle\ttrue`).grapherConfig - .yScaleToggle + new ExplorerProgram("test", `yScaleToggle\ttrue`) + .explorerGrapherConfig.yScaleToggle ).toEqual(true) const program = new ExplorerProgram( "test", @@ -118,7 +118,7 @@ columns\ttable1\ttable2\ttable3 \ttrue\tLine` ) expect(program.currentlySelectedGrapherRow).toEqual(2) - expect(program.grapherConfig.yScaleToggle).toEqual(true) + expect(program.explorerGrapherConfig.yScaleToggle).toEqual(true) }) it("can convert \\n to a newline", () => { @@ -128,7 +128,7 @@ columns\ttable1\ttable2\ttable3 \tsubtitle\tLine Checkbox \tThis is a\\ntwo-line subtitle\tLine` ) - expect(program.grapherConfig.subtitle).toEqual( + expect(program.explorerGrapherConfig.subtitle).toEqual( "This is a\ntwo-line subtitle" ) }) @@ -142,9 +142,24 @@ graphers \tyScaleToggle\tLine Checkbox \ttrue\tLine` ) - expect(program.grapherConfig.hasMapTab).toEqual(true) + expect(program.explorerGrapherConfig.hasMapTab).toEqual(true) // Only parse white listed grapher props - expect((program.grapherConfig as any).table).toEqual(undefined) + expect((program.explorerGrapherConfig as any).table).toEqual( + undefined + ) + }) + + it("can translate explorer-specific settings to valid config fields", () => { + const program = new ExplorerProgram( + "test", + `hasMapTab\ttrue +table\tfoo +graphers +\tyAxisMin\tLine Checkbox +\t1\tLine` + ) + expect(program.explorerGrapherConfig.yAxisMin).toEqual(1) + expect(program.grapherConfig.yAxis?.min).toEqual(1) }) }) diff --git a/explorer/ExplorerProgram.ts b/explorer/ExplorerProgram.ts index 8a73ffa4cfb..ead964eeb15 100644 --- a/explorer/ExplorerProgram.ts +++ b/explorer/ExplorerProgram.ts @@ -18,7 +18,8 @@ import { PromiseCache, SerializedGridProgram, trimObject, - omit, + merge, + isEmpty, } from "@ourworldindata/utils" import { CellDef, @@ -39,6 +40,7 @@ import { import { DecisionMatrix } from "./ExplorerDecisionMatrix.js" import { ExplorerGrammar } from "./ExplorerGrammar.js" import { GrapherGrammar } from "./GrapherGrammar.js" +import { defaultGrapherConfig } from "@ourworldindata/grapher" export const EXPLORER_FILE_SUFFIX = ".explorer.tsv" @@ -241,8 +243,8 @@ export class ExplorerProgram extends GridProgram { // that use Grapher IDs as well as CSV data files to create charts, // but we plan to drop support for mixed-content explorers in the future get chartCreationMode(): ExplorerChartCreationMode { - const { decisionMatrix, grapherConfig } = this - const { grapherId } = grapherConfig + const { decisionMatrix, explorerGrapherConfig } = this + const { grapherId } = explorerGrapherConfig const yVariableIdsColumn = decisionMatrix.table.get( GrapherGrammar.yVariableIds.keyword ) @@ -358,29 +360,56 @@ export class ExplorerProgram extends GridProgram { return clone } - get grapherConfig(): ExplorerGrapherInterface { + /** + * Grapher config for the currently selected row including global settings. + * Includes all columns that are part of the GrapherGrammar. + */ + get explorerGrapherConfig(): ExplorerGrapherInterface { const rootObject = trimAndParseObject(this.tuplesObject, GrapherGrammar) - - Object.keys(rootObject).forEach((key) => { - if (!GrapherGrammar[key]) delete rootObject[key] - }) + let config = { ...rootObject } const selectedGrapherRow = this.decisionMatrix.selectedRow if (selectedGrapherRow && Object.keys(selectedGrapherRow).length) { - return { ...rootObject, ...selectedGrapherRow } + config = { ...config, ...selectedGrapherRow } } - return rootObject + // remove all keys that are not part of the GrapherGrammar + Object.keys(config).forEach((key) => { + if (!GrapherGrammar[key]) delete config[key] + }) + + return config } - get grapherConfigOnlyGrapherProps() { - return omit(this.grapherConfig, [ - GrapherGrammar.yVariableIds.keyword, - GrapherGrammar.xVariableId.keyword, - GrapherGrammar.colorVariableId.keyword, - GrapherGrammar.sizeVariableId.keyword, - GrapherGrammar.mapTargetTime.keyword, - ]) + /** + * Grapher config for the currently selected row, with explorer-specific + * fields translated to valid GrapherInterface fields. + * + * For example, `yAxisMin` is translated to `{yAxis: {min: ... }}` + */ + get grapherConfig(): GrapherInterface { + const partialConfigs: GrapherInterface[] = [] + const fields = Object.entries(this.explorerGrapherConfig) + for (const [field, value] of fields) { + const cellDef = GrapherGrammar[field] + partialConfigs.push(cellDef.toGrapherObject(value)) + } + + const mergedConfig = merge({}, ...partialConfigs) + + // assume config is valid against the latest schema + mergedConfig.$schema = defaultGrapherConfig.$schema + + // TODO: can be removed once relatedQuestions is refactored + const { relatedQuestionUrl, relatedQuestionText } = + this.explorerGrapherConfig + if (relatedQuestionUrl && relatedQuestionText) { + mergedConfig.relatedQuestions = [ + { url: relatedQuestionUrl, text: relatedQuestionText }, + ] + } + + return mergedConfig } /** diff --git a/explorer/GrapherGrammar.ts b/explorer/GrapherGrammar.ts index c9f51a291e8..ecc953d3779 100644 --- a/explorer/GrapherGrammar.ts +++ b/explorer/GrapherGrammar.ts @@ -22,6 +22,7 @@ import { UrlCellDef, IndicatorIdsOrEtlPathsCellDef, IndicatorIdOrEtlPathCellDef, + GrapherCellDef, } from "../gridLang/GridLangConstants.js" const toTerminalOptions = (keywords: string[]): CellDef[] => { @@ -32,103 +33,121 @@ const toTerminalOptions = (keywords: string[]): CellDef[] => { })) } -export const GrapherGrammar: Grammar = { +export const GrapherGrammar: Grammar = { title: { ...StringCellDef, keyword: "title", description: "Chart title", valuePlaceholder: "Life Expectancy around the world.", + toGrapherObject: (value) => ({ title: value }), }, subtitle: { ...StringCellDef, keyword: "subtitle", description: "Chart subtitle", valuePlaceholder: "Life Expectancy has risen over time.", + toGrapherObject: (value) => ({ subtitle: value }), }, ySlugs: { ...SlugsDeclarationCellDef, description: "ColumnSlug(s) for the yAxis", keyword: "ySlugs", + toGrapherObject: (value) => ({ ySlugs: value }), }, yVariableIds: { ...IndicatorIdsOrEtlPathsCellDef, keyword: "yVariableIds", description: "Variable ID(s) or ETL path(s) for the yAxis", + toGrapherObject: () => ({}), // explorer-specific, not used in grapher config }, type: { ...StringCellDef, keyword: "type", description: `The type of chart to show such as LineChart or ScatterPlot.`, terminalOptions: toTerminalOptions(Object.values(ChartTypeName)), + toGrapherObject: (value) => ({ type: value }), }, grapherId: { ...IntegerCellDef, description: "ID of a legacy Grapher to load", keyword: "grapherId", + toGrapherObject: (value) => ({ id: value }), }, tableSlug: { ...SlugDeclarationCellDef, description: "Slug of the explorer table (i.e. csv file) to use for this row. All variables used in this row must be present in the table/file.", keyword: "tableSlug", + toGrapherObject: () => ({}), // explorer-specific, not used in grapher config }, hasMapTab: { ...BooleanCellDef, keyword: "hasMapTab", description: "Show the map tab?", + toGrapherObject: (value) => ({ hasMapTab: value }), }, tab: { ...EnumCellDef, keyword: "tab", description: "Which tab to show by default", terminalOptions: toTerminalOptions(Object.values(GrapherTabOption)), + toGrapherObject: (value) => ({ tab: value }), }, hasChartTab: { ...BooleanCellDef, keyword: "hasChartTab", description: "Show the chart tab?", + toGrapherObject: (value) => ({ hasChartTab: value }), }, xSlug: { ...SlugDeclarationCellDef, description: "ColumnSlug for the xAxis", keyword: "xSlug", + toGrapherObject: (value) => ({ xSlug: value }), }, xVariableId: { ...IndicatorIdOrEtlPathCellDef, keyword: "xVariableId", description: "Variable ID or ETL path for the xAxis", + toGrapherObject: () => ({}), // explorer-specific, not used in grapher config }, colorSlug: { ...SlugDeclarationCellDef, description: "ColumnSlug for the color", keyword: "colorSlug", + toGrapherObject: (value) => ({ colorSlug: value }), }, colorVariableId: { ...IndicatorIdOrEtlPathCellDef, keyword: "colorVariableId", description: "Variable ID or ETL path for the color", + toGrapherObject: () => ({}), // explorer-specific, not used in grapher config }, sizeSlug: { ...SlugDeclarationCellDef, description: "ColumnSlug for the size of points on scatters", keyword: "sizeSlug", + toGrapherObject: (value) => ({ sizeSlug: value }), }, sizeVariableId: { ...IndicatorIdOrEtlPathCellDef, keyword: "sizeVariableId", description: "Variable ID or ETL path for the size of points on scatters", + toGrapherObject: () => ({}), // explorer-specific, not used in grapher config }, tableSlugs: { ...SlugsDeclarationCellDef, description: "Columns to show in the Table tab of the chart. If not specified all active slugs will be used.", keyword: "tableSlugs", + toGrapherObject: (value) => ({ tableSlugs: value }), }, sourceDesc: { ...StringCellDef, keyword: "sourceDesc", description: "Short comma-separated list of source names", + toGrapherObject: (value) => ({ sourceDesc: value }), }, hideAnnotationFieldsInTitle: { ...BooleanCellDef, @@ -142,16 +161,25 @@ export const GrapherGrammar: Grammar = { changeInPrefix: parsedValue, } }, + toGrapherObject: (value) => ({ + hideAnnotationFieldsInTitle: { + entity: value, + time: value, + changeInPrefix: value, + }, + }), }, yScaleToggle: { ...BooleanCellDef, keyword: "yScaleToggle", description: "Set to 'true' if the user can change the yAxis", + toGrapherObject: (value) => ({ yAxis: { canChangeScaleType: value } }), }, yAxisMin: { ...NumericCellDef, keyword: "yAxisMin", description: "Set the minimum value for the yAxis", + toGrapherObject: (value) => ({ yAxis: { min: value } }), }, facetYDomain: { ...EnumCellDef, @@ -159,18 +187,21 @@ export const GrapherGrammar: Grammar = { description: "Whether facets axes default to shared or independent domain", terminalOptions: toTerminalOptions(Object.values(FacetAxisDomain)), + toGrapherObject: (value) => ({ yAxis: { facetDomain: value } }), }, selectedFacetStrategy: { ...EnumCellDef, keyword: "selectedFacetStrategy", description: "Whether the chart should be faceted or not", terminalOptions: toTerminalOptions(Object.values(FacetStrategy)), + toGrapherObject: (value) => ({ selectedFacetStrategy: value }), }, entityType: { ...StringCellDef, keyword: "entityType", description: "Default is 'country', but you can specify a different one such as 'state' or 'region'.", + toGrapherObject: (value) => ({ entityType: value }), }, baseColorScheme: { ...EnumCellDef, @@ -178,29 +209,34 @@ export const GrapherGrammar: Grammar = { description: "The default color scheme if no color overrides are specified", terminalOptions: toTerminalOptions(Object.keys(ColorSchemeName)), + toGrapherObject: (value) => ({ baseColorScheme: value }), }, note: { ...StringCellDef, keyword: "note", description: "Chart footnote", + toGrapherObject: (value) => ({ note: value }), }, sortBy: { ...EnumCellDef, keyword: "sortBy", description: "Specify what to sort the entities by", terminalOptions: toTerminalOptions(Object.values(SortBy)), + toGrapherObject: (value) => ({ sortBy: value }), }, sortOrder: { ...EnumCellDef, keyword: "sortOrder", description: "Whether to sort entities ascending or descending", terminalOptions: toTerminalOptions(Object.values(SortOrder)), + toGrapherObject: (value) => ({ sortOrder: value }), }, sortColumnSlug: { ...SlugDeclarationCellDef, keyword: "sortColumnSlug", description: "This setting is only respected when `sortBy` is set to `column`", + toGrapherObject: (value) => ({ sortColumnSlug: value }), }, stackMode: { ...EnumCellDef, @@ -208,51 +244,60 @@ export const GrapherGrammar: Grammar = { description: "Show chart in absolute (default) or relative mode. Only works for some chart types.", terminalOptions: toTerminalOptions(Object.values(StackMode)), + toGrapherObject: (value) => ({ stackMode: value }), }, hideTotalValueLabel: { ...BooleanCellDef, keyword: "hideTotalValueLabel", description: "Hide the total value that is normally displayed to the right of the bars in a stacked bar chart.", + toGrapherObject: (value) => ({ hideTotalValueLabel: value }), }, hideRelativeToggle: { ...BooleanCellDef, keyword: "hideRelativeToggle", description: "Whether to hide the relative mode UI toggle", + toGrapherObject: (value) => ({ hideRelativeToggle: value }), }, timelineMinTime: { ...IntegerCellDef, keyword: "timelineMinTime", description: "Set the minimum time for the timeline. For days, use days since 21 Jan 2020, e.g. 24 Jan 2020 is '3'.", + toGrapherObject: (value) => ({ timelineMinTime: value }), }, timelineMaxTime: { ...IntegerCellDef, keyword: "timelineMaxTime", description: "Set the maximum time for the timeline. For days, use days since 21 Jan 2020, e.g. 24 Jan 2020 is '3'.", + toGrapherObject: (value) => ({ timelineMaxTime: value }), }, defaultView: { ...BooleanCellDef, keyword: "defaultView", description: "Whether this view is used as the default view.", + toGrapherObject: () => ({}), // explorer-specific, not used in grapher config }, relatedQuestionText: { ...StringCellDef, keyword: "relatedQuestionText", description: "The text used for the related question (at the very bottom of the chart)", + toGrapherObject: () => ({}), // handled in code (can be done properly once the relatedQuestion field is refactored) }, relatedQuestionUrl: { ...UrlCellDef, keyword: "relatedQuestionUrl", description: "The link of the related question text", + toGrapherObject: () => ({}), // handled in code (can be done properly once the relatedQuestion field is refactored) }, mapTargetTime: { ...IntegerCellDef, keyword: "mapTargetTime", description: "Set the 'target time' for the map chart. This is the year that will be shown by default in the map chart.", + toGrapherObject: (value) => ({ map: { time: value } }), }, missingDataStrategy: { ...EnumCellDef, @@ -260,15 +305,18 @@ export const GrapherGrammar: Grammar = { description: "Hide or show entities for which one or more variables are missing", terminalOptions: toTerminalOptions(Object.values(MissingDataStrategy)), + toGrapherObject: (value) => ({ missingDataStrategy: value }), }, minTime: { ...IntegerCellDef, keyword: "minTime", description: "Start point of the initially selected time span", + toGrapherObject: (value) => ({ minTime: value }), }, maxTime: { ...IntegerCellDef, keyword: "maxTime", description: "End point of the initially selected time span", + toGrapherObject: (value) => ({ maxTime: value }), }, } as const diff --git a/gridLang/GridLangConstants.ts b/gridLang/GridLangConstants.ts index 1fa69f782ab..9259535b7cc 100644 --- a/gridLang/GridLangConstants.ts +++ b/gridLang/GridLangConstants.ts @@ -11,7 +11,9 @@ export const GRID_EDGE_DELIMITER = "\t" export type CellCoordinate = number // An integer >= 0 -export type Grammar = { [keywordSlug: string]: CellDef } +export type Grammar = { + [keywordSlug: string]: TCellDef +} // A CellDef is a tuple: part keyword and the other half is the contents. The contents can be 1 cell, a list of cells, and/or a subtable. export interface CellDef { @@ -30,6 +32,10 @@ export interface CellDef { parse?: (value: any) => any } +export interface GrapherCellDef extends CellDef { + toGrapherObject: (value: any) => any // map to a partial config that is a valid GrapherInterface +} + export interface ParsedCell { errorMessage?: string cssClasses?: string[] diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index a875400a223..e82a2d23b1c 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -592,6 +592,7 @@ export interface GrapherInterface extends SortConfig { xSlug?: ColumnSlug sizeSlug?: ColumnSlug colorSlug?: ColumnSlug + tableSlugs?: ColumnSlugs } export interface LegacyGrapherInterface extends GrapherInterface {