@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,2369 @@
1
+ // @ts-nocheck
2
+ // This file contains CMS-specific utility functions that have complex type dependencies.
3
+ // Type checking is disabled until these can be properly refactored.
4
+ import * as _ from "lodash-es"
5
+ import * as R from "remeda"
6
+ import { extent } from "d3-array"
7
+ import dayjs from "./dayjs.js"
8
+ import { formatLocale, FormatLocaleObject } from "d3-format"
9
+ import striptags from "striptags"
10
+ import {
11
+ type Integer,
12
+ IDEAL_PLOT_ASPECT_RATIO,
13
+ EPOCH_DATE,
14
+ SortOrder,
15
+ TimeBoundValue,
16
+ ScaleType,
17
+ VerticalAlign,
18
+ type GridParameters,
19
+ HorizontalAlign,
20
+ type OwidEnrichedGdocBlock,
21
+ type EnrichedBlockKeyInsightsSlide,
22
+ type EnrichedTopicPageIntroRelatedTopic,
23
+ type EnrichedTopicPageIntroDownloadButton,
24
+ type EnrichedHybridLink,
25
+ type OwidGdocPostInterface,
26
+ type OwidGdocDataInsightInterface,
27
+ type OwidGdocAuthorInterface,
28
+ type OwidGdoc,
29
+ OwidGdocType,
30
+ type OwidGdocJSON,
31
+ type Span,
32
+ UserCountryInformation,
33
+ Time,
34
+ TimeBound,
35
+ TagGraphRoot,
36
+ TagGraphRootName,
37
+ TagGraphNode,
38
+ GrapherInterface,
39
+ DimensionProperty,
40
+ GRAPHER_CHART_TYPES,
41
+ DbPlainTag,
42
+ AssetMap,
43
+ OwidGdocAboutInterface,
44
+ OwidGdocHomepageInterface,
45
+ PrimitiveType,
46
+ GrapherTrendArrowDirection,
47
+ TocHeadingWithTitleSupertitle,
48
+ ALL_CHARTS_ID,
49
+ FEATURED_DATA_INSIGHTS_ID,
50
+ EXPLORE_DATA_SECTION_DEFAULT_TITLE,
51
+ EXPLORE_DATA_SECTION_ID,
52
+ } from "../types/index.js"
53
+ import { PointVector } from "./PointVector.js"
54
+ import * as React from "react"
55
+ import { match, P } from "ts-pattern"
56
+ import urlSlug from "url-slug"
57
+
58
+ export type NoUndefinedValues<T> = {
59
+ [P in keyof T]: Required<NonNullable<T[P]>>
60
+ }
61
+
62
+ type OptionalKeysOf<T> = Exclude<
63
+ {
64
+ [K in keyof T]: T extends Record<K, T[K]> ? never : K
65
+ }[keyof T],
66
+ undefined
67
+ >
68
+
69
+ type AllowUndefinedValues<T> = {
70
+ [K in keyof T]: T[K] | undefined
71
+ }
72
+
73
+ /**
74
+ * This generic makes every (top-level) optional property in an interface required,
75
+ * but with `undefined` as an allowed value.
76
+ *
77
+ * For example:
78
+ * AllKeysRequired<{
79
+ * a: number
80
+ * b?: number
81
+ * }>
82
+ * becomes:
83
+ * {
84
+ * a: number
85
+ * b: number | undefined
86
+ * }
87
+ */
88
+ // This was tricky to construct.
89
+ // It seems like the initial, elegant approach is:
90
+ //
91
+ // export type AllKeysRequired<T> = {
92
+ // [K in keyof T]-?: T extends Record<K, T[K]> ? T[K] : T[K] | undefined
93
+ // }
94
+ //
95
+ // But TypeScript will omit `undefined` from the value type whenever you
96
+ // make the key required with `-?`. So we have this ugly workaround.
97
+ //
98
+ // -@danielgavrilov, 2022-02-15
99
+ export type AllKeysRequired<T> = AllowUndefinedValues<
100
+ Required<Pick<T, OptionalKeysOf<T>>>
101
+ > &
102
+ Exclude<T, OptionalKeysOf<T>>
103
+
104
+ export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
105
+
106
+ export type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
107
+
108
+ // doesn't do anything fancy, but makes it a bit more readable by skipping one layer of angle brackets:
109
+ // PartialRecord<A, B> = Partial<Record<A, B>>
110
+ export type PartialRecord<K extends keyof any, V> = Partial<Record<K, V>>
111
+
112
+ // d3 v6 changed the default minus sign used in d3-format to "−" (Unicode minus sign), which looks
113
+ // nicer but can cause issues when copy-pasting values into a spreadsheet or script.
114
+ // For that reason we change that back to a plain old hyphen.
115
+ // See https://observablehq.com/@d3/d3v6-migration-guide#minus
116
+ export const createFormatter = (
117
+ currency: string = "$"
118
+ ): FormatLocaleObject["format"] =>
119
+ formatLocale({
120
+ decimal: ".",
121
+ thousands: ",",
122
+ grouping: [3],
123
+ minus: "-",
124
+ currency: [currency, ""],
125
+ }).format
126
+
127
+ const getRootSVG = (
128
+ element: Element | SVGGraphicsElement | SVGSVGElement
129
+ ): SVGSVGElement | undefined => {
130
+ if ("createSVGPoint" in element) return element
131
+ if ("ownerSVGElement" in element)
132
+ return element.ownerSVGElement || undefined
133
+ return undefined
134
+ }
135
+
136
+ export const getRelativeMouse = (
137
+ node: Element | SVGGraphicsElement | SVGSVGElement,
138
+ event: React.TouchEvent | TouchEvent | { clientX: number; clientY: number }
139
+ ): PointVector => {
140
+ const eventOwner = checkIsTouchEvent(event) ? event.targetTouches[0] : event
141
+
142
+ const { clientX, clientY } = eventOwner
143
+
144
+ const svg = getRootSVG(node)
145
+ if (svg && "getScreenCTM" in node) {
146
+ const svgPoint = svg.createSVGPoint()
147
+ svgPoint.x = clientX
148
+ svgPoint.y = clientY
149
+ const point = svgPoint.matrixTransform(node.getScreenCTM()?.inverse())
150
+ return new PointVector(point.x, point.y)
151
+ }
152
+
153
+ const rect = node.getBoundingClientRect()
154
+ return new PointVector(
155
+ clientX - rect.left - node.clientLeft,
156
+ clientY - rect.top - node.clientTop
157
+ )
158
+ }
159
+
160
+ // Just a quick and dirty way to expose window.chart/explorer/etc for debugging. Last caller wins.
161
+ export const exposeInstanceOnWindow = (
162
+ component: unknown,
163
+ name = "chart",
164
+ alsoOnTopWindow?: boolean
165
+ ): void => {
166
+ if (typeof window === "undefined") return
167
+ const win = window as any
168
+ win[name] = component
169
+ if (alsoOnTopWindow && win !== win.top) win.top[name] = component
170
+ }
171
+
172
+ // Make an arbitrary string workable as a css class name
173
+ export const makeSafeForCSS = (name: string): string =>
174
+ name.replace(/[^a-z0-9]/g, (str) => {
175
+ const char = str.charCodeAt(0)
176
+ if (char === 32 || char === 45) return "-"
177
+ if (char === 95) return "_"
178
+ if (char >= 65 && char <= 90) return str
179
+ return "__" + ("000" + char.toString(16)).slice(-4)
180
+ })
181
+
182
+ function makeSafeForFigma(name: string): string {
183
+ return name.replace(/\s/g, "-")
184
+ }
185
+
186
+ /**
187
+ * Make a human-readable string meant to be be used as the ID of a SVG chart
188
+ * element. This is useful when a static chart is manually edited in a SVG
189
+ * editor since SVG manipulation software like Figma often show the element's
190
+ * id as its title.
191
+ *
192
+ * Note that these IDs are not meant to be used in CSS!
193
+ */
194
+ export function makeIdForHumanConsumption(
195
+ ...unsafeKeys: (string | undefined)[]
196
+ ): string {
197
+ return makeSafeForFigma(unsafeKeys.filter((key) => key).join("__"))
198
+ }
199
+
200
+ export function convertDaysSinceEpochToDate(dayAsYear: number): dayjs.Dayjs {
201
+ // Use dayjs' UTC mode
202
+ // This will force dayjs to format in UTC time instead of local time,
203
+ // making dates consistent no matter what timezone the user is in.
204
+ return dayjs.utc(EPOCH_DATE).add(dayAsYear, "days")
205
+ }
206
+
207
+ export function formatDay(
208
+ dayAsYear: number,
209
+ options?: { format?: string }
210
+ ): string {
211
+ const format = options?.format ?? "MMM D, YYYY"
212
+ return convertDaysSinceEpochToDate(dayAsYear).format(format)
213
+ }
214
+
215
+ export const formatYear = (year: number): string => {
216
+ if (isNaN(year)) {
217
+ console.warn(`Invalid year '${year}'`)
218
+ return ""
219
+ }
220
+
221
+ return year < 0
222
+ ? `${createFormatter()(",.0f")(Math.abs(year))} BCE`
223
+ : year.toString()
224
+ }
225
+
226
+ /**
227
+ * Computes the base-10 magnitude of a number, which can be useful for rounding by sigfigs etc.
228
+ * Formally, numberMagnitude computes `m` such that `10^(m-1) <= abs(num) < 10^m`.
229
+ * Equivalently, `num / 10^(numberMagnitude(num))` is always in the range ±[0.1, 1[.
230
+ *
231
+ * - numberMagnitude(0.5) = 0
232
+ * - numberMagnitude(1) = 1
233
+ * - numberMagnitude(-2) = 1
234
+ * - numberMagnitude(100) = 3
235
+ */
236
+ export const numberMagnitude = (num: number): number => {
237
+ if (num === 0) return 0
238
+ const magnitude = Math.floor(Math.log10(Math.abs(num))) + 1
239
+ return Number.isFinite(magnitude) ? magnitude : 0
240
+ }
241
+
242
+ // Turns every number except 0 to a number in the range [1, 9.9999] or [-9.9999, -1] for negative inputs
243
+ // Also returns the factor needed to un-normalise the value back to its original scale
244
+ // Turns 100 -> 1 (factor 100), 0.2 -> 2 (factor 0.1), -100 -> -1 (factor -100), 35 -> 3.5 (factor 10)
245
+ export const normaliseToSingleDigitNumber = (
246
+ num: number
247
+ ): { normalised: number; factor: number } => {
248
+ if (num === 0) return { normalised: 0, factor: 1 }
249
+ const magnitude = numberMagnitude(num)
250
+ const factor = Math.pow(10, magnitude - 1)
251
+
252
+ const normalised = num / factor
253
+ return { normalised, factor }
254
+ }
255
+
256
+ export const roundSigFig = (num: number, sigfigs: number = 1): number => {
257
+ if (num === 0) return 0
258
+ const magnitude = numberMagnitude(num)
259
+ return _.round(num, -magnitude + sigfigs)
260
+ }
261
+
262
+ export const excludeUndefined = <T>(arr: (T | undefined)[]): T[] =>
263
+ arr.filter((x) => x !== undefined) as T[]
264
+
265
+ export const excludeNull = <T>(arr: (T | null)[]): T[] =>
266
+ arr.filter((x) => x !== null) as T[]
267
+
268
+ export const excludeNullish = <T>(arr: (T | null | undefined | void)[]): T[] =>
269
+ arr.filter((x) => x !== null && x !== undefined) as T[]
270
+
271
+ export const firstOfNonEmptyArray = <T>(arr: T[]): T => {
272
+ if (arr.length < 1) throw new Error("array is empty")
273
+ return R.first(arr) as T
274
+ }
275
+
276
+ export const lastOfNonEmptyArray = <T>(arr: T[]): T => {
277
+ if (arr.length < 1) throw new Error("array is empty")
278
+ return R.last(arr) as T
279
+ }
280
+
281
+ export function next<T>(set: T[], current: T): T {
282
+ let nextIndex = set.indexOf(current) + 1
283
+ nextIndex = nextIndex === -1 ? 0 : nextIndex
284
+ return set[nextIndex === set.length ? 0 : nextIndex]
285
+ }
286
+
287
+ export const previous = <T>(set: T[], current: T): T => {
288
+ const nextIndex = set.indexOf(current) - 1
289
+ return set[nextIndex < 0 ? set.length - 1 : nextIndex]
290
+ }
291
+
292
+ // Calculate the extents of a set of numbers, with safeguards for log scales
293
+ export const domainExtent = (
294
+ numValues: number[],
295
+ scaleType: ScaleType,
296
+ maxValueMultiplierForPadding = 1
297
+ ): [number, number] | undefined => {
298
+ const filterValues =
299
+ scaleType === ScaleType.log ? numValues.filter((v) => v > 0) : numValues
300
+ const [minValue, maxValue] = extent(filterValues)
301
+
302
+ if (
303
+ minValue !== undefined &&
304
+ maxValue !== undefined &&
305
+ isFinite(minValue) &&
306
+ isFinite(maxValue)
307
+ ) {
308
+ if (minValue !== maxValue) {
309
+ return [minValue, maxValue * maxValueMultiplierForPadding]
310
+ } else {
311
+ // Only one value, make up a reasonable default
312
+ return scaleType === ScaleType.log
313
+ ? [minValue / 10, minValue * 10]
314
+ : [minValue - 1, maxValue + 1]
315
+ }
316
+ }
317
+
318
+ return undefined
319
+ }
320
+
321
+ // Compound annual growth rate
322
+ // cagr = ((new_value - old_value) ** (1 / Δt)) - 1
323
+ // see https://en.wikipedia.org/wiki/Compound_annual_growth_rate
324
+ export const cagr = (
325
+ startValue: number,
326
+ endValue: number,
327
+ yearsElapsed: number
328
+ ): number => {
329
+ const ratio = endValue / startValue
330
+ return (
331
+ Math.sign(ratio) *
332
+ (Math.pow(Math.abs(ratio), 1 / yearsElapsed) - 1) *
333
+ 100
334
+ )
335
+ }
336
+
337
+ export const makeAnnotationsSlug = (columnSlug: string): string =>
338
+ `${columnSlug}-annotations`
339
+
340
+ // Take an arbitrary string and turn it into a nice url slug
341
+ export const slugify = (str: string, allowSlashes?: boolean): string => {
342
+ // Convert subscript and superscript numbers to regular numbers
343
+ const normalizedStr = str.replace(/[₀₁₂₃₄₅₆₇₈₉⁰¹²³⁴⁵⁶⁷⁸⁹]/g, (match) => {
344
+ // Subscript characters (₀₁₂₃₄₅₆₇₈₉)
345
+ const subscriptMap: { [key: string]: string } = {
346
+ "₀": "0",
347
+ "₁": "1",
348
+ "₂": "2",
349
+ "₃": "3",
350
+ "₄": "4",
351
+ "₅": "5",
352
+ "₆": "6",
353
+ "₇": "7",
354
+ "₈": "8",
355
+ "₉": "9",
356
+ }
357
+ // Superscript characters (⁰¹²³⁴⁵⁶⁷⁸⁹)
358
+ const superscriptMap: { [key: string]: string } = {
359
+ "⁰": "0",
360
+ "¹": "1",
361
+ "²": "2",
362
+ "³": "3",
363
+ "⁴": "4",
364
+ "⁵": "5",
365
+ "⁶": "6",
366
+ "⁷": "7",
367
+ "⁸": "8",
368
+ "⁹": "9",
369
+ }
370
+ return subscriptMap[match] || superscriptMap[match] || match
371
+ })
372
+ return slugifySameCase(normalizedStr.toLowerCase(), allowSlashes)
373
+ }
374
+
375
+ export const slugifySameCase = (
376
+ str: string,
377
+ allowSlashes: boolean = false
378
+ ): string => {
379
+ let slug = str
380
+ .trim()
381
+ .replace(/\s*\*.+\*/, "")
382
+ .replace(/[^\w\- /]+/g, "")
383
+ .replace(/ +/g, "-")
384
+ if (!allowSlashes) {
385
+ slug = slug.replace(/\//g, "")
386
+ }
387
+ return slug
388
+ }
389
+
390
+ // Unique number for this execution context
391
+ // Useful for coordinating between embeds to avoid conflicts in their ids
392
+ let _guid = 0
393
+ let _guidsDisabledForTesting = false
394
+ export const guid = (): number => (_guidsDisabledForTesting ? 1 : ++_guid)
395
+ export const TESTING_ONLY_disable_guid = (): boolean =>
396
+ (_guidsDisabledForTesting = true)
397
+
398
+ // Take an array of points and make it into an SVG path specification string
399
+ export const pointsToPath = (points: Array<[number, number]>): string => {
400
+ let path = ""
401
+ for (let i = 0; i < points.length; i++) {
402
+ if (i === 0) path += `M${points[i][0]} ${points[i][1]}`
403
+ else path += `L${points[i][0]} ${points[i][1]}`
404
+ }
405
+ return path
406
+ }
407
+
408
+ // Based on https://stackoverflow.com/a/30245398/1983739
409
+ // In case of tie returns higher value
410
+ // todo: add unit tests
411
+ export const sortedFindClosestIndex = (
412
+ array: number[],
413
+ value: number,
414
+ startIndex: number = 0,
415
+ // non-inclusive end
416
+ endIndex: number = array.length
417
+ ): number => {
418
+ if (startIndex >= endIndex) return -1
419
+
420
+ if (value < array[startIndex]) return startIndex
421
+
422
+ if (value > array[endIndex - 1]) return endIndex - 1
423
+
424
+ let lo = startIndex
425
+ let hi = endIndex - 1
426
+
427
+ while (lo <= hi) {
428
+ const mid = Math.round((hi + lo) / 2)
429
+
430
+ if (value < array[mid]) {
431
+ hi = mid - 1
432
+ } else if (value > array[mid]) {
433
+ lo = mid + 1
434
+ } else {
435
+ return mid
436
+ }
437
+ }
438
+
439
+ // lo == hi + 1
440
+ return array[lo] - value < value - array[hi] ? lo : hi
441
+ }
442
+
443
+ export const sortedFindClosest = (
444
+ array: number[],
445
+ value: number,
446
+ startIndex?: number,
447
+ endIndex?: number
448
+ ): number | undefined => {
449
+ const index = sortedFindClosestIndex(array, value, startIndex, endIndex)
450
+ return index !== -1 ? array[index] : undefined
451
+ }
452
+
453
+ export const isMobile = (): boolean =>
454
+ typeof window === "undefined"
455
+ ? false
456
+ : !!window?.navigator?.userAgent.toLowerCase().includes("mobi")
457
+
458
+ export const isTouchDevice = (): boolean =>
459
+ typeof window === "undefined" ? false : !!("ontouchstart" in window)
460
+
461
+ // General type representing arbitrary json data; basically a non-nullable 'any'
462
+ export interface Json {
463
+ [x: string]: any
464
+ }
465
+
466
+ // Escape a function for storage in a csv cell
467
+ export const csvEscape = (value: unknown): string => {
468
+ const valueStr = _.toString(value)
469
+ return valueStr.includes(",")
470
+ ? `"${valueStr.replace(/"/g, '""')}"`
471
+ : valueStr
472
+ }
473
+
474
+ // Removes all undefineds from an object.
475
+ export const trimObject = <Obj>(
476
+ obj: Obj,
477
+ trimStringEmptyStrings = false
478
+ ): NoUndefinedValues<Obj> => {
479
+ const clone: any = {}
480
+ for (const key in obj) {
481
+ const val = obj[key] as any
482
+ if (_.isObject(val) && _.isEmpty(val)) {
483
+ // Drop empty objects
484
+ } else if (trimStringEmptyStrings && val === "") {
485
+ // ignore
486
+ } else if (val !== undefined) clone[key] = obj[key]
487
+ }
488
+ return clone
489
+ }
490
+
491
+ export const fetchText = async (url: string): Promise<string> => {
492
+ return await fetchWithRetry(url).then((res) => {
493
+ if (!res.ok)
494
+ throw new Error(`Fetch failed: ${res.status} ${res.statusText}`)
495
+ return res.text()
496
+ })
497
+ }
498
+
499
+ export async function fetchJson<TResult>(
500
+ url: string,
501
+ options?: { timeoutMs?: number }
502
+ ): Promise<TResult> {
503
+ const response =
504
+ options?.timeoutMs !== undefined
505
+ ? await fetchWithTimeout(url, options.timeoutMs)
506
+ : await fetch(url)
507
+
508
+ if (!response.ok) {
509
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
510
+ }
511
+
512
+ return response.json()
513
+ }
514
+
515
+ // Adapted from https://github.com/sindresorhus/ky/blob/main/source/utils/timeout.ts
516
+ export async function fetchWithTimeout(
517
+ url: string,
518
+ timeoutMs: number,
519
+ options?: RequestInit
520
+ ): Promise<Response> {
521
+ const abortController = new AbortController()
522
+
523
+ return new Promise((resolve, reject) => {
524
+ const timeoutId = setTimeout(() => {
525
+ abortController.abort()
526
+ reject(new Error(`Request timed out: ${url}`))
527
+ }, timeoutMs)
528
+
529
+ void fetch(url, { ...options, signal: abortController.signal })
530
+ .then(resolve)
531
+ .catch(reject)
532
+ .finally(() => clearTimeout(timeoutId))
533
+ })
534
+ }
535
+
536
+ const _getUserCountryInformation = async (): Promise<
537
+ UserCountryInformation | undefined
538
+ > =>
539
+ await fetchWithRetry("https://detect-country.owid.io")
540
+ .then((res) => res.json())
541
+ .then((res) => res.country)
542
+ .catch(() => undefined)
543
+
544
+ // Memoized because this will pretty much never change during a session.
545
+ // The memoization, however, also means that any failures will also be cached.
546
+ // This is okay currently, because currently this information is very much an optional nice-to-have.
547
+ export const getUserCountryInformation: () => Promise<
548
+ UserCountryInformation | undefined
549
+ > = _.memoize(_getUserCountryInformation)
550
+
551
+ export const stripHTML = (html: string): string => striptags(html)
552
+
553
+ // Math.rand doesn't have between nor seed. Lodash's Random doesn't take a seed, making it bad for testing.
554
+ // So we have our own *very* psuedo-RNG.
555
+ export const getRandomNumberGenerator =
556
+ (min: Integer = 0, max: Integer = 100, seed = Date.now()) =>
557
+ (): Integer => {
558
+ const semiRand = Math.sin(seed++) * 10000
559
+ return Math.floor(min + (max - min) * (semiRand - Math.floor(semiRand)))
560
+ }
561
+
562
+ export const sampleFrom = <T>(
563
+ collection: T[],
564
+ howMany: number,
565
+ seed: number
566
+ ): T[] => shuffleArray(collection, seed).slice(0, howMany)
567
+
568
+ // A seeded array shuffle
569
+ // https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
570
+ const shuffleArray = <T>(array: T[], seed = Date.now()): T[] => {
571
+ const rand = getRandomNumberGenerator(0, 100, seed)
572
+ const clonedArr = array.slice()
573
+ for (let index = clonedArr.length - 1; index > 0; index--) {
574
+ const replacerIndex = Math.floor((rand() / 100) * (index + 1))
575
+ ;[clonedArr[index], clonedArr[replacerIndex]] = [
576
+ clonedArr[replacerIndex],
577
+ clonedArr[index],
578
+ ]
579
+ }
580
+ return clonedArr
581
+ }
582
+
583
+ export const getIdealGridParams = ({
584
+ count,
585
+ containerAspectRatio,
586
+ idealAspectRatio = IDEAL_PLOT_ASPECT_RATIO,
587
+ }: {
588
+ count: number
589
+ containerAspectRatio: number
590
+ idealAspectRatio?: number
591
+ }): GridParameters => {
592
+ // See Observable notebook: https://observablehq.com/@danielgavrilov/pack-rectangles-of-a-preferred-aspect-ratio
593
+ // Also Desmos graph: https://www.desmos.com/calculator/tmajzuq5tm
594
+ const ratio = containerAspectRatio / idealAspectRatio
595
+ // Prefer vertical grid for count=2.
596
+ if (count === 2 && containerAspectRatio < 2.8)
597
+ return { rows: 2, columns: 1, count }
598
+ // Otherwise, optimize for closest to the ideal aspect ratio.
599
+ const initialColumns = Math.min(Math.round(Math.sqrt(count * ratio)), count)
600
+ const rows = Math.ceil(count / initialColumns)
601
+ // Remove extra columns if we can fit everything in fewer.
602
+ // This will result in wider aspect ratios than ideal, which is ok.
603
+ const columns = Math.ceil(count / rows)
604
+ return {
605
+ rows,
606
+ columns,
607
+ count,
608
+ }
609
+ }
610
+
611
+ export const findClosestTimeIndex = (
612
+ timesAsc: Time[],
613
+ targetTime: Time,
614
+ tolerance?: number // When not specified, the tolerance is infinite
615
+ ): Time | undefined => {
616
+ const closestIndex = R.sortedIndex(timesAsc, targetTime)
617
+
618
+ // This value is >= targetTime, or undefined in case there is no such value in the arr
619
+ const higherOrEqualVal = timesAsc.at(closestIndex)
620
+ if (higherOrEqualVal === targetTime) return closestIndex
621
+
622
+ // if tolerance is set to 0, and no exact match was found, return undefined
623
+ if (tolerance === 0) return undefined
624
+
625
+ // This value is < targetTime, or undefined in case there is no such value in the arr
626
+ const lowerVal = timesAsc[closestIndex - 1] as Time | undefined
627
+ const lowerDiff = lowerVal !== undefined ? targetTime - lowerVal : Infinity
628
+ const higherDiff =
629
+ higherOrEqualVal !== undefined
630
+ ? higherOrEqualVal - targetTime
631
+ : Infinity
632
+
633
+ if (lowerDiff === Infinity && higherDiff === Infinity) return undefined
634
+
635
+ // Prefer later times, e.g. if targetTime is 2010, prefer 2011 to 2009
636
+ if (higherDiff <= lowerDiff) {
637
+ if (tolerance !== undefined && higherDiff > tolerance) return undefined
638
+ return closestIndex
639
+ } else {
640
+ if (tolerance !== undefined && lowerDiff > tolerance) return undefined
641
+ return closestIndex - 1
642
+ }
643
+ }
644
+
645
+ export const isNegativeInfinity = (
646
+ timeBound: TimeBound
647
+ ): timeBound is TimeBoundValue => timeBound === TimeBoundValue.negativeInfinity
648
+
649
+ export const isPositiveInfinity = (
650
+ timeBound: TimeBound
651
+ ): timeBound is TimeBoundValue => timeBound === TimeBoundValue.positiveInfinity
652
+
653
+ export const findClosestTime = (
654
+ timesAsc: Time[],
655
+ targetTime: Time,
656
+ tolerance?: number
657
+ ): Time | undefined => {
658
+ if (isNegativeInfinity(targetTime)) return timesAsc.at(0)
659
+ if (isPositiveInfinity(targetTime)) return timesAsc.at(-1)
660
+ const index = findClosestTimeIndex(timesAsc, targetTime, tolerance)
661
+ return index !== undefined ? timesAsc[index] : undefined
662
+ }
663
+
664
+ // _.mapValues() equivalent for ES6 Maps
665
+ export const es6mapValues = <K, V, M>(
666
+ input: Map<K, V>,
667
+ mapper: (value: V, key: K) => M
668
+ ): Map<K, M> =>
669
+ new Map(
670
+ Array.from(input, ([key, value]) => {
671
+ return [key, mapper(value, key)]
672
+ })
673
+ )
674
+
675
+ export interface DataValue {
676
+ time: Time | undefined
677
+ value: number | string | undefined
678
+ }
679
+
680
+ const valuesAtTimes = (
681
+ valueByTime: Map<number, string | number>,
682
+ targetTimes: Time[],
683
+ tolerance = 0
684
+ ): { time: number | undefined; value: string | number | undefined }[] => {
685
+ const timesAsc = sortNumeric(Array.from(valueByTime.keys()))
686
+ return targetTimes.map((targetTime) => {
687
+ const time = findClosestTime(timesAsc, targetTime, tolerance)
688
+ const value = time === undefined ? undefined : valueByTime.get(time)
689
+ return {
690
+ time,
691
+ value,
692
+ }
693
+ })
694
+ }
695
+
696
+ export const valuesByEntityAtTimes = (
697
+ valueByEntityAndTime: Map<string, Map<number, string | number>>,
698
+ targetTimes: Time[],
699
+ tolerance = 0
700
+ ): Map<string, DataValue[]> =>
701
+ es6mapValues(valueByEntityAndTime, (valueByTime) =>
702
+ valuesAtTimes(valueByTime, targetTimes, tolerance)
703
+ )
704
+
705
+ const MS_PER_DAY = 1000 * 60 * 60 * 24
706
+
707
+ // From https://stackoverflow.com/a/15289883
708
+ export function dateDiffInDays(a: Date, b: Date): number {
709
+ // Discard the time and time-zone information.
710
+ const utca = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())
711
+ const utcb = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate())
712
+ return Math.floor((utca - utcb) / MS_PER_DAY)
713
+ }
714
+
715
+ export const diffDateISOStringInDays = (a: string, b: string): number =>
716
+ dayjs.utc(a).diff(dayjs.utc(b), "day")
717
+
718
+ export const getYearFromISOStringAndDayOffset = (
719
+ epoch: string,
720
+ daysOffset: number
721
+ ): number => {
722
+ const date = dayjs.utc(epoch).add(daysOffset, "day")
723
+ return date.year()
724
+ }
725
+
726
+ export const sleep = (ms: number): Promise<void> =>
727
+ new Promise((resolve) => setTimeout(resolve, ms))
728
+
729
+ interface RetryOptions {
730
+ maxRetries?: number
731
+ exponentialBackoff?: boolean
732
+ initialDelay?: number
733
+ }
734
+
735
+ export async function fetchWithRetry(
736
+ url: string,
737
+ fetchOptions?: RequestInit,
738
+ retryOptions?: RetryOptions
739
+ ): Promise<Response> {
740
+ const defaultRetryOptions: RetryOptions = {
741
+ maxRetries: 5,
742
+ exponentialBackoff: true,
743
+ initialDelay: 250,
744
+ }
745
+ return retryPromise(
746
+ () => fetch(url, fetchOptions),
747
+ retryOptions ?? defaultRetryOptions
748
+ )
749
+ }
750
+ export async function retryPromise<T>(
751
+ promiseGetter: () => Promise<T>,
752
+ {
753
+ maxRetries = 3,
754
+ exponentialBackoff = false,
755
+ initialDelay = 200,
756
+ }: RetryOptions = {}
757
+ ): Promise<T> {
758
+ let retried = 0
759
+ let lastError
760
+ let delay = initialDelay
761
+
762
+ while (retried++ < maxRetries) {
763
+ try {
764
+ return await promiseGetter()
765
+ } catch (error) {
766
+ lastError = error
767
+ if (exponentialBackoff && retried < maxRetries) {
768
+ await sleep(delay)
769
+ delay *= 2 // Double the delay for next retry
770
+ }
771
+ }
772
+ }
773
+ throw lastError
774
+ }
775
+
776
+ export function parseIntOrUndefined(s: string | undefined): number | undefined {
777
+ if (s === undefined) return undefined
778
+ const value = parseInt(s)
779
+ return isNaN(value) ? undefined : value
780
+ }
781
+
782
+ export function parseFloatOrUndefined(
783
+ s: string | undefined
784
+ ): number | undefined {
785
+ if (s === undefined) return undefined
786
+ const value = parseFloat(s)
787
+ return isNaN(value) ? undefined : value
788
+ }
789
+
790
+ export const anyToString = (value: unknown): string => {
791
+ if (typeof value === "undefined" || value === null) return ""
792
+ return String(value)
793
+ }
794
+
795
+ // Scroll Helpers
796
+ // Borrowed from: https://github.com/JedWatson/react-select/blob/32ad5c040b/packages/react-select/src/utils.js
797
+
798
+ function isDocumentElement(el: HTMLElement): boolean {
799
+ return [document.documentElement, document.body].indexOf(el) > -1
800
+ }
801
+
802
+ function scrollTo(el: HTMLElement, top: number): void {
803
+ // with a scroll distance, we perform scroll on the element
804
+ if (isDocumentElement(el)) {
805
+ window.scrollTo(0, top)
806
+ return
807
+ }
808
+
809
+ el.scrollTop = top
810
+ }
811
+
812
+ export function scrollIntoViewIfNeeded(
813
+ containerEl: HTMLElement,
814
+ focusedEl: HTMLElement
815
+ ): void {
816
+ const menuRect = containerEl.getBoundingClientRect()
817
+ const focusedRect = focusedEl.getBoundingClientRect()
818
+ const overScroll = focusedEl.offsetHeight / 3
819
+
820
+ if (focusedRect.bottom + overScroll > menuRect.bottom) {
821
+ scrollTo(
822
+ containerEl,
823
+ Math.min(
824
+ focusedEl.offsetTop +
825
+ focusedEl.clientHeight -
826
+ containerEl.offsetHeight +
827
+ overScroll,
828
+ containerEl.scrollHeight
829
+ )
830
+ )
831
+ } else if (focusedRect.top - overScroll < menuRect.top) {
832
+ scrollTo(containerEl, Math.max(focusedEl.offsetTop - overScroll, 0))
833
+ }
834
+ }
835
+
836
+ export function rollingMap<T, U>(array: T[], mapper: (a: T, b: T) => U): U[] {
837
+ const result: U[] = []
838
+ if (array.length <= 1) return result
839
+ for (let i = 0; i < array.length - 1; i++) {
840
+ result.push(mapper(array[i], array[i + 1]))
841
+ }
842
+ return result
843
+ }
844
+
845
+ export function keyMap<Key, Value>(
846
+ array: Value[],
847
+ accessor: (v: Value) => Key
848
+ ): Map<Key, Value> {
849
+ const result = new Map<Key, Value>()
850
+ array.forEach((item) => {
851
+ const key = accessor(item)
852
+ if (!result.has(key)) {
853
+ result.set(key, item)
854
+ }
855
+ })
856
+ return result
857
+ }
858
+
859
+ export const intersectionOfSets = <T>(sets: Set<T>[]): Set<T> => {
860
+ if (!sets.length) return new Set<T>()
861
+ const intersection = new Set<T>(sets[0])
862
+
863
+ sets.slice(1).forEach((set) => {
864
+ for (const elem of intersection) {
865
+ if (!set.has(elem)) {
866
+ intersection.delete(elem)
867
+ }
868
+ }
869
+ })
870
+ return intersection
871
+ }
872
+
873
+ export const differenceOfSets = <T>(sets: Set<T>[]): Set<T> => {
874
+ if (!sets.length) return new Set<T>()
875
+ const diff = new Set<T>(sets[0])
876
+
877
+ sets.slice(1).forEach((set) => {
878
+ for (const elem of set) {
879
+ diff.delete(elem)
880
+ }
881
+ })
882
+ return diff
883
+ }
884
+
885
+ export const areSetsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean =>
886
+ setA.size === setB.size && [...setA].every((value) => setB.has(value))
887
+
888
+ /** Tests whether the first argument is a strict subset of the second. The arguments do not have
889
+ to be sets yet, they can be any iterable. Sets will be created by the function internally */
890
+ export function isSubsetOf<T>(
891
+ subsetIter: Iterable<T>,
892
+ supersetIter: Iterable<T>
893
+ ): boolean {
894
+ const subset = new Set(subsetIter)
895
+ const superset = new Set(supersetIter)
896
+ return intersectionOfSets([subset, superset]).size === subset.size
897
+ }
898
+
899
+ // ES6 is now significantly faster than lodash's intersection
900
+ export const intersection = <T>(...arrs: T[][]): T[] => {
901
+ if (arrs.length === 0) return []
902
+ if (arrs.length === 1) return arrs[0]
903
+ if (arrs.length === 2) {
904
+ const set = new Set(arrs[0])
905
+ return arrs[1].filter((value) => set.has(value))
906
+ }
907
+ return intersection(arrs[0], intersection(...arrs.slice(1)))
908
+ }
909
+
910
+ export function sortByUndefinedLast<T>(
911
+ array: T[],
912
+ accessor: (t: T) => string | number | undefined,
913
+ order: SortOrder = SortOrder.asc
914
+ ): T[] {
915
+ const sorted = _.sortBy(array, (value) => {
916
+ const mapped = accessor(value)
917
+ if (mapped === undefined) {
918
+ return order === SortOrder.asc ? Infinity : -Infinity
919
+ }
920
+ return mapped
921
+ })
922
+ return order === SortOrder.asc ? sorted : sorted.reverse()
923
+ }
924
+
925
+ export const mapNullToUndefined = <T>(
926
+ array: (T | undefined | null)[]
927
+ ): (T | undefined)[] => array.map((v) => (v === null ? undefined : v))
928
+
929
+ export const lowerCaseFirstLetterUnlessAbbreviation = (str: string): string =>
930
+ str.charAt(1).match(/[A-Z]/)
931
+ ? str
932
+ : str.charAt(0).toLowerCase() + str.slice(1)
933
+
934
+ /**
935
+ * Use with caution - please note that this sort function only sorts on numeric data, and that sorts
936
+ * **in-place** and **not stable**.
937
+ * If you need a more general sort function that is stable and leaves the original array untouched,
938
+ * please use lodash's `sortBy` instead. This function is faster, though.
939
+ */
940
+ export const sortNumeric = <T>(
941
+ arr: T[],
942
+ sortByFn: (el: T) => number = _.identity,
943
+ sortOrder: SortOrder = SortOrder.asc
944
+ ): T[] =>
945
+ arr.sort(
946
+ sortOrder === SortOrder.asc
947
+ ? (a: T, b: T): number => sortByFn(a) - sortByFn(b)
948
+ : (a: T, b: T): number => sortByFn(b) - sortByFn(a)
949
+ )
950
+
951
+ export function getClosestTimePairs(
952
+ sortedTimesA: Time[],
953
+ sortedTimesB: Time[],
954
+ maxDiff: Integer = Infinity
955
+ ): [number, number][] {
956
+ if (sortedTimesA.length === 0 || sortedTimesB.length === 0) return []
957
+
958
+ const decidedPairs: [Time, Time][] = []
959
+ const undecidedPairs: [Time, Time][] = []
960
+
961
+ let indexB = 0
962
+
963
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
964
+ for (let indexA = 0; indexA < sortedTimesA.length; indexA++) {
965
+ const timeA = sortedTimesA[indexA]
966
+
967
+ const closestIndexInB = sortedFindClosestIndex(
968
+ sortedTimesB,
969
+ timeA,
970
+ indexB
971
+ )
972
+
973
+ /**
974
+ * the index that holds the value that is definitely lower than timeA, the candidate time
975
+ */
976
+ const lowCandidateIndexB =
977
+ sortedTimesB[closestIndexInB] < timeA
978
+ ? closestIndexInB
979
+ : closestIndexInB > indexB
980
+ ? closestIndexInB - 1
981
+ : undefined
982
+
983
+ /**
984
+ * the index that holds the value that is definitely equal to or greater than timeA, the candidate time
985
+ */
986
+ const highCandidateIndexB =
987
+ sortedTimesB[closestIndexInB] >= timeA ? closestIndexInB : undefined
988
+
989
+ if (
990
+ lowCandidateIndexB !== undefined &&
991
+ highCandidateIndexB !== undefined &&
992
+ timeA - sortedTimesB[lowCandidateIndexB] <= maxDiff &&
993
+ timeA - sortedTimesB[lowCandidateIndexB] <
994
+ sortedTimesB[highCandidateIndexB] - timeA
995
+ ) {
996
+ decidedPairs.push([timeA, sortedTimesB[lowCandidateIndexB]])
997
+ } else if (
998
+ highCandidateIndexB !== undefined &&
999
+ timeA === sortedTimesB[highCandidateIndexB]
1000
+ ) {
1001
+ decidedPairs.push([timeA, sortedTimesB[highCandidateIndexB]])
1002
+ } else {
1003
+ if (
1004
+ lowCandidateIndexB !== undefined &&
1005
+ timeA - sortedTimesB[lowCandidateIndexB] <= maxDiff
1006
+ ) {
1007
+ undecidedPairs.push([timeA, sortedTimesB[lowCandidateIndexB]])
1008
+ }
1009
+ if (
1010
+ highCandidateIndexB !== undefined &&
1011
+ sortedTimesB[highCandidateIndexB] - timeA <= maxDiff
1012
+ ) {
1013
+ undecidedPairs.push([timeA, sortedTimesB[highCandidateIndexB]])
1014
+ }
1015
+ }
1016
+
1017
+ indexB = closestIndexInB
1018
+ }
1019
+
1020
+ const seenTimes = new Set(decidedPairs.flat())
1021
+
1022
+ sortNumeric(undecidedPairs, (pair) => Math.abs(pair[0] - pair[1])).forEach(
1023
+ (pair) => {
1024
+ if (!seenTimes.has(pair[0]) && !seenTimes.has(pair[1])) {
1025
+ decidedPairs.push(pair)
1026
+ seenTimes.add(pair[0])
1027
+ seenTimes.add(pair[1])
1028
+ }
1029
+ }
1030
+ )
1031
+
1032
+ return decidedPairs
1033
+ }
1034
+
1035
+ export const omitUndefinedValues = <T>(object: T): NoUndefinedValues<T> => {
1036
+ const result: any = {}
1037
+ for (const key in object) {
1038
+ if (object[key] !== undefined) result[key] = object[key]
1039
+ }
1040
+ return result
1041
+ }
1042
+
1043
+ export function omitUndefinedValuesRecursive<T extends Record<string, any>>(
1044
+ obj: T
1045
+ ): NoUndefinedValues<T> {
1046
+ const result: any = {}
1047
+ for (const key in obj) {
1048
+ if (R.isPlainObject(obj[key])) {
1049
+ // re-apply the function if we encounter a non-empty object
1050
+ result[key] = omitUndefinedValuesRecursive(obj[key])
1051
+ } else if (obj[key] === undefined) {
1052
+ // omit undefined values
1053
+ } else {
1054
+ // otherwise, keep the value
1055
+ result[key] = obj[key]
1056
+ }
1057
+ }
1058
+ return result
1059
+ }
1060
+
1061
+ export function omitEmptyObjectsRecursive<T extends Record<string, any>>(
1062
+ obj: T
1063
+ ): Partial<T> {
1064
+ const result: any = {}
1065
+ for (const key in obj) {
1066
+ if (R.isPlainObject(obj[key])) {
1067
+ const isObjectEmpty = _.isEmpty(omitEmptyObjectsRecursive(obj[key]))
1068
+ if (!isObjectEmpty) result[key] = obj[key]
1069
+ } else {
1070
+ result[key] = obj[key]
1071
+ }
1072
+ }
1073
+ return result
1074
+ }
1075
+
1076
+ export const isInIFrame = (): boolean => {
1077
+ try {
1078
+ return window.self !== window.top
1079
+ } catch {
1080
+ return false
1081
+ }
1082
+ }
1083
+
1084
+ export const differenceObj = <A extends Record<string, unknown>>(
1085
+ obj: A,
1086
+ defaultObj: Record<string, unknown>
1087
+ ): Partial<A> => {
1088
+ const result: Partial<A> = {}
1089
+ for (const key in obj) {
1090
+ if (defaultObj[key] !== obj[key]) {
1091
+ result[key] = obj[key]
1092
+ }
1093
+ }
1094
+ return result
1095
+ }
1096
+
1097
+ export const findDOMParent = (
1098
+ el: HTMLElement,
1099
+ condition: (el: HTMLElement) => boolean
1100
+ ): HTMLElement | null => {
1101
+ let current: HTMLElement | null = el
1102
+ while (current) {
1103
+ if (condition(current)) return current
1104
+ current = current.parentElement
1105
+ }
1106
+ return null
1107
+ }
1108
+
1109
+ export const wrapInDiv = (el: Element, classes?: string[]): Element => {
1110
+ if (!el.parentNode) return el
1111
+ const wrapper = document.createElement("div")
1112
+ if (classes) wrapper.classList.add(...classes)
1113
+ el.parentNode.insertBefore(wrapper, el)
1114
+ wrapper.appendChild(el)
1115
+ return wrapper
1116
+ }
1117
+
1118
+ export const textAnchorFromAlign = (
1119
+ align: HorizontalAlign
1120
+ ): "start" | "middle" | "end" => {
1121
+ if (align === HorizontalAlign.center) return "middle"
1122
+ if (align === HorizontalAlign.right) return "end"
1123
+ return "start"
1124
+ }
1125
+
1126
+ export const dyFromAlign = (align: VerticalAlign): string => {
1127
+ if (align === VerticalAlign.middle) return ".32em"
1128
+ if (align === VerticalAlign.bottom) return ".71em"
1129
+ return "0"
1130
+ }
1131
+
1132
+ export function stringifyUnknownError(error: unknown): string | undefined {
1133
+ if (error === undefined || error === null) return undefined
1134
+ if (error instanceof Error) {
1135
+ return `${error.name}: ${error.message}`
1136
+ }
1137
+ if (typeof error === "function") {
1138
+ // Within this branch, `error` has type `Function`,
1139
+ // so we can access the function's `name` property
1140
+ const functionName = error.name || "(anonymous)"
1141
+ return `[function ${functionName}]`
1142
+ }
1143
+
1144
+ if (error instanceof Date) {
1145
+ // Within this branch, `error` has type `Date`,
1146
+ // so we can call the `toISOString` method
1147
+ return error.toISOString()
1148
+ }
1149
+
1150
+ if (typeof error === "object" && !Array.isArray(error) && error !== null) {
1151
+ if (Object.prototype.hasOwnProperty.call(error, "message")) {
1152
+ // Within this branch, `error` is an object with the `message`
1153
+ // property, so we can access the object's `message` property.
1154
+ return (error as any).message
1155
+ } else {
1156
+ // Otherwise, `error` is an object with an unknown structure, so
1157
+ // we stringify it.
1158
+ return JSON.stringify(error)
1159
+ }
1160
+ }
1161
+
1162
+ return String(error)
1163
+ }
1164
+
1165
+ /**
1166
+ * Turns a 2D array that is not necessarily rectangular into a rectangular array
1167
+ * by appending missing values and filling them with `fill`.
1168
+ */
1169
+ export function toRectangularMatrix<T, F>(arr: T[][], fill: F): (T | F)[][] {
1170
+ if (arr.length === 0) return []
1171
+ const width = _.max(arr.map((row) => row.length)) as number
1172
+
1173
+ return arr.map((row) => {
1174
+ if (row.length < width)
1175
+ return [...row, ...Array(width - row.length).fill(fill)]
1176
+ else return row
1177
+ })
1178
+ }
1179
+
1180
+ export function checkIsStringIndexable(
1181
+ x: unknown
1182
+ ): x is Record<string, unknown> {
1183
+ return R.isPlainObject(x) || R.isArray(x)
1184
+ }
1185
+
1186
+ export function checkIsTouchEvent(
1187
+ event: unknown
1188
+ ): event is React.TouchEvent | TouchEvent {
1189
+ if (_.isObject(event)) {
1190
+ return "targetTouches" in event
1191
+ }
1192
+ return false
1193
+ }
1194
+
1195
+ export const triggerDownloadFromBlob = (filename: string, blob: Blob): void => {
1196
+ const objectUrl = URL.createObjectURL(blob)
1197
+ triggerDownloadFromUrl(filename, objectUrl)
1198
+ URL.revokeObjectURL(objectUrl)
1199
+ }
1200
+
1201
+ export const triggerDownloadFromUrl = (filename: string, url: string): void => {
1202
+ const downloadLink = document.createElement("a")
1203
+ downloadLink.setAttribute("href", url)
1204
+ downloadLink.setAttribute("download", filename)
1205
+ downloadLink.click()
1206
+ }
1207
+
1208
+ export async function downloadImage(
1209
+ url: string,
1210
+ filename: string
1211
+ ): Promise<void> {
1212
+ const response = await fetch(url)
1213
+ const blob = await response.blob()
1214
+ triggerDownloadFromBlob(filename, blob)
1215
+ }
1216
+
1217
+ export const removeAllWhitespace = (text: string): string => {
1218
+ return text.replace(/\s+|\n/g, "")
1219
+ }
1220
+
1221
+ export function moveArrayItemToIndex<Item>(
1222
+ arr: Item[],
1223
+ fromIndex: number,
1224
+ toIndex: number
1225
+ ): Item[] {
1226
+ const newArray = Array.from(arr)
1227
+ const [removed] = newArray.splice(fromIndex, 1)
1228
+ newArray.splice(toIndex, 0, removed)
1229
+ return newArray
1230
+ }
1231
+
1232
+ export const getIndexableKeys = Object.keys as <T extends object>(
1233
+ obj: T
1234
+ ) => Array<keyof T>
1235
+
1236
+ /** Formats a date like this: "October 10, 2024"
1237
+ */
1238
+ export const formatDate = (date: Date): string => {
1239
+ return date.toLocaleDateString("en-US", {
1240
+ year: "numeric",
1241
+ month: "long",
1242
+ day: "2-digit",
1243
+ })
1244
+ }
1245
+
1246
+ /**
1247
+ *
1248
+ * Parses a gdoc article JSON with non-primitive types (Date)
1249
+ *
1250
+ * Note: if dates could also be found deeper in the JSON, it could make sense to
1251
+ * write a custom JSON parser to handle that automatically for all keys. At this
1252
+ * stage, the manual approach is probably simpler.
1253
+ */
1254
+ export const getOwidGdocFromJSON = (json: OwidGdocJSON): OwidGdoc => {
1255
+ return {
1256
+ ...json,
1257
+ createdAt: new Date(json.createdAt),
1258
+ publishedAt: json.publishedAt ? new Date(json.publishedAt) : null,
1259
+ updatedAt: json.updatedAt ? new Date(json.updatedAt) : null,
1260
+ }
1261
+ }
1262
+
1263
+ // We want to infer the return type from the existing types instead of having to
1264
+ // manually specify it.
1265
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1266
+ export function extractGdocPageData(gdoc: OwidGdoc) {
1267
+ // Generic properties every gdoc has
1268
+ const gdocProps = R.pick(gdoc, [
1269
+ "id",
1270
+ "slug",
1271
+ "content",
1272
+ "contentMd5",
1273
+ "createdAt",
1274
+ "updatedAt",
1275
+ "published",
1276
+ "publishedAt",
1277
+ "breadcrumbs",
1278
+ "manualBreadcrumbs",
1279
+ "tags",
1280
+ ])
1281
+
1282
+ // Also generic properties. A separate function call because R.pick can only take so many arguments before TS complains
1283
+ const attachmentProps = R.pick(gdoc, [
1284
+ "linkedAuthors",
1285
+ "linkedDocuments",
1286
+ "linkedStaticViz",
1287
+ "linkedCharts",
1288
+ "linkedNarrativeCharts",
1289
+ "linkedIndicators",
1290
+ "imageMetadata",
1291
+ "relatedCharts",
1292
+ ])
1293
+
1294
+ const commonProps = {
1295
+ ...gdocProps,
1296
+ ...attachmentProps,
1297
+ }
1298
+
1299
+ return match(gdoc)
1300
+ .when(checkIsAboutPage, (aboutGdoc) => {
1301
+ return {
1302
+ ...commonProps,
1303
+ ...R.pick(aboutGdoc, ["donors"]),
1304
+ }
1305
+ })
1306
+ .when(checkIsHomepage, (homepageGdoc) => {
1307
+ return {
1308
+ ...commonProps,
1309
+ ...R.pick(homepageGdoc, [
1310
+ "homepageMetadata",
1311
+ "latestDataInsights",
1312
+ ]),
1313
+ }
1314
+ })
1315
+ .when(checkIsDataInsight, (dataInsightGdoc) => {
1316
+ return {
1317
+ ...commonProps,
1318
+ ...R.pick(dataInsightGdoc, ["latestDataInsights"]),
1319
+ }
1320
+ })
1321
+ .when(checkIsAuthor, (authorGdoc) => {
1322
+ return {
1323
+ ...commonProps,
1324
+ ...R.pick(authorGdoc, ["latestWorkLinks"]),
1325
+ }
1326
+ })
1327
+ .otherwise(() => commonProps)
1328
+ }
1329
+
1330
+ export type OwidGdocPageProps = ReturnType<typeof extractGdocPageData>
1331
+
1332
+ export type OwidGdocPageData = Omit<
1333
+ OwidGdocPageProps,
1334
+ "createdAt" | "publishedAt" | "updatedAt"
1335
+ > & {
1336
+ createdAt: string
1337
+ publishedAt: string | null
1338
+ updatedAt: string | null
1339
+ }
1340
+
1341
+ export function deserializeOwidGdocPageData(
1342
+ json: OwidGdocPageData
1343
+ ): OwidGdocPageProps {
1344
+ // NOTE: We have to do manual type casting around the content.type property
1345
+ // because it can be undefined in OwidGdocPostContent. That makes sense
1346
+ // during the gdoc creation, where we do manual validation for various
1347
+ // properties. But at some point we should only pass around a valid gdoc
1348
+ // where content.type can't be undefined anymore. So we should likely create
1349
+ // a new type for that use case and use the less strict type only until we
1350
+ // do the validation.
1351
+ return {
1352
+ ...json,
1353
+ createdAt: new Date(json.createdAt),
1354
+ publishedAt: json.publishedAt ? new Date(json.publishedAt) : null,
1355
+ updatedAt: json.updatedAt ? new Date(json.updatedAt) : null,
1356
+ } as OwidGdocPageProps
1357
+ }
1358
+
1359
+ // Checking whether we have clipboard write access is surprisingly complicated.
1360
+ // For example, if a chart is embedded in an iframe, then Chrome will prevent the
1361
+ // use of clipboard.writeText() unless the iframe has allow="clipboard-write".
1362
+ // On the other hand, Firefox and Safari haven't implemented the Permissions API
1363
+ // for "clipboard-write", so we need to handle that case gracefully.
1364
+ // See https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API#browser_compatibility
1365
+ export const canWriteToClipboard = async (): Promise<boolean> => {
1366
+ if (!("clipboard" in navigator)) return false
1367
+
1368
+ if ("permissions" in navigator) {
1369
+ // Is Permissions API implemented?
1370
+
1371
+ try {
1372
+ // clipboard-write permission is not supported in all browsers - need to catch that case
1373
+ const res = await navigator.permissions.query({
1374
+ name: "clipboard-write" as PermissionName,
1375
+ })
1376
+
1377
+ // Asking permission was successful, we may use clipboard-write methods if permission wasn't denied.
1378
+ return ["granted", "prompt"].includes(res.state)
1379
+ } catch {
1380
+ // ignore
1381
+ }
1382
+ }
1383
+ // navigator.clipboard is available, but we couldn't check for permissions -- assume we can use it.
1384
+ return true
1385
+ }
1386
+
1387
+ /** Function to copy to clipboard. This uses the new Clipboard API if it is available.
1388
+ */
1389
+ export async function copyToClipboard(text: string): Promise<boolean> {
1390
+ const useModernClipboardApi = await canWriteToClipboard()
1391
+ if (useModernClipboardApi) {
1392
+ // We can use the new clipboard API
1393
+ return navigator.clipboard
1394
+ .writeText(text)
1395
+ .then(() => true)
1396
+ .catch((err) => {
1397
+ console.error("Failed to copy text to clipboard", err)
1398
+ return false
1399
+ })
1400
+ } else {
1401
+ // GPT 4 suggested attempt to work around the lack of clipboard API
1402
+ const textarea = document.createElement("textarea")
1403
+ textarea.value = text
1404
+ textarea.style.position = "fixed"
1405
+ textarea.style.opacity = "0"
1406
+ document.body.appendChild(textarea)
1407
+ textarea.focus()
1408
+ textarea.select()
1409
+
1410
+ try {
1411
+ return document.execCommand("copy")
1412
+ } catch (err) {
1413
+ console.error("Failed to copy text to clipboard", err)
1414
+ return false
1415
+ } finally {
1416
+ document.body.removeChild(textarea)
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ // Memoization for immutable getters. Run the function once for this instance and cache the result.
1422
+ export const imemo = <Type, This extends Record<string, any>>(
1423
+ target: () => Type,
1424
+ context: ClassGetterDecoratorContext<This, Type>
1425
+ ) => {
1426
+ const { name } = context
1427
+ const propName = `${String(name)}_memoized`
1428
+
1429
+ return function (this: This): Type {
1430
+ if (propName in this) {
1431
+ return this[propName]
1432
+ }
1433
+ const value = target.call(this)
1434
+ Object.defineProperty(this, propName, {
1435
+ configurable: false,
1436
+ enumerable: false,
1437
+ writable: false,
1438
+ value,
1439
+ })
1440
+ return value
1441
+ }
1442
+ }
1443
+
1444
+ // A decorator to log the evaluation time of a class method or class getter.
1445
+ export function logPerf(
1446
+ target: ((...args: any[]) => any) | ClassAccessorDecoratorTarget<any, any>,
1447
+ context:
1448
+ | ClassMethodDecoratorContext
1449
+ | ClassAccessorDecoratorContext
1450
+ | ClassGetterDecoratorContext
1451
+ ): ((...args: any[]) => any) | ClassAccessorDecoratorResult<any, any> {
1452
+ const { name, kind } = context
1453
+ const propertyKey = String(name)
1454
+
1455
+ const logPerfWrapper = (fn: () => unknown, ...log: unknown[]): unknown => {
1456
+ // eslint-disable-next-line no-console
1457
+ console.log("⏱︎▶️ logging", propertyKey, ...log)
1458
+ const start = performance.now()
1459
+ const result = fn()
1460
+ const end = performance.now()
1461
+ // eslint-disable-next-line no-console
1462
+ console.log(
1463
+ `⏱︎🏁 Perf: ${propertyKey} took ${(end - start).toFixed(3)}ms`
1464
+ )
1465
+ return result
1466
+ }
1467
+
1468
+ if (kind === "method") {
1469
+ const methodTarget = target as (...args: any[]) => any
1470
+ return function (this: any, ...args: unknown[]): unknown {
1471
+ return logPerfWrapper(() => methodTarget.apply(this, args), args)
1472
+ }
1473
+ } else if (kind === "getter") {
1474
+ const getterTarget = target as () => any
1475
+ return function (this: any): unknown {
1476
+ return logPerfWrapper(() => getterTarget.call(this))
1477
+ }
1478
+ } else if (kind === "accessor") {
1479
+ const accessorTarget = target as ClassAccessorDecoratorTarget<any, any>
1480
+ return {
1481
+ get(this: any): unknown {
1482
+ return logPerfWrapper(() => accessorTarget.get.call(this))
1483
+ },
1484
+ set: accessorTarget.set,
1485
+ }
1486
+ }
1487
+
1488
+ return target
1489
+ }
1490
+
1491
+ // A decorator to bind methods to the class instance, ensuring 'this' is always correct
1492
+ export function bind<This, Args extends any[], Return>(
1493
+ target: (this: This, ...args: Args) => Return,
1494
+ context: ClassMethodDecoratorContext<
1495
+ This,
1496
+ (this: This, ...args: Args) => Return
1497
+ >
1498
+ ): (this: This, ...args: Args) => Return {
1499
+ const { name } = context
1500
+
1501
+ context.addInitializer(function (this: This) {
1502
+ const boundMethod = target.bind(this)
1503
+ // Store the bound method on the instance
1504
+ Object.defineProperty(this, name as string | symbol, {
1505
+ value: boundMethod,
1506
+ writable: false,
1507
+ enumerable: false,
1508
+ configurable: true,
1509
+ })
1510
+ })
1511
+
1512
+ return target
1513
+ }
1514
+
1515
+ // These are all the types that we need to be able to iterate through to extract their URLs/filenames.
1516
+ // It's more than just the EnrichedBlocks and Spans, because some EnrichedBlocks have nested children
1517
+ // that contain URLs/filenames
1518
+ export type NodeWithUrl =
1519
+ | OwidEnrichedGdocBlock
1520
+ | Span
1521
+ | EnrichedHybridLink
1522
+ | EnrichedTopicPageIntroRelatedTopic
1523
+ | EnrichedTopicPageIntroDownloadButton
1524
+ | EnrichedBlockKeyInsightsSlide
1525
+
1526
+ export function recursivelyMapArticleContent(
1527
+ node: NodeWithUrl,
1528
+ callback: (node: NodeWithUrl) => NodeWithUrl
1529
+ ): NodeWithUrl {
1530
+ if (checkNodeIsSpan(node)) {
1531
+ if ("children" in node) {
1532
+ node.children.map((node) =>
1533
+ recursivelyMapArticleContent(node, callback)
1534
+ )
1535
+ }
1536
+ } else if (node.type === "gray-section") {
1537
+ node.items.map((block) => recursivelyMapArticleContent(block, callback))
1538
+ } else if (node.type === "conditional-section") {
1539
+ node.content.map((block) =>
1540
+ recursivelyMapArticleContent(block, callback)
1541
+ )
1542
+ } else if (
1543
+ node.type === "sticky-left" ||
1544
+ node.type === "sticky-right" ||
1545
+ node.type === "side-by-side"
1546
+ ) {
1547
+ node.left.map((node) => recursivelyMapArticleContent(node, callback))
1548
+ node.right.map((node) => recursivelyMapArticleContent(node, callback))
1549
+ } else if (node.type === "text") {
1550
+ node.value.map((node) => recursivelyMapArticleContent(node, callback))
1551
+ } else if (node.type === "additional-charts") {
1552
+ node.items.map((spans) =>
1553
+ spans.map((span) => recursivelyMapArticleContent(span, callback))
1554
+ )
1555
+ } else if (node.type === "chart-story") {
1556
+ node.items.map((item) =>
1557
+ recursivelyMapArticleContent(item.chart, callback)
1558
+ )
1559
+ } else if (node.type === "recirc") {
1560
+ node.links.map((link) => callback(link))
1561
+ } else if (node.type === "topic-page-intro") {
1562
+ const { downloadButton, relatedTopics, content } = node
1563
+ if (downloadButton) callback(downloadButton)
1564
+ if (relatedTopics) relatedTopics.forEach(callback)
1565
+ content.forEach(callback)
1566
+ } else if (node.type === "key-insights") {
1567
+ node.insights.forEach((insight) => {
1568
+ callback(insight)
1569
+ insight.content.forEach(callback)
1570
+ })
1571
+ }
1572
+
1573
+ return callback(node)
1574
+ }
1575
+
1576
+ export function traverseEnrichedSpan(
1577
+ span: Span,
1578
+ callback: (x: Span) => void
1579
+ ): void {
1580
+ match(span)
1581
+ .with({ children: P.any }, (span) => {
1582
+ callback(span)
1583
+ span.children.forEach((child) =>
1584
+ traverseEnrichedSpan(child, callback)
1585
+ )
1586
+ })
1587
+ .with({ spanType: "span-simple-text" }, (simpleSpan) => {
1588
+ callback(simpleSpan)
1589
+ })
1590
+ .with({ spanType: "span-newline" }, (newlineSpan) => {
1591
+ callback(newlineSpan)
1592
+ })
1593
+ .exhaustive()
1594
+ }
1595
+
1596
+ // If your node is a OwidEnrichedGdocBlock, the callback will apply to it
1597
+ // If your node has children that are Spans, the spanCallback will apply to them
1598
+ // If your node has children that aren't OwidEnrichedGdocBlocks or Spans
1599
+ // you'll have to handle those children yourself in your callback
1600
+ export function traverseEnrichedBlock(
1601
+ node: OwidEnrichedGdocBlock,
1602
+ callback: (x: OwidEnrichedGdocBlock) => void,
1603
+ spanCallback?: (x: Span) => void
1604
+ ): void {
1605
+ match(node)
1606
+ .with(
1607
+ { type: P.union("sticky-right", "sticky-left", "side-by-side") },
1608
+ (container) => {
1609
+ callback(container)
1610
+ container.left.forEach((leftNode) =>
1611
+ traverseEnrichedBlock(leftNode, callback, spanCallback)
1612
+ )
1613
+ container.right.forEach((rightNode) =>
1614
+ traverseEnrichedBlock(rightNode, callback, spanCallback)
1615
+ )
1616
+ }
1617
+ )
1618
+ .with({ type: "gray-section" }, (graySection) => {
1619
+ callback(graySection)
1620
+ graySection.items.forEach((node) =>
1621
+ traverseEnrichedBlock(node, callback, spanCallback)
1622
+ )
1623
+ })
1624
+ .with({ type: "explore-data-section" }, (exploreDataSection) => {
1625
+ callback(exploreDataSection)
1626
+ exploreDataSection.content.forEach((node) =>
1627
+ traverseEnrichedBlock(node, callback, spanCallback)
1628
+ )
1629
+ })
1630
+ .with({ type: "conditional-section" }, (conditional) => {
1631
+ callback(conditional)
1632
+ conditional.content.forEach((node) =>
1633
+ traverseEnrichedBlock(node, callback, spanCallback)
1634
+ )
1635
+ })
1636
+ .with({ type: "key-insights" }, (keyInsights) => {
1637
+ callback(keyInsights)
1638
+ keyInsights.insights.forEach((insight) =>
1639
+ insight.content.forEach((node) =>
1640
+ traverseEnrichedBlock(node, callback, spanCallback)
1641
+ )
1642
+ )
1643
+ })
1644
+ .with({ type: "expander" }, (expander) => {
1645
+ callback(expander)
1646
+ expander.content.forEach((block) =>
1647
+ traverseEnrichedBlock(block, callback, spanCallback)
1648
+ )
1649
+ })
1650
+ .with({ type: "callout" }, (callout) => {
1651
+ callback(callout)
1652
+ if (spanCallback) {
1653
+ callout.text.forEach((textBlock) =>
1654
+ traverseEnrichedBlock(textBlock, callback, spanCallback)
1655
+ )
1656
+ }
1657
+ })
1658
+ .with({ type: "aside" }, (aside) => {
1659
+ callback(aside)
1660
+ if (spanCallback) {
1661
+ aside.caption.forEach((span) =>
1662
+ traverseEnrichedSpan(span, spanCallback)
1663
+ )
1664
+ }
1665
+ })
1666
+ .with({ type: "list" }, (list) => {
1667
+ callback(list)
1668
+ if (spanCallback) {
1669
+ list.items.forEach((textBlock) =>
1670
+ traverseEnrichedBlock(textBlock, callback, spanCallback)
1671
+ )
1672
+ }
1673
+ })
1674
+ .with({ type: "numbered-list" }, (numberedList) => {
1675
+ callback(numberedList)
1676
+ if (spanCallback) {
1677
+ numberedList.items.forEach((textBlock) =>
1678
+ traverseEnrichedBlock(textBlock, callback, spanCallback)
1679
+ )
1680
+ }
1681
+ })
1682
+ .with({ type: "text" }, (textNode) => {
1683
+ callback(textNode)
1684
+ if (spanCallback) {
1685
+ textNode.value.forEach((span) => {
1686
+ traverseEnrichedSpan(span, spanCallback)
1687
+ })
1688
+ }
1689
+ })
1690
+ .with({ type: "simple-text" }, (simpleTextNode) => {
1691
+ if (spanCallback) {
1692
+ spanCallback(simpleTextNode.value)
1693
+ }
1694
+ })
1695
+ .with({ type: "additional-charts" }, (additionalCharts) => {
1696
+ callback(additionalCharts)
1697
+ if (spanCallback) {
1698
+ additionalCharts.items.forEach((spans) => {
1699
+ spans.forEach((span) =>
1700
+ traverseEnrichedSpan(span, spanCallback)
1701
+ )
1702
+ })
1703
+ }
1704
+ })
1705
+ .with({ type: "heading" }, (heading) => {
1706
+ callback(heading)
1707
+ if (spanCallback) {
1708
+ heading.text.forEach((span) => {
1709
+ traverseEnrichedSpan(span, spanCallback)
1710
+ })
1711
+ }
1712
+ })
1713
+ .with({ type: "expandable-paragraph" }, (expandableParagraph) => {
1714
+ callback(expandableParagraph)
1715
+ expandableParagraph.items.forEach((textBlock) => {
1716
+ traverseEnrichedBlock(textBlock, callback, spanCallback)
1717
+ })
1718
+ })
1719
+ .with({ type: "guided-chart" }, (guidedChart) => {
1720
+ callback(guidedChart)
1721
+ guidedChart.content.forEach((block) => {
1722
+ traverseEnrichedBlock(block, callback, spanCallback)
1723
+ })
1724
+ })
1725
+ .with({ type: "align" }, (align) => {
1726
+ callback(align)
1727
+ align.content.forEach((node) => {
1728
+ traverseEnrichedBlock(node, callback, spanCallback)
1729
+ })
1730
+ })
1731
+ .with({ type: "table" }, (table) => {
1732
+ callback(table)
1733
+ table.rows.forEach((row) => {
1734
+ row.cells.forEach((cell) => {
1735
+ cell.content.forEach((node) => {
1736
+ traverseEnrichedBlock(node, callback, spanCallback)
1737
+ })
1738
+ })
1739
+ })
1740
+ })
1741
+ .with({ type: "blockquote" }, (blockquote) => {
1742
+ callback(blockquote)
1743
+ blockquote.text.forEach((node) => {
1744
+ traverseEnrichedBlock(node, callback, spanCallback)
1745
+ })
1746
+ })
1747
+ .with(
1748
+ {
1749
+ type: "key-indicator",
1750
+ },
1751
+ (keyIndicator) => {
1752
+ callback(keyIndicator)
1753
+ keyIndicator.text.forEach((node) => {
1754
+ traverseEnrichedBlock(node, callback, spanCallback)
1755
+ })
1756
+ }
1757
+ )
1758
+ .with(
1759
+ { type: "key-indicator-collection" },
1760
+ (keyIndicatorCollection) => {
1761
+ callback(keyIndicatorCollection)
1762
+ keyIndicatorCollection.blocks.forEach((node) =>
1763
+ traverseEnrichedBlock(node, callback, spanCallback)
1764
+ )
1765
+ }
1766
+ )
1767
+ .with({ type: "people" }, (people) => {
1768
+ callback(people)
1769
+ for (const item of people.items) {
1770
+ traverseEnrichedBlock(item, callback, spanCallback)
1771
+ }
1772
+ })
1773
+ .with({ type: "people-rows" }, (peopleRows) => {
1774
+ callback(peopleRows)
1775
+ for (const person of peopleRows.people) {
1776
+ traverseEnrichedBlock(person, callback, spanCallback)
1777
+ }
1778
+ })
1779
+ .with({ type: "person" }, (person) => {
1780
+ callback(person)
1781
+ for (const node of person.text) {
1782
+ traverseEnrichedBlock(node, callback, spanCallback)
1783
+ }
1784
+ })
1785
+ .with(
1786
+ {
1787
+ type: P.union(
1788
+ "chart-story",
1789
+ "chart",
1790
+ "narrative-chart",
1791
+ "code",
1792
+ "cookie-notice",
1793
+ "cta",
1794
+ "donors",
1795
+ "horizontal-rule",
1796
+ "html",
1797
+ "script",
1798
+ "image",
1799
+ "video",
1800
+ "missing-data",
1801
+ "prominent-link",
1802
+ "pull-quote",
1803
+ "recirc",
1804
+ "subscribe-banner",
1805
+ "resource-panel",
1806
+ "research-and-writing",
1807
+ "sdg-grid",
1808
+ "sdg-toc",
1809
+ "ltp-toc",
1810
+ "topic-page-intro",
1811
+ "all-charts",
1812
+ "entry-summary",
1813
+ "explorer-tiles",
1814
+ "pill-row",
1815
+ "homepage-search",
1816
+ "homepage-intro",
1817
+ "featured-metrics",
1818
+ "featured-data-insights",
1819
+ "latest-data-insights",
1820
+ "socials",
1821
+ "static-viz"
1822
+ ),
1823
+ },
1824
+ callback
1825
+ )
1826
+ .exhaustive()
1827
+ }
1828
+
1829
+ export function checkNodeIsSpan(node: NodeWithUrl): node is Span {
1830
+ return "spanType" in node
1831
+ }
1832
+
1833
+ export function spansToUnformattedPlainText(spans: Span[]): string {
1834
+ return spans
1835
+ .map((span) =>
1836
+ match(span)
1837
+ .with({ spanType: "span-simple-text" }, (span) => span.text)
1838
+ .with(
1839
+ {
1840
+ spanType: P.union(
1841
+ "span-link",
1842
+ "span-italic",
1843
+ "span-bold",
1844
+ "span-fallback",
1845
+ "span-quote",
1846
+ "span-superscript",
1847
+ "span-subscript",
1848
+ "span-underline",
1849
+ "span-ref",
1850
+ "span-dod",
1851
+ "span-guided-chart-link"
1852
+ ),
1853
+ },
1854
+ (span) => spansToUnformattedPlainText(span.children)
1855
+ )
1856
+ .with({ spanType: "span-newline" }, () => "")
1857
+ .exhaustive()
1858
+ )
1859
+ .join("")
1860
+ }
1861
+
1862
+ export function generateToc(
1863
+ body: OwidEnrichedGdocBlock[] | undefined,
1864
+ isTocForLinearTopicPage: boolean = false
1865
+ ): TocHeadingWithTitleSupertitle[] {
1866
+ if (!body) return []
1867
+
1868
+ // For linear topic pages, we record only h1s
1869
+ // For the sdg-toc, we record h2s & h3s (as it was developed before we decided to use h1s as our top level heading)
1870
+ // It would be nice to standardise this but it would require a migration, updating CSS, updating Gdocs, etc.
1871
+ const [primary, secondary] = isTocForLinearTopicPage
1872
+ ? [1, undefined]
1873
+ : [2, 3]
1874
+ const toc: TocHeadingWithTitleSupertitle[] = []
1875
+
1876
+ body.forEach((block) =>
1877
+ traverseEnrichedBlock(block, (child) => {
1878
+ if (child.type === "heading") {
1879
+ const { level, text, supertitle } = child
1880
+ const titleString = spansToUnformattedPlainText(text)
1881
+ const supertitleString = supertitle
1882
+ ? spansToUnformattedPlainText(supertitle)
1883
+ : ""
1884
+ if (titleString && (level === primary || level === secondary)) {
1885
+ toc.push({
1886
+ title: titleString,
1887
+ supertitle: supertitleString,
1888
+ text: titleString,
1889
+ slug: urlSlug(`${supertitleString} ${titleString}`),
1890
+ isSubheading: level === secondary,
1891
+ })
1892
+ }
1893
+ }
1894
+ if (!isTocForLinearTopicPage) return
1895
+
1896
+ if (child.type === "all-charts") {
1897
+ toc.push({
1898
+ title: child.heading,
1899
+ text: child.heading,
1900
+ slug: ALL_CHARTS_ID,
1901
+ isSubheading: false,
1902
+ })
1903
+ return
1904
+ }
1905
+
1906
+ if (child.type === "featured-data-insights") {
1907
+ const title = "Data insights"
1908
+ toc.push({
1909
+ title,
1910
+ text: title,
1911
+ slug: FEATURED_DATA_INSIGHTS_ID,
1912
+ isSubheading: false,
1913
+ })
1914
+ return
1915
+ }
1916
+
1917
+ if (child.type === "explore-data-section") {
1918
+ const title = child.title || EXPLORE_DATA_SECTION_DEFAULT_TITLE
1919
+ toc.push({
1920
+ title,
1921
+ text: title,
1922
+ slug: EXPLORE_DATA_SECTION_ID,
1923
+ isSubheading: false,
1924
+ })
1925
+ return
1926
+ }
1927
+ })
1928
+ )
1929
+
1930
+ return toc
1931
+ }
1932
+
1933
+ export function checkIsOwidGdocType(
1934
+ gdocType: unknown
1935
+ ): gdocType is OwidGdocType {
1936
+ return Object.values(OwidGdocType).includes(gdocType as any)
1937
+ }
1938
+
1939
+ export function isArrayOfNumbers(arr: unknown[]): arr is number[] {
1940
+ return arr.every((item) => typeof item === "number")
1941
+ }
1942
+
1943
+ export function greatestCommonDivisor(a: number, b: number): number {
1944
+ if (a === 0) return Math.abs(b)
1945
+ return greatestCommonDivisor(b % a, a)
1946
+ }
1947
+
1948
+ export function findGreatestCommonDivisorOfArray(arr: number[]): number | null {
1949
+ if (arr.length === 0) return null
1950
+ if (arr.includes(1)) return 1
1951
+ return _.uniq(arr).reduce((acc, num) => greatestCommonDivisor(acc, num))
1952
+ }
1953
+ export function lowercaseObjectKeys(
1954
+ obj: Record<string, unknown>
1955
+ ): Record<string, unknown> {
1956
+ return Object.fromEntries(
1957
+ Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value])
1958
+ )
1959
+ }
1960
+
1961
+ /** Works for:
1962
+ * #dod:text
1963
+ * #dod:text-hyphenated
1964
+ * #dod:text_underscored
1965
+ * #dod:text_underscored-and-hyphenated
1966
+ * Duplicated in parser.ts
1967
+ */
1968
+ export const detailOnDemandRegex = /#dod:([\w\-_]+)/
1969
+
1970
+ export const guidedChartRegex = /#guide:(https?:\/\/[^\s]+)/
1971
+
1972
+ export function extractDetailsFromSyntax(str: string): string[] {
1973
+ return [...str.matchAll(new RegExp(detailOnDemandRegex, "g"))].map(
1974
+ ([_, term]) => term
1975
+ )
1976
+ }
1977
+
1978
+ /**
1979
+ * If you're using this type guard, make sure you're okay with Fragments
1980
+ * See https://github.com/owid/owid-grapher/issues/3426
1981
+ */
1982
+ export function checkIsGdocPost(x: unknown): x is OwidGdocPostInterface {
1983
+ const type = _.get(x, "content.type") as OwidGdocType | undefined
1984
+ return [
1985
+ OwidGdocType.Article,
1986
+ OwidGdocType.TopicPage,
1987
+ OwidGdocType.LinearTopicPage,
1988
+ OwidGdocType.Fragment,
1989
+ OwidGdocType.AboutPage,
1990
+ ].includes(type as any)
1991
+ }
1992
+
1993
+ /**
1994
+ * Fragments were developed before we had a robust gdoc type system in place
1995
+ * Use this function when you want to be sure you're dealing with published editorial content
1996
+ * and not just content that has the right shape
1997
+ * See https://github.com/owid/owid-grapher/issues/3426
1998
+ */
1999
+ export function checkIsGdocPostExcludingFragments(
2000
+ x: unknown
2001
+ ): x is OwidGdocPostInterface {
2002
+ const type = _.get(x, "content.type") as OwidGdocType | undefined
2003
+ return [
2004
+ OwidGdocType.Article,
2005
+ OwidGdocType.TopicPage,
2006
+ OwidGdocType.LinearTopicPage,
2007
+ OwidGdocType.AboutPage,
2008
+ ].includes(type as any)
2009
+ }
2010
+
2011
+ export function checkIsDataInsight(
2012
+ gdoc: OwidGdoc
2013
+ ): gdoc is OwidGdocDataInsightInterface {
2014
+ return gdoc.content.type === OwidGdocType.DataInsight
2015
+ }
2016
+
2017
+ export function checkIsAboutPage(
2018
+ gdoc: OwidGdoc
2019
+ ): gdoc is OwidGdocAboutInterface {
2020
+ return gdoc.content.type === OwidGdocType.AboutPage
2021
+ }
2022
+
2023
+ export function checkIsAuthor(gdoc: OwidGdoc): gdoc is OwidGdocAuthorInterface {
2024
+ return gdoc.content.type === OwidGdocType.Author
2025
+ }
2026
+
2027
+ export function checkIsHomepage(
2028
+ gdoc: OwidGdoc
2029
+ ): gdoc is OwidGdocHomepageInterface {
2030
+ return gdoc.content.type === OwidGdocType.Homepage
2031
+ }
2032
+
2033
+ /**
2034
+ * Returns the cartesian product of the given arrays.
2035
+ *
2036
+ * For example, `cartesian([["a", "b"], ["x", "y"]])` returns `[["a", "x"], ["a", "y"], ["b", "x"], ["b", "y"]]`
2037
+ */
2038
+ export function cartesian<T>(matrix: T[][]): T[][] {
2039
+ if (matrix.length === 0) return []
2040
+ if (matrix.length === 1) return matrix[0].map((i) => [i])
2041
+ return matrix.reduce<T[][]>(
2042
+ (acc, curr) => acc.flatMap((i) => curr.map((j) => [...i, j])),
2043
+ [[]]
2044
+ )
2045
+ }
2046
+
2047
+ // Remove any parenthetical content from _the end_ of a string
2048
+ // E.g. "Africa (UN)" -> "Africa"
2049
+ export function removeTrailingParenthetical(str: string): string {
2050
+ return str.replace(/\s*\(.*\)$/, "")
2051
+ }
2052
+
2053
+ const commafyFormatter = lazy(() => new Intl.NumberFormat("en-US"))
2054
+ /**
2055
+ * Example: 12000 -> "12,000"
2056
+ */
2057
+ export function commafyNumber(value: number): string {
2058
+ return commafyFormatter().format(value)
2059
+ }
2060
+
2061
+ export function isFiniteWithGuard(value: unknown): value is number {
2062
+ return isFinite(value as any)
2063
+ }
2064
+
2065
+ /**
2066
+ * Collapse all paths to topic tags into a single array of unique parent tag
2067
+ * names, including the original tags if they are topics. This is used across
2068
+ * all Algolia indexing utilities to ensure comprehensive search results when
2069
+ * faceting by topic.
2070
+ *
2071
+ * Use with getTagHierarchiesByChildName to get the topic hierarchies
2072
+ *
2073
+ */
2074
+ export const getUniqueNamesFromTagHierarchies = (
2075
+ tagNames: string[],
2076
+ tagHierarchiesByChildName: Record<
2077
+ string,
2078
+ Pick<DbPlainTag, "id" | "name" | "slug">[][]
2079
+ >
2080
+ ): string[] => {
2081
+ return R.unique(
2082
+ tagNames.flatMap((tagName) =>
2083
+ (tagHierarchiesByChildName[tagName] ?? []) // fallback for non-topic tags
2084
+ .flatMap((tagHierarchy) => tagHierarchy.map((tag) => tag.name))
2085
+ )
2086
+ )
2087
+ }
2088
+
2089
+ export function createTagGraph(
2090
+ tagGraphByParentId: Record<number, any>,
2091
+ rootId: number
2092
+ ): TagGraphRoot {
2093
+ const tagGraph: TagGraphRoot = {
2094
+ id: rootId,
2095
+ name: TagGraphRootName,
2096
+ slug: null,
2097
+ isTopic: false,
2098
+ path: [rootId],
2099
+ weight: 0,
2100
+ children: [],
2101
+ }
2102
+
2103
+ function recursivelySetChildren(node: TagGraphNode): TagGraphNode {
2104
+ const children = tagGraphByParentId[node.id]
2105
+ if (!children) return node
2106
+
2107
+ for (const child of children) {
2108
+ const childNode: TagGraphNode = {
2109
+ id: child.childId,
2110
+ path: [...node.path, child.childId],
2111
+ name: child.name,
2112
+ slug: child.slug,
2113
+ isTopic: child.isTopic,
2114
+ weight: child.weight,
2115
+ children: [],
2116
+ }
2117
+
2118
+ node.children.push(recursivelySetChildren(childNode))
2119
+ }
2120
+ return node
2121
+ }
2122
+
2123
+ return recursivelySetChildren(tagGraph) as TagGraphRoot
2124
+ }
2125
+
2126
+ export const getAllChildrenOfArea = (area: TagGraphNode): TagGraphNode[] => {
2127
+ const children = []
2128
+ for (const child of area.children) {
2129
+ children.push(child)
2130
+ children.push(...getAllChildrenOfArea(child))
2131
+ }
2132
+ return children
2133
+ }
2134
+
2135
+ /**
2136
+ * topicTagGraph.json includes sub-areas: non-topic tags that have topic children
2137
+ * e.g. "Health" is an area, "Life & Death" is a sub-area, and "Life Expectancy" is a topic,
2138
+ * This function flattens the graph by removing sub-areas and moving their children up to the area level
2139
+ * e.g. "Life Expectancy" becomes a child of "Health" instead of "Life & Death"
2140
+ * We need this because the all-topics section on the homepage renders sub-areas, but the site nav doesn't
2141
+ * Note that topics can have children (e.g. "Air Pollution" is a topic, and "Indoor Air Pollution" is a sub-topic)
2142
+ * Such cases are not flattened here, but in the frontend with getAllChildrenOfArea
2143
+ */
2144
+ export function flattenNonTopicNodes(tagGraph: TagGraphRoot): TagGraphRoot {
2145
+ const flattenNodes = (nodes: TagGraphNode[]): TagGraphNode[] =>
2146
+ nodes.flatMap((node) =>
2147
+ !node.isTopic && node.children.length
2148
+ ? flattenNodes(node.children)
2149
+ : node.isTopic
2150
+ ? [{ ...node, children: flattenNodes(node.children) }]
2151
+ : []
2152
+ )
2153
+
2154
+ return {
2155
+ ...tagGraph,
2156
+ children: tagGraph.children.map((area) => ({
2157
+ ...area,
2158
+ children: flattenNodes(area.children),
2159
+ })),
2160
+ }
2161
+ }
2162
+
2163
+ export function formatInlineList(
2164
+ array: unknown[],
2165
+ connector: "and" | "or" = "and"
2166
+ ): string {
2167
+ if (array.length === 0) return ""
2168
+ if (array.length === 1) return `${array[0]}`
2169
+ return `${array.slice(0, -1).join(", ")} ${connector} ${R.last(array)}`
2170
+ }
2171
+
2172
+ // The below comment marks this function as side-effect free, meaning that the bundler
2173
+ // can safely remove it if it is not used.
2174
+ // This is useful for e.g. constants that are only used in some parts of the codebase.
2175
+ // See https://rollupjs.org/configuration-options/#no-side-effects
2176
+ // @__NO_SIDE_EFFECTS__
2177
+ // Other than that, this function is like lodash's once, in that it'll run fn at most once
2178
+ // and then save the result for future calls.
2179
+ export function lazy<T>(fn: () => T): () => T {
2180
+ let hasRun = false
2181
+ let _value: T
2182
+ return () => {
2183
+ if (!hasRun) {
2184
+ _value = fn()
2185
+ hasRun = true
2186
+ }
2187
+ return _value
2188
+ }
2189
+ }
2190
+
2191
+ export function traverseObjects<T extends Record<string, any>>(
2192
+ obj: T,
2193
+ ref: Record<string, any>,
2194
+ cb: (objValue: unknown, refValue: unknown, key: string) => unknown
2195
+ ): Partial<T> {
2196
+ const result: any = {}
2197
+ for (const key in obj) {
2198
+ if (R.isPlainObject(obj[key]) && R.isPlainObject(ref[key])) {
2199
+ result[key] = traverseObjects(obj[key], ref[key], cb)
2200
+ } else {
2201
+ result[key] = cb(obj[key], ref[key], key)
2202
+ }
2203
+ }
2204
+ return result
2205
+ }
2206
+
2207
+ export function getParentVariableIdFromChartConfig(
2208
+ config: GrapherInterface
2209
+ ): number | undefined {
2210
+ const { chartTypes, dimensions } = config
2211
+
2212
+ const chartType = chartTypes?.[0] ?? GRAPHER_CHART_TYPES.LineChart
2213
+ if (chartType === GRAPHER_CHART_TYPES.ScatterPlot) return undefined
2214
+ if (!dimensions) return undefined
2215
+
2216
+ const yVariableIds = dimensions
2217
+ .filter((d) => d.property === DimensionProperty.y)
2218
+ .map((d) => d.variableId)
2219
+
2220
+ if (yVariableIds.length !== 1) return undefined
2221
+
2222
+ return yVariableIds[0]
2223
+ }
2224
+
2225
+ export function extractLinksFromMarkdown(markdown: string): [string, string][] {
2226
+ return [...markdown.matchAll(/\[(.*?)\]\((.*?)\)/g)].map((match) => [
2227
+ match[1],
2228
+ match[2],
2229
+ ])
2230
+ }
2231
+
2232
+ export function getPaginationPageNumbers(
2233
+ currentPageNumber: number,
2234
+ totalPageCount: number,
2235
+ size: number = 5
2236
+ ): number[] {
2237
+ let start = Math.max(1, currentPageNumber - Math.floor(size / 2))
2238
+
2239
+ if (start + size > totalPageCount) {
2240
+ start = Math.max(1, totalPageCount - size + 1)
2241
+ }
2242
+
2243
+ const pageNumbers = []
2244
+
2245
+ for (let i = start; i <= Math.min(start + size - 1, totalPageCount); i++) {
2246
+ pageNumbers.push(i)
2247
+ }
2248
+
2249
+ return pageNumbers
2250
+ }
2251
+
2252
+ /**
2253
+ * Checks for content equality, but doesn't care about the order of elements.
2254
+ *
2255
+ * For example, `isArrayDifferentFromReference([1, 2], [2, 1])` returns `false`.
2256
+ */
2257
+ export function isArrayDifferentFromReference<T>(
2258
+ array: T[],
2259
+ referenceArray: T[]
2260
+ ): boolean {
2261
+ if (array.length !== referenceArray.length) return true
2262
+ return _.difference(array, referenceArray).length > 0
2263
+ }
2264
+
2265
+ // When reading from an asset map, we want a very particular behavior:
2266
+ // If the asset map is entirely undefined, then we want to just fail silently and return the fallback.
2267
+ // If the asset map is defined but the asset is not found, however, then we want to throw an error.
2268
+ // This is to avoid invisible errors that'll lead to runtime errors or 404s.
2269
+
2270
+ export function readFromAssetMap(
2271
+ assetMap: AssetMap | undefined,
2272
+ { path, fallback }: { path: string; fallback: string }
2273
+ ): string
2274
+
2275
+ export function readFromAssetMap(
2276
+ assetMap: AssetMap | undefined,
2277
+ { path, fallback }: { path: string; fallback?: string }
2278
+ ): string | undefined
2279
+
2280
+ export function readFromAssetMap(
2281
+ assetMap: AssetMap | undefined,
2282
+ { path, fallback }: { path: string; fallback?: string }
2283
+ ): string | undefined {
2284
+ if (!assetMap) return fallback
2285
+
2286
+ const assetValue = assetMap[path]
2287
+ if (assetValue === undefined)
2288
+ throw new Error(`Entry for asset not found in asset map: ${path}`)
2289
+ return assetValue
2290
+ }
2291
+
2292
+ export const getUserNavigatorLanguages = (): readonly string[] => {
2293
+ return navigator.languages ?? [navigator.language]
2294
+ }
2295
+
2296
+ export const getUserNavigatorLanguagesNonEnglish = (): readonly string[] => {
2297
+ return getUserNavigatorLanguages().filter((lang) => !lang.startsWith("en"))
2298
+ }
2299
+
2300
+ /**
2301
+ * Merge multiple objects into a single object.
2302
+ * Arrays are overwritten completely instead of merged.
2303
+ */
2304
+ export const merge: typeof _.merge = (
2305
+ ...objects: Parameters<typeof _.merge>
2306
+ ) => {
2307
+ return _.mergeWith(
2308
+ {}, // merge mutates the first argument
2309
+ ...objects,
2310
+ // Overwrite arrays completely instead of merging them.
2311
+ // Otherwise fall back to the default merge behavior.
2312
+ (_: unknown, srcValue: unknown) => {
2313
+ return Array.isArray(srcValue) ? srcValue : undefined
2314
+ }
2315
+ )
2316
+ }
2317
+
2318
+ export function calculateTrendDirection(
2319
+ startValue?: PrimitiveType,
2320
+ endValue?: PrimitiveType
2321
+ ): GrapherTrendArrowDirection | undefined {
2322
+ if (typeof startValue !== "number" || typeof endValue !== "number")
2323
+ return undefined
2324
+ return endValue > startValue
2325
+ ? "up"
2326
+ : endValue < startValue
2327
+ ? "down"
2328
+ : "right"
2329
+ }
2330
+
2331
+ /**
2332
+ * Removes a single pair of outer parentheses from a string, if present.
2333
+ *
2334
+ * For example:
2335
+ * "(example)" => "example"
2336
+ * "no parentheses" => "no parentheses"
2337
+ * "(example (with inner))" => "example (with inner)"
2338
+ *
2339
+ * Leading and trailing whitespace is trimmed before checking for parentheses.
2340
+ */
2341
+ export function stripOuterParentheses(input: string): string {
2342
+ return input.trim().replace(/^\((.*)\)$/, "$1")
2343
+ }
2344
+
2345
+ export function getDisplayUnit(
2346
+ column: { unit?: string; shortUnit?: string },
2347
+ { allowTrivial = false }: { allowTrivial?: boolean } = {}
2348
+ ): string | undefined {
2349
+ if (!column.unit) return undefined
2350
+
2351
+ // The unit is considered trivial if it is the same as the short unit
2352
+ const isTrivial = column.unit === column.shortUnit
2353
+ const unit = allowTrivial || !isTrivial ? column.unit : undefined
2354
+
2355
+ // Remove parentheses from the beginning and end of the unit
2356
+ const strippedUnit = unit ? stripOuterParentheses(unit) : undefined
2357
+
2358
+ return strippedUnit
2359
+ }
2360
+
2361
+ export function dimensionsToViewId(
2362
+ dimensions: Record<string, string> // Keys: dimension slugs, values: choice slugs
2363
+ ): string {
2364
+ return Object.entries(dimensions)
2365
+ .sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
2366
+ .map(([_, value]) => slugify(value))
2367
+ .join("__")
2368
+ .toLowerCase()
2369
+ }