@buildcanada/charts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (404) hide show
  1. package/LICENSE.md +8 -0
  2. package/README.md +113 -0
  3. package/package.json +137 -0
  4. package/src/components/BodyPortal/BodyPortal.tsx +40 -0
  5. package/src/components/Button/Button.scss +110 -0
  6. package/src/components/Button/Button.tsx +101 -0
  7. package/src/components/Checkbox.scss +93 -0
  8. package/src/components/Checkbox.tsx +47 -0
  9. package/src/components/ExpandableToggle/ExpandableToggle.scss +123 -0
  10. package/src/components/ExpandableToggle/ExpandableToggle.tsx +60 -0
  11. package/src/components/GrapherTabIcon.tsx +156 -0
  12. package/src/components/GrapherTrendArrow.scss +16 -0
  13. package/src/components/GrapherTrendArrow.tsx +30 -0
  14. package/src/components/Halo/Halo.tsx +44 -0
  15. package/src/components/LabeledSwitch/LabeledSwitch.scss +109 -0
  16. package/src/components/LabeledSwitch/LabeledSwitch.tsx +62 -0
  17. package/src/components/MarkdownTextWrap/MarkdownTextWrap.tsx +1173 -0
  18. package/src/components/OverlayHeader.scss +18 -0
  19. package/src/components/OverlayHeader.tsx +29 -0
  20. package/src/components/RadioButton.scss +69 -0
  21. package/src/components/RadioButton.tsx +42 -0
  22. package/src/components/SimpleMarkdownText.tsx +89 -0
  23. package/src/components/TextInput.scss +17 -0
  24. package/src/components/TextInput.tsx +19 -0
  25. package/src/components/TextWrap/TextWrap.tsx +361 -0
  26. package/src/components/TextWrap/TextWrapUtils.ts +32 -0
  27. package/src/components/closeButton/CloseButton.scss +40 -0
  28. package/src/components/closeButton/CloseButton.tsx +27 -0
  29. package/src/components/index.ts +70 -0
  30. package/src/components/loadingIndicator/LoadingIndicator.scss +40 -0
  31. package/src/components/loadingIndicator/LoadingIndicator.tsx +28 -0
  32. package/src/components/markdown/remarkPlainLinks.ts +36 -0
  33. package/src/components/reactUtil.ts +20 -0
  34. package/src/components/stubs/CodeSnippet.tsx +19 -0
  35. package/src/components/stubs/DataCitation.tsx +16 -0
  36. package/src/components/stubs/IndicatorKeyData.tsx +45 -0
  37. package/src/components/stubs/IndicatorProcessing.tsx +15 -0
  38. package/src/components/stubs/IndicatorSources.tsx +15 -0
  39. package/src/components/styles/colors.scss +113 -0
  40. package/src/components/styles/mixins.scss +630 -0
  41. package/src/components/styles/typography.scss +579 -0
  42. package/src/components/styles/util.scss +89 -0
  43. package/src/components/styles/variables.scss +208 -0
  44. package/src/config/ChartsConfig.ts +163 -0
  45. package/src/config/ChartsProvider.tsx +157 -0
  46. package/src/config/index.ts +20 -0
  47. package/src/core-table/CoreTable.ts +1355 -0
  48. package/src/core-table/CoreTableColumns.ts +973 -0
  49. package/src/core-table/CoreTableUtils.ts +793 -0
  50. package/src/core-table/ErrorValues.ts +73 -0
  51. package/src/core-table/OwidTable.ts +1175 -0
  52. package/src/core-table/OwidTableSynthesizers.ts +272 -0
  53. package/src/core-table/OwidTableUtil.ts +76 -0
  54. package/src/core-table/Transforms.ts +484 -0
  55. package/src/core-table/index.ts +82 -0
  56. package/src/explorer/ColumnGrammar.ts +217 -0
  57. package/src/explorer/Explorer.sample.ts +212 -0
  58. package/src/explorer/Explorer.scss +148 -0
  59. package/src/explorer/Explorer.tsx +1283 -0
  60. package/src/explorer/ExplorerConstants.ts +85 -0
  61. package/src/explorer/ExplorerControls.scss +156 -0
  62. package/src/explorer/ExplorerControls.tsx +210 -0
  63. package/src/explorer/ExplorerDecisionMatrix.ts +471 -0
  64. package/src/explorer/ExplorerGrammar.ts +161 -0
  65. package/src/explorer/ExplorerProgram.ts +568 -0
  66. package/src/explorer/ExplorerUtils.ts +59 -0
  67. package/src/explorer/GrapherGrammar.ts +387 -0
  68. package/src/explorer/gridLang/GrammarUtils.ts +121 -0
  69. package/src/explorer/gridLang/GridCell.ts +298 -0
  70. package/src/explorer/gridLang/GridLangConstants.ts +255 -0
  71. package/src/explorer/gridLang/GridProgram.ts +311 -0
  72. package/src/explorer/gridLang/readme.md +17 -0
  73. package/src/explorer/index.ts +69 -0
  74. package/src/explorer/readme.md +19 -0
  75. package/src/explorer/urlMigrations/CO2UrlMigration.ts +46 -0
  76. package/src/explorer/urlMigrations/CovidUrlMigration.ts +37 -0
  77. package/src/explorer/urlMigrations/EnergyUrlMigration.ts +41 -0
  78. package/src/explorer/urlMigrations/ExplorerPageUrlMigrationSpec.ts +12 -0
  79. package/src/explorer/urlMigrations/ExplorerUrlMigrationUtils.ts +45 -0
  80. package/src/explorer/urlMigrations/ExplorerUrlMigrations.ts +33 -0
  81. package/src/explorer/urlMigrations/LegacyCovidUrlMigration.ts +144 -0
  82. package/src/explorer/urlMigrations/readme.md +39 -0
  83. package/src/grapher/axis/Axis.ts +973 -0
  84. package/src/grapher/axis/AxisConfig.ts +179 -0
  85. package/src/grapher/axis/AxisViews.tsx +597 -0
  86. package/src/grapher/barCharts/DiscreteBarChart.tsx +728 -0
  87. package/src/grapher/barCharts/DiscreteBarChartConstants.ts +60 -0
  88. package/src/grapher/barCharts/DiscreteBarChartHelpers.ts +338 -0
  89. package/src/grapher/barCharts/DiscreteBarChartState.ts +354 -0
  90. package/src/grapher/barCharts/DiscreteBarChartThumbnail.tsx +34 -0
  91. package/src/grapher/captionedChart/CaptionedChart.scss +61 -0
  92. package/src/grapher/captionedChart/CaptionedChart.tsx +523 -0
  93. package/src/grapher/captionedChart/Logos.tsx +141 -0
  94. package/src/grapher/captionedChart/LogosSVG.tsx +16 -0
  95. package/src/grapher/captionedChart/StaticChartRasterizer.tsx +178 -0
  96. package/src/grapher/captionedChart/assets/buildcanada-logo-square.svg +15 -0
  97. package/src/grapher/captionedChart/assets/buildcanada-logo.svg +15 -0
  98. package/src/grapher/captionedChart/assets/canadaspends.svg +7 -0
  99. package/src/grapher/captionedChart/readme.md +14 -0
  100. package/src/grapher/chart/Chart.tsx +62 -0
  101. package/src/grapher/chart/ChartAreaContent.tsx +172 -0
  102. package/src/grapher/chart/ChartDimension.ts +121 -0
  103. package/src/grapher/chart/ChartInterface.ts +83 -0
  104. package/src/grapher/chart/ChartManager.ts +113 -0
  105. package/src/grapher/chart/ChartTabs.ts +178 -0
  106. package/src/grapher/chart/ChartTypeMap.tsx +158 -0
  107. package/src/grapher/chart/ChartTypeSwitcher.tsx +26 -0
  108. package/src/grapher/chart/ChartUtils.tsx +364 -0
  109. package/src/grapher/chart/DimensionSlot.ts +45 -0
  110. package/src/grapher/chart/StaticChartWrapper.tsx +94 -0
  111. package/src/grapher/chart/guidedChartUtils.ts +82 -0
  112. package/src/grapher/color/BinningStrategies.ts +484 -0
  113. package/src/grapher/color/BinningStrategyEqualSizeBins.ts +132 -0
  114. package/src/grapher/color/BinningStrategyLogarithmic.ts +121 -0
  115. package/src/grapher/color/CategoricalColorAssigner.ts +97 -0
  116. package/src/grapher/color/ColorBrewerSchemes.ts +80 -0
  117. package/src/grapher/color/ColorConstants.ts +20 -0
  118. package/src/grapher/color/ColorScale.ts +339 -0
  119. package/src/grapher/color/ColorScaleBin.ts +147 -0
  120. package/src/grapher/color/ColorScaleConfig.ts +204 -0
  121. package/src/grapher/color/ColorScheme.ts +137 -0
  122. package/src/grapher/color/ColorSchemes.ts +149 -0
  123. package/src/grapher/color/ColorUtils.ts +86 -0
  124. package/src/grapher/color/CustomSchemes.ts +1772 -0
  125. package/src/grapher/color/readme.md +84 -0
  126. package/src/grapher/comparisonLine/ComparisonLine.tsx +31 -0
  127. package/src/grapher/comparisonLine/ComparisonLineConstants.ts +11 -0
  128. package/src/grapher/comparisonLine/ComparisonLineGenerator.ts +60 -0
  129. package/src/grapher/comparisonLine/ComparisonLineHelpers.ts +10 -0
  130. package/src/grapher/comparisonLine/CustomComparisonLine.tsx +159 -0
  131. package/src/grapher/comparisonLine/VerticalComparisonLine.tsx +208 -0
  132. package/src/grapher/controls/ActionButtons.scss +97 -0
  133. package/src/grapher/controls/ActionButtons.tsx +453 -0
  134. package/src/grapher/controls/CommandPalette.scss +50 -0
  135. package/src/grapher/controls/CommandPalette.tsx +74 -0
  136. package/src/grapher/controls/ContentSwitchers.scss +93 -0
  137. package/src/grapher/controls/ContentSwitchers.tsx +238 -0
  138. package/src/grapher/controls/Controls.scss +158 -0
  139. package/src/grapher/controls/DataTableFilterDropdown.scss +7 -0
  140. package/src/grapher/controls/DataTableFilterDropdown.tsx +168 -0
  141. package/src/grapher/controls/DataTableSearchField.scss +3 -0
  142. package/src/grapher/controls/DataTableSearchField.tsx +76 -0
  143. package/src/grapher/controls/Dropdown.scss +252 -0
  144. package/src/grapher/controls/Dropdown.tsx +235 -0
  145. package/src/grapher/controls/EntitySelectionToggle.tsx +135 -0
  146. package/src/grapher/controls/MapRegionDropdown.scss +3 -0
  147. package/src/grapher/controls/MapRegionDropdown.tsx +104 -0
  148. package/src/grapher/controls/MapResetButton.tsx +115 -0
  149. package/src/grapher/controls/MapZoomDropdown.scss +9 -0
  150. package/src/grapher/controls/MapZoomDropdown.tsx +270 -0
  151. package/src/grapher/controls/MapZoomToSelectionButton.tsx +87 -0
  152. package/src/grapher/controls/SearchField.scss +78 -0
  153. package/src/grapher/controls/SearchField.tsx +63 -0
  154. package/src/grapher/controls/SettingsMenu.scss +191 -0
  155. package/src/grapher/controls/SettingsMenu.tsx +399 -0
  156. package/src/grapher/controls/ShareMenu.scss +58 -0
  157. package/src/grapher/controls/ShareMenu.tsx +304 -0
  158. package/src/grapher/controls/SortIcon.tsx +39 -0
  159. package/src/grapher/controls/VerticalScrollContainer.tsx +263 -0
  160. package/src/grapher/controls/controlsRow/ControlsRow.tsx +168 -0
  161. package/src/grapher/controls/dropdown-icons.scss +4 -0
  162. package/src/grapher/controls/entityPicker/EntityPicker.scss +255 -0
  163. package/src/grapher/controls/entityPicker/EntityPicker.tsx +816 -0
  164. package/src/grapher/controls/entityPicker/EntityPickerConstants.ts +23 -0
  165. package/src/grapher/controls/globalEntitySelector/GlobalEntitySelector.scss +129 -0
  166. package/src/grapher/controls/globalEntitySelector/GlobalEntitySelector.tsx +463 -0
  167. package/src/grapher/controls/globalEntitySelector/GlobalEntitySelectorConstants.ts +3 -0
  168. package/src/grapher/controls/globalEntitySelector/readme.md +17 -0
  169. package/src/grapher/controls/settings/AbsRelToggle.tsx +64 -0
  170. package/src/grapher/controls/settings/AxisScaleToggle.tsx +53 -0
  171. package/src/grapher/controls/settings/FacetStrategySelector.tsx +110 -0
  172. package/src/grapher/controls/settings/FacetYDomainToggle.tsx +51 -0
  173. package/src/grapher/controls/settings/NoDataAreaToggle.tsx +38 -0
  174. package/src/grapher/controls/settings/ZoomToggle.tsx +36 -0
  175. package/src/grapher/core/EntitiesByRegionType.ts +174 -0
  176. package/src/grapher/core/EntityCodes.ts +19 -0
  177. package/src/grapher/core/EntityUrlBuilder.ts +200 -0
  178. package/src/grapher/core/FetchingGrapher.tsx +156 -0
  179. package/src/grapher/core/Grapher.tsx +760 -0
  180. package/src/grapher/core/GrapherAnalytics.ts +229 -0
  181. package/src/grapher/core/GrapherConstants.ts +173 -0
  182. package/src/grapher/core/GrapherState.tsx +3659 -0
  183. package/src/grapher/core/GrapherUrl.ts +184 -0
  184. package/src/grapher/core/GrapherUrlMigrations.ts +29 -0
  185. package/src/grapher/core/GrapherUseHelpers.tsx +147 -0
  186. package/src/grapher/core/LegacyToOwidTable.ts +841 -0
  187. package/src/grapher/core/grapher.entry.ts +5 -0
  188. package/src/grapher/core/grapher.scss +257 -0
  189. package/src/grapher/core/loadGrapherTableHelpers.ts +116 -0
  190. package/src/grapher/core/loadVariable.ts +104 -0
  191. package/src/grapher/core/relatedQuestion.ts +12 -0
  192. package/src/grapher/core/typography.scss +206 -0
  193. package/src/grapher/dataTable/DataTable.sample.ts +206 -0
  194. package/src/grapher/dataTable/DataTable.scss +249 -0
  195. package/src/grapher/dataTable/DataTable.tsx +1332 -0
  196. package/src/grapher/dataTable/DataTableConstants.ts +186 -0
  197. package/src/grapher/entitySelector/EntitySelector.scss +255 -0
  198. package/src/grapher/entitySelector/EntitySelector.tsx +1838 -0
  199. package/src/grapher/facet/FacetChart.tsx +943 -0
  200. package/src/grapher/facet/FacetChartConstants.ts +24 -0
  201. package/src/grapher/facet/FacetChartUtils.ts +51 -0
  202. package/src/grapher/facet/FacetMap.tsx +604 -0
  203. package/src/grapher/facet/FacetMapConstants.ts +23 -0
  204. package/src/grapher/facet/readme.md +13 -0
  205. package/src/grapher/focus/FocusArray.ts +79 -0
  206. package/src/grapher/footer/Footer.scss +63 -0
  207. package/src/grapher/footer/Footer.tsx +809 -0
  208. package/src/grapher/footer/FooterManager.ts +44 -0
  209. package/src/grapher/fullScreen/FullScreen.scss +11 -0
  210. package/src/grapher/fullScreen/FullScreen.tsx +61 -0
  211. package/src/grapher/header/Header.scss +35 -0
  212. package/src/grapher/header/Header.tsx +372 -0
  213. package/src/grapher/header/HeaderManager.ts +28 -0
  214. package/src/grapher/index.ts +157 -0
  215. package/src/grapher/interaction/InteractionState.ts +60 -0
  216. package/src/grapher/legend/HorizontalColorLegends.tsx +923 -0
  217. package/src/grapher/legend/LegendInteractionState.ts +40 -0
  218. package/src/grapher/legend/VerticalColorLegend.tsx +295 -0
  219. package/src/grapher/lineCharts/LineChart.tsx +968 -0
  220. package/src/grapher/lineCharts/LineChartConstants.ts +89 -0
  221. package/src/grapher/lineCharts/LineChartHelpers.ts +184 -0
  222. package/src/grapher/lineCharts/LineChartState.ts +394 -0
  223. package/src/grapher/lineCharts/LineChartThumbnail.tsx +437 -0
  224. package/src/grapher/lineCharts/Lines.tsx +258 -0
  225. package/src/grapher/lineLegend/LineLegend.tsx +723 -0
  226. package/src/grapher/lineLegend/LineLegendConstants.ts +9 -0
  227. package/src/grapher/lineLegend/LineLegendFilterAlgorithms.ts +143 -0
  228. package/src/grapher/lineLegend/LineLegendHelpers.ts +253 -0
  229. package/src/grapher/lineLegend/LineLegendTypes.ts +32 -0
  230. package/src/grapher/mapCharts/CanadaTopology.ts +17922 -0
  231. package/src/grapher/mapCharts/ChoroplethGlobe.tsx +949 -0
  232. package/src/grapher/mapCharts/ChoroplethMap.tsx +662 -0
  233. package/src/grapher/mapCharts/GeoFeatures.ts +184 -0
  234. package/src/grapher/mapCharts/GlobeController.ts +496 -0
  235. package/src/grapher/mapCharts/MapAnnotationPlacements.json +1040 -0
  236. package/src/grapher/mapCharts/MapAnnotationPlacements.ts +31 -0
  237. package/src/grapher/mapCharts/MapAnnotations.ts +723 -0
  238. package/src/grapher/mapCharts/MapChart.sample.ts +59 -0
  239. package/src/grapher/mapCharts/MapChart.scss +5 -0
  240. package/src/grapher/mapCharts/MapChart.tsx +720 -0
  241. package/src/grapher/mapCharts/MapChartConstants.ts +260 -0
  242. package/src/grapher/mapCharts/MapChartState.ts +416 -0
  243. package/src/grapher/mapCharts/MapChartThumbnail.tsx +25 -0
  244. package/src/grapher/mapCharts/MapComponents.tsx +338 -0
  245. package/src/grapher/mapCharts/MapConfig.ts +156 -0
  246. package/src/grapher/mapCharts/MapHelpers.ts +181 -0
  247. package/src/grapher/mapCharts/MapProjections.ts +49 -0
  248. package/src/grapher/mapCharts/MapSparkline.tsx +257 -0
  249. package/src/grapher/mapCharts/MapTooltip.scss +49 -0
  250. package/src/grapher/mapCharts/MapTooltip.tsx +409 -0
  251. package/src/grapher/mapCharts/MapTopology.ts +1766 -0
  252. package/src/grapher/mapCharts/d3-bboxCollide.js +204 -0
  253. package/src/grapher/mapCharts/d3-geo-projection.ts +198 -0
  254. package/src/grapher/modal/DownloadIcons.tsx +39 -0
  255. package/src/grapher/modal/DownloadModal.scss +300 -0
  256. package/src/grapher/modal/DownloadModal.tsx +1226 -0
  257. package/src/grapher/modal/EmbedModal.scss +40 -0
  258. package/src/grapher/modal/EmbedModal.tsx +160 -0
  259. package/src/grapher/modal/EntitySelectorModal.tsx +59 -0
  260. package/src/grapher/modal/Modal.scss +31 -0
  261. package/src/grapher/modal/Modal.tsx +90 -0
  262. package/src/grapher/modal/ModalHeader.scss +12 -0
  263. package/src/grapher/modal/ModalHeader.tsx +16 -0
  264. package/src/grapher/modal/SourcesDescriptions.scss +87 -0
  265. package/src/grapher/modal/SourcesDescriptions.tsx +89 -0
  266. package/src/grapher/modal/SourcesKeyDataTable.scss +49 -0
  267. package/src/grapher/modal/SourcesKeyDataTable.tsx +87 -0
  268. package/src/grapher/modal/SourcesModal.scss +301 -0
  269. package/src/grapher/modal/SourcesModal.tsx +568 -0
  270. package/src/grapher/noDataModal/NoDataModal.tsx +125 -0
  271. package/src/grapher/scatterCharts/ConnectedScatterLegend.tsx +143 -0
  272. package/src/grapher/scatterCharts/MultiColorPolyline.tsx +129 -0
  273. package/src/grapher/scatterCharts/NoDataSection.scss +14 -0
  274. package/src/grapher/scatterCharts/NoDataSection.tsx +56 -0
  275. package/src/grapher/scatterCharts/ScatterPlotChart.tsx +792 -0
  276. package/src/grapher/scatterCharts/ScatterPlotChartConstants.ts +157 -0
  277. package/src/grapher/scatterCharts/ScatterPlotChartState.ts +678 -0
  278. package/src/grapher/scatterCharts/ScatterPlotChartThumbnail.tsx +155 -0
  279. package/src/grapher/scatterCharts/ScatterPlotTooltip.tsx +560 -0
  280. package/src/grapher/scatterCharts/ScatterPoints.tsx +153 -0
  281. package/src/grapher/scatterCharts/ScatterPointsWithLabels.tsx +708 -0
  282. package/src/grapher/scatterCharts/ScatterSizeLegend.tsx +327 -0
  283. package/src/grapher/scatterCharts/ScatterUtils.ts +265 -0
  284. package/src/grapher/scatterCharts/Triangle.tsx +41 -0
  285. package/src/grapher/schema/README.md +33 -0
  286. package/src/grapher/schema/defaultGrapherConfig.ts +100 -0
  287. package/src/grapher/schema/grapher-schema.009.yaml +781 -0
  288. package/src/grapher/schema/migrations/helpers.ts +58 -0
  289. package/src/grapher/schema/migrations/migrate.ts +75 -0
  290. package/src/grapher/schema/migrations/migrations.ts +158 -0
  291. package/src/grapher/selection/MapSelectionArray.ts +99 -0
  292. package/src/grapher/selection/SelectionArray.ts +71 -0
  293. package/src/grapher/selection/readme.md +16 -0
  294. package/src/grapher/sidePanel/SidePanel.scss +10 -0
  295. package/src/grapher/sidePanel/SidePanel.tsx +23 -0
  296. package/src/grapher/slideInDrawer/SlideInDrawer.scss +57 -0
  297. package/src/grapher/slideInDrawer/SlideInDrawer.tsx +125 -0
  298. package/src/grapher/slideshowController/SlideShowController.tsx +43 -0
  299. package/src/grapher/slideshowController/readme.md +7 -0
  300. package/src/grapher/slopeCharts/MarkX.tsx +45 -0
  301. package/src/grapher/slopeCharts/Slope.tsx +102 -0
  302. package/src/grapher/slopeCharts/SlopeChart.tsx +1152 -0
  303. package/src/grapher/slopeCharts/SlopeChartConstants.ts +33 -0
  304. package/src/grapher/slopeCharts/SlopeChartHelpers.ts +73 -0
  305. package/src/grapher/slopeCharts/SlopeChartState.ts +392 -0
  306. package/src/grapher/slopeCharts/SlopeChartThumbnail.tsx +368 -0
  307. package/src/grapher/stackedCharts/AbstractStackedChartState.ts +370 -0
  308. package/src/grapher/stackedCharts/MarimekkoBars.tsx +190 -0
  309. package/src/grapher/stackedCharts/MarimekkoBarsForOneEntity.tsx +168 -0
  310. package/src/grapher/stackedCharts/MarimekkoChart.tsx +1144 -0
  311. package/src/grapher/stackedCharts/MarimekkoChartConstants.ts +112 -0
  312. package/src/grapher/stackedCharts/MarimekkoChartHelpers.ts +21 -0
  313. package/src/grapher/stackedCharts/MarimekkoChartState.ts +465 -0
  314. package/src/grapher/stackedCharts/MarimekkoChartThumbnail.tsx +168 -0
  315. package/src/grapher/stackedCharts/MarimekkoInternalLabels.tsx +124 -0
  316. package/src/grapher/stackedCharts/StackedAreaChart.tsx +678 -0
  317. package/src/grapher/stackedCharts/StackedAreaChartState.ts +34 -0
  318. package/src/grapher/stackedCharts/StackedAreaChartThumbnail.tsx +215 -0
  319. package/src/grapher/stackedCharts/StackedAreas.tsx +223 -0
  320. package/src/grapher/stackedCharts/StackedBarChart.tsx +619 -0
  321. package/src/grapher/stackedCharts/StackedBarChartState.ts +80 -0
  322. package/src/grapher/stackedCharts/StackedBarChartThumbnail.tsx +220 -0
  323. package/src/grapher/stackedCharts/StackedBarSegment.tsx +87 -0
  324. package/src/grapher/stackedCharts/StackedBars.tsx +102 -0
  325. package/src/grapher/stackedCharts/StackedConstants.ts +109 -0
  326. package/src/grapher/stackedCharts/StackedDiscreteBarChart.tsx +270 -0
  327. package/src/grapher/stackedCharts/StackedDiscreteBarChartState.ts +296 -0
  328. package/src/grapher/stackedCharts/StackedDiscreteBarChartThumbnail.tsx +27 -0
  329. package/src/grapher/stackedCharts/StackedDiscreteBars.tsx +648 -0
  330. package/src/grapher/stackedCharts/StackedUtils.ts +142 -0
  331. package/src/grapher/tabs/Tabs.scss +169 -0
  332. package/src/grapher/tabs/Tabs.tsx +54 -0
  333. package/src/grapher/tabs/TabsWithDropdown.scss +62 -0
  334. package/src/grapher/tabs/TabsWithDropdown.tsx +114 -0
  335. package/src/grapher/testData/OwidTestData.sample.ts +273 -0
  336. package/src/grapher/testData/OwidTestData.ts +64 -0
  337. package/src/grapher/timeline/TimelineComponent.scss +139 -0
  338. package/src/grapher/timeline/TimelineComponent.tsx +658 -0
  339. package/src/grapher/timeline/TimelineController.ts +368 -0
  340. package/src/grapher/timeline/readme.md +7 -0
  341. package/src/grapher/tooltip/Tooltip.scss +510 -0
  342. package/src/grapher/tooltip/Tooltip.tsx +294 -0
  343. package/src/grapher/tooltip/TooltipContents.tsx +383 -0
  344. package/src/grapher/tooltip/TooltipProps.ts +123 -0
  345. package/src/grapher/tooltip/TooltipState.ts +81 -0
  346. package/src/grapher/verticalLabels/VerticalLabels.tsx +31 -0
  347. package/src/grapher/verticalLabels/VerticalLabelsState.ts +154 -0
  348. package/src/index.ts +226 -0
  349. package/src/styles/charts.scss +15 -0
  350. package/src/types/NominalType.ts +30 -0
  351. package/src/types/OwidOrigin.ts +18 -0
  352. package/src/types/OwidSource.ts +9 -0
  353. package/src/types/OwidVariable.ts +133 -0
  354. package/src/types/OwidVariableDisplayConfigInterface.ts +49 -0
  355. package/src/types/analyticsTypes.ts +54 -0
  356. package/src/types/dbTypes/Tags.ts +11 -0
  357. package/src/types/domainTypes/Archive.ts +139 -0
  358. package/src/types/domainTypes/Author.ts +28 -0
  359. package/src/types/domainTypes/ContentGraph.ts +76 -0
  360. package/src/types/domainTypes/CoreTableTypes.ts +305 -0
  361. package/src/types/domainTypes/DeployStatus.ts +23 -0
  362. package/src/types/domainTypes/Layout.ts +34 -0
  363. package/src/types/domainTypes/Posts.ts +34 -0
  364. package/src/types/domainTypes/Search.ts +299 -0
  365. package/src/types/domainTypes/Site.ts +8 -0
  366. package/src/types/domainTypes/StaticViz.ts +64 -0
  367. package/src/types/domainTypes/Toc.ts +11 -0
  368. package/src/types/domainTypes/Tombstone.ts +19 -0
  369. package/src/types/domainTypes/Various.ts +79 -0
  370. package/src/types/gdocTypes/Gdoc.ts +280 -0
  371. package/src/types/grapherTypes/BinningStrategyTypes.ts +46 -0
  372. package/src/types/grapherTypes/GrapherConstants.ts +53 -0
  373. package/src/types/grapherTypes/GrapherTypes.ts +743 -0
  374. package/src/types/index.ts +316 -0
  375. package/src/types/wordpressTypes/WordpressTypes.ts +9 -0
  376. package/src/utils/Bounds.ts +439 -0
  377. package/src/utils/BrowserUtils.ts +12 -0
  378. package/src/utils/FuzzySearch.ts +74 -0
  379. package/src/utils/MultiDimDataPageConfig.ts +31 -0
  380. package/src/utils/OwidVariable.ts +82 -0
  381. package/src/utils/PointVector.ts +97 -0
  382. package/src/utils/PromiseCache.ts +36 -0
  383. package/src/utils/PromiseSwitcher.ts +52 -0
  384. package/src/utils/TimeBounds.ts +130 -0
  385. package/src/utils/Tippy.tsx +57 -0
  386. package/src/utils/Util.ts +2369 -0
  387. package/src/utils/archival/archivalDate.ts +48 -0
  388. package/src/utils/dayjs.ts +32 -0
  389. package/src/utils/formatValue.ts +242 -0
  390. package/src/utils/grapherConfigUtils.ts +81 -0
  391. package/src/utils/image.ts +225 -0
  392. package/src/utils/index.ts +318 -0
  393. package/src/utils/isPresent.ts +5 -0
  394. package/src/utils/metadataHelpers.ts +329 -0
  395. package/src/utils/persistable/Persistable.ts +82 -0
  396. package/src/utils/persistable/readme.md +50 -0
  397. package/src/utils/regions.json +5635 -0
  398. package/src/utils/regions.ts +463 -0
  399. package/src/utils/serializers.ts +16 -0
  400. package/src/utils/string.ts +42 -0
  401. package/src/utils/urls/Url.ts +195 -0
  402. package/src/utils/urls/UrlMigration.ts +10 -0
  403. package/src/utils/urls/UrlUtils.ts +54 -0
  404. package/src/utils/urls/readme.md +90 -0
@@ -0,0 +1,3659 @@
1
+ import {
2
+ MarkdownTextWrap,
3
+ sumTextWrapHeights,
4
+ reactRenderToStringClientOnly,
5
+ } from "../../components/index.js"
6
+ import {
7
+ OwidTable,
8
+ BlankOwidTable,
9
+ CoreColumn,
10
+ ColumnTypeMap,
11
+ } from "../../core-table/index.js"
12
+ import {
13
+ GrapherChartType,
14
+ GRAPHER_CHART_TYPES,
15
+ AnnotationFieldsInTitle,
16
+ TimeBound,
17
+ Time,
18
+ EntitySelectionMode,
19
+ StackMode,
20
+ LogoOption,
21
+ GrapherTabConfigOption,
22
+ GRAPHER_TAB_CONFIG_OPTIONS,
23
+ ColorSchemeName,
24
+ ScatterPointLabelStrategy,
25
+ MissingDataStrategy,
26
+ ColumnSlugs,
27
+ ColumnSlug,
28
+ EntityName,
29
+ SeriesName,
30
+ ComparisonLineConfig,
31
+ RelatedQuestionsConfig,
32
+ FacetStrategy,
33
+ SortBy,
34
+ SortOrder,
35
+ QueryParams,
36
+ LegacyGrapherInterface,
37
+ ArchiveContext,
38
+ AdditionalGrapherDataFetchFn,
39
+ GrapherInterface,
40
+ grapherKeysToSerialize,
41
+ GrapherQueryParams,
42
+ GRAPHER_QUERY_PARAM_KEYS,
43
+ ScaleType,
44
+ FacetAxisDomain,
45
+ TimeBounds,
46
+ GrapherTabName,
47
+ GRAPHER_TAB_NAMES,
48
+ AxisConfigInterface,
49
+ SeriesStrategy,
50
+ AssetMap,
51
+ GRAPHER_MAP_TYPE,
52
+ GrapherVariant,
53
+ SeriesColorMap,
54
+ OwidChartDimensionInterface,
55
+ DimensionProperty,
56
+ DetailsMarker,
57
+ EnrichedDetail,
58
+ ProjectionColumnInfo,
59
+ OwidColumnDef,
60
+ OwidVariableRow,
61
+ SortConfig,
62
+ Color,
63
+ GlobeRegionName,
64
+ GrapherWindowType,
65
+ MapRegionName,
66
+ } from "../../types/index.js"
67
+ import {
68
+ objectWithPersistablesToObject,
69
+ deleteRuntimeAndUnchangedProps,
70
+ minTimeToJSON,
71
+ maxTimeToJSON,
72
+ updatePersistables,
73
+ minTimeBoundFromJSONOrNegativeInfinity,
74
+ maxTimeBoundFromJSONOrPositiveInfinity,
75
+ parseFloatOrUndefined,
76
+ Url,
77
+ getTimeDomainFromQueryString,
78
+ findClosestTime,
79
+ Bounds,
80
+ excludeUndefined,
81
+ isInIFrame,
82
+ bind,
83
+ slugify,
84
+ extractDetailsFromSyntax,
85
+ lowerCaseFirstLetterUnlessAbbreviation,
86
+ getOriginAttributionFragments,
87
+ isMobile,
88
+ isTouchDevice,
89
+ omitUndefinedValues,
90
+ firstOfNonEmptyArray,
91
+ getWindowUrl,
92
+ isArrayDifferentFromReference,
93
+ differenceObj,
94
+ queryParamsToStr,
95
+ timeBoundToTimeBoundString,
96
+ getRegionByName,
97
+ checkIsCountry,
98
+ checkIsOwidContinent,
99
+ checkIsIncomeGroup,
100
+ checkHasMembers,
101
+ sortNumeric,
102
+ } from "../../utils/index.js"
103
+ import Cookies from "js-cookie"
104
+ import * as _ from "lodash-es"
105
+ import {
106
+ computed,
107
+ action,
108
+ makeObservable,
109
+ observable,
110
+ autorun,
111
+ runInAction,
112
+ } from "mobx"
113
+ import React from "react"
114
+ import * as R from "remeda"
115
+ import { match } from "ts-pattern"
116
+ import { AxisConfig } from "../axis/AxisConfig.js"
117
+ import {
118
+ GrapherRasterizeFn,
119
+ StaticChartRasterizer,
120
+ } from "../captionedChart/StaticChartRasterizer.js"
121
+ import { Chart } from "../chart/Chart.js"
122
+ import { ChartDimension } from "../chart/ChartDimension.js"
123
+ import { ChartState } from "../chart/ChartInterface.js"
124
+ import {
125
+ isChartTypeName,
126
+ isChartTab,
127
+ isMapTab,
128
+ findValidChartTypeCombination,
129
+ isValidTabConfigOption,
130
+ mapChartTypeNameToTabConfigOption,
131
+ mapTabConfigOptionToChartTypeName,
132
+ } from "../chart/ChartTabs.js"
133
+ import { makeChartState } from "../chart/ChartTypeMap.js"
134
+ import {
135
+ autoDetectSeriesStrategy,
136
+ autoDetectYColumnSlugs,
137
+ } from "../chart/ChartUtils.js"
138
+ import { DimensionSlot } from "../chart/DimensionSlot.js"
139
+ import {
140
+ GRAPHER_LIGHT_TEXT,
141
+ GRAPHER_BACKGROUND_BEIGE,
142
+ GRAPHER_BACKGROUND_DEFAULT,
143
+ } from "../color/ColorConstants.js"
144
+ import { ColorScaleConfig } from "../color/ColorScaleConfig.js"
145
+ import { isValidDataTableFilter } from "../dataTable/DataTable.js"
146
+ import { DataTableConfig } from "../dataTable/DataTableConstants.js"
147
+ import {
148
+ type EntitySelectorState,
149
+ EntitySelector,
150
+ } from "../entitySelector/EntitySelector.js"
151
+ import { FacetChart } from "../facet/FacetChart.js"
152
+ import { FocusArray } from "../focus/FocusArray.js"
153
+ import { GlobeController } from "../mapCharts/GlobeController.js"
154
+ import {
155
+ MAP_REGION_LABELS,
156
+ MAP_REGION_NAMES,
157
+ } from "../mapCharts/MapChartConstants.js"
158
+ import { MapConfig } from "../mapCharts/MapConfig.js"
159
+ import {
160
+ isValidMapRegionName,
161
+ isOnTheMap,
162
+ getCountriesByRegion,
163
+ } from "../mapCharts/MapHelpers.js"
164
+ import { DownloadModalTabName } from "../modal/DownloadModal.js"
165
+ import { SelectionArray } from "../selection/SelectionArray.js"
166
+ import { SlideShowController } from "../slideshowController/SlideShowController.js"
167
+ import {
168
+ TimelineDragTarget,
169
+ TimelineController,
170
+ } from "../timeline/TimelineController.js"
171
+ import { TooltipManager } from "../tooltip/TooltipProps.js"
172
+ import {
173
+ EntityRegionTypeGroup,
174
+ groupEntityNamesByRegionType,
175
+ EntityNamesByRegionType,
176
+ isEntityRegionType,
177
+ } from "./EntitiesByRegionType.js"
178
+ import {
179
+ getEntityNamesParam,
180
+ getSelectedEntityNamesParam,
181
+ getFocusedSeriesNamesParam,
182
+ } from "./EntityUrlBuilder.js"
183
+ import {
184
+ MinimalNarrativeChartInfo,
185
+ GrapherProgrammaticInterface,
186
+ GrapherManager,
187
+ DEFAULT_MS_PER_TICK,
188
+ } from "./Grapher.js"
189
+ import { GrapherAnalytics } from "./GrapherAnalytics.js"
190
+ import {
191
+ type GrapherAnalyticsContext,
192
+ type EntitySelectorEvent,
193
+ type GrapherImageDownloadEvent,
194
+ type GrapherInteractionEvent,
195
+ } from "../../types/index.js"
196
+ import {
197
+ latestGrapherConfigSchema,
198
+ DEFAULT_GRAPHER_ENTITY_TYPE,
199
+ DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL,
200
+ GRAPHER_FRAME_PADDING_HORIZONTAL,
201
+ GRAPHER_FRAME_PADDING_VERTICAL,
202
+ DEFAULT_GRAPHER_BOUNDS,
203
+ GrapherModal,
204
+ CookieKey,
205
+ GRAPHER_PROD_URL,
206
+ BASE_FONT_SIZE,
207
+ isContinentsVariableId,
208
+ isPopulationVariableETLPath,
209
+ DEFAULT_GRAPHER_WIDTH,
210
+ DEFAULT_GRAPHER_HEIGHT,
211
+ STATIC_EXPORT_DETAIL_SPACING,
212
+ DEFAULT_GRAPHER_BOUNDS_SQUARE,
213
+ CHART_TYPES_THAT_SHOW_ALL_ENTITIES,
214
+ } from "./GrapherConstants.js"
215
+ import { parseGlobeRotation, grapherObjectToQueryParams } from "./GrapherUrl.js"
216
+ import { legacyToCurrentGrapherQueryParams } from "./GrapherUrlMigrations.js"
217
+ import { getErrorMessageRelatedQuestionUrl } from "./relatedQuestion.js"
218
+
219
+ export class GrapherState {
220
+ $schema = latestGrapherConfigSchema
221
+ chartTypes: GrapherChartType[] = [
222
+ GRAPHER_CHART_TYPES.LineChart,
223
+ GRAPHER_CHART_TYPES.DiscreteBar,
224
+ ]
225
+ id: number | undefined = undefined
226
+ version = 1
227
+ slug: string | undefined = undefined
228
+
229
+ // Initializing text fields with `undefined` ensures that empty strings get serialised
230
+ title: string | undefined = undefined
231
+ subtitle: string | undefined = undefined
232
+ sourceDesc: string | undefined = undefined
233
+ note: string | undefined = undefined
234
+ // Missing from GrapherInterface: details
235
+ internalNotes: string | undefined = undefined
236
+ variantName: string | undefined = undefined
237
+ originUrl: string | undefined = undefined
238
+ hideAnnotationFieldsInTitle: AnnotationFieldsInTitle | undefined = undefined
239
+
240
+ minTime: TimeBound | undefined = undefined
241
+ maxTime: TimeBound | undefined = undefined
242
+ timelineMinTime: Time | undefined = undefined
243
+ timelineMaxTime: Time | undefined = undefined
244
+ addCountryMode = EntitySelectionMode.MultipleEntities
245
+ stackMode = StackMode.absolute
246
+ showNoDataArea = true
247
+ hideLegend: boolean | undefined = false
248
+ logo: LogoOption | undefined = undefined
249
+ hideLogo: boolean | undefined = undefined
250
+ hideRelativeToggle: boolean | undefined = true
251
+ entityType = DEFAULT_GRAPHER_ENTITY_TYPE
252
+ entityTypePlural = DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL
253
+ facettingLabelByYVariables = "metric"
254
+ hideTimeline: boolean | undefined = undefined
255
+ zoomToSelection: boolean | undefined = undefined
256
+ showYearLabels: boolean | undefined = undefined // Always show year in labels for bar charts
257
+ hasMapTab = false
258
+ tab: GrapherTabConfigOption = GRAPHER_TAB_CONFIG_OPTIONS.chart
259
+ isPublished: boolean | undefined = undefined
260
+ baseColorScheme: ColorSchemeName | undefined = undefined
261
+ invertColorScheme: boolean | undefined = undefined
262
+ hideConnectedScatterLines: boolean | undefined = undefined // Hides lines between points when timeline spans multiple years. Requested by core-econ for certain charts
263
+ hideScatterLabels: boolean | undefined = undefined
264
+ scatterPointLabelStrategy: ScatterPointLabelStrategy | undefined = undefined
265
+ compareEndPointsOnly: boolean | undefined = undefined
266
+ matchingEntitiesOnly: boolean | undefined = undefined
267
+ /** Hides the total value label that is normally displayed for stacked bar charts */
268
+ hideTotalValueLabel: boolean | undefined = undefined
269
+
270
+ missingDataStrategy: MissingDataStrategy | undefined = undefined
271
+
272
+ xAxis = new AxisConfig(undefined, this)
273
+ yAxis = new AxisConfig(undefined, this)
274
+ colorScale = new ColorScaleConfig()
275
+ map = new MapConfig()
276
+ dimensions: ChartDimension[] = []
277
+ ySlugs: ColumnSlugs | undefined = undefined
278
+ xSlug: ColumnSlug | undefined = undefined
279
+ colorSlug: ColumnSlug | undefined = undefined
280
+ sizeSlug: ColumnSlug | undefined = undefined
281
+ tableSlugs: ColumnSlugs | undefined = undefined
282
+ selectedEntityColors: {
283
+ [entityName: string]: string | undefined
284
+ } = {}
285
+ selectedEntityNames: EntityName[] = []
286
+ focusedSeriesNames: SeriesName[] = []
287
+ excludedEntityNames: EntityName[] | undefined = undefined
288
+ includedEntityNames: EntityName[] | undefined = undefined
289
+ comparisonLines: ComparisonLineConfig[] | undefined = undefined // todo: Persistables?
290
+ relatedQuestions: RelatedQuestionsConfig[] | undefined = undefined // todo: Persistables?
291
+
292
+ dataTableConfig: DataTableConfig = {
293
+ filter: "all",
294
+ search: "",
295
+ }
296
+
297
+ /**
298
+ * Used to highlight particular times in a line chart.
299
+ * The sparkline in map tooltips makes use of this.
300
+ */
301
+ highlightedTimesInLineChart?: Time[]
302
+
303
+ hideFacetControl = true
304
+
305
+ /**
306
+ * Indicates whether the chart is embedded alongside a complementary table.
307
+ * If that's the case, the chart can be simplified (e.g. hide legends or
308
+ * annotations) since the table serves as an additional source of information.
309
+ */
310
+ isDisplayedAlongsideComplementaryTable = false
311
+
312
+ // the desired faceting strategy, which might not be possible if we change the data
313
+ selectedFacetStrategy: FacetStrategy | undefined = undefined
314
+ sortBy: SortBy | undefined = SortBy.total
315
+ sortOrder: SortOrder | undefined = SortOrder.desc
316
+ sortColumnSlug: string | undefined = undefined
317
+ _isInFullScreenMode = false
318
+ windowInnerWidth: number | undefined = undefined
319
+ windowInnerHeight: number | undefined = undefined
320
+ manuallyProvideData? = false // This will be removed.
321
+
322
+ // This will be removed.
323
+ @computed get isDev(): boolean {
324
+ return this.initialOptions.env === "dev"
325
+ }
326
+ isEditor =
327
+ typeof window !== "undefined" && (window as any).isEditor === true
328
+ bakedGrapherURL: string | undefined = undefined
329
+ adminBaseUrl: string | undefined = undefined
330
+ externalQueryParams: QueryParams = {}
331
+ private framePaddingHorizontal = GRAPHER_FRAME_PADDING_HORIZONTAL
332
+ private framePaddingVertical = GRAPHER_FRAME_PADDING_VERTICAL
333
+ _inputTable: OwidTable = new OwidTable()
334
+
335
+ // TODO Daniel: probably obsolete?
336
+ // @observable.ref interpolatedSortColumnsBySlug:
337
+ // | CoreColumnBySlug
338
+ // | undefined = {}
339
+ get inputTable(): OwidTable {
340
+ return this._inputTable
341
+ }
342
+
343
+ @action set inputTable(table: OwidTable) {
344
+ this._inputTable = table
345
+
346
+ if (this.manager?.selection?.hasSelection) {
347
+ // Selection is managed externally, do nothing.
348
+ } else if (this.areSelectedEntitiesDifferentThanAuthors) {
349
+ // User has changed the selection, use theirs
350
+ } else this.applyOriginalSelectionAsAuthored()
351
+ }
352
+
353
+ legacyConfigAsAuthored: Partial<LegacyGrapherInterface> = {}
354
+ entitySelectorState: Partial<EntitySelectorState> = {}
355
+ @computed get dataTableSlugs(): ColumnSlug[] {
356
+ return this.tableSlugs ? this.tableSlugs.split(" ") : this.newSlugs
357
+ }
358
+ isEmbeddedInAnOwidPage?: boolean = false
359
+ isEmbeddedInADataPage?: boolean = false
360
+
361
+ // These are explicitly set to `false` if FetchingGrapher or some other
362
+ // external code is fetching the config and data
363
+ isConfigReady: boolean | undefined = true
364
+ isDataReady: boolean | undefined = true
365
+
366
+ /** Whether external grapher controls can be hidden in embeds. */
367
+ canHideExternalControlsInEmbed: boolean = false
368
+
369
+ /**
370
+ * Value of the query parameter in the embed URL that hides external grapher
371
+ * controls.
372
+ */
373
+ hideExternalControlsInEmbedUrl: boolean =
374
+ this.canHideExternalControlsInEmbed
375
+
376
+ narrativeChartInfo?: MinimalNarrativeChartInfo = undefined
377
+ archiveContext?: ArchiveContext
378
+
379
+ selection: SelectionArray = new SelectionArray()
380
+ focusArray = new FocusArray()
381
+ analytics: GrapherAnalytics
382
+
383
+ _additionalDataLoaderFn: AdditionalGrapherDataFetchFn | undefined =
384
+ undefined
385
+ /**
386
+ * todo: factor this out and make more RAII.
387
+ *
388
+ * Explorers create 1 Grapher instance, but as the user clicks around the Explorer loads other author created Graphers.
389
+ * But currently some Grapher features depend on knowing how the current state is different than the "authored state".
390
+ * So when an Explorer updates the grapher, it also needs to update this "original state".
391
+ */
392
+ @action.bound setAuthoredVersion(
393
+ config: Partial<LegacyGrapherInterface>
394
+ ): void {
395
+ this.legacyConfigAsAuthored = config
396
+ }
397
+
398
+ @action.bound updateAuthoredVersion(
399
+ config: Partial<LegacyGrapherInterface>
400
+ ): void {
401
+ this.legacyConfigAsAuthored = {
402
+ ...this.legacyConfigAsAuthored,
403
+ ...config,
404
+ }
405
+ }
406
+ constructor(options: GrapherProgrammaticInterface) {
407
+ makeObservable(this, {
408
+ $schema: observable.ref,
409
+ chartTypes: observable.ref,
410
+ id: observable.ref,
411
+ version: observable.ref,
412
+ slug: observable.ref,
413
+ title: observable.ref,
414
+ subtitle: observable.ref,
415
+ sourceDesc: observable.ref,
416
+ note: observable.ref,
417
+ internalNotes: observable.ref,
418
+ variantName: observable.ref,
419
+ originUrl: observable.ref,
420
+ hideAnnotationFieldsInTitle: observable,
421
+ minTime: observable.ref,
422
+ maxTime: observable.ref,
423
+ timelineMinTime: observable.ref,
424
+ timelineMaxTime: observable.ref,
425
+ addCountryMode: observable.ref,
426
+ stackMode: observable.ref,
427
+ showNoDataArea: observable.ref,
428
+ hideLegend: observable.ref,
429
+ logo: observable.ref,
430
+ hideLogo: observable.ref,
431
+ hideRelativeToggle: observable.ref,
432
+ entityType: observable.ref,
433
+ entityTypePlural: observable.ref,
434
+ facettingLabelByYVariables: observable.ref,
435
+ hideTimeline: observable.ref,
436
+ zoomToSelection: observable.ref,
437
+ showYearLabels: observable.ref,
438
+ hasMapTab: observable.ref,
439
+ tab: observable.ref,
440
+ isPublished: observable.ref,
441
+ baseColorScheme: observable.ref,
442
+ invertColorScheme: observable.ref,
443
+ hideConnectedScatterLines: observable,
444
+ hideScatterLabels: observable.ref,
445
+ scatterPointLabelStrategy: observable,
446
+ compareEndPointsOnly: observable.ref,
447
+ matchingEntitiesOnly: observable.ref,
448
+ hideTotalValueLabel: observable.ref,
449
+ missingDataStrategy: observable.ref,
450
+ xAxis: observable.ref,
451
+ yAxis: observable.ref,
452
+ colorScale: observable,
453
+ map: observable,
454
+ dimensions: observable.ref,
455
+ ySlugs: observable,
456
+ xSlug: observable,
457
+ colorSlug: observable,
458
+ sizeSlug: observable,
459
+ tableSlugs: observable,
460
+ selectedEntityColors: observable,
461
+ selectedEntityNames: observable,
462
+ focusedSeriesNames: observable,
463
+ excludedEntityNames: observable,
464
+ includedEntityNames: observable,
465
+ comparisonLines: observable,
466
+ relatedQuestions: observable,
467
+ dataTableConfig: observable,
468
+ highlightedTimesInLineChart: observable.ref,
469
+ hideFacetControl: observable.ref,
470
+ selectedFacetStrategy: observable,
471
+ sortBy: observable,
472
+ sortOrder: observable,
473
+ sortColumnSlug: observable,
474
+ _isInFullScreenMode: observable.ref,
475
+ windowInnerWidth: observable.ref,
476
+ windowInnerHeight: observable.ref,
477
+ bakedGrapherURL: observable,
478
+ externalQueryParams: observable.ref,
479
+ _inputTable: observable.ref,
480
+ legacyConfigAsAuthored: observable.ref,
481
+ entitySelectorState: observable,
482
+ isConfigReady: observable,
483
+ isDataReady: observable,
484
+ canHideExternalControlsInEmbed: observable.ref,
485
+ hideExternalControlsInEmbedUrl: observable.ref,
486
+ isExportingToSvgOrPng: observable.ref,
487
+ isSocialMediaExport: observable.ref,
488
+ isWikimediaExport: observable.ref,
489
+ variant: observable.ref,
490
+ staticBounds: observable,
491
+ isPlaying: observable.ref,
492
+ isTimelineAnimationActive: observable.ref,
493
+ animationStartTime: observable.ref,
494
+ areHandlesOnSameTimeBeforeAnimation: observable.ref,
495
+ timelineDragTarget: observable.ref,
496
+ isEntitySelectorModalOrDrawerOpen: observable.ref,
497
+ activeModal: observable.ref,
498
+ activeDownloadModalTab: observable.ref,
499
+ shouldIncludeDetailsInStaticExport: observable,
500
+ _externalBounds: observable,
501
+ slideShow: observable,
502
+ _baseFontSize: observable,
503
+ isShareMenuActive: observable,
504
+ hideTitle: observable,
505
+ hideSubtitle: observable,
506
+ hideNote: observable,
507
+ hideOriginUrl: observable,
508
+ hideEntityControls: observable,
509
+ enableMapSelection: observable,
510
+ forceHideAnnotationFieldsInTitle: observable,
511
+ hasTableTab: observable,
512
+ hideShareButton: observable,
513
+ hideExploreTheDataButton: observable,
514
+ isDisplayedAlongsideComplementaryTable: observable,
515
+ })
516
+ // prefer the manager's selection over the config's selectedEntityNames
517
+ // if both are passed in and the manager's selection is not empty.
518
+ // this is necessary for the global entity selector to work correctly.
519
+ if (options.manager?.selection?.hasSelection) {
520
+ this.updateFromObject(_.omit(options, "selectedEntityNames"))
521
+ } else {
522
+ this.updateFromObject(options)
523
+ }
524
+
525
+ this._additionalDataLoaderFn = options.additionalDataLoaderFn
526
+ this.isEmbeddedInAnOwidPage = options.isEmbeddedInAnOwidPage ?? false
527
+ this.isEmbeddedInADataPage = options.isEmbeddedInADataPage ?? false
528
+
529
+ this._inputTable =
530
+ options.table ?? BlankOwidTable(`initialGrapherTable`)
531
+ this.initialOptions = options
532
+ this.analytics = new GrapherAnalytics(this.initialOptions.env ?? "")
533
+ this.selection =
534
+ this.manager?.selection ??
535
+ new SelectionArray(this.initialOptions.selectedEntityNames ?? [])
536
+ this.setAuthoredVersion(options)
537
+ this.canHideExternalControlsInEmbed =
538
+ options.canHideExternalControlsInEmbed ?? false
539
+
540
+ // make sure the static bounds are set
541
+ this.staticBounds = options.staticBounds ?? DEFAULT_GRAPHER_BOUNDS
542
+
543
+ this.narrativeChartInfo = options.narrativeChartInfo
544
+ this.archiveContext = options.archiveContext
545
+
546
+ this.populateFromQueryParams(
547
+ legacyToCurrentGrapherQueryParams(
548
+ this.initialOptions.queryStr ?? ""
549
+ )
550
+ )
551
+ if (this.isEditor) {
552
+ this.ensureValidConfigWhenEditing()
553
+ }
554
+ }
555
+
556
+ toObject(deleteUnchanged: boolean = true): GrapherInterface {
557
+ const obj: GrapherInterface = objectWithPersistablesToObject(
558
+ this,
559
+ grapherKeysToSerialize
560
+ )
561
+
562
+ obj.selectedEntityNames = this.selection.selectedEntityNames
563
+ obj.focusedSeriesNames = this.focusArray.seriesNames
564
+
565
+ if (deleteUnchanged) {
566
+ deleteRuntimeAndUnchangedProps(obj, defaultObject)
567
+ }
568
+
569
+ // always include the schema, even if it's the default
570
+ obj.$schema = this.$schema || latestGrapherConfigSchema
571
+
572
+ // JSON doesn't support Infinity, so we use strings instead.
573
+ if (obj.minTime) obj.minTime = minTimeToJSON(this.minTime) as any
574
+ if (obj.maxTime) obj.maxTime = maxTimeToJSON(this.maxTime) as any
575
+
576
+ if (obj.timelineMinTime)
577
+ obj.timelineMinTime = minTimeToJSON(this.timelineMinTime) as any
578
+ if (obj.timelineMaxTime)
579
+ obj.timelineMaxTime = maxTimeToJSON(this.timelineMaxTime) as any
580
+
581
+ // don't serialise tab if the default chart is currently shown
582
+ if (
583
+ this.activeChartType &&
584
+ this.activeChartType === this.defaultChartType
585
+ ) {
586
+ delete obj.tab
587
+ }
588
+
589
+ // todo: remove dimensions concept
590
+ // if (this.legacyConfigAsAuthored?.dimensions)
591
+ // obj.dimensions = this.legacyConfigAsAuthored.dimensions
592
+ return obj
593
+ }
594
+
595
+ @action.bound updateFromObject(obj?: GrapherProgrammaticInterface): void {
596
+ if (!obj) return
597
+
598
+ updatePersistables(this, obj)
599
+
600
+ this.bindUrlToWindow = obj.bindUrlToWindow ?? false
601
+
602
+ // Regression fix: some legacies have this set to Null. Todo: clean DB.
603
+ if (obj.originUrl === null) this.originUrl = ""
604
+
605
+ // update selection
606
+ if (obj.selectedEntityNames)
607
+ this.selection.setSelectedEntities(obj.selectedEntityNames)
608
+
609
+ // update focus
610
+ if (obj.focusedSeriesNames)
611
+ this.focusArray.clearAllAndAdd(...obj.focusedSeriesNames)
612
+
613
+ // JSON doesn't support Infinity, so we use strings instead.
614
+ this.minTime = minTimeBoundFromJSONOrNegativeInfinity(obj.minTime)
615
+ this.maxTime = maxTimeBoundFromJSONOrPositiveInfinity(obj.maxTime)
616
+
617
+ this.timelineMinTime = minTimeBoundFromJSONOrNegativeInfinity(
618
+ obj.timelineMinTime
619
+ )
620
+ this.timelineMaxTime = maxTimeBoundFromJSONOrPositiveInfinity(
621
+ obj.timelineMaxTime
622
+ )
623
+
624
+ // Todo: remove once we are more RAII.
625
+ if (obj?.dimensions?.length)
626
+ this.setDimensionsFromConfigs(obj.dimensions)
627
+ }
628
+
629
+ @action.bound populateFromQueryParams(params: GrapherQueryParams): void {
630
+ this.externalQueryParams = _.omit(params, GRAPHER_QUERY_PARAM_KEYS)
631
+
632
+ // Set tab if specified
633
+ const parsedTab = params.tab
634
+ ? this.mapQueryParamToTabName(params.tab)
635
+ : undefined
636
+ if (parsedTab) this.setTab(parsedTab)
637
+ else if (params.tab !== undefined)
638
+ console.error("Unexpected tab: " + params.tab)
639
+
640
+ // Set overlay if specified
641
+ const overlay = params.overlay
642
+ if (overlay) {
643
+ if (overlay === "sources") {
644
+ this.activeModal = GrapherModal.Sources
645
+ } else if (overlay === "download-data") {
646
+ this.activeModal = GrapherModal.Download
647
+ this.activeDownloadModalTab = DownloadModalTabName.Data
648
+ } else if (overlay === "download-vis") {
649
+ this.activeModal = GrapherModal.Download
650
+ this.activeDownloadModalTab = DownloadModalTabName.Vis
651
+ } else if (overlay === "download") {
652
+ this.activeModal = GrapherModal.Download
653
+ } else if (overlay === "embed") {
654
+ // We could include the embed modal in the `overlay=` params, but there has been an issue in the past
655
+ // where we accidentally included that in the Embed dialog's URL, and then embeds would always show
656
+ // the modal.
657
+ // So, if it is specified in the query params, we just ignore it.
658
+ // Linking directly to the modal doesn't have much of a use case, anyway.
659
+ } else {
660
+ console.error("Unexpected overlay: " + overlay)
661
+ }
662
+ }
663
+
664
+ // Stack mode for bar and stacked area charts
665
+ this.stackMode = (params.stackMode ?? this.stackMode) as StackMode
666
+
667
+ this.zoomToSelection =
668
+ params.zoomToSelection === "true" ? true : this.zoomToSelection
669
+
670
+ // Axis scale mode
671
+ const xScaleType = params.xScale
672
+ if (xScaleType) {
673
+ if (xScaleType === ScaleType.linear || xScaleType === ScaleType.log)
674
+ this.xAxis.scaleType = xScaleType
675
+ else console.error("Unexpected xScale: " + xScaleType)
676
+ }
677
+
678
+ const yScaleType = params.yScale
679
+ if (yScaleType) {
680
+ if (yScaleType === ScaleType.linear || yScaleType === ScaleType.log)
681
+ this.yAxis.scaleType = yScaleType
682
+ else console.error("Unexpected xScale: " + yScaleType)
683
+ }
684
+
685
+ const time = params.time
686
+ if (time !== undefined && time !== "")
687
+ this.setTimeFromTimeQueryParam(time)
688
+
689
+ const endpointsOnly = params.endpointsOnly
690
+ if (endpointsOnly !== undefined)
691
+ this.compareEndPointsOnly = endpointsOnly === "1" ? true : undefined
692
+
693
+ // globe
694
+ const globe = params.globe
695
+ if (globe !== undefined) {
696
+ this.mapConfig.globe.isActive = globe === "1"
697
+ }
698
+
699
+ // globe rotation
700
+ const globeRotation = params.globeRotation
701
+ if (globeRotation !== undefined) {
702
+ this.mapConfig.globe.rotation = parseGlobeRotation(globeRotation)
703
+ }
704
+
705
+ // globe zoom
706
+ const globeZoom = params.globeZoom
707
+ if (globeZoom !== undefined) {
708
+ const parsedZoom = parseFloatOrUndefined(globeZoom)
709
+ if (parsedZoom !== undefined) this.mapConfig.globe.zoom = parsedZoom
710
+ }
711
+
712
+ // region
713
+ const region = params.region
714
+ if (region !== undefined && isValidMapRegionName(region)) {
715
+ this.map.region = region
716
+ }
717
+
718
+ // map selection
719
+ const mapSelection = getEntityNamesParam(params.mapSelect)
720
+ if (mapSelection) {
721
+ this.mapConfig.selection.setSelectedEntities(mapSelection)
722
+ }
723
+
724
+ // selection
725
+ const url = Url.fromQueryParams(params)
726
+ const selection = getSelectedEntityNamesParam(url)
727
+ if (this.addCountryMode !== EntitySelectionMode.Disabled && selection)
728
+ this.selection.setSelectedEntities(selection)
729
+
730
+ // focus
731
+ const focusedSeriesNames = getFocusedSeriesNamesParam(params.focus)
732
+ if (focusedSeriesNames) {
733
+ this.focusArray.clearAllAndAdd(...focusedSeriesNames)
734
+ }
735
+
736
+ // faceting
737
+ if (params.facet && params.facet in FacetStrategy) {
738
+ this.selectedFacetStrategy = params.facet as FacetStrategy
739
+ }
740
+ if (params.uniformYAxis === "0") {
741
+ this.yAxis.facetDomain = FacetAxisDomain.independent
742
+ } else if (params.uniformYAxis === "1") {
743
+ this.yAxis.facetDomain = FacetAxisDomain.shared
744
+ }
745
+
746
+ // no data area in marimekko charts
747
+ if (params.showNoDataArea) {
748
+ this.showNoDataArea = params.showNoDataArea === "1"
749
+ }
750
+
751
+ // deprecated; support for legacy URLs
752
+ if (params.showSelectionOnlyInTable) {
753
+ this.dataTableConfig.filter =
754
+ params.showSelectionOnlyInTable === "1" ? "selection" : "all"
755
+ }
756
+
757
+ // data table filter
758
+ if (params.tableFilter) {
759
+ this.dataTableConfig.filter = isValidDataTableFilter(
760
+ params.tableFilter
761
+ )
762
+ ? params.tableFilter
763
+ : "all"
764
+ }
765
+
766
+ // data table search
767
+ if (params.tableSearch) {
768
+ this.dataTableConfig.search = params.tableSearch
769
+ }
770
+ }
771
+
772
+ @action.bound setTimeFromTimeQueryParam(time: string): void {
773
+ this.timelineHandleTimeBounds = getTimeDomainFromQueryString(time).map(
774
+ (time) => findClosestTime(this.times, time) ?? time
775
+ ) as TimeBounds
776
+ }
777
+
778
+ @computed get activeTab(): GrapherTabName {
779
+ return this.mapTabConfigOptionToTabName(this.tab)
780
+ }
781
+
782
+ @computed get activeChartType(): GrapherChartType | undefined {
783
+ return isChartTypeName(this.activeTab) ? this.activeTab : undefined
784
+ }
785
+
786
+ @computed private get defaultChartType(): GrapherChartType {
787
+ return this.chartType ?? GRAPHER_CHART_TYPES.LineChart
788
+ }
789
+
790
+ @computed private get defaultTab(): GrapherTabName {
791
+ if (this.chartType) return this.chartType
792
+ if (this.hasMapTab) return GRAPHER_TAB_NAMES.WorldMap
793
+ return GRAPHER_TAB_NAMES.Table
794
+ }
795
+
796
+ @computed get chartType(): GrapherChartType | undefined {
797
+ return this.validChartTypes[0]
798
+ }
799
+
800
+ @computed get hasChartTab(): boolean {
801
+ return this.validChartTypes.length > 0
802
+ }
803
+ @computed get isOnChartTab(): boolean {
804
+ return !this.isOnMapTab && !this.isOnTableTab
805
+ }
806
+
807
+ @computed get isOnMapTab(): boolean {
808
+ return this.activeTab === GRAPHER_TAB_NAMES.WorldMap
809
+ }
810
+
811
+ @computed get isOnTableTab(): boolean {
812
+ return this.activeTab === GRAPHER_TAB_NAMES.Table
813
+ }
814
+
815
+ @computed get isOnChartOrMapTab(): boolean {
816
+ return this.isOnChartTab || this.isOnMapTab
817
+ }
818
+ @computed get yAxisConfig(): Readonly<AxisConfigInterface> {
819
+ return this.yAxis.toObject()
820
+ }
821
+
822
+ @computed get xAxisConfig(): Readonly<AxisConfigInterface> {
823
+ return this.xAxis.toObject()
824
+ }
825
+
826
+ @computed get showLegend(): boolean {
827
+ // hide the legend for stacked bar charts
828
+ // if the legend only ever shows a single entity
829
+ if (this.isOnStackedBarTab) {
830
+ const seriesStrategy =
831
+ this.chartState.seriesStrategy ||
832
+ autoDetectSeriesStrategy(this, true)
833
+ const isEntityStrategy = seriesStrategy === SeriesStrategy.entity
834
+ const hasSingleEntity = this.selection.numSelectedEntities === 1
835
+ const hideLegend =
836
+ this.hideLegend || (isEntityStrategy && hasSingleEntity)
837
+ return !hideLegend
838
+ }
839
+
840
+ return !this.hideLegend
841
+ }
842
+
843
+ private isChartTypeThatShowsAllEntities(
844
+ chartType: GrapherChartType
845
+ ): boolean {
846
+ return CHART_TYPES_THAT_SHOW_ALL_ENTITIES.includes(chartType)
847
+ }
848
+
849
+ @computed private get hasChartThatShowsAllEntities(): boolean {
850
+ return this.validChartTypes.some((chartType) =>
851
+ this.isChartTypeThatShowsAllEntities(chartType)
852
+ )
853
+ }
854
+
855
+ @computed get isOnArchivalPage(): boolean {
856
+ return this.archiveContext?.type === "archive-page"
857
+ }
858
+
859
+ @computed get hasArchivedPage(): boolean {
860
+ return this.archiveContext?.type === "archived-page-version"
861
+ }
862
+
863
+ @computed private get runtimeAssetMap(): AssetMap | undefined {
864
+ return this.archiveContext?.type === "archive-page"
865
+ ? this.archiveContext.assets.runtime
866
+ : undefined
867
+ }
868
+
869
+ @computed get additionalDataLoaderFn():
870
+ | AdditionalGrapherDataFetchFn
871
+ | undefined {
872
+ if (this.isOnArchivalPage) return undefined
873
+ return this._additionalDataLoaderFn
874
+ }
875
+
876
+ /**
877
+ * We only show the selected entities in the data table if entity selection
878
+ * is disabled – unless there is a view that displays all data points, like
879
+ * a map or a scatter plot.
880
+ */
881
+ @computed get shouldShowSelectionOnlyInDataTable(): boolean {
882
+ return (
883
+ this.selection.hasSelection &&
884
+ !this.canChangeAddOrHighlightEntities &&
885
+ this.hasChartTab &&
886
+ !this.hasChartThatShowsAllEntities &&
887
+ !this.hasMapTab
888
+ )
889
+ }
890
+
891
+ /**
892
+ * Selection used in Grapher's data table.
893
+ *
894
+ * If a map selection is set, it is preferred over the chart selection.
895
+ */
896
+ @computed get dataTableSelection(): SelectionArray {
897
+ return this.mapConfig.selection.hasSelection
898
+ ? this.mapConfig.selection
899
+ : this.selection
900
+ }
901
+
902
+ // table that is used for display in the table tab
903
+ @computed get tableForDisplay(): OwidTable {
904
+ let table = this.table
905
+
906
+ if (!this.isReady || !this.isOnTableTab) return table
907
+
908
+ if (this.chartState.transformTableForDisplay) {
909
+ table = this.chartState.transformTableForDisplay(table)
910
+ }
911
+
912
+ if (this.shouldShowSelectionOnlyInDataTable) {
913
+ table = table.filterByEntityNames(
914
+ this.selection.selectedEntityNames
915
+ )
916
+ }
917
+
918
+ return table
919
+ }
920
+
921
+ @computed get filteredTableForDisplay(): OwidTable {
922
+ let table = this.tableForDisplay
923
+ const { filter } = this.dataTableConfig
924
+
925
+ const availableEntities = table.availableEntityNames
926
+
927
+ // Determine which entities should be visible based on the filter
928
+ const visibleEntities = match(filter)
929
+ .with("all", () => availableEntities)
930
+ .with("selection", () =>
931
+ this.selection.hasSelection
932
+ ? this.selection.selectedEntityNames
933
+ : availableEntities
934
+ )
935
+ .when(isEntityRegionType, (filter) => {
936
+ const regionNames = this.entityNamesByRegionType.get(filter)
937
+ return regionNames ?? availableEntities
938
+ })
939
+ .exhaustive()
940
+
941
+ // Apply entity filter if necessary
942
+ if (visibleEntities.length < availableEntities.length)
943
+ table = table.filterByEntityNames(visibleEntities)
944
+
945
+ return table
946
+ }
947
+
948
+ @computed get tableForSelection(): OwidTable {
949
+ // This table specifies which entities can be selected in the charts EntitySelectorModal.
950
+ // It should contain all entities that can be selected, and none more.
951
+ // Depending on the chart type, the criteria for being able to select an entity are
952
+ // different; e.g. for scatterplots, the entity needs to (1) not be excluded and
953
+ // (2) needs to have data for the x and y dimension.
954
+ let table =
955
+ this.isOnScatterTab || this.isOnMarimekkoTab
956
+ ? this.tableAfterAuthorTimelineAndActiveChartTransform
957
+ : this.table
958
+
959
+ if (!this.isReady) return table
960
+
961
+ // Some chart types (e.g. stacked area charts) choose not to show an entity
962
+ // with incomplete data. Such chart types define a custom transform function
963
+ // to ensure that the entity selector only offers entities that are actually plotted.
964
+ if (this.chartState.transformTableForSelection) {
965
+ table = this.chartState.transformTableForSelection(table)
966
+ }
967
+
968
+ return table
969
+ }
970
+
971
+ /**
972
+ * Input table with color and size tolerance applied.
973
+ *
974
+ * This happens _before_ applying the author's timeline filter to avoid
975
+ * accidentally dropping all color values before applying tolerance.
976
+ * This is especially important for scatter plots and Marimekko charts,
977
+ * where color and size columns are often transformed with infinite tolerance.
978
+ *
979
+ * Line and discrete bar charts also support a color dimension, but their
980
+ * tolerance transformations run in their respective transformTable functions
981
+ * since it's more efficient to run them on a table that has been filtered
982
+ * by selected entities.
983
+ */
984
+ @computed get tableAfterColorAndSizeToleranceApplication(): OwidTable {
985
+ let table = this.inputTable
986
+
987
+ if (this.hasScatter && this.sizeColumnSlug) {
988
+ const tolerance =
989
+ table.get(this.sizeColumnSlug)?.display?.tolerance ?? Infinity
990
+ table = table.interpolateColumnWithTolerance(this.sizeColumnSlug, {
991
+ toleranceOverride: tolerance,
992
+ })
993
+ }
994
+
995
+ if (
996
+ (this.hasScatter || this.hasMarimekko) &&
997
+ this.categoricalColorColumnSlug
998
+ ) {
999
+ const tolerance =
1000
+ table.get(this.categoricalColorColumnSlug)?.display
1001
+ ?.tolerance ?? Infinity
1002
+ table = table.interpolateColumnWithTolerance(
1003
+ this.categoricalColorColumnSlug,
1004
+ { toleranceOverride: tolerance }
1005
+ )
1006
+ }
1007
+
1008
+ return table
1009
+ }
1010
+
1011
+ // If an author sets a timeline or entity filter, run it early in the pipeline
1012
+ // so to the charts it's as if the filtered times and entities do not exist
1013
+ @computed get tableAfterAuthorTimelineAndEntityFilter(): OwidTable {
1014
+ let table = this.tableAfterColorAndSizeToleranceApplication
1015
+
1016
+ // Filter entities
1017
+ table = table.filterByEntityNamesUsingIncludeExcludePattern({
1018
+ excluded: this.excludedEntityNames,
1019
+ included: this.includedEntityNames,
1020
+ })
1021
+
1022
+ // Filter times
1023
+ if (
1024
+ this.timelineMinTime === undefined &&
1025
+ this.timelineMaxTime === undefined
1026
+ )
1027
+ return table
1028
+ return table.filterByTimeRange(
1029
+ this.timelineMinTime ?? -Infinity,
1030
+ this.timelineMaxTime ?? Infinity
1031
+ )
1032
+ }
1033
+
1034
+ @computed
1035
+ get tableAfterAuthorTimelineAndActiveChartTransform(): OwidTable {
1036
+ const table = this.table
1037
+ if (!this.isReady || !this.isOnChartOrMapTab) return table
1038
+
1039
+ const startMark = performance.now()
1040
+
1041
+ const transformedTable = this.chartState.transformTable(table)
1042
+
1043
+ this.createPerformanceMeasurement(
1044
+ "chartInstance.transformTable",
1045
+ startMark
1046
+ )
1047
+ return transformedTable
1048
+ }
1049
+
1050
+ @computed get chartState(): ChartState {
1051
+ // Note: when timeline handles on a LineChart are collapsed into a single handle, the
1052
+ // LineChart turns into a DiscreteBar.
1053
+ return this.isOnMapTab
1054
+ ? makeChartState(GRAPHER_MAP_TYPE, this)
1055
+ : this.chartStateExceptMap
1056
+ }
1057
+
1058
+ @computed get facetChartInstance(): FacetChart | undefined {
1059
+ if (!this.isFaceted) return undefined
1060
+ return new FacetChart({
1061
+ manager: this,
1062
+ chartTypeName: this.activeChartType,
1063
+ })
1064
+ }
1065
+
1066
+ // When Map becomes a first-class chart instance, we should drop this
1067
+ @computed get chartStateExceptMap(): ChartState {
1068
+ const chartType = this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart
1069
+
1070
+ return makeChartState(chartType, this)
1071
+ }
1072
+
1073
+ @computed get chartSeriesNames(): SeriesName[] {
1074
+ if (!this.isReady) return []
1075
+
1076
+ // collect series names from all chart instances when faceted
1077
+ if (this.isFaceted) {
1078
+ return _.uniq(
1079
+ this.facetChartInstance?.intermediateChartInstances.flatMap(
1080
+ (chartInstance) =>
1081
+ chartInstance.chartState.series.map(
1082
+ (series) => series.seriesName
1083
+ )
1084
+ ) ?? []
1085
+ )
1086
+ }
1087
+
1088
+ return this.chartState.series.map((series) => series.seriesName)
1089
+ }
1090
+
1091
+ @computed get table(): OwidTable {
1092
+ return this.tableAfterAuthorTimelineAndEntityFilter
1093
+ }
1094
+ @computed
1095
+ private get tableAfterAllTransformsAndFilters(): OwidTable {
1096
+ const { startTime, endTime } = this
1097
+ const table = this.tableAfterAuthorTimelineAndActiveChartTransform
1098
+
1099
+ if (startTime === undefined || endTime === undefined) return table
1100
+
1101
+ if (this.isOnMapTab) {
1102
+ const targetTimes = this.isFaceted
1103
+ ? [startTime, endTime]
1104
+ : [endTime]
1105
+
1106
+ return table.filterByTargetTimes(targetTimes)
1107
+ }
1108
+
1109
+ if (this.isOnDiscreteBarTab || this.isOnMarimekkoTab)
1110
+ return table.filterByTargetTimes([endTime])
1111
+
1112
+ if (this.isOnSlopeChartTab)
1113
+ return table.filterByTargetTimes([startTime, endTime])
1114
+
1115
+ return table.filterByTimeRange(startTime, endTime)
1116
+ }
1117
+
1118
+ @computed get transformedTable(): OwidTable {
1119
+ return this.tableAfterAllTransformsAndFilters
1120
+ }
1121
+ isExportingToSvgOrPng = false
1122
+ isSocialMediaExport = false
1123
+ isWikimediaExport = false
1124
+
1125
+ variant = GrapherVariant.Default
1126
+
1127
+ staticBounds: Bounds = DEFAULT_GRAPHER_BOUNDS
1128
+
1129
+ enableKeyboardShortcuts: boolean = false
1130
+ bindUrlToWindow: boolean = false
1131
+ tooltip?: TooltipManager["tooltip"] = observable.box(undefined, {
1132
+ deep: false,
1133
+ })
1134
+ isPlaying = false
1135
+ isTimelineAnimationActive = false // true if the timeline animation is either playing or paused but not finished
1136
+
1137
+ animationStartTime: Time | undefined = undefined
1138
+ areHandlesOnSameTimeBeforeAnimation: boolean | undefined = undefined
1139
+ timelineDragTarget: TimelineDragTarget | undefined = undefined
1140
+
1141
+ isEntitySelectorModalOrDrawerOpen = false
1142
+
1143
+ activeModal?: GrapherModal
1144
+ activeDownloadModalTab: DownloadModalTabName = DownloadModalTabName.Vis
1145
+
1146
+ @computed get isStatic(): boolean {
1147
+ return this.isExportingToSvgOrPng
1148
+ }
1149
+
1150
+ private get isStaging(): boolean {
1151
+ if (typeof location === "undefined") return false
1152
+ return location.host.includes("staging")
1153
+ }
1154
+
1155
+ private get isLocalhost(): boolean {
1156
+ if (typeof location === "undefined") return false
1157
+ return location.host.includes("localhost")
1158
+ }
1159
+
1160
+ @computed get editUrl(): string | undefined {
1161
+ let editPath = this.manager?.adminEditPath
1162
+ if (!editPath && this.id) {
1163
+ editPath = `charts/${this.id}/edit`
1164
+ }
1165
+ if (this.showAdminControls && this.adminBaseUrl && editPath) {
1166
+ return `${this.adminBaseUrl}/admin/${editPath}`
1167
+ }
1168
+ return undefined
1169
+ }
1170
+
1171
+ @computed get createNarrativeChartUrl(): string | undefined {
1172
+ const adminPath = this.manager?.adminCreateNarrativeChartPath
1173
+ if (this.showAdminControls && this.isPublished && adminPath) {
1174
+ return `${this.adminBaseUrl}/admin/${adminPath}`
1175
+ }
1176
+ return undefined
1177
+ }
1178
+
1179
+ @computed get isAdminObjectAvailable(): boolean {
1180
+ if (typeof window === "undefined") return false
1181
+ return (
1182
+ window.admin !== undefined &&
1183
+ // Ensure that we're not accidentally matching on a DOM element with an ID of "admin"
1184
+ typeof window.admin.isSuperuser === "boolean"
1185
+ )
1186
+ }
1187
+
1188
+ @computed get isAdmin(): boolean {
1189
+ if (typeof window === "undefined") return false
1190
+ if (this.isAdminObjectAvailable) return true
1191
+ // Using this.isAdminObjectAvailable is not enough because it's not
1192
+ // available in gdoc previews, which render in an iframe without the
1193
+ // admin scaffolding.
1194
+ if (this.adminBaseUrl) {
1195
+ try {
1196
+ const adminUrl = new URL(this.adminBaseUrl)
1197
+ const currentUrl = new URL(window.location.href)
1198
+ return adminUrl.host === currentUrl.host
1199
+ } catch {
1200
+ return false
1201
+ }
1202
+ }
1203
+ return false
1204
+ }
1205
+
1206
+ @computed get isUserLoggedInAsAdmin(): boolean {
1207
+ // This cookie is set by visiting ourworldindata.org/identifyadmin on the static site.
1208
+ // There is an iframe on owid.cloud to trigger a visit to that page.
1209
+ try {
1210
+ // Cookie access can be restricted by iframe sandboxing, in which case the below code will throw an error
1211
+ // see https://github.com/owid/owid-grapher/pull/2452
1212
+ return !!Cookies.get(CookieKey.isAdmin)
1213
+ } catch {
1214
+ return false
1215
+ }
1216
+ }
1217
+ @computed get showAdminControls(): boolean {
1218
+ return (
1219
+ this.isUserLoggedInAsAdmin ||
1220
+ this.isAdmin || // Useful for gdoc previews.
1221
+ this.isDev ||
1222
+ this.isLocalhost ||
1223
+ this.isStaging
1224
+ )
1225
+ }
1226
+ // Exclusively used for the performance.measurement API, so that DevTools can show some context
1227
+ createPerformanceMeasurement(name: string, startMark: number): void {
1228
+ const endMark = performance.now()
1229
+ const detail = {
1230
+ devtools: {
1231
+ track: "Grapher",
1232
+ properties: [
1233
+ // might be missing for charts within explorers or mdims
1234
+ ["slug", this.slug ?? "missing-slug"],
1235
+ ["chartTypes", this.validChartTypes],
1236
+ ["tab", this.tab],
1237
+ ],
1238
+ },
1239
+ }
1240
+
1241
+ try {
1242
+ performance.measure(name, {
1243
+ start: startMark,
1244
+ end: endMark,
1245
+ detail,
1246
+ })
1247
+ } catch {
1248
+ // In old browsers, the above may throw an error - just ignore it
1249
+ }
1250
+ }
1251
+
1252
+ @action.bound private applyOriginalFocusAsAuthored(): void {
1253
+ if (this.focusedSeriesNames?.length)
1254
+ this.focusArray.clearAllAndAdd(...this.focusedSeriesNames)
1255
+ }
1256
+
1257
+ @action.bound applyOriginalSelectionAsAuthored(): void {
1258
+ if (this.selectedEntityNames?.length)
1259
+ this.selection.setSelectedEntities(this.selectedEntityNames)
1260
+ }
1261
+ // The below properties are here so the admin can access them
1262
+ @computed get hasData(): boolean {
1263
+ return this.dimensions.length > 0 || this.newSlugs.length > 0
1264
+ }
1265
+ // Ready to go iff we have retrieved data for every variable associated with the chart
1266
+ @computed get isReady(): boolean {
1267
+ if (!this.isConfigReady) return false
1268
+ if (!this.isDataReady) return false
1269
+ return this.whatAreWeWaitingFor === ""
1270
+ }
1271
+
1272
+ @computed get whatAreWeWaitingFor(): string {
1273
+ const { newSlugs, inputTable, dimensions } = this
1274
+ if (newSlugs.length || dimensions.length === 0) {
1275
+ const missingColumns = newSlugs.filter(
1276
+ (slug) => !inputTable.has(slug)
1277
+ )
1278
+ return missingColumns.length
1279
+ ? `Waiting for columns ${missingColumns.join(",")} in table '${inputTable.tableSlug}'. ${inputTable.tableDescription}`
1280
+ : ""
1281
+ }
1282
+ if (dimensions.length > 0 && this.loadingDimensions.length === 0)
1283
+ return ""
1284
+ return `Waiting for dimensions ${this.loadingDimensions.join(",")}.`
1285
+ }
1286
+
1287
+ // If we are using new slugs and not dimensions, Grapher is ready.
1288
+ @computed get newSlugs(): string[] {
1289
+ const { xSlug, colorSlug, sizeSlug } = this
1290
+ const ySlugs = this.ySlugs ? this.ySlugs.split(" ") : []
1291
+ return excludeUndefined([...ySlugs, xSlug, colorSlug, sizeSlug])
1292
+ }
1293
+
1294
+ @computed private get loadingDimensions(): ChartDimension[] {
1295
+ return this.dimensions.filter(
1296
+ (dim) => !this.inputTable.has(dim.columnSlug)
1297
+ )
1298
+ }
1299
+ @computed get isInIFrame(): boolean {
1300
+ return isInIFrame()
1301
+ }
1302
+ @computed get times(): Time[] {
1303
+ const { mapColumnSlug, projectionColumnInfoBySlug, yColumnSlugs } = this
1304
+
1305
+ // If the map shows historical and projected data, then the time range
1306
+ // has to extend to the full range of both indicators
1307
+ const mapColumnInfo = projectionColumnInfoBySlug.get(mapColumnSlug)
1308
+ const mapColumnSlugs = mapColumnInfo
1309
+ ? [mapColumnInfo.projectedSlug, mapColumnInfo.historicalSlug]
1310
+ : [mapColumnSlug]
1311
+
1312
+ const columnSlugs = this.isOnMapTab ? mapColumnSlugs : yColumnSlugs
1313
+
1314
+ // Generate the times only after the chart transform has been applied, so that we don't show
1315
+ // times on the timeline for which data may not exist, e.g. when the selected entity
1316
+ // doesn't contain data for all years in the table.
1317
+ // -@danielgavrilov, 2020-10-22
1318
+ return this.tableAfterAuthorTimelineAndActiveChartTransform.getTimesUniqSortedAscForColumns(
1319
+ columnSlugs
1320
+ )
1321
+ }
1322
+
1323
+ /**
1324
+ * Plots time on the x-axis.
1325
+ */
1326
+ @computed private get hasTimeDimension(): boolean {
1327
+ return this.isStackedBar || this.isStackedArea || this.isLineChart
1328
+ }
1329
+
1330
+ @computed private get hasTimeDimensionButTimelineIsHidden(): boolean {
1331
+ return this.hasTimeDimension && !!this.hideTimeline
1332
+ }
1333
+ @computed get startHandleTimeBound(): TimeBound {
1334
+ if (this.isSingleTimeSelectionActive) return this.endHandleTimeBound
1335
+ return this.timelineHandleTimeBounds[0]
1336
+ }
1337
+ set startHandleTimeBound(newValue: TimeBound) {
1338
+ if (this.isSingleTimeSelectionActive)
1339
+ this.timelineHandleTimeBounds = [newValue, newValue]
1340
+ else
1341
+ this.timelineHandleTimeBounds = [
1342
+ newValue,
1343
+ this.timelineHandleTimeBounds[1],
1344
+ ]
1345
+ }
1346
+
1347
+ set endHandleTimeBound(newValue: TimeBound) {
1348
+ if (this.isSingleTimeSelectionActive)
1349
+ this.timelineHandleTimeBounds = [newValue, newValue]
1350
+ else
1351
+ this.timelineHandleTimeBounds = [
1352
+ this.timelineHandleTimeBounds[0],
1353
+ newValue,
1354
+ ]
1355
+ }
1356
+
1357
+ @computed get endHandleTimeBound(): TimeBound {
1358
+ return this.timelineHandleTimeBounds[1]
1359
+ }
1360
+ @action.bound resetHandleTimeBounds(): void {
1361
+ this.startHandleTimeBound = this.timelineMinTime ?? -Infinity
1362
+ this.endHandleTimeBound = this.timelineMaxTime ?? Infinity
1363
+ }
1364
+
1365
+ // Keeps a running cache of series colors at the Grapher level.
1366
+ seriesColorMap: SeriesColorMap = new Map()
1367
+
1368
+ @computed get closestTimelineMinTime(): Time | undefined {
1369
+ return findClosestTime(this.times, this.timelineMinTime ?? -Infinity)
1370
+ }
1371
+
1372
+ @computed get closestTimelineMaxTime(): Time | undefined {
1373
+ return findClosestTime(this.times, this.timelineMaxTime ?? Infinity)
1374
+ }
1375
+
1376
+ @computed get startTime(): Time | undefined {
1377
+ return findClosestTime(this.times, this.startHandleTimeBound)
1378
+ }
1379
+
1380
+ @computed get endTime(): Time | undefined {
1381
+ return findClosestTime(this.times, this.endHandleTimeBound)
1382
+ }
1383
+
1384
+ @computed get isSingleTimeScatterAnimationActive(): boolean {
1385
+ return (
1386
+ this.isTimelineAnimationActive &&
1387
+ this.isOnScatterTab &&
1388
+ !this.isRelativeMode &&
1389
+ !!this.areHandlesOnSameTimeBeforeAnimation
1390
+ )
1391
+ }
1392
+
1393
+ @computed get isSingleTimeMapAnimationActive(): boolean {
1394
+ return (
1395
+ this.isTimelineAnimationActive &&
1396
+ this.isOnMapTab &&
1397
+ !!this.areHandlesOnSameTimeBeforeAnimation
1398
+ )
1399
+ }
1400
+
1401
+ @computed private get onlySingleTimeSelectionPossible(): boolean {
1402
+ return this.checkOnlySingleTimeSelectionPossible(this.activeTab)
1403
+ }
1404
+
1405
+ @computed get onlyTimeRangeSelectionPossible(): boolean {
1406
+ return this.checkOnlyTimeRangeSelectionPossible(this.activeTab)
1407
+ }
1408
+
1409
+ @computed get isSingleTimeSelectionActive(): boolean {
1410
+ return (
1411
+ this.onlySingleTimeSelectionPossible ||
1412
+ this.isSingleTimeScatterAnimationActive ||
1413
+ this.isSingleTimeMapAnimationActive
1414
+ )
1415
+ }
1416
+
1417
+ @computed get shouldLinkToOwid(): boolean {
1418
+ if (
1419
+ this.isEmbeddedInAnOwidPage ||
1420
+ this.isExportingToSvgOrPng ||
1421
+ !this.isInIFrame
1422
+ )
1423
+ return false
1424
+
1425
+ return true
1426
+ }
1427
+
1428
+ @computed get hasOWIDLogo(): boolean {
1429
+ return (
1430
+ !this.hideLogo && (this.logo === undefined || this.logo === "owid")
1431
+ )
1432
+ }
1433
+ @computed get hasFatalErrors(): boolean {
1434
+ const { relatedQuestions = [] } = this
1435
+ return relatedQuestions.some(
1436
+ (question) => !!getErrorMessageRelatedQuestionUrl(question)
1437
+ )
1438
+ }
1439
+ disposers: (() => void)[] = []
1440
+
1441
+ @bind dispose(): void {
1442
+ this.disposers.forEach((dispose) => dispose())
1443
+ }
1444
+
1445
+ @action.bound setTab(newTab: GrapherTabName): void {
1446
+ this.tab = this.mapTabNameToTabConfigOption(newTab)
1447
+ }
1448
+
1449
+ @action.bound private ensureHandlesAreOnSameTime(): void {
1450
+ if (this.areHandlesOnSameTime) return
1451
+
1452
+ this.timelineHandleTimeBounds = [
1453
+ this.endHandleTimeBound,
1454
+ this.endHandleTimeBound,
1455
+ ]
1456
+ }
1457
+
1458
+ @action.bound private ensureHandlesAreOnDifferentTimes(): void {
1459
+ if (!this.areHandlesOnSameTime) return
1460
+
1461
+ const time = this.startTime // startTime = endTime
1462
+ if (time === this.closestTimelineMinTime) {
1463
+ this.timelineHandleTimeBounds = [time ?? -Infinity, Infinity]
1464
+ } else {
1465
+ this.timelineHandleTimeBounds = [-Infinity, time ?? Infinity]
1466
+ }
1467
+ }
1468
+
1469
+ @computed get entitySelector(): EntitySelector {
1470
+ const entitySelectorArray = this.isOnMapTab
1471
+ ? this.mapConfig.selection
1472
+ : this.selection
1473
+ return new EntitySelector({
1474
+ manager: this,
1475
+ selection: entitySelectorArray,
1476
+ })
1477
+ }
1478
+
1479
+ private checkOnlySingleTimeSelectionPossible = (
1480
+ tabName: GrapherTabName
1481
+ ): boolean => {
1482
+ // Scatters aren't included here because although single-time selection
1483
+ // is preferred, start and end time selection is still possible
1484
+ return [
1485
+ GRAPHER_TAB_NAMES.DiscreteBar,
1486
+ GRAPHER_TAB_NAMES.StackedDiscreteBar,
1487
+ GRAPHER_TAB_NAMES.Marimekko,
1488
+ ].includes(tabName as any)
1489
+ }
1490
+
1491
+ private checkOnlyTimeRangeSelectionPossible = (
1492
+ tabName: GrapherTabName
1493
+ ): boolean => {
1494
+ return [
1495
+ GRAPHER_TAB_NAMES.LineChart,
1496
+ GRAPHER_TAB_NAMES.SlopeChart,
1497
+ GRAPHER_TAB_NAMES.StackedArea,
1498
+ GRAPHER_TAB_NAMES.StackedBar,
1499
+ ].includes(tabName as any)
1500
+ }
1501
+
1502
+ @action.bound ensureTimeHandlesAreSensibleForTab(
1503
+ tab: GrapherTabName
1504
+ ): void {
1505
+ if (this.checkOnlySingleTimeSelectionPossible(tab)) {
1506
+ this.ensureHandlesAreOnSameTime()
1507
+ } else if (this.checkOnlyTimeRangeSelectionPossible(tab)) {
1508
+ this.ensureHandlesAreOnDifferentTimes()
1509
+ }
1510
+ }
1511
+
1512
+ @action.bound private ensureEntitySelectionIsSensibleForTab(
1513
+ tab: GrapherTabName
1514
+ ): void {
1515
+ // No-op if the current tab is a map or table tab
1516
+ if (!isChartTab(tab)) return
1517
+
1518
+ const isChartTypeThatShowsAllEntities =
1519
+ this.isChartTypeThatShowsAllEntities(tab)
1520
+
1521
+ // If the chart show all entities (e.g. scatter plot or Marimekko chart),
1522
+ // then we typically prefer no selection unless the user has explicitly
1523
+ // made changes to the default selection
1524
+ if (
1525
+ isChartTypeThatShowsAllEntities &&
1526
+ !this.areSelectedEntitiesDifferentThanAuthors
1527
+ ) {
1528
+ this.selection.clearSelection()
1529
+ }
1530
+
1531
+ // If the chart type only shows a subset of entities at a time
1532
+ // (e.g. line chart), then an empty selection is not useful, so we
1533
+ // automatically apply the author's selection
1534
+ if (!isChartTypeThatShowsAllEntities && !this.selection.hasSelection) {
1535
+ this.applyOriginalSelectionAsAuthored()
1536
+ }
1537
+ }
1538
+
1539
+ @action.bound onChartSwitching(
1540
+ _oldTab: GrapherTabName,
1541
+ newTab: GrapherTabName
1542
+ ): void {
1543
+ if (!this.isReady)
1544
+ console.warn(
1545
+ "onChartSwitching has been called before grapher has loaded its data, this is probably a mistake"
1546
+ )
1547
+
1548
+ this.ensureTimeHandlesAreSensibleForTab(newTab)
1549
+ this.ensureEntitySelectionIsSensibleForTab(newTab)
1550
+ }
1551
+
1552
+ @action.bound syncEntitySelectionBetweenChartAndMap(
1553
+ oldTab: GrapherTabName,
1554
+ newTab: GrapherTabName
1555
+ ): void {
1556
+ // sync entity selection between the map and the chart tab if entity
1557
+ // selection is enabled for the map, and the map has been interacted
1558
+ // with, i.e. at least one country has been selected on the map
1559
+ const shouldSyncSelection =
1560
+ this.addCountryMode !== EntitySelectionMode.Disabled &&
1561
+ this.isMapSelectionEnabled &&
1562
+ this.mapConfig.selection.hasSelection
1563
+
1564
+ // switching from the chart tab to the map tab
1565
+ if (!isMapTab(oldTab) && isMapTab(newTab) && shouldSyncSelection) {
1566
+ this.mapConfig.selection.setSelectedEntities(
1567
+ this.selection.selectedEntityNames
1568
+ )
1569
+ }
1570
+
1571
+ // switching from the map tab to the chart tab
1572
+ if (isMapTab(oldTab) && !isMapTab(newTab) && shouldSyncSelection) {
1573
+ this.selection.setSelectedEntities(
1574
+ this.mapConfig.selection.selectedEntityNames
1575
+ )
1576
+ }
1577
+ }
1578
+
1579
+ @action.bound private validateEntitySelectorState(
1580
+ newTab: GrapherTabName
1581
+ ): void {
1582
+ if (isMapTab(newTab) || isChartTab(newTab)) {
1583
+ const { entitySelector } = this
1584
+
1585
+ // the map and chart tab might have a different set of sort columns;
1586
+ // if the currently selected sort column is invalid, reset it to the default
1587
+ const sortSlug = entitySelector.sortConfig.slug
1588
+ if (!entitySelector.isSortSlugValid(sortSlug)) {
1589
+ this.entitySelectorState.sortConfig =
1590
+ entitySelector.getDefaultSortConfig()
1591
+ }
1592
+
1593
+ // the map and chart tab might have a different set of entity filters;
1594
+ // if the currently selected entity filter is invalid, reset it
1595
+ const { entityFilter } = this.entitySelectorState
1596
+ if (entityFilter) {
1597
+ if (!this.entitySelector.isEntityFilterValid(entityFilter)) {
1598
+ this.entitySelectorState.entityFilter = undefined
1599
+ }
1600
+ }
1601
+
1602
+ // the map column slug might be interpolated with different
1603
+ // tolerance values on the chart and the map tab
1604
+ entitySelector.resetInterpolatedMapColumn()
1605
+ }
1606
+ }
1607
+
1608
+ @action.bound onTabChange(
1609
+ oldTab: GrapherTabName,
1610
+ newTab: GrapherTabName
1611
+ ): void {
1612
+ this.onChartSwitching(oldTab, newTab)
1613
+ this.syncEntitySelectionBetweenChartAndMap(oldTab, newTab)
1614
+ this.validateEntitySelectorState(newTab)
1615
+ }
1616
+
1617
+ // todo: can we remove this?
1618
+ // I believe these states can only occur during editing.
1619
+ @action.bound private ensureValidConfigWhenEditing(): void {
1620
+ const disposers = [
1621
+ autorun(() => {
1622
+ if (!this.availableTabs.includes(this.activeTab))
1623
+ runInAction(() => this.setTab(this.availableTabs[0]))
1624
+ }),
1625
+ autorun(() => {
1626
+ const validDimensions = this.validDimensions
1627
+ if (!_.isEqual(this.dimensions, validDimensions))
1628
+ this.dimensions = validDimensions
1629
+ }),
1630
+ ]
1631
+ this.disposers.push(...disposers)
1632
+ }
1633
+ @computed private get validDimensions(): ChartDimension[] {
1634
+ const { dimensions } = this
1635
+ const validProperties = this.dimensionSlots.map((d) => d.property)
1636
+ let validDimensions = dimensions.filter((dim) =>
1637
+ validProperties.includes(dim.property)
1638
+ )
1639
+
1640
+ this.dimensionSlots.forEach((slot) => {
1641
+ if (!slot.allowMultiple)
1642
+ validDimensions = _.uniqWith(
1643
+ validDimensions,
1644
+ (
1645
+ a: OwidChartDimensionInterface,
1646
+ b: OwidChartDimensionInterface
1647
+ ) =>
1648
+ a.property === slot.property &&
1649
+ a.property === b.property
1650
+ )
1651
+ })
1652
+
1653
+ return validDimensions
1654
+ }
1655
+
1656
+ @computed get originUrlWithProtocol(): string {
1657
+ if (!this.originUrl) return ""
1658
+ let url = this.originUrl
1659
+
1660
+ // If the URL is relative, make it absolute to ourworldindata.org.
1661
+ // we could also opt to make it relative to bakedGrapherUrl, but then we'd
1662
+ // end up with different URLs than prod on staging servers and in the SVG tester,
1663
+ // which could also affect positioning.
1664
+ if (url.startsWith("/")) {
1665
+ url = new URL(url, GRAPHER_PROD_URL).href
1666
+ }
1667
+ if (!url.startsWith("http")) url = `https://${url}`
1668
+ return url
1669
+ }
1670
+
1671
+ @computed get timelineHandleTimeBounds(): TimeBounds {
1672
+ if (this.isOnMapTab) {
1673
+ const endTime = maxTimeBoundFromJSONOrPositiveInfinity(
1674
+ this.map.time
1675
+ )
1676
+
1677
+ // If a start time is provided, use it; otherwise, set it to the end time
1678
+ // so that a single map (not a faceted one) is shown by default
1679
+ const startTime =
1680
+ this.map.startTime === undefined
1681
+ ? endTime
1682
+ : minTimeBoundFromJSONOrNegativeInfinity(this.map.startTime)
1683
+
1684
+ return [startTime, endTime]
1685
+ }
1686
+
1687
+ // If the timeline is hidden on the chart tab but displayed on the table tab
1688
+ // (which is the case for charts that plot time on the x-axis),
1689
+ // we always want to use the authored `minTime` and `maxTime` for the chart,
1690
+ // irrespective of the time range the user might have selected on the table tab
1691
+ if (this.isOnChartTab && this.hasTimeDimensionButTimelineIsHidden) {
1692
+ const { minTime, maxTime } = this.authorsVersion
1693
+ return [
1694
+ minTimeBoundFromJSONOrNegativeInfinity(minTime),
1695
+ maxTimeBoundFromJSONOrPositiveInfinity(maxTime),
1696
+ ]
1697
+ }
1698
+
1699
+ return [
1700
+ // Handle `undefined` values in minTime/maxTime
1701
+ minTimeBoundFromJSONOrNegativeInfinity(this.minTime),
1702
+ maxTimeBoundFromJSONOrPositiveInfinity(this.maxTime),
1703
+ ]
1704
+ }
1705
+
1706
+ set timelineHandleTimeBounds(value: TimeBounds) {
1707
+ if (this.isOnMapTab) {
1708
+ this.map.startTime = value[0]
1709
+ this.map.time = value[1]
1710
+ } else {
1711
+ this.minTime = value[0]
1712
+ this.maxTime = value[1]
1713
+ }
1714
+ }
1715
+
1716
+ // Get the dimension slots appropriate for this type of chart
1717
+ @computed get dimensionSlots(): DimensionSlot[] {
1718
+ const xAxis = new DimensionSlot(this, DimensionProperty.x)
1719
+ const yAxis = new DimensionSlot(this, DimensionProperty.y)
1720
+ const color = new DimensionSlot(this, DimensionProperty.color)
1721
+ const size = new DimensionSlot(this, DimensionProperty.size)
1722
+
1723
+ if (this.hasScatter) return [yAxis, xAxis, size, color]
1724
+ if (this.hasMarimekko) return [yAxis, xAxis, color]
1725
+ if (this.hasLineChart || this.hasDiscreteBar) return [yAxis, color]
1726
+ return [yAxis]
1727
+ }
1728
+
1729
+ @computed.struct get filledDimensions(): ChartDimension[] {
1730
+ return this.isReady ? this.dimensions : []
1731
+ }
1732
+ @action.bound addDimension(config: OwidChartDimensionInterface): void {
1733
+ this.dimensions.push(new ChartDimension(config, this))
1734
+ }
1735
+ @action.bound setDimensionsForProperty(
1736
+ property: DimensionProperty,
1737
+ newConfigs: OwidChartDimensionInterface[]
1738
+ ): void {
1739
+ let newDimensions: ChartDimension[] = []
1740
+ this.dimensionSlots.forEach((slot) => {
1741
+ if (slot.property === property)
1742
+ newDimensions = newDimensions.concat(
1743
+ newConfigs.map((config) => new ChartDimension(config, this))
1744
+ )
1745
+ else newDimensions = newDimensions.concat(slot.dimensions)
1746
+ })
1747
+ this.dimensions = newDimensions
1748
+ }
1749
+ @action.bound setDimensionsFromConfigs(
1750
+ configs: OwidChartDimensionInterface[]
1751
+ ): void {
1752
+ this.dimensions = configs.map(
1753
+ (config) => new ChartDimension(config, this)
1754
+ )
1755
+ }
1756
+
1757
+ @computed get defaultSlug(): string {
1758
+ return slugify(this.displayTitle)
1759
+ }
1760
+
1761
+ @computed get displaySlug(): string {
1762
+ return this.slug ?? this.defaultSlug
1763
+ }
1764
+
1765
+ shouldIncludeDetailsInStaticExport = true
1766
+ // Used for superscript numbers in static exports
1767
+ @computed get detailsOrderedByReference(): string[] {
1768
+ if (typeof window === "undefined") return []
1769
+
1770
+ // extract details from supporting text
1771
+ const subtitleDetails = !this.hideSubtitle
1772
+ ? extractDetailsFromSyntax(this.currentSubtitle)
1773
+ : []
1774
+ const noteDetails = !this.hideNote
1775
+ ? extractDetailsFromSyntax(this.note ?? "")
1776
+ : []
1777
+
1778
+ // extract details from axis labels
1779
+ const yAxisDetails = extractDetailsFromSyntax(
1780
+ this.yAxisConfig.label || ""
1781
+ )
1782
+ const xAxisDetails = extractDetailsFromSyntax(
1783
+ this.xAxisConfig.label || ""
1784
+ )
1785
+
1786
+ // text fragments are ordered by appearance
1787
+ const uniqueDetails = _.uniq([
1788
+ ...subtitleDetails,
1789
+ ...yAxisDetails,
1790
+ ...xAxisDetails,
1791
+ ...noteDetails,
1792
+ ])
1793
+
1794
+ return uniqueDetails
1795
+ }
1796
+
1797
+ @computed get detailsMarkerInSvg(): DetailsMarker {
1798
+ const { isStatic, shouldIncludeDetailsInStaticExport } = this
1799
+ return !isStatic
1800
+ ? "underline"
1801
+ : shouldIncludeDetailsInStaticExport
1802
+ ? "superscript"
1803
+ : "none"
1804
+ }
1805
+
1806
+ // Used for static exports. Defined at this level because they need to
1807
+ // be accessed by CaptionedChart and DownloadModal
1808
+ @computed get detailRenderers(): MarkdownTextWrap[] {
1809
+ if (typeof window === "undefined") return []
1810
+ return this.detailsOrderedByReference.map((term, i) => {
1811
+ let text = `**${i + 1}.** `
1812
+ const detail: EnrichedDetail | undefined = window.details?.[term]
1813
+ if (detail && detail.text) {
1814
+ const lines = detail.text.split("\n")
1815
+ const title = lines[0]
1816
+ const description = lines.slice(2).join("\n")
1817
+ text += `**${title}** ${description}`
1818
+ }
1819
+
1820
+ // can't use the computed property here because Grapher might not currently be in static mode
1821
+ const baseFontSize = this.areStaticBoundsSmall
1822
+ ? this.computeBaseFontSizeFromHeight(this.staticBounds)
1823
+ : 18
1824
+
1825
+ return new MarkdownTextWrap({
1826
+ text,
1827
+ fontSize: (11 / BASE_FONT_SIZE) * baseFontSize,
1828
+ // leave room for padding on the left and right
1829
+ maxWidth:
1830
+ this.staticBounds.width - 2 * this.framePaddingHorizontal,
1831
+ lineHeight: 1.2,
1832
+ style: { fill: GRAPHER_LIGHT_TEXT },
1833
+ })
1834
+ })
1835
+ }
1836
+
1837
+ @computed get hasProjectedData(): boolean {
1838
+ return this.inputTable.numericColumnSlugs.some(
1839
+ (slug) => this.inputTable.get(slug).isProjection
1840
+ )
1841
+ }
1842
+
1843
+ @computed get projectionColumnInfoBySlug(): Map<
1844
+ ColumnSlug,
1845
+ ProjectionColumnInfo
1846
+ > {
1847
+ const table = this.inputTable
1848
+
1849
+ const [projectionSlugs, nonProjectionSlugs] = R.partition(
1850
+ this.yColumnSlugs,
1851
+ (slug) => table.get(slug).isProjection
1852
+ )
1853
+
1854
+ if (!projectionSlugs.length) return new Map()
1855
+
1856
+ const projectionColumnInfoBySlug = new Map<
1857
+ ColumnSlug,
1858
+ ProjectionColumnInfo
1859
+ >()
1860
+
1861
+ const findHistoricalSlugForProjection = (
1862
+ projectedSlug: ColumnSlug
1863
+ ): ColumnSlug | undefined => {
1864
+ // If there is only one non-projection column, we trivially match it to the projection
1865
+ if (nonProjectionSlugs.length === 1) return nonProjectionSlugs[0]
1866
+
1867
+ // Try to find a historical column with the same display name
1868
+ const displayName = table.get(projectedSlug).displayName
1869
+ return nonProjectionSlugs.find(
1870
+ (slug) => table.get(slug).displayName === displayName
1871
+ )
1872
+ }
1873
+
1874
+ for (const projectedSlug of projectionSlugs) {
1875
+ const historicalSlug =
1876
+ findHistoricalSlugForProjection(projectedSlug)
1877
+ if (historicalSlug) {
1878
+ const combinedSlug = `${projectedSlug}-${historicalSlug}`
1879
+ const slugForIsProjectionColumn = `${combinedSlug}-isProjection`
1880
+
1881
+ projectionColumnInfoBySlug.set(projectedSlug, {
1882
+ projectedSlug,
1883
+ historicalSlug,
1884
+ combinedSlug,
1885
+ slugForIsProjectionColumn,
1886
+ })
1887
+ }
1888
+ }
1889
+
1890
+ return projectionColumnInfoBySlug
1891
+ }
1892
+
1893
+ @computed get validChartTypes(): GrapherChartType[] {
1894
+ const chartTypeSet = new Set(this.chartTypes)
1895
+
1896
+ // all single-chart Graphers are valid
1897
+ if (chartTypeSet.size <= 1) return Array.from(chartTypeSet)
1898
+
1899
+ // find valid combination in a pre-defined list
1900
+ const validChartTypes = findValidChartTypeCombination(
1901
+ Array.from(chartTypeSet)
1902
+ )
1903
+
1904
+ // if the given combination is not valid, then ignore all but the first chart type
1905
+ if (!validChartTypes) return this.chartTypes.slice(0, 1)
1906
+
1907
+ // projected data is only supported for line charts
1908
+ const isLineChart = validChartTypes[0] === GRAPHER_CHART_TYPES.LineChart
1909
+ if (isLineChart && this.hasProjectedData) {
1910
+ return [
1911
+ GRAPHER_CHART_TYPES.LineChart,
1912
+ GRAPHER_CHART_TYPES.DiscreteBar,
1913
+ ]
1914
+ }
1915
+
1916
+ return validChartTypes
1917
+ }
1918
+
1919
+ @computed get validChartTypeSet(): Set<GrapherChartType> {
1920
+ return new Set(this.validChartTypes)
1921
+ }
1922
+
1923
+ @computed get availableTabs(): GrapherTabName[] {
1924
+ const availableTabs: GrapherTabName[] = []
1925
+ if (this.hasTableTab) availableTabs.push(GRAPHER_TAB_NAMES.Table)
1926
+ if (this.hasMapTab) availableTabs.push(GRAPHER_TAB_NAMES.WorldMap)
1927
+ availableTabs.push(...this.validChartTypes)
1928
+ return availableTabs
1929
+ }
1930
+ @computed get hasMultipleChartTypes(): boolean {
1931
+ return this.validChartTypes.length > 1
1932
+ }
1933
+
1934
+ @computed get currentSubtitle(): string {
1935
+ const subtitle = this.subtitle
1936
+ if (subtitle !== undefined) return subtitle
1937
+ const yColumns = this.yColumnsFromDimensions
1938
+ if (yColumns.length === 1) return yColumns[0].def.descriptionShort ?? ""
1939
+ return ""
1940
+ }
1941
+
1942
+ @computed get shouldAddEntitySuffixToTitle(): boolean {
1943
+ const selectedEntityNames = this.selection.selectedEntityNames
1944
+ const showEntityAnnotation = !this.hideAnnotationFieldsInTitle?.entity
1945
+
1946
+ const seriesStrategy =
1947
+ this.chartState.seriesStrategy ||
1948
+ autoDetectSeriesStrategy(this, true)
1949
+
1950
+ return !!(
1951
+ !this.forceHideAnnotationFieldsInTitle?.entity &&
1952
+ this.isOnChartTab &&
1953
+ (seriesStrategy !== SeriesStrategy.entity || !this.showLegend) &&
1954
+ selectedEntityNames.length === 1 &&
1955
+ (showEntityAnnotation ||
1956
+ this.canChangeEntity ||
1957
+ this.canSelectMultipleEntities)
1958
+ )
1959
+ }
1960
+
1961
+ @computed get shouldAddTimeSuffixToTitle(): boolean {
1962
+ const showTimeAnnotation = !this.hideAnnotationFieldsInTitle?.time
1963
+ return (
1964
+ !this.forceHideAnnotationFieldsInTitle?.time &&
1965
+ this.isReady &&
1966
+ (showTimeAnnotation ||
1967
+ (this.hasTimeline &&
1968
+ // chart types that refer to the current time only in the timeline
1969
+ (this.isOnDiscreteBarTab ||
1970
+ this.isOnStackedDiscreteBarTab ||
1971
+ this.isOnMarimekkoTab ||
1972
+ this.isOnMapTab)))
1973
+ )
1974
+ }
1975
+
1976
+ @computed get shouldAddChangeInPrefixToTitle(): boolean {
1977
+ const showChangeInPrefix =
1978
+ !this.hideAnnotationFieldsInTitle?.changeInPrefix
1979
+ return (
1980
+ !this.forceHideAnnotationFieldsInTitle?.changeInPrefix &&
1981
+ (this.isOnLineChartTab || this.isOnSlopeChartTab) &&
1982
+ this.isRelativeMode &&
1983
+ showChangeInPrefix
1984
+ )
1985
+ }
1986
+
1987
+ @computed get currentTitle(): string {
1988
+ let text = this.displayTitle.trim()
1989
+ if (text.length === 0) return text
1990
+
1991
+ // helper function to add an annotation fragment to the title
1992
+ // only adds a comma if the text does not end with a question mark
1993
+ const appendAnnotationField = (
1994
+ text: string,
1995
+ annotation: string
1996
+ ): string => {
1997
+ const separator = text.endsWith("?") ? "" : ","
1998
+ return `${text}${separator} ${annotation}`
1999
+ }
2000
+
2001
+ if (this.shouldAddEntitySuffixToTitle) {
2002
+ const selectedEntityNames = this.selection.selectedEntityNames
2003
+ const entityStr = selectedEntityNames[0]
2004
+ if (entityStr?.length) text = appendAnnotationField(text, entityStr)
2005
+ }
2006
+
2007
+ if (this.shouldAddChangeInPrefixToTitle)
2008
+ text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text)
2009
+
2010
+ if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix)
2011
+ text = appendAnnotationField(text, this.timeTitleSuffix)
2012
+
2013
+ return text.trim()
2014
+ }
2015
+
2016
+ /**
2017
+ * Uses some explicit and implicit information to decide whether a timeline is shown.
2018
+ */
2019
+ @computed get hasTimeline(): boolean {
2020
+ // we don't have more than one distinct time point in our data, so it doesn't make sense to show a timeline
2021
+ if (this.times.length <= 1) return false
2022
+
2023
+ switch (this.activeTab) {
2024
+ // the map tab has its own `hideTimeline` option
2025
+ case GRAPHER_TAB_NAMES.WorldMap:
2026
+ return !this.map.hideTimeline
2027
+
2028
+ // use the chart-level `hideTimeline` option for the table, with some exceptions
2029
+ case GRAPHER_TAB_NAMES.Table:
2030
+ // always show the timeline for charts that plot time on the x-axis
2031
+ if (this.hasTimeDimension) return true
2032
+ return !this.hideTimeline
2033
+
2034
+ // use the chart-level `hideTimeline` option
2035
+ default:
2036
+ return !this.hideTimeline
2037
+ }
2038
+ }
2039
+
2040
+ @computed private get areHandlesOnSameTime(): boolean {
2041
+ const times = sortNumeric(this.table.timeColumn.uniqValues.slice())
2042
+ const [start, end] = this.timelineHandleTimeBounds.map((time) =>
2043
+ findClosestTime(times, time)
2044
+ )
2045
+ return start === end
2046
+ }
2047
+
2048
+ @computed get mapColumnSlug(): string {
2049
+ const mapColumnSlug = this.map.columnSlug
2050
+ // If there's no mapColumnSlug or there is one but it's not in the dimensions array, use the first ycolumn
2051
+ if (
2052
+ !mapColumnSlug ||
2053
+ !this.dimensions.some((dim) => dim.columnSlug === mapColumnSlug)
2054
+ )
2055
+ return this.yColumnSlug!
2056
+ return mapColumnSlug
2057
+ }
2058
+
2059
+ getColumnForProperty(property: DimensionProperty): CoreColumn | undefined {
2060
+ return this.dimensions.find((dim) => dim.property === property)?.column
2061
+ }
2062
+ getSlugForProperty(property: DimensionProperty): string | undefined {
2063
+ return this.dimensions.find((dim) => dim.property === property)
2064
+ ?.columnSlug
2065
+ }
2066
+ @computed get yColumnsFromDimensions(): CoreColumn[] {
2067
+ return this.filledDimensions
2068
+ .filter((dim) => dim.property === DimensionProperty.y)
2069
+ .map((dim) => dim.column)
2070
+ }
2071
+
2072
+ @computed get yColumnSlugs(): string[] {
2073
+ return this.ySlugs
2074
+ ? this.ySlugs.split(" ")
2075
+ : this.dimensions
2076
+ .filter((dim) => dim.property === DimensionProperty.y)
2077
+ .map((dim) => dim.columnSlug)
2078
+ }
2079
+
2080
+ @computed get yColumnSlug(): string | undefined {
2081
+ return this.ySlugs
2082
+ ? this.ySlugs.split(" ")[0]
2083
+ : this.getSlugForProperty(DimensionProperty.y)
2084
+ }
2085
+
2086
+ @computed get xColumnSlug(): string | undefined {
2087
+ return this.xSlug ?? this.getSlugForProperty(DimensionProperty.x)
2088
+ }
2089
+
2090
+ @computed get sizeColumnSlug(): string | undefined {
2091
+ return this.sizeSlug ?? this.getSlugForProperty(DimensionProperty.size)
2092
+ }
2093
+
2094
+ @computed get colorColumnSlug(): string | undefined {
2095
+ return (
2096
+ this.colorSlug ?? this.getSlugForProperty(DimensionProperty.color)
2097
+ )
2098
+ }
2099
+
2100
+ @computed get numericColorColumnSlug(): string | undefined {
2101
+ if (!this.colorColumnSlug) return undefined
2102
+
2103
+ const colorColumn = this.inputTable.get(this.colorColumnSlug)
2104
+ if (!colorColumn.isMissing && colorColumn.hasNumberFormatting)
2105
+ return this.colorColumnSlug
2106
+
2107
+ return undefined
2108
+ }
2109
+
2110
+ @computed get categoricalColorColumnSlug(): string | undefined {
2111
+ if (!this.colorColumnSlug) return undefined
2112
+ return this.numericColorColumnSlug ? undefined : this.colorColumnSlug
2113
+ }
2114
+
2115
+ @computed get yScaleType(): ScaleType | undefined {
2116
+ return this.yAxis.scaleType
2117
+ }
2118
+ @computed get xScaleType(): ScaleType | undefined {
2119
+ return this.xAxis.scaleType
2120
+ }
2121
+ @computed private get timeTitleSuffix(): string | undefined {
2122
+ const timeColumn = this.table.timeColumn
2123
+ if (timeColumn.isMissing) return undefined // Do not show year until data is loaded
2124
+ const { startTime, endTime } = this
2125
+ if (startTime === undefined || endTime === undefined) return undefined
2126
+
2127
+ const time =
2128
+ startTime === endTime
2129
+ ? timeColumn.formatValue(startTime)
2130
+ : timeColumn.formatValue(startTime) +
2131
+ " to " +
2132
+ timeColumn.formatValue(endTime)
2133
+
2134
+ return time
2135
+ }
2136
+
2137
+ @computed get sourcesLine(): string {
2138
+ return this.sourceDesc ?? this.defaultSourcesLine
2139
+ }
2140
+ // Columns that are used as a dimension in the currently active view
2141
+ @computed get activeColumnSlugs(): string[] {
2142
+ const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } =
2143
+ this
2144
+
2145
+ return excludeUndefined([
2146
+ ...yColumnSlugs,
2147
+ xColumnSlug,
2148
+ sizeColumnSlug,
2149
+ colorColumnSlug,
2150
+ ])
2151
+ }
2152
+
2153
+ @computed get columnsWithSourcesExtensive(): CoreColumn[] {
2154
+ const { yColumnSlugs, xColumnSlug, sizeColumnSlug, colorColumnSlug } =
2155
+ this
2156
+
2157
+ const columnSlugs = excludeUndefined([
2158
+ ...yColumnSlugs,
2159
+ xColumnSlug,
2160
+ sizeColumnSlug,
2161
+ colorColumnSlug,
2162
+ ])
2163
+
2164
+ return this.inputTable
2165
+ .getColumns(_.uniq(columnSlugs))
2166
+ .filter(
2167
+ (column) =>
2168
+ !!column.source.name || !_.isEmpty(column.def.origins)
2169
+ )
2170
+ }
2171
+
2172
+ set facetStrategy(facet: FacetStrategy) {
2173
+ this.selectedFacetStrategy = facet
2174
+ }
2175
+ set baseFontSize(val: number) {
2176
+ this._baseFontSize = val
2177
+ }
2178
+
2179
+ getColumnSlugsForCondensedSources(): string[] {
2180
+ const { xColumnSlug, sizeColumnSlug, colorColumnSlug, hasMarimekko } =
2181
+ this
2182
+ const columnSlugs: string[] = []
2183
+
2184
+ // exclude "Countries Continent" if it's used as the color dimension in a scatter plot, slope chart etc.
2185
+ if (
2186
+ colorColumnSlug !== undefined &&
2187
+ !isContinentsVariableId(colorColumnSlug)
2188
+ )
2189
+ columnSlugs.push(colorColumnSlug)
2190
+
2191
+ if (xColumnSlug !== undefined) {
2192
+ const xColumn = this.inputTable.get(xColumnSlug)
2193
+ .def as OwidColumnDef
2194
+ // exclude population variable if it's used as the x dimension in a marimekko
2195
+ if (
2196
+ !hasMarimekko ||
2197
+ !isPopulationVariableETLPath(xColumn?.catalogPath ?? "")
2198
+ )
2199
+ columnSlugs.push(xColumnSlug)
2200
+ }
2201
+
2202
+ // exclude population variable if it's used as the size dimension in a scatter plot
2203
+ if (sizeColumnSlug !== undefined) {
2204
+ const sizeColumn = this.inputTable.get(sizeColumnSlug)
2205
+ .def as OwidColumnDef
2206
+ if (!isPopulationVariableETLPath(sizeColumn?.catalogPath ?? ""))
2207
+ columnSlugs.push(sizeColumnSlug)
2208
+ }
2209
+ return columnSlugs
2210
+ }
2211
+ @computed get columnsWithSourcesCondensed(): CoreColumn[] {
2212
+ const { yColumnSlugs } = this
2213
+
2214
+ const columnSlugs = [...yColumnSlugs]
2215
+ columnSlugs.push(...this.getColumnSlugsForCondensedSources())
2216
+
2217
+ return this.inputTable
2218
+ .getColumns(_.uniq(columnSlugs))
2219
+ .filter(
2220
+ (column) =>
2221
+ !!column.source.name || !_.isEmpty(column.def.origins)
2222
+ )
2223
+ }
2224
+ @computed private get defaultSourcesLine(): string {
2225
+ const attributions = this.columnsWithSourcesCondensed.flatMap(
2226
+ (column) => {
2227
+ const { presentation = {} } = column.def
2228
+ // if the variable metadata specifies an attribution on the
2229
+ // variable level then this is preferred over assembling it from
2230
+ // the source and origins
2231
+ if (
2232
+ presentation.attribution !== undefined &&
2233
+ presentation.attribution !== ""
2234
+ )
2235
+ return [presentation.attribution]
2236
+ else {
2237
+ const originFragments = getOriginAttributionFragments(
2238
+ column.def.origins
2239
+ )
2240
+ return [column.source.name, ...originFragments]
2241
+ }
2242
+ }
2243
+ )
2244
+
2245
+ const uniqueAttributions = _.uniq(_.compact(attributions))
2246
+
2247
+ if (uniqueAttributions.length > 3)
2248
+ return `${uniqueAttributions[0]} and other sources`
2249
+
2250
+ return uniqueAttributions.join("; ")
2251
+ }
2252
+
2253
+ @computed private get axisDimensions(): ChartDimension[] {
2254
+ return this.filledDimensions.filter(
2255
+ (dim) =>
2256
+ dim.property === DimensionProperty.y ||
2257
+ dim.property === DimensionProperty.x
2258
+ )
2259
+ }
2260
+ @computed get yColumnsFromDimensionsOrSlugsOrAuto(): CoreColumn[] {
2261
+ return this.yColumnsFromDimensions.length
2262
+ ? this.yColumnsFromDimensions
2263
+ : this.table.getColumns(autoDetectYColumnSlugs(this))
2264
+ }
2265
+ @computed get defaultTitle(): string {
2266
+ const yColumns = this.yColumnsFromDimensionsOrSlugsOrAuto
2267
+
2268
+ if (this.isScatter)
2269
+ return this.axisDimensions
2270
+ .map(
2271
+ (dimension) =>
2272
+ dimension.column.titlePublicOrDisplayName.title
2273
+ )
2274
+ .join(" vs. ")
2275
+
2276
+ const uniqueDatasetNames = _.uniq(
2277
+ excludeUndefined(
2278
+ yColumns.map((col) => (col.def as OwidColumnDef).datasetName)
2279
+ )
2280
+ )
2281
+
2282
+ if (this.hasMultipleYColumns && uniqueDatasetNames.length === 1)
2283
+ return uniqueDatasetNames[0]
2284
+
2285
+ if (yColumns.length === 2)
2286
+ return yColumns
2287
+ .map((col) => col.titlePublicOrDisplayName.title)
2288
+ .join(" and ")
2289
+
2290
+ return yColumns
2291
+ .map((col) => col.titlePublicOrDisplayName.title)
2292
+ .join(", ")
2293
+ }
2294
+
2295
+ @computed get displayTitle(): string {
2296
+ if (this.title) return this.title
2297
+ if (this.isReady) return this.defaultTitle
2298
+ return ""
2299
+ }
2300
+ // Returns an object ready to be serialized to JSON
2301
+ @computed get object(): GrapherInterface {
2302
+ return this.toObject()
2303
+ }
2304
+ @computed get isLineChart(): boolean {
2305
+ return (
2306
+ this.chartType === GRAPHER_CHART_TYPES.LineChart || !this.chartType
2307
+ )
2308
+ }
2309
+ @computed get isScatter(): boolean {
2310
+ return this.chartType === GRAPHER_CHART_TYPES.ScatterPlot
2311
+ }
2312
+ @computed get isStackedArea(): boolean {
2313
+ return this.chartType === GRAPHER_CHART_TYPES.StackedArea
2314
+ }
2315
+ @computed get isSlopeChart(): boolean {
2316
+ return this.chartType === GRAPHER_CHART_TYPES.SlopeChart
2317
+ }
2318
+ @computed get isDiscreteBar(): boolean {
2319
+ return this.chartType === GRAPHER_CHART_TYPES.DiscreteBar
2320
+ }
2321
+ @computed get isStackedBar(): boolean {
2322
+ return this.chartType === GRAPHER_CHART_TYPES.StackedBar
2323
+ }
2324
+ @computed get isMarimekko(): boolean {
2325
+ return this.chartType === GRAPHER_CHART_TYPES.Marimekko
2326
+ }
2327
+ @computed get isStackedDiscreteBar(): boolean {
2328
+ return this.chartType === GRAPHER_CHART_TYPES.StackedDiscreteBar
2329
+ }
2330
+ @computed get isOnLineChartTab(): boolean {
2331
+ return this.activeChartType === GRAPHER_CHART_TYPES.LineChart
2332
+ }
2333
+
2334
+ @computed get isOnScatterTab(): boolean {
2335
+ return this.activeChartType === GRAPHER_CHART_TYPES.ScatterPlot
2336
+ }
2337
+ @computed get isOnStackedAreaTab(): boolean {
2338
+ return this.activeChartType === GRAPHER_CHART_TYPES.StackedArea
2339
+ }
2340
+ @computed get isOnSlopeChartTab(): boolean {
2341
+ return this.activeChartType === GRAPHER_CHART_TYPES.SlopeChart
2342
+ }
2343
+ @computed get isOnDiscreteBarTab(): boolean {
2344
+ return this.activeChartType === GRAPHER_CHART_TYPES.DiscreteBar
2345
+ }
2346
+ @computed get isOnStackedBarTab(): boolean {
2347
+ return this.activeChartType === GRAPHER_CHART_TYPES.StackedBar
2348
+ }
2349
+ @computed get isOnMarimekkoTab(): boolean {
2350
+ return this.activeChartType === GRAPHER_CHART_TYPES.Marimekko
2351
+ }
2352
+ @computed get isOnStackedDiscreteBarTab(): boolean {
2353
+ return this.activeChartType === GRAPHER_CHART_TYPES.StackedDiscreteBar
2354
+ }
2355
+
2356
+ @computed get hasLineChart(): boolean {
2357
+ return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.LineChart)
2358
+ }
2359
+ @computed get hasSlopeChart(): boolean {
2360
+ return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.SlopeChart)
2361
+ }
2362
+ @computed get hasDiscreteBar(): boolean {
2363
+ return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.DiscreteBar)
2364
+ }
2365
+ @computed get hasMarimekko(): boolean {
2366
+ return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.Marimekko)
2367
+ }
2368
+ @computed get hasScatter(): boolean {
2369
+ return this.validChartTypeSet.has(GRAPHER_CHART_TYPES.ScatterPlot)
2370
+ }
2371
+ @computed get supportsMultipleYColumns(): boolean {
2372
+ return !this.isScatter
2373
+ }
2374
+ @computed get xDimension(): ChartDimension | undefined {
2375
+ return this.filledDimensions.find(
2376
+ (d) => d.property === DimensionProperty.x
2377
+ )
2378
+ }
2379
+ // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class?
2380
+ // todo: remove this. Should be done as a simple column transform at the data level.
2381
+ // Possible to override the x axis dimension to target a special year
2382
+ // In case you want to graph say, education in the past and democracy today https://ourworldindata.org/grapher/correlation-between-education-and-democracy
2383
+ @computed get xOverrideTime(): number | undefined {
2384
+ return this.xDimension?.targetYear
2385
+ }
2386
+ // todo: this is only relevant for scatter plots and Marimekko. move to scatter plot class?
2387
+ set xOverrideTime(value: number | undefined) {
2388
+ this.xDimension!.targetYear = value
2389
+ }
2390
+ @computed get defaultBounds(): Bounds {
2391
+ return new Bounds(0, 0, DEFAULT_GRAPHER_WIDTH, DEFAULT_GRAPHER_HEIGHT)
2392
+ }
2393
+ @computed get hasYDimension(): boolean {
2394
+ return this.dimensions.some((d) => d.property === DimensionProperty.y)
2395
+ }
2396
+
2397
+ generateStaticSvg(
2398
+ renderToHtmlString: (element: React.ReactElement) => string
2399
+ ): string {
2400
+ const _isExportingToSvgOrPng = this.isExportingToSvgOrPng
2401
+ this.isExportingToSvgOrPng = true
2402
+
2403
+ const innerHTML = renderToHtmlString(<Chart manager={this} />)
2404
+
2405
+ this.isExportingToSvgOrPng = _isExportingToSvgOrPng
2406
+ return innerHTML
2407
+ }
2408
+ @computed get staticBoundsWithDetails(): Bounds {
2409
+ const includeDetails =
2410
+ this.shouldIncludeDetailsInStaticExport &&
2411
+ !_.isEmpty(this.detailRenderers)
2412
+
2413
+ let height = this.staticBounds.height
2414
+ if (includeDetails) {
2415
+ height +=
2416
+ 2 * this.framePaddingVertical +
2417
+ sumTextWrapHeights(
2418
+ this.detailRenderers,
2419
+ STATIC_EXPORT_DETAIL_SPACING
2420
+ )
2421
+ }
2422
+
2423
+ return new Bounds(0, 0, this.staticBounds.width, height)
2424
+ }
2425
+
2426
+ rasterize: GrapherRasterizeFn = ({ includeDetails }) => {
2427
+ const _shouldIncludeDetailsInStaticExport =
2428
+ this.shouldIncludeDetailsInStaticExport
2429
+ this.shouldIncludeDetailsInStaticExport = includeDetails
2430
+
2431
+ const { width, height } = this.staticBoundsWithDetails
2432
+
2433
+ try {
2434
+ // We need to ensure `rasterize` is only called on the client-side, otherwise this will fail
2435
+ const staticSVG = this.generateStaticSvg(
2436
+ reactRenderToStringClientOnly
2437
+ )
2438
+ return new StaticChartRasterizer(staticSVG, width, height).render()
2439
+ } finally {
2440
+ this.shouldIncludeDetailsInStaticExport =
2441
+ _shouldIncludeDetailsInStaticExport
2442
+ }
2443
+ }
2444
+ @computed get disableIntroAnimation(): boolean {
2445
+ return this.isStatic
2446
+ }
2447
+ @computed get mapConfig(): MapConfig {
2448
+ return this.map
2449
+ }
2450
+ @computed get cacheTag(): string {
2451
+ return this.version.toString()
2452
+ }
2453
+
2454
+ @computed get relativeToggleLabel(): string {
2455
+ if (this.isOnScatterTab) return "Display average annual change"
2456
+ else if (this.isOnLineChartTab || this.isOnSlopeChartTab)
2457
+ return "Display relative change"
2458
+ return "Display relative values"
2459
+ }
2460
+ // NB: The timeline scatterplot in relative mode calculates changes relative
2461
+ // to the lower bound year rather than creating an arrow chart
2462
+ @computed get isRelativeMode(): boolean {
2463
+ // don't allow relative mode in some cases
2464
+ if (
2465
+ this.hasSingleMetricInFacets ||
2466
+ this.hasSingleEntityInFacets ||
2467
+ this.isStackedChartSplitByMetric
2468
+ )
2469
+ return false
2470
+ return this.stackMode === StackMode.relative
2471
+ }
2472
+ @computed get canToggleRelativeMode(): boolean {
2473
+ const {
2474
+ isOnLineChartTab,
2475
+ isOnSlopeChartTab,
2476
+ hideRelativeToggle,
2477
+ areHandlesOnSameTime,
2478
+ yScaleType,
2479
+ hasSingleEntityInFacets,
2480
+ hasSingleMetricInFacets,
2481
+ xColumnSlug,
2482
+ isOnMarimekkoTab,
2483
+ isStackedChartSplitByMetric,
2484
+ } = this
2485
+
2486
+ if (isOnLineChartTab || isOnSlopeChartTab)
2487
+ return (
2488
+ !hideRelativeToggle &&
2489
+ !areHandlesOnSameTime &&
2490
+ yScaleType !== ScaleType.log
2491
+ )
2492
+
2493
+ // actually trying to exclude relative mode with just one metric or entity
2494
+ if (
2495
+ hasSingleEntityInFacets ||
2496
+ hasSingleMetricInFacets ||
2497
+ isStackedChartSplitByMetric
2498
+ )
2499
+ return false
2500
+
2501
+ if (isOnMarimekkoTab && xColumnSlug === undefined) return false
2502
+ return !hideRelativeToggle
2503
+ }
2504
+
2505
+ // Filter data to what can be display on the map (across all times)
2506
+ @computed get mappableData(): OwidVariableRow<any>[] {
2507
+ return this.inputTable
2508
+ .get(this.mapColumnSlug)
2509
+ .owidRows.filter((row) => isOnTheMap(row.entityName))
2510
+ }
2511
+ @computed get isMobile(): boolean {
2512
+ return isMobile()
2513
+ }
2514
+ @computed get isTouchDevice(): boolean {
2515
+ return isTouchDevice()
2516
+ }
2517
+ _externalBounds: Bounds | undefined = undefined
2518
+ /** externalBounds should be set to the available plotting area for a
2519
+ Grapher that resizes itself to fit. When this area changes,
2520
+ externalBounds should be updated. Updating externalBounds can
2521
+ trigger a bunch of somewhat expensive recalculations so it might
2522
+ be worth debouncing updates (e.g. when drag-resizing) */
2523
+ @computed get externalBounds(): Bounds {
2524
+ const { _externalBounds, initialOptions } = this
2525
+ return (
2526
+ _externalBounds ?? initialOptions.bounds ?? DEFAULT_GRAPHER_BOUNDS
2527
+ )
2528
+ }
2529
+ set externalBounds(bounds: Bounds) {
2530
+ this._externalBounds = bounds
2531
+ }
2532
+ @computed get isPortrait(): boolean {
2533
+ return (
2534
+ this.externalBounds.width < this.externalBounds.height &&
2535
+ this.externalBounds.width < DEFAULT_GRAPHER_WIDTH
2536
+ )
2537
+ }
2538
+ @computed private get widthForDeviceOrientation(): number {
2539
+ return this.isPortrait ? 400 : 680
2540
+ }
2541
+
2542
+ @computed private get heightForDeviceOrientation(): number {
2543
+ return this.isPortrait ? 640 : 480
2544
+ }
2545
+ @computed private get useIdealBounds(): boolean {
2546
+ const {
2547
+ isEditor,
2548
+ isExportingToSvgOrPng,
2549
+ externalBounds,
2550
+ widthForDeviceOrientation,
2551
+ heightForDeviceOrientation,
2552
+ isInIFrame,
2553
+ isInFullScreenMode,
2554
+ windowInnerWidth,
2555
+ windowInnerHeight,
2556
+ } = this
2557
+
2558
+ // In full-screen mode, we usually use all space available to us
2559
+ // We use the ideal bounds only if the available space is very large
2560
+ if (isInFullScreenMode) {
2561
+ if (
2562
+ windowInnerHeight! > 2 * heightForDeviceOrientation &&
2563
+ windowInnerWidth! > 2 * widthForDeviceOrientation
2564
+ )
2565
+ return true
2566
+ return false
2567
+ }
2568
+
2569
+ // For these, defer to the bounds that are set externally
2570
+ if (
2571
+ this.isEmbeddedInADataPage ||
2572
+ this.isEmbeddedInAnOwidPage ||
2573
+ this.manager ||
2574
+ isInIFrame
2575
+ )
2576
+ return false
2577
+
2578
+ // If the user is using interactive version and then goes to export chart, use current bounds to maintain WYSIWYG
2579
+ if (isExportingToSvgOrPng) return false
2580
+
2581
+ // In the editor, we usually want ideal bounds, except when we're rendering a static preview;
2582
+ // in that case, we want to use the given static bounds
2583
+ if (isEditor) return !this.isExportingToSvgOrPng
2584
+
2585
+ // If the available space is very small, we use all of the space given to us
2586
+ if (
2587
+ externalBounds.height < heightForDeviceOrientation ||
2588
+ externalBounds.width < widthForDeviceOrientation
2589
+ )
2590
+ return false
2591
+
2592
+ return true
2593
+ }
2594
+
2595
+ // If we have a big screen to be in, we can define our own aspect ratio and sit in the center
2596
+ @computed private get scaleToFitIdeal(): number {
2597
+ return Math.min(
2598
+ (this.availableWidth * 0.95) / this.widthForDeviceOrientation,
2599
+ (this.availableHeight * 0.95) / this.heightForDeviceOrientation
2600
+ )
2601
+ }
2602
+
2603
+ @computed private get fullScreenPadding(): number {
2604
+ const { windowInnerWidth } = this
2605
+ if (!windowInnerWidth) return 0
2606
+ return windowInnerWidth < 940 ? 0 : 40
2607
+ }
2608
+ @computed get hideFullScreenButton(): boolean {
2609
+ if (this.isInFullScreenMode) return false
2610
+ if (!this.isSmall) return false
2611
+ // hide the full screen button if the full screen height
2612
+ // is barely larger than the current chart height
2613
+ const fullScreenHeight = this.windowInnerHeight!
2614
+ return fullScreenHeight < this.frameBounds.height + 80
2615
+ }
2616
+
2617
+ @computed private get availableWidth(): number {
2618
+ const {
2619
+ externalBounds,
2620
+ isInFullScreenMode,
2621
+ windowInnerWidth,
2622
+ fullScreenPadding,
2623
+ } = this
2624
+
2625
+ return Math.floor(
2626
+ isInFullScreenMode
2627
+ ? windowInnerWidth! - 2 * fullScreenPadding
2628
+ : externalBounds.width
2629
+ )
2630
+ }
2631
+
2632
+ @computed private get availableHeight(): number {
2633
+ const {
2634
+ externalBounds,
2635
+ isInFullScreenMode,
2636
+ windowInnerHeight,
2637
+ fullScreenPadding,
2638
+ } = this
2639
+
2640
+ return Math.floor(
2641
+ isInFullScreenMode
2642
+ ? windowInnerHeight! - 2 * fullScreenPadding
2643
+ : externalBounds.height
2644
+ )
2645
+ }
2646
+
2647
+ @computed private get idealWidth(): number {
2648
+ return Math.floor(this.widthForDeviceOrientation * this.scaleToFitIdeal)
2649
+ }
2650
+
2651
+ @computed private get idealHeight(): number {
2652
+ return Math.floor(
2653
+ this.heightForDeviceOrientation * this.scaleToFitIdeal
2654
+ )
2655
+ }
2656
+
2657
+ /** Bounds of the entire Grapher frame including the chart area and entity selector panel */
2658
+ @computed get frameBounds(): Bounds {
2659
+ return this.useIdealBounds
2660
+ ? new Bounds(0, 0, this.idealWidth, this.idealHeight)
2661
+ : new Bounds(0, 0, this.availableWidth, this.availableHeight)
2662
+ }
2663
+
2664
+ @computed get activeBounds(): Bounds {
2665
+ return this.isExportingToSvgOrPng ? this.staticBounds : this.frameBounds
2666
+ }
2667
+
2668
+ /** Bounds of the CaptionedChart that renders the header, chart area and footer */
2669
+ @computed get captionedChartBounds(): Bounds {
2670
+ // if there's no panel, the chart takes up the whole frame
2671
+ if (!this.isEntitySelectorPanelActive) return this.frameBounds
2672
+
2673
+ return new Bounds(
2674
+ 0,
2675
+ 0,
2676
+ // the chart takes up 9 columns in 12-column grid
2677
+ (9 / 12) * this.frameBounds.width,
2678
+ this.frameBounds.height - 2 // 2px accounts for the border
2679
+ )
2680
+ }
2681
+
2682
+ @computed get chartAreaPadding(): number {
2683
+ // Choose padding based on chart size, ensuring it's at most 24px
2684
+ return Math.min(24, Math.ceil(0.025 * this.activeBounds.width))
2685
+ }
2686
+
2687
+ /** Bounds of the chart area if no CaptionedChart is rendered */
2688
+ @computed get chartAreaBounds(): Bounds {
2689
+ return this.activeBounds.pad(this.chartAreaPadding)
2690
+ }
2691
+
2692
+ /** Bounds of the entity selector if rendered into the side panel */
2693
+ @computed get sidePanelBounds(): Bounds | undefined {
2694
+ if (!this.isEntitySelectorPanelActive) return
2695
+
2696
+ return new Bounds(
2697
+ 0, // not in use; intentionally set to zero
2698
+ 0, // not in use; intentionally set to zero
2699
+ this.frameBounds.width - this.captionedChartBounds.width,
2700
+ this.captionedChartBounds.height
2701
+ )
2702
+ }
2703
+
2704
+ base = React.createRef<HTMLDivElement>()
2705
+ @computed get containerElement(): HTMLDivElement | undefined {
2706
+ return this.base.current || undefined
2707
+ }
2708
+
2709
+ // private hasLoggedGAViewEvent = false
2710
+ // @observable private hasBeenVisible = false
2711
+ // @observable private uncaughtError?: Error
2712
+ @computed private get analyticsContext(): GrapherAnalyticsContext {
2713
+ const ctx = this.manager?.analyticsContext
2714
+ return {
2715
+ slug: ctx?.mdimSlug ?? this.slug,
2716
+ viewConfigId: ctx?.mdimViewConfigId,
2717
+ narrativeChartName: this.narrativeChartInfo?.name,
2718
+ }
2719
+ }
2720
+
2721
+ logEntitySelectorEvent(action: EntitySelectorEvent, target?: string): void {
2722
+ this.analytics.logEntitySelectorEvent(action, {
2723
+ ...this.analyticsContext,
2724
+ target,
2725
+ })
2726
+ }
2727
+
2728
+ logImageDownloadEvent(action: GrapherImageDownloadEvent): void {
2729
+ this.analytics.logGrapherImageDownloadEvent(action, {
2730
+ ...this.analyticsContext,
2731
+ context: omitUndefinedValues({
2732
+ tab: this.activeTab,
2733
+ globe: this.isOnMapTab
2734
+ ? this.mapConfig.globe.isActive
2735
+ : undefined,
2736
+ }),
2737
+ })
2738
+ }
2739
+
2740
+ logGrapherInteractionEvent(
2741
+ action: GrapherInteractionEvent,
2742
+ target?: string
2743
+ ): void {
2744
+ this.analytics.logGrapherInteractionEvent(action, {
2745
+ ...this.analyticsContext,
2746
+ target,
2747
+ })
2748
+ }
2749
+
2750
+ // @action.bound setError(err: Error): void {
2751
+ // this.uncaughtError = err
2752
+ // }
2753
+ // @action.bound clearErrors(): void {
2754
+ // this.uncaughtError = undefined
2755
+ // }
2756
+ // private get commandPalette(): React.ReactElement | null {
2757
+ // return this.props.enableKeyboardShortcuts ? (
2758
+ // <CommandPalette commands={this.keyboardShortcuts} display="none" />
2759
+ // ) : null
2760
+ // }
2761
+ formatTimeFn(time: Time): string {
2762
+ return this.inputTable.timeColumn.formatTime(time)
2763
+ }
2764
+ @computed get availableEntityNames(): EntityName[] {
2765
+ return this.tableForSelection.availableEntityNames
2766
+ }
2767
+ @computed get entityRegionTypeGroups(): EntityRegionTypeGroup[] {
2768
+ return groupEntityNamesByRegionType(this.availableEntityNames)
2769
+ }
2770
+
2771
+ @computed get entityNamesByRegionType(): EntityNamesByRegionType {
2772
+ return new Map(
2773
+ this.entityRegionTypeGroups.map(({ regionType, entityNames }) => [
2774
+ regionType,
2775
+ entityNames,
2776
+ ])
2777
+ )
2778
+ }
2779
+ slideShow: SlideShowController<any> | undefined = undefined
2780
+ @computed get _sortConfig(): Readonly<SortConfig> {
2781
+ return {
2782
+ sortBy: this.sortBy ?? SortBy.total,
2783
+ sortOrder: this.sortOrder ?? SortOrder.desc,
2784
+ sortColumnSlug: this.sortColumnSlug,
2785
+ }
2786
+ }
2787
+
2788
+ @computed get sortConfig(): SortConfig {
2789
+ const sortConfig = { ...this._sortConfig }
2790
+ // In relative mode, where the values for every entity sum up to 100%, sorting by total
2791
+ // doesn't make sense. It's also jumpy because of some rounding errors. For this reason,
2792
+ // we sort by entity name instead.
2793
+ // Marimekko charts are special and there we don't do this forcing of sort order
2794
+ if (
2795
+ !this.isOnMarimekkoTab &&
2796
+ this.isRelativeMode &&
2797
+ sortConfig.sortBy === SortBy.total
2798
+ ) {
2799
+ sortConfig.sortBy = SortBy.entityName
2800
+ sortConfig.sortOrder = SortOrder.asc
2801
+ }
2802
+ return sortConfig
2803
+ }
2804
+ @computed get hasMultipleYColumns(): boolean {
2805
+ return this.yColumnSlugs.length > 1
2806
+ }
2807
+ @computed private get hasSingleMetricInFacets(): boolean {
2808
+ const {
2809
+ isOnStackedDiscreteBarTab,
2810
+ isOnStackedAreaTab,
2811
+ isOnStackedBarTab,
2812
+ selectedFacetStrategy,
2813
+ hasMultipleYColumns,
2814
+ } = this
2815
+
2816
+ if (isOnStackedDiscreteBarTab) {
2817
+ return (
2818
+ selectedFacetStrategy === FacetStrategy.entity ||
2819
+ selectedFacetStrategy === FacetStrategy.metric
2820
+ )
2821
+ }
2822
+
2823
+ if (isOnStackedAreaTab || isOnStackedBarTab) {
2824
+ return (
2825
+ selectedFacetStrategy === FacetStrategy.entity &&
2826
+ !hasMultipleYColumns
2827
+ )
2828
+ }
2829
+
2830
+ return false
2831
+ }
2832
+
2833
+ @computed private get hasSingleEntityInFacets(): boolean {
2834
+ const {
2835
+ isOnStackedAreaTab,
2836
+ isOnStackedBarTab,
2837
+ selectedFacetStrategy,
2838
+ selection,
2839
+ } = this
2840
+
2841
+ if (isOnStackedAreaTab || isOnStackedBarTab) {
2842
+ return (
2843
+ selectedFacetStrategy === FacetStrategy.metric &&
2844
+ selection.numSelectedEntities === 1
2845
+ )
2846
+ }
2847
+
2848
+ return false
2849
+ }
2850
+
2851
+ // TODO: remove once #2136 is fixed
2852
+ // issue #2136 describes a serious bug that relates to relative mode and
2853
+ // affects all stacked area/bar charts that are split by metric. for now,
2854
+ // we simply turn off relative mode in such cases. once the bug is properly
2855
+ // addressed, this computed property and its references can be removed
2856
+ @computed
2857
+ private get isStackedChartSplitByMetric(): boolean {
2858
+ return (
2859
+ (this.isOnStackedAreaTab || this.isOnStackedBarTab) &&
2860
+ this.selectedFacetStrategy === FacetStrategy.metric
2861
+ )
2862
+ }
2863
+
2864
+ @computed get availableFacetStrategies(): FacetStrategy[] {
2865
+ return this.chartState.availableFacetStrategies?.length
2866
+ ? this.chartState.availableFacetStrategies
2867
+ : [FacetStrategy.none]
2868
+ }
2869
+ // the actual facet setting used by a chart, potentially overriding selectedFacetStrategy
2870
+ @computed get facetStrategy(): FacetStrategy {
2871
+ if (
2872
+ this.selectedFacetStrategy &&
2873
+ this.availableFacetStrategies.includes(this.selectedFacetStrategy)
2874
+ )
2875
+ return this.selectedFacetStrategy
2876
+
2877
+ if (
2878
+ this.addCountryMode === EntitySelectionMode.SingleEntity &&
2879
+ this.selection.selectedEntityNames.length > 1
2880
+ ) {
2881
+ return FacetStrategy.entity
2882
+ }
2883
+
2884
+ if (this.availableFacetStrategies.length === 0)
2885
+ throw new Error("No facet strategy available")
2886
+
2887
+ return firstOfNonEmptyArray(this.availableFacetStrategies)
2888
+ }
2889
+
2890
+ @computed get isFaceted(): boolean {
2891
+ // Show map facets if start and end time are different
2892
+ if (this.isOnMapTab) return this.startTime !== this.endTime
2893
+
2894
+ const hasFacetStrategy = this.facetStrategy !== FacetStrategy.none
2895
+ return this.isOnChartTab && hasFacetStrategy
2896
+ }
2897
+
2898
+ @computed get hasMultipleSeriesPerFacet(): boolean {
2899
+ return (
2900
+ this.isFaceted &&
2901
+ this.selection.numSelectedEntities > 1 &&
2902
+ this.yColumnSlugs.length > 1
2903
+ )
2904
+ }
2905
+
2906
+ @computed get isInFullScreenMode(): boolean {
2907
+ return this._isInFullScreenMode
2908
+ }
2909
+
2910
+ set isInFullScreenMode(newValue: boolean) {
2911
+ // prevent scrolling when in full-screen mode
2912
+ if (newValue) {
2913
+ document.documentElement.classList.add("no-scroll")
2914
+ } else {
2915
+ document.documentElement.classList.remove("no-scroll")
2916
+ }
2917
+
2918
+ // dismiss the share menu
2919
+ this.isShareMenuActive = false
2920
+
2921
+ this._isInFullScreenMode = newValue
2922
+ }
2923
+
2924
+ @action.bound toggleFullScreenMode(): void {
2925
+ this.isInFullScreenMode = !this.isInFullScreenMode
2926
+ }
2927
+
2928
+ @action.bound dismissFullScreen(): void {
2929
+ // if a modal is open, dismiss it instead of exiting full-screen mode
2930
+ if (this.isModalOpen || this.isShareMenuActive) {
2931
+ this.isEntitySelectorModalOrDrawerOpen = false
2932
+ this.activeModal = undefined
2933
+ this.isShareMenuActive = false
2934
+ } else {
2935
+ this.isInFullScreenMode = false
2936
+ }
2937
+ }
2938
+
2939
+ @action.bound setHideExternalControlsInEmbedUrl(value: boolean): void {
2940
+ this.hideExternalControlsInEmbedUrl = value
2941
+ }
2942
+
2943
+ @computed get isModalOpen(): boolean {
2944
+ return this.isEntitySelectorModalOpen || this.activeModal !== undefined
2945
+ }
2946
+
2947
+ // Whether a server-side download is available for the download modal
2948
+ @computed get isServerSideDownloadAvailable(): boolean {
2949
+ return (
2950
+ // We're not on an archival grapher page
2951
+ !this.isOnArchivalPage &&
2952
+ // We're not inside the admin
2953
+ window.admin === undefined &&
2954
+ // We're not in a narrative chart
2955
+ !this.narrativeChartInfo &&
2956
+ // We have a baseUrl to send the request to
2957
+ !!this.baseUrl
2958
+ )
2959
+ }
2960
+ _baseFontSize = BASE_FONT_SIZE
2961
+ @computed get baseFontSize(): number {
2962
+ if (this.isStatic && this.initialOptions.baseFontSize)
2963
+ return this.initialOptions.baseFontSize
2964
+ if (this.isStaticAndSmall) {
2965
+ return this.computeBaseFontSizeFromHeight(this.staticBounds)
2966
+ }
2967
+ if (this.isStatic) return 18
2968
+ return this._baseFontSize
2969
+ }
2970
+
2971
+ // the header and footer don't rely on the base font size unless explicitly specified
2972
+ @computed get useBaseFontSize(): boolean {
2973
+ return this.initialOptions.baseFontSize !== undefined
2974
+ }
2975
+
2976
+ private computeBaseFontSizeFromHeight(bounds: Bounds): number {
2977
+ const squareBounds = DEFAULT_GRAPHER_BOUNDS_SQUARE
2978
+ const factor = squareBounds.height / 21
2979
+ return Math.max(10, bounds.height / factor)
2980
+ }
2981
+
2982
+ computeBaseFontSizeFromWidth(bounds: Bounds): number {
2983
+ if (bounds.width <= 400) return 14
2984
+ else if (bounds.width < 1080) return 16
2985
+ else if (bounds.width >= 1080) return 18
2986
+ else return 16
2987
+ }
2988
+ @computed get fontSize(): number {
2989
+ return this.baseFontSize
2990
+ }
2991
+ @computed get isNarrow(): boolean {
2992
+ if (this.isStatic) return false
2993
+ return this.frameBounds.width <= 420
2994
+ }
2995
+ @computed get isSemiNarrow(): boolean {
2996
+ if (this.isStatic) return false
2997
+ return this.frameBounds.width <= 550
2998
+ }
2999
+ // Small charts are rendered into 6 or 7 columns in a 12-column grid layout
3000
+ // (e.g. side-by-side charts or charts in the All Charts block)
3001
+ @computed get isSmall(): boolean {
3002
+ if (this.isStatic) return false
3003
+ return this.frameBounds.width <= 740
3004
+ }
3005
+ // Medium charts are rendered into 8 columns in a 12-column grid layout
3006
+ // (e.g. stand-alone charts in the main text of an article)
3007
+ @computed get isMedium(): boolean {
3008
+ if (this.isStatic) return false
3009
+ return this.frameBounds.width <= 845
3010
+ }
3011
+
3012
+ @computed get isStaticAndSmall(): boolean {
3013
+ if (!this.isStatic) return false
3014
+ return this.areStaticBoundsSmall
3015
+ }
3016
+
3017
+ @computed get areStaticBoundsSmall(): boolean {
3018
+ const { defaultBounds, staticBounds } = this
3019
+ const idealPixelCount = defaultBounds.width * defaultBounds.height
3020
+ const staticPixelCount = staticBounds.width * staticBounds.height
3021
+ return staticPixelCount < 0.66 * idealPixelCount
3022
+ }
3023
+
3024
+ @computed get isExportingForSocialMedia(): boolean {
3025
+ return (
3026
+ this.isExportingToSvgOrPng &&
3027
+ this.isStaticAndSmall &&
3028
+ this.isSocialMediaExport
3029
+ )
3030
+ }
3031
+
3032
+ @computed get isExportingForWikimedia(): boolean {
3033
+ return this.isExportingToSvgOrPng && this.isWikimediaExport
3034
+ }
3035
+
3036
+ @computed get backgroundColor(): Color {
3037
+ return this.isExportingForSocialMedia
3038
+ ? GRAPHER_BACKGROUND_BEIGE
3039
+ : GRAPHER_BACKGROUND_DEFAULT
3040
+ }
3041
+
3042
+ @computed get shouldPinTooltipToBottom(): boolean {
3043
+ return this.isTouchDevice
3044
+ }
3045
+
3046
+ isShareMenuActive = false
3047
+ @computed get hasRelatedQuestion(): boolean {
3048
+ if (
3049
+ this.hideRelatedQuestion ||
3050
+ !this.relatedQuestions ||
3051
+ !this.relatedQuestions.length
3052
+ )
3053
+ return false
3054
+ const question = this.relatedQuestions[0]
3055
+ return !!question && !!question.text && !!question.url
3056
+ }
3057
+
3058
+ @computed get isRelatedQuestionTargetDifferentFromCurrentPage(): boolean {
3059
+ // comparing paths rather than full URLs for this to work as
3060
+ // expected on local and staging where the origin (e.g.
3061
+ // hans.owid.cloud) doesn't match the production origin that has
3062
+ // been entered in the related question URL field:
3063
+ // "ourworldindata.org" and yet should still yield a match.
3064
+ // - Note that this won't work on production previews (where the
3065
+ // path is /admin/posts/preview/ID)
3066
+ const { relatedQuestions = [], hasRelatedQuestion } = this
3067
+ const relatedQuestion = relatedQuestions[0]
3068
+ return (
3069
+ hasRelatedQuestion &&
3070
+ !!relatedQuestion &&
3071
+ getWindowUrl().pathname !==
3072
+ Url.fromURL(relatedQuestion.url).pathname
3073
+ )
3074
+ }
3075
+
3076
+ @computed get showRelatedQuestion(): boolean {
3077
+ return (
3078
+ !!this.relatedQuestions &&
3079
+ !!this.hasRelatedQuestion &&
3080
+ !!this.isRelatedQuestionTargetDifferentFromCurrentPage
3081
+ )
3082
+ }
3083
+
3084
+ @action.bound clearSelection(): void {
3085
+ this.selection.clearSelection()
3086
+ this.applyOriginalSelectionAsAuthored()
3087
+ }
3088
+ @action.bound clearFocus(): void {
3089
+ this.focusArray.clear()
3090
+ this.applyOriginalFocusAsAuthored()
3091
+ }
3092
+ @action.bound clearQueryParams(): void {
3093
+ const { authorsVersion } = this
3094
+ this.tab = authorsVersion.tab
3095
+ this.xAxis.scaleType = authorsVersion.xAxis.scaleType
3096
+ this.yAxis.scaleType = authorsVersion.yAxis.scaleType
3097
+ this.stackMode = authorsVersion.stackMode
3098
+ this.zoomToSelection = authorsVersion.zoomToSelection
3099
+ this.compareEndPointsOnly = authorsVersion.compareEndPointsOnly
3100
+ this.minTime = authorsVersion.minTime
3101
+ this.maxTime = authorsVersion.maxTime
3102
+ this.map.time = authorsVersion.map.time
3103
+ this.map.startTime = authorsVersion.map.startTime
3104
+ this.map.region = authorsVersion.map.region
3105
+ this.showNoDataArea = authorsVersion.showNoDataArea
3106
+ this.dataTableConfig.filter = authorsVersion.dataTableConfig.filter
3107
+ this.dataTableConfig.search = authorsVersion.dataTableConfig.search
3108
+ this.mapConfig.globe.isActive = authorsVersion.mapConfig.globe.isActive
3109
+ this.clearSelection()
3110
+ this.clearFocus()
3111
+ this.mapConfig.selection.clearSelection()
3112
+ }
3113
+ // Todo: come up with a more general pattern?
3114
+ // 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
3115
+ // won't overwrite defaults (like type == LineChart). RAII would probably be better, but this works for now.
3116
+ @action.bound reset(): void {
3117
+ const grapherState = new GrapherState({})
3118
+ for (const key of grapherKeysToSerialize) {
3119
+ // grapherKeysToSerialize is not properly typed
3120
+ this[key] = grapherState[key]
3121
+ }
3122
+ this.seriesColorMap = new Map()
3123
+
3124
+ this.ySlugs = grapherState.ySlugs
3125
+ this.xSlug = grapherState.xSlug
3126
+ this.colorSlug = grapherState.colorSlug
3127
+ this.sizeSlug = grapherState.sizeSlug
3128
+
3129
+ this.selection.clearSelection()
3130
+ this.focusArray.clear()
3131
+ }
3132
+
3133
+ debounceMode: boolean = false
3134
+
3135
+ @computed.struct get allParams(): GrapherQueryParams {
3136
+ return grapherObjectToQueryParams(this)
3137
+ }
3138
+
3139
+ @computed get overlayParam(): string | undefined {
3140
+ if (!this.activeModal) return undefined
3141
+ return match(this.activeModal)
3142
+ .with(GrapherModal.Download, () => {
3143
+ return match(this.activeDownloadModalTab)
3144
+ .with(DownloadModalTabName.Data, () => "download-data")
3145
+ .with(DownloadModalTabName.Vis, () => "download-vis")
3146
+ .exhaustive()
3147
+ })
3148
+ .with(GrapherModal.Embed, () => {
3149
+ // We could include the embed modal in the `overlay=` params, but there has been an issue in the past
3150
+ // where we accidentally included that in the Embed dialog's URL, and then embeds would always show
3151
+ // the modal.
3152
+ // Linking directly to the modal doesn't have much of a use case, anyway.
3153
+ return undefined
3154
+ })
3155
+ .with(GrapherModal.Sources, () => "sources")
3156
+ .exhaustive()
3157
+ }
3158
+
3159
+ @computed get areSelectedEntitiesDifferentThanAuthors(): boolean {
3160
+ const authoredConfig = this.legacyConfigAsAuthored
3161
+ const currentSelectedEntityNames = this.selection.selectedEntityNames
3162
+ const originalSelectedEntityNames =
3163
+ authoredConfig.selectedEntityNames ?? []
3164
+
3165
+ return isArrayDifferentFromReference(
3166
+ currentSelectedEntityNames,
3167
+ originalSelectedEntityNames
3168
+ )
3169
+ }
3170
+ @computed get areFocusedSeriesNamesDifferentThanAuthors(): boolean {
3171
+ const authoredConfig = this.legacyConfigAsAuthored
3172
+ const currentFocusedSeriesNames = this.focusArray.seriesNames
3173
+ const originalFocusedSeriesNames =
3174
+ authoredConfig.focusedSeriesNames ?? []
3175
+
3176
+ return isArrayDifferentFromReference(
3177
+ currentFocusedSeriesNames,
3178
+ originalFocusedSeriesNames
3179
+ )
3180
+ }
3181
+
3182
+ // Autocomputed url params to reflect difference between current grapher state
3183
+ // and original config state
3184
+ @computed.struct get changedParams(): Partial<GrapherQueryParams> {
3185
+ return differenceObj(this.allParams, this.authorsVersion.allParams)
3186
+ }
3187
+
3188
+ // If you want to compare current state against the published grapher.
3189
+ @computed get authorsVersion(): GrapherState {
3190
+ return new GrapherState({
3191
+ ...this.legacyConfigAsAuthored,
3192
+ manager: undefined,
3193
+ queryStr: "",
3194
+ })
3195
+ }
3196
+
3197
+ @computed get queryStr(): string {
3198
+ return queryParamsToStr({
3199
+ ...this.changedParams,
3200
+ ...this.externalQueryParams,
3201
+ })
3202
+ }
3203
+
3204
+ /** Static root URL of the chart, e.g. https://ourworldindata.org/grapher/life-expectancy */
3205
+ @computed get baseUrl(): string | undefined {
3206
+ if (this.isOnArchivalPage) return this.archiveContext?.archiveUrl
3207
+
3208
+ if (this.manager?.baseUrl) return this.manager.baseUrl
3209
+
3210
+ return this.isPublished
3211
+ ? `${this.bakedGrapherURL ?? "/grapher"}/${this.displaySlug}`
3212
+ : undefined
3213
+ }
3214
+
3215
+ readonly manager: GrapherManager | undefined = undefined
3216
+ @computed get canonicalUrlIfIsNarrativeChart(): string | undefined {
3217
+ if (!this.narrativeChartInfo) return undefined
3218
+
3219
+ const { parentChartSlug, queryParamsForParentChart } =
3220
+ this.narrativeChartInfo
3221
+
3222
+ const combinedQueryParams = {
3223
+ ...queryParamsForParentChart,
3224
+ ...this.changedParams,
3225
+ }
3226
+
3227
+ return `${this.bakedGrapherURL}/${parentChartSlug}${queryParamsToStr(
3228
+ combinedQueryParams
3229
+ )}`
3230
+ }
3231
+
3232
+ /**
3233
+ * Full URL representing the canonical location of this grapher state,
3234
+ * e.g. https://ourworldindata.org/grapher/life-expectancy?tab=map
3235
+ */
3236
+ @computed get canonicalUrl(): string | undefined {
3237
+ return (
3238
+ this.manager?.canonicalUrl ??
3239
+ this.canonicalUrlIfIsNarrativeChart ??
3240
+ (this.baseUrl ? this.baseUrl + this.queryStr : undefined)
3241
+ )
3242
+ }
3243
+
3244
+ @computed get isOnCanonicalUrl(): boolean {
3245
+ if (!this.canonicalUrl) return false
3246
+ return (
3247
+ getWindowUrl().pathname === Url.fromURL(this.canonicalUrl).pathname
3248
+ )
3249
+ }
3250
+
3251
+ private makeEmbedUrl(baseUrl: string): string {
3252
+ let url = Url.fromURL(baseUrl)
3253
+ // We want to preserve the tab in the embed URL so that if we change the
3254
+ // default view of the chart, it won't change existing embeds.
3255
+ // See https://github.com/owid/owid-grapher/issues/2805
3256
+ const { tab } = this.allParams
3257
+ if (tab && !url.queryParams.tab) {
3258
+ url = url.updateQueryParams({ tab })
3259
+ }
3260
+ if (this.canHideExternalControlsInEmbed) {
3261
+ url = url.updateQueryParams({
3262
+ hideControls: this.hideExternalControlsInEmbedUrl.toString(),
3263
+ })
3264
+ }
3265
+ return url.fullUrl
3266
+ }
3267
+
3268
+ @computed get embedUrl(): string | undefined {
3269
+ const baseUrl = this.canonicalUrl
3270
+ if (!baseUrl) return undefined
3271
+ return this.makeEmbedUrl(baseUrl)
3272
+ }
3273
+
3274
+ @computed get embedArchivedUrl(): string | undefined {
3275
+ if (!this.archiveContext) return undefined
3276
+ const baseUrl = this.archiveContext.archiveUrl + this.queryStr
3277
+ return this.makeEmbedUrl(baseUrl)
3278
+ }
3279
+
3280
+ @computed get hasUserChangedTimeHandles(): boolean {
3281
+ const authorsVersion = this.authorsVersion
3282
+ return (
3283
+ this.minTime !== authorsVersion.minTime ||
3284
+ this.maxTime !== authorsVersion.maxTime
3285
+ )
3286
+ }
3287
+
3288
+ @computed private get hasUserChangedMapTimeHandle(): boolean {
3289
+ const authorsVersion = this.authorsVersion
3290
+ return (
3291
+ this.map.startTime !== authorsVersion.map.startTime ||
3292
+ this.map.time !== authorsVersion.map.time
3293
+ )
3294
+ }
3295
+
3296
+ @computed get timeParam(): string | undefined {
3297
+ const { timeColumn } = this.table
3298
+ const formatTime = (time: Time): string =>
3299
+ timeBoundToTimeBoundString(
3300
+ time,
3301
+ timeColumn instanceof ColumnTypeMap.Day
3302
+ )
3303
+
3304
+ if (this.isOnMapTab) {
3305
+ if (!this.hasUserChangedMapTimeHandle) return undefined
3306
+ if (this.map.time === undefined) return undefined
3307
+
3308
+ if (this.map.startTime === undefined)
3309
+ return formatTime(this.map.time)
3310
+
3311
+ const startTime = formatTime(this.map.startTime)
3312
+ const endTime = formatTime(this.map.time)
3313
+
3314
+ return startTime === endTime
3315
+ ? startTime
3316
+ : `${startTime}..${endTime}`
3317
+ }
3318
+
3319
+ if (!this.hasUserChangedTimeHandles) return undefined
3320
+
3321
+ const [startTime, endTime] =
3322
+ this.timelineHandleTimeBounds.map(formatTime)
3323
+ return startTime === endTime ? startTime : `${startTime}..${endTime}`
3324
+ }
3325
+
3326
+ msPerTick = DEFAULT_MS_PER_TICK
3327
+ timelineController = new TimelineController(this)
3328
+ globeController = new GlobeController(this)
3329
+
3330
+ private dismissTooltip(): void {
3331
+ const tooltip = this.tooltip?.get()
3332
+ if (tooltip) tooltip.dismiss?.()
3333
+ }
3334
+
3335
+ @action.bound onTimelineClick(): void {
3336
+ this.dismissTooltip()
3337
+ }
3338
+
3339
+ // called when an entity is selected in the entity selector
3340
+ @action.bound onSelectEntity(entityName: EntityName): void {
3341
+ const { selectedCountryNamesInForeground } = this.mapConfig.selection
3342
+
3343
+ if (!this.isOnMapTab || !this.isMapSelectionEnabled) return
3344
+
3345
+ const region = getRegionByName(entityName)
3346
+ if (!region) return
3347
+
3348
+ if (this.mapConfig.globe.isActive) {
3349
+ if (
3350
+ checkIsCountry(region) &&
3351
+ region.isMappable &&
3352
+ selectedCountryNamesInForeground.includes(region.name)
3353
+ ) {
3354
+ // Rotate to the selected country
3355
+ this.globeController.rotateToCountry(region.name)
3356
+ this.mapConfig.region = MapRegionName.World
3357
+ } else if (checkIsOwidContinent(region)) {
3358
+ // Rotate to the selected owid continent
3359
+ const regionName = MAP_REGION_NAMES[
3360
+ region.name
3361
+ ] as GlobeRegionName
3362
+ this.globeController.rotateToOwidContinent(regionName)
3363
+ this.mapConfig.region = regionName
3364
+ } else if (checkIsIncomeGroup(region)) {
3365
+ // Switch back to the 2d map if an income group is selected
3366
+ this.globeController.hideGlobe()
3367
+ this.globeController.resetGlobe()
3368
+ this.mapConfig.region = MapRegionName.World
3369
+ } else if (checkHasMembers(region)) {
3370
+ // Rotate to the selected region
3371
+ this.globeController.rotateToRegion(region.name)
3372
+ this.mapConfig.region = MapRegionName.World
3373
+ }
3374
+ }
3375
+ }
3376
+
3377
+ // called when an entity is deselected in the entity selector
3378
+ @action.bound onDeselectEntity(entityName: EntityName): void {
3379
+ // Remove focus from an entity that has been removed from the selection
3380
+ this.focusArray.remove(entityName)
3381
+
3382
+ // Remove focus from the deselected country
3383
+ this.globeController.dismissCountryFocus()
3384
+ }
3385
+
3386
+ // called when all entities are cleared in the entity selector
3387
+ @action.bound onClearEntities(): void {
3388
+ // remove focus from all entities if all entities have been deselected
3389
+ this.focusArray.clear()
3390
+
3391
+ // switch back to the 2d map if all entities were deselected
3392
+ if (this.isOnMapTab) {
3393
+ this.globeController.hideGlobe()
3394
+ this.globeController.resetGlobe()
3395
+ }
3396
+ }
3397
+
3398
+ isEntityMutedInSelector(entityName: EntityName): boolean {
3399
+ // For now, muted entities are only relevant on the map tab
3400
+ if (!this.isOnMapTab) return false
3401
+
3402
+ // Entities disabled on the map are muted
3403
+ if (
3404
+ this.mapConfig.selection.selectedCountryNamesInBackground.includes(
3405
+ entityName
3406
+ )
3407
+ )
3408
+ return true
3409
+
3410
+ // If a 2d continent is active, then all countries outside of the continent
3411
+ // are not shown on the map, so they're muted in the entity selector as well
3412
+ if (this.mapConfig.is2dContinentActive()) {
3413
+ const region = getRegionByName(entityName)
3414
+ if (!region) return false
3415
+
3416
+ // Don't mute the selected continent
3417
+ if (checkIsOwidContinent(region))
3418
+ return region.name !== MAP_REGION_LABELS[this.mapConfig.region]
3419
+
3420
+ const countriesInRegion = getCountriesByRegion(
3421
+ MAP_REGION_LABELS[this.mapConfig.region]
3422
+ )
3423
+ if (!countriesInRegion) return false
3424
+
3425
+ return !countriesInRegion.has(entityName)
3426
+ }
3427
+
3428
+ return false
3429
+ }
3430
+
3431
+ // todo: restore this behavior??
3432
+ onStartPlayOrDrag(): void {
3433
+ this.debounceMode = true
3434
+ }
3435
+
3436
+ onStopPlayOrDrag(): void {
3437
+ this.debounceMode = false
3438
+ }
3439
+ @computed get disablePlay(): boolean {
3440
+ return false
3441
+ }
3442
+
3443
+ @computed get animationEndTime(): Time {
3444
+ const { timeColumn } = this.table
3445
+ if (this.timelineMaxTime) {
3446
+ const timesAsc = sortNumeric(timeColumn.uniqValues.slice())
3447
+ return (
3448
+ findClosestTime(timesAsc, this.timelineMaxTime) ??
3449
+ timeColumn.maxTime
3450
+ )
3451
+ }
3452
+ return timeColumn.maxTime
3453
+ }
3454
+
3455
+ formatTime(value: Time): string {
3456
+ const timeColumn = this.table.timeColumn
3457
+ return isMobile()
3458
+ ? timeColumn.formatValueForMobile(value)
3459
+ : timeColumn.formatValue(value)
3460
+ }
3461
+ @computed get canSelectMultipleEntities(): boolean {
3462
+ if (this.isOnMapTab) return true
3463
+
3464
+ if (this.numSelectableEntityNames < 2) return false
3465
+ if (this.addCountryMode === EntitySelectionMode.MultipleEntities)
3466
+ return true
3467
+
3468
+ // if the chart is currently faceted by entity, then use multi-entity
3469
+ // selection, even if the author specified single-entity selection
3470
+ if (
3471
+ this.addCountryMode === EntitySelectionMode.SingleEntity &&
3472
+ this.facetStrategy === FacetStrategy.entity
3473
+ )
3474
+ return true
3475
+
3476
+ return false
3477
+ }
3478
+
3479
+ @computed get canChangeEntity(): boolean {
3480
+ return (
3481
+ this.hasChartTab &&
3482
+ !this.isOnScatterTab &&
3483
+ !this.canSelectMultipleEntities &&
3484
+ this.addCountryMode === EntitySelectionMode.SingleEntity &&
3485
+ this.numSelectableEntityNames > 1
3486
+ )
3487
+ }
3488
+
3489
+ @computed get canAddEntities(): boolean {
3490
+ return (
3491
+ this.hasChartTab &&
3492
+ this.canSelectMultipleEntities &&
3493
+ (this.isOnLineChartTab ||
3494
+ this.isOnSlopeChartTab ||
3495
+ this.isOnStackedAreaTab ||
3496
+ this.isOnStackedBarTab ||
3497
+ this.isOnDiscreteBarTab ||
3498
+ this.isOnStackedDiscreteBarTab)
3499
+ )
3500
+ }
3501
+
3502
+ @computed get canHighlightEntities(): boolean {
3503
+ return (
3504
+ this.hasChartTab &&
3505
+ this.addCountryMode !== EntitySelectionMode.Disabled &&
3506
+ this.numSelectableEntityNames > 1 &&
3507
+ !this.canAddEntities &&
3508
+ !this.canChangeEntity
3509
+ )
3510
+ }
3511
+ @computed get canChangeAddOrHighlightEntities(): boolean {
3512
+ return (
3513
+ this.canChangeEntity ||
3514
+ this.canAddEntities ||
3515
+ this.canHighlightEntities
3516
+ )
3517
+ }
3518
+
3519
+ @computed get shouldShowEntitySelectorAs(): GrapherWindowType {
3520
+ if (
3521
+ this.frameBounds.width > 940 &&
3522
+ // don't use the panel if the grapher is embedded
3523
+ ((!this.isInIFrame && !this.isEmbeddedInAnOwidPage) ||
3524
+ // unless we're in full-screen mode
3525
+ this.isInFullScreenMode)
3526
+ )
3527
+ return GrapherWindowType.panel
3528
+
3529
+ return this.isSemiNarrow
3530
+ ? GrapherWindowType.modal
3531
+ : GrapherWindowType.drawer
3532
+ }
3533
+
3534
+ @computed get isEntitySelectorPanelActive(): boolean {
3535
+ if (this.hideEntityControls) return false
3536
+
3537
+ const shouldShowPanel =
3538
+ this.shouldShowEntitySelectorAs === GrapherWindowType.panel
3539
+
3540
+ if (this.isOnMapTab && this.isMapSelectionEnabled && shouldShowPanel)
3541
+ return true
3542
+
3543
+ return (
3544
+ this.isOnChartTab &&
3545
+ this.canChangeAddOrHighlightEntities &&
3546
+ shouldShowPanel
3547
+ )
3548
+ }
3549
+
3550
+ @computed get isEntitySelectorModalOpen(): boolean {
3551
+ return (
3552
+ this.isEntitySelectorModalOrDrawerOpen &&
3553
+ this.shouldShowEntitySelectorAs === GrapherWindowType.modal
3554
+ )
3555
+ }
3556
+
3557
+ @computed get isEntitySelectorDrawerOpen(): boolean {
3558
+ return (
3559
+ this.isEntitySelectorModalOrDrawerOpen &&
3560
+ this.shouldShowEntitySelectorAs === GrapherWindowType.drawer
3561
+ )
3562
+ }
3563
+
3564
+ @computed get isMapSelectionEnabled(): boolean {
3565
+ if (this.enableMapSelection) return true
3566
+
3567
+ return (
3568
+ // If the entity controls are hidden, then selecting entities from
3569
+ // the map should also be disabled
3570
+ !this.hideEntityControls &&
3571
+ // Only show the entity selector on the map tab if it's rendered
3572
+ // into the side panel or into the slide-in drawer
3573
+ this.shouldShowEntitySelectorAs !== GrapherWindowType.modal
3574
+ )
3575
+ }
3576
+
3577
+ // This is just a helper method to return the correct table for providing entity choices. We want to
3578
+ // provide the root table, not the transformed table.
3579
+ // A user may have added time or other filters that would filter out all rows from certain entities, but
3580
+ // we may still want to show those entities as available in a picker. We also do not want to do things like
3581
+ // hide the Add Entity button as the user drags the timeline.
3582
+ @computed private get numSelectableEntityNames(): number {
3583
+ return this.availableEntityNames.length
3584
+ }
3585
+
3586
+ private mapQueryParamToTabName(tab: string): GrapherTabName | undefined {
3587
+ return isValidTabConfigOption(tab)
3588
+ ? this.mapTabConfigOptionToTabName(tab)
3589
+ : this.defaultTab
3590
+ }
3591
+
3592
+ mapGrapherTabToQueryParam(tabName: GrapherTabName): string {
3593
+ return this.mapTabNameToTabConfigOption(tabName)
3594
+ }
3595
+
3596
+ private mapTabNameToTabConfigOption(
3597
+ tabName: GrapherTabName
3598
+ ): GrapherTabConfigOption {
3599
+ switch (tabName) {
3600
+ case GRAPHER_TAB_NAMES.Table:
3601
+ return GRAPHER_TAB_CONFIG_OPTIONS.table
3602
+ case GRAPHER_TAB_NAMES.WorldMap:
3603
+ return GRAPHER_TAB_CONFIG_OPTIONS.map
3604
+ default:
3605
+ return this.hasMultipleChartTypes
3606
+ ? mapChartTypeNameToTabConfigOption(tabName)
3607
+ : GRAPHER_TAB_CONFIG_OPTIONS.chart
3608
+ }
3609
+ }
3610
+
3611
+ private mapTabConfigOptionToTabName(
3612
+ tabOption: GrapherTabConfigOption
3613
+ ): GrapherTabName {
3614
+ if (tabOption === GRAPHER_TAB_CONFIG_OPTIONS.table)
3615
+ return GRAPHER_TAB_NAMES.Table
3616
+
3617
+ if (tabOption === GRAPHER_TAB_CONFIG_OPTIONS.map)
3618
+ return this.hasMapTab ? GRAPHER_TAB_NAMES.WorldMap : this.defaultTab
3619
+
3620
+ if (tabOption === GRAPHER_TAB_CONFIG_OPTIONS.chart) {
3621
+ return this.defaultTab
3622
+ }
3623
+
3624
+ const chartTypeName = mapTabConfigOptionToChartTypeName(tabOption)
3625
+ return this.validChartTypeSet.has(chartTypeName)
3626
+ ? chartTypeName
3627
+ : this.defaultTab
3628
+ }
3629
+
3630
+ hideTitle = false
3631
+ hideSubtitle = false
3632
+ hideNote = false
3633
+ hideOriginUrl = false
3634
+
3635
+ // For now I am only exposing this programmatically for the dashboard builder. Setting this to true
3636
+ // allows you to still use add country "modes" without showing the buttons in order to prioritize
3637
+ // another entity selector over the built in ones.
3638
+ hideEntityControls = false
3639
+
3640
+ enableMapSelection = false
3641
+
3642
+ // enforces hiding an annotation, even if that means that a crucial piece of information is missing from the chart title
3643
+ forceHideAnnotationFieldsInTitle: AnnotationFieldsInTitle = {
3644
+ entity: false,
3645
+ time: false,
3646
+ changeInPrefix: false,
3647
+ }
3648
+ hasTableTab = true
3649
+ hideShareButton = false
3650
+ hideExploreTheDataButton = true
3651
+ hideRelatedQuestion = false
3652
+
3653
+ initialOptions: GrapherProgrammaticInterface
3654
+ }
3655
+
3656
+ export const defaultObject = objectWithPersistablesToObject(
3657
+ new GrapherState({}),
3658
+ grapherKeysToSerialize
3659
+ )