@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.
- package/LICENSE.md +8 -0
- package/README.md +113 -0
- package/package.json +137 -0
- package/src/components/BodyPortal/BodyPortal.tsx +40 -0
- package/src/components/Button/Button.scss +110 -0
- package/src/components/Button/Button.tsx +101 -0
- package/src/components/Checkbox.scss +93 -0
- package/src/components/Checkbox.tsx +47 -0
- package/src/components/ExpandableToggle/ExpandableToggle.scss +123 -0
- package/src/components/ExpandableToggle/ExpandableToggle.tsx +60 -0
- package/src/components/GrapherTabIcon.tsx +156 -0
- package/src/components/GrapherTrendArrow.scss +16 -0
- package/src/components/GrapherTrendArrow.tsx +30 -0
- package/src/components/Halo/Halo.tsx +44 -0
- package/src/components/LabeledSwitch/LabeledSwitch.scss +109 -0
- package/src/components/LabeledSwitch/LabeledSwitch.tsx +62 -0
- package/src/components/MarkdownTextWrap/MarkdownTextWrap.tsx +1173 -0
- package/src/components/OverlayHeader.scss +18 -0
- package/src/components/OverlayHeader.tsx +29 -0
- package/src/components/RadioButton.scss +69 -0
- package/src/components/RadioButton.tsx +42 -0
- package/src/components/SimpleMarkdownText.tsx +89 -0
- package/src/components/TextInput.scss +17 -0
- package/src/components/TextInput.tsx +19 -0
- package/src/components/TextWrap/TextWrap.tsx +361 -0
- package/src/components/TextWrap/TextWrapUtils.ts +32 -0
- package/src/components/closeButton/CloseButton.scss +40 -0
- package/src/components/closeButton/CloseButton.tsx +27 -0
- package/src/components/index.ts +70 -0
- package/src/components/loadingIndicator/LoadingIndicator.scss +40 -0
- package/src/components/loadingIndicator/LoadingIndicator.tsx +28 -0
- package/src/components/markdown/remarkPlainLinks.ts +36 -0
- package/src/components/reactUtil.ts +20 -0
- package/src/components/stubs/CodeSnippet.tsx +19 -0
- package/src/components/stubs/DataCitation.tsx +16 -0
- package/src/components/stubs/IndicatorKeyData.tsx +45 -0
- package/src/components/stubs/IndicatorProcessing.tsx +15 -0
- package/src/components/stubs/IndicatorSources.tsx +15 -0
- package/src/components/styles/colors.scss +113 -0
- package/src/components/styles/mixins.scss +630 -0
- package/src/components/styles/typography.scss +579 -0
- package/src/components/styles/util.scss +89 -0
- package/src/components/styles/variables.scss +208 -0
- package/src/config/ChartsConfig.ts +163 -0
- package/src/config/ChartsProvider.tsx +157 -0
- package/src/config/index.ts +20 -0
- package/src/core-table/CoreTable.ts +1355 -0
- package/src/core-table/CoreTableColumns.ts +973 -0
- package/src/core-table/CoreTableUtils.ts +793 -0
- package/src/core-table/ErrorValues.ts +73 -0
- package/src/core-table/OwidTable.ts +1175 -0
- package/src/core-table/OwidTableSynthesizers.ts +272 -0
- package/src/core-table/OwidTableUtil.ts +76 -0
- package/src/core-table/Transforms.ts +484 -0
- package/src/core-table/index.ts +82 -0
- package/src/explorer/ColumnGrammar.ts +217 -0
- package/src/explorer/Explorer.sample.ts +212 -0
- package/src/explorer/Explorer.scss +148 -0
- package/src/explorer/Explorer.tsx +1283 -0
- package/src/explorer/ExplorerConstants.ts +85 -0
- package/src/explorer/ExplorerControls.scss +156 -0
- package/src/explorer/ExplorerControls.tsx +210 -0
- package/src/explorer/ExplorerDecisionMatrix.ts +471 -0
- package/src/explorer/ExplorerGrammar.ts +161 -0
- package/src/explorer/ExplorerProgram.ts +568 -0
- package/src/explorer/ExplorerUtils.ts +59 -0
- package/src/explorer/GrapherGrammar.ts +387 -0
- package/src/explorer/gridLang/GrammarUtils.ts +121 -0
- package/src/explorer/gridLang/GridCell.ts +298 -0
- package/src/explorer/gridLang/GridLangConstants.ts +255 -0
- package/src/explorer/gridLang/GridProgram.ts +311 -0
- package/src/explorer/gridLang/readme.md +17 -0
- package/src/explorer/index.ts +69 -0
- package/src/explorer/readme.md +19 -0
- package/src/explorer/urlMigrations/CO2UrlMigration.ts +46 -0
- package/src/explorer/urlMigrations/CovidUrlMigration.ts +37 -0
- package/src/explorer/urlMigrations/EnergyUrlMigration.ts +41 -0
- package/src/explorer/urlMigrations/ExplorerPageUrlMigrationSpec.ts +12 -0
- package/src/explorer/urlMigrations/ExplorerUrlMigrationUtils.ts +45 -0
- package/src/explorer/urlMigrations/ExplorerUrlMigrations.ts +33 -0
- package/src/explorer/urlMigrations/LegacyCovidUrlMigration.ts +144 -0
- package/src/explorer/urlMigrations/readme.md +39 -0
- package/src/grapher/axis/Axis.ts +973 -0
- package/src/grapher/axis/AxisConfig.ts +179 -0
- package/src/grapher/axis/AxisViews.tsx +597 -0
- package/src/grapher/barCharts/DiscreteBarChart.tsx +728 -0
- package/src/grapher/barCharts/DiscreteBarChartConstants.ts +60 -0
- package/src/grapher/barCharts/DiscreteBarChartHelpers.ts +338 -0
- package/src/grapher/barCharts/DiscreteBarChartState.ts +354 -0
- package/src/grapher/barCharts/DiscreteBarChartThumbnail.tsx +34 -0
- package/src/grapher/captionedChart/CaptionedChart.scss +61 -0
- package/src/grapher/captionedChart/CaptionedChart.tsx +523 -0
- package/src/grapher/captionedChart/Logos.tsx +141 -0
- package/src/grapher/captionedChart/LogosSVG.tsx +16 -0
- package/src/grapher/captionedChart/StaticChartRasterizer.tsx +178 -0
- package/src/grapher/captionedChart/assets/buildcanada-logo-square.svg +15 -0
- package/src/grapher/captionedChart/assets/buildcanada-logo.svg +15 -0
- package/src/grapher/captionedChart/assets/canadaspends.svg +7 -0
- package/src/grapher/captionedChart/readme.md +14 -0
- package/src/grapher/chart/Chart.tsx +62 -0
- package/src/grapher/chart/ChartAreaContent.tsx +172 -0
- package/src/grapher/chart/ChartDimension.ts +121 -0
- package/src/grapher/chart/ChartInterface.ts +83 -0
- package/src/grapher/chart/ChartManager.ts +113 -0
- package/src/grapher/chart/ChartTabs.ts +178 -0
- package/src/grapher/chart/ChartTypeMap.tsx +158 -0
- package/src/grapher/chart/ChartTypeSwitcher.tsx +26 -0
- package/src/grapher/chart/ChartUtils.tsx +364 -0
- package/src/grapher/chart/DimensionSlot.ts +45 -0
- package/src/grapher/chart/StaticChartWrapper.tsx +94 -0
- package/src/grapher/chart/guidedChartUtils.ts +82 -0
- package/src/grapher/color/BinningStrategies.ts +484 -0
- package/src/grapher/color/BinningStrategyEqualSizeBins.ts +132 -0
- package/src/grapher/color/BinningStrategyLogarithmic.ts +121 -0
- package/src/grapher/color/CategoricalColorAssigner.ts +97 -0
- package/src/grapher/color/ColorBrewerSchemes.ts +80 -0
- package/src/grapher/color/ColorConstants.ts +20 -0
- package/src/grapher/color/ColorScale.ts +339 -0
- package/src/grapher/color/ColorScaleBin.ts +147 -0
- package/src/grapher/color/ColorScaleConfig.ts +204 -0
- package/src/grapher/color/ColorScheme.ts +137 -0
- package/src/grapher/color/ColorSchemes.ts +149 -0
- package/src/grapher/color/ColorUtils.ts +86 -0
- package/src/grapher/color/CustomSchemes.ts +1772 -0
- package/src/grapher/color/readme.md +84 -0
- package/src/grapher/comparisonLine/ComparisonLine.tsx +31 -0
- package/src/grapher/comparisonLine/ComparisonLineConstants.ts +11 -0
- package/src/grapher/comparisonLine/ComparisonLineGenerator.ts +60 -0
- package/src/grapher/comparisonLine/ComparisonLineHelpers.ts +10 -0
- package/src/grapher/comparisonLine/CustomComparisonLine.tsx +159 -0
- package/src/grapher/comparisonLine/VerticalComparisonLine.tsx +208 -0
- package/src/grapher/controls/ActionButtons.scss +97 -0
- package/src/grapher/controls/ActionButtons.tsx +453 -0
- package/src/grapher/controls/CommandPalette.scss +50 -0
- package/src/grapher/controls/CommandPalette.tsx +74 -0
- package/src/grapher/controls/ContentSwitchers.scss +93 -0
- package/src/grapher/controls/ContentSwitchers.tsx +238 -0
- package/src/grapher/controls/Controls.scss +158 -0
- package/src/grapher/controls/DataTableFilterDropdown.scss +7 -0
- package/src/grapher/controls/DataTableFilterDropdown.tsx +168 -0
- package/src/grapher/controls/DataTableSearchField.scss +3 -0
- package/src/grapher/controls/DataTableSearchField.tsx +76 -0
- package/src/grapher/controls/Dropdown.scss +252 -0
- package/src/grapher/controls/Dropdown.tsx +235 -0
- package/src/grapher/controls/EntitySelectionToggle.tsx +135 -0
- package/src/grapher/controls/MapRegionDropdown.scss +3 -0
- package/src/grapher/controls/MapRegionDropdown.tsx +104 -0
- package/src/grapher/controls/MapResetButton.tsx +115 -0
- package/src/grapher/controls/MapZoomDropdown.scss +9 -0
- package/src/grapher/controls/MapZoomDropdown.tsx +270 -0
- package/src/grapher/controls/MapZoomToSelectionButton.tsx +87 -0
- package/src/grapher/controls/SearchField.scss +78 -0
- package/src/grapher/controls/SearchField.tsx +63 -0
- package/src/grapher/controls/SettingsMenu.scss +191 -0
- package/src/grapher/controls/SettingsMenu.tsx +399 -0
- package/src/grapher/controls/ShareMenu.scss +58 -0
- package/src/grapher/controls/ShareMenu.tsx +304 -0
- package/src/grapher/controls/SortIcon.tsx +39 -0
- package/src/grapher/controls/VerticalScrollContainer.tsx +263 -0
- package/src/grapher/controls/controlsRow/ControlsRow.tsx +168 -0
- package/src/grapher/controls/dropdown-icons.scss +4 -0
- package/src/grapher/controls/entityPicker/EntityPicker.scss +255 -0
- package/src/grapher/controls/entityPicker/EntityPicker.tsx +816 -0
- package/src/grapher/controls/entityPicker/EntityPickerConstants.ts +23 -0
- package/src/grapher/controls/globalEntitySelector/GlobalEntitySelector.scss +129 -0
- package/src/grapher/controls/globalEntitySelector/GlobalEntitySelector.tsx +463 -0
- package/src/grapher/controls/globalEntitySelector/GlobalEntitySelectorConstants.ts +3 -0
- package/src/grapher/controls/globalEntitySelector/readme.md +17 -0
- package/src/grapher/controls/settings/AbsRelToggle.tsx +64 -0
- package/src/grapher/controls/settings/AxisScaleToggle.tsx +53 -0
- package/src/grapher/controls/settings/FacetStrategySelector.tsx +110 -0
- package/src/grapher/controls/settings/FacetYDomainToggle.tsx +51 -0
- package/src/grapher/controls/settings/NoDataAreaToggle.tsx +38 -0
- package/src/grapher/controls/settings/ZoomToggle.tsx +36 -0
- package/src/grapher/core/EntitiesByRegionType.ts +174 -0
- package/src/grapher/core/EntityCodes.ts +19 -0
- package/src/grapher/core/EntityUrlBuilder.ts +200 -0
- package/src/grapher/core/FetchingGrapher.tsx +156 -0
- package/src/grapher/core/Grapher.tsx +760 -0
- package/src/grapher/core/GrapherAnalytics.ts +229 -0
- package/src/grapher/core/GrapherConstants.ts +173 -0
- package/src/grapher/core/GrapherState.tsx +3659 -0
- package/src/grapher/core/GrapherUrl.ts +184 -0
- package/src/grapher/core/GrapherUrlMigrations.ts +29 -0
- package/src/grapher/core/GrapherUseHelpers.tsx +147 -0
- package/src/grapher/core/LegacyToOwidTable.ts +841 -0
- package/src/grapher/core/grapher.entry.ts +5 -0
- package/src/grapher/core/grapher.scss +257 -0
- package/src/grapher/core/loadGrapherTableHelpers.ts +116 -0
- package/src/grapher/core/loadVariable.ts +104 -0
- package/src/grapher/core/relatedQuestion.ts +12 -0
- package/src/grapher/core/typography.scss +206 -0
- package/src/grapher/dataTable/DataTable.sample.ts +206 -0
- package/src/grapher/dataTable/DataTable.scss +249 -0
- package/src/grapher/dataTable/DataTable.tsx +1332 -0
- package/src/grapher/dataTable/DataTableConstants.ts +186 -0
- package/src/grapher/entitySelector/EntitySelector.scss +255 -0
- package/src/grapher/entitySelector/EntitySelector.tsx +1838 -0
- package/src/grapher/facet/FacetChart.tsx +943 -0
- package/src/grapher/facet/FacetChartConstants.ts +24 -0
- package/src/grapher/facet/FacetChartUtils.ts +51 -0
- package/src/grapher/facet/FacetMap.tsx +604 -0
- package/src/grapher/facet/FacetMapConstants.ts +23 -0
- package/src/grapher/facet/readme.md +13 -0
- package/src/grapher/focus/FocusArray.ts +79 -0
- package/src/grapher/footer/Footer.scss +63 -0
- package/src/grapher/footer/Footer.tsx +809 -0
- package/src/grapher/footer/FooterManager.ts +44 -0
- package/src/grapher/fullScreen/FullScreen.scss +11 -0
- package/src/grapher/fullScreen/FullScreen.tsx +61 -0
- package/src/grapher/header/Header.scss +35 -0
- package/src/grapher/header/Header.tsx +372 -0
- package/src/grapher/header/HeaderManager.ts +28 -0
- package/src/grapher/index.ts +157 -0
- package/src/grapher/interaction/InteractionState.ts +60 -0
- package/src/grapher/legend/HorizontalColorLegends.tsx +923 -0
- package/src/grapher/legend/LegendInteractionState.ts +40 -0
- package/src/grapher/legend/VerticalColorLegend.tsx +295 -0
- package/src/grapher/lineCharts/LineChart.tsx +968 -0
- package/src/grapher/lineCharts/LineChartConstants.ts +89 -0
- package/src/grapher/lineCharts/LineChartHelpers.ts +184 -0
- package/src/grapher/lineCharts/LineChartState.ts +394 -0
- package/src/grapher/lineCharts/LineChartThumbnail.tsx +437 -0
- package/src/grapher/lineCharts/Lines.tsx +258 -0
- package/src/grapher/lineLegend/LineLegend.tsx +723 -0
- package/src/grapher/lineLegend/LineLegendConstants.ts +9 -0
- package/src/grapher/lineLegend/LineLegendFilterAlgorithms.ts +143 -0
- package/src/grapher/lineLegend/LineLegendHelpers.ts +253 -0
- package/src/grapher/lineLegend/LineLegendTypes.ts +32 -0
- package/src/grapher/mapCharts/CanadaTopology.ts +17922 -0
- package/src/grapher/mapCharts/ChoroplethGlobe.tsx +949 -0
- package/src/grapher/mapCharts/ChoroplethMap.tsx +662 -0
- package/src/grapher/mapCharts/GeoFeatures.ts +184 -0
- package/src/grapher/mapCharts/GlobeController.ts +496 -0
- package/src/grapher/mapCharts/MapAnnotationPlacements.json +1040 -0
- package/src/grapher/mapCharts/MapAnnotationPlacements.ts +31 -0
- package/src/grapher/mapCharts/MapAnnotations.ts +723 -0
- package/src/grapher/mapCharts/MapChart.sample.ts +59 -0
- package/src/grapher/mapCharts/MapChart.scss +5 -0
- package/src/grapher/mapCharts/MapChart.tsx +720 -0
- package/src/grapher/mapCharts/MapChartConstants.ts +260 -0
- package/src/grapher/mapCharts/MapChartState.ts +416 -0
- package/src/grapher/mapCharts/MapChartThumbnail.tsx +25 -0
- package/src/grapher/mapCharts/MapComponents.tsx +338 -0
- package/src/grapher/mapCharts/MapConfig.ts +156 -0
- package/src/grapher/mapCharts/MapHelpers.ts +181 -0
- package/src/grapher/mapCharts/MapProjections.ts +49 -0
- package/src/grapher/mapCharts/MapSparkline.tsx +257 -0
- package/src/grapher/mapCharts/MapTooltip.scss +49 -0
- package/src/grapher/mapCharts/MapTooltip.tsx +409 -0
- package/src/grapher/mapCharts/MapTopology.ts +1766 -0
- package/src/grapher/mapCharts/d3-bboxCollide.js +204 -0
- package/src/grapher/mapCharts/d3-geo-projection.ts +198 -0
- package/src/grapher/modal/DownloadIcons.tsx +39 -0
- package/src/grapher/modal/DownloadModal.scss +300 -0
- package/src/grapher/modal/DownloadModal.tsx +1226 -0
- package/src/grapher/modal/EmbedModal.scss +40 -0
- package/src/grapher/modal/EmbedModal.tsx +160 -0
- package/src/grapher/modal/EntitySelectorModal.tsx +59 -0
- package/src/grapher/modal/Modal.scss +31 -0
- package/src/grapher/modal/Modal.tsx +90 -0
- package/src/grapher/modal/ModalHeader.scss +12 -0
- package/src/grapher/modal/ModalHeader.tsx +16 -0
- package/src/grapher/modal/SourcesDescriptions.scss +87 -0
- package/src/grapher/modal/SourcesDescriptions.tsx +89 -0
- package/src/grapher/modal/SourcesKeyDataTable.scss +49 -0
- package/src/grapher/modal/SourcesKeyDataTable.tsx +87 -0
- package/src/grapher/modal/SourcesModal.scss +301 -0
- package/src/grapher/modal/SourcesModal.tsx +568 -0
- package/src/grapher/noDataModal/NoDataModal.tsx +125 -0
- package/src/grapher/scatterCharts/ConnectedScatterLegend.tsx +143 -0
- package/src/grapher/scatterCharts/MultiColorPolyline.tsx +129 -0
- package/src/grapher/scatterCharts/NoDataSection.scss +14 -0
- package/src/grapher/scatterCharts/NoDataSection.tsx +56 -0
- package/src/grapher/scatterCharts/ScatterPlotChart.tsx +792 -0
- package/src/grapher/scatterCharts/ScatterPlotChartConstants.ts +157 -0
- package/src/grapher/scatterCharts/ScatterPlotChartState.ts +678 -0
- package/src/grapher/scatterCharts/ScatterPlotChartThumbnail.tsx +155 -0
- package/src/grapher/scatterCharts/ScatterPlotTooltip.tsx +560 -0
- package/src/grapher/scatterCharts/ScatterPoints.tsx +153 -0
- package/src/grapher/scatterCharts/ScatterPointsWithLabels.tsx +708 -0
- package/src/grapher/scatterCharts/ScatterSizeLegend.tsx +327 -0
- package/src/grapher/scatterCharts/ScatterUtils.ts +265 -0
- package/src/grapher/scatterCharts/Triangle.tsx +41 -0
- package/src/grapher/schema/README.md +33 -0
- package/src/grapher/schema/defaultGrapherConfig.ts +100 -0
- package/src/grapher/schema/grapher-schema.009.yaml +781 -0
- package/src/grapher/schema/migrations/helpers.ts +58 -0
- package/src/grapher/schema/migrations/migrate.ts +75 -0
- package/src/grapher/schema/migrations/migrations.ts +158 -0
- package/src/grapher/selection/MapSelectionArray.ts +99 -0
- package/src/grapher/selection/SelectionArray.ts +71 -0
- package/src/grapher/selection/readme.md +16 -0
- package/src/grapher/sidePanel/SidePanel.scss +10 -0
- package/src/grapher/sidePanel/SidePanel.tsx +23 -0
- package/src/grapher/slideInDrawer/SlideInDrawer.scss +57 -0
- package/src/grapher/slideInDrawer/SlideInDrawer.tsx +125 -0
- package/src/grapher/slideshowController/SlideShowController.tsx +43 -0
- package/src/grapher/slideshowController/readme.md +7 -0
- package/src/grapher/slopeCharts/MarkX.tsx +45 -0
- package/src/grapher/slopeCharts/Slope.tsx +102 -0
- package/src/grapher/slopeCharts/SlopeChart.tsx +1152 -0
- package/src/grapher/slopeCharts/SlopeChartConstants.ts +33 -0
- package/src/grapher/slopeCharts/SlopeChartHelpers.ts +73 -0
- package/src/grapher/slopeCharts/SlopeChartState.ts +392 -0
- package/src/grapher/slopeCharts/SlopeChartThumbnail.tsx +368 -0
- package/src/grapher/stackedCharts/AbstractStackedChartState.ts +370 -0
- package/src/grapher/stackedCharts/MarimekkoBars.tsx +190 -0
- package/src/grapher/stackedCharts/MarimekkoBarsForOneEntity.tsx +168 -0
- package/src/grapher/stackedCharts/MarimekkoChart.tsx +1144 -0
- package/src/grapher/stackedCharts/MarimekkoChartConstants.ts +112 -0
- package/src/grapher/stackedCharts/MarimekkoChartHelpers.ts +21 -0
- package/src/grapher/stackedCharts/MarimekkoChartState.ts +465 -0
- package/src/grapher/stackedCharts/MarimekkoChartThumbnail.tsx +168 -0
- package/src/grapher/stackedCharts/MarimekkoInternalLabels.tsx +124 -0
- package/src/grapher/stackedCharts/StackedAreaChart.tsx +678 -0
- package/src/grapher/stackedCharts/StackedAreaChartState.ts +34 -0
- package/src/grapher/stackedCharts/StackedAreaChartThumbnail.tsx +215 -0
- package/src/grapher/stackedCharts/StackedAreas.tsx +223 -0
- package/src/grapher/stackedCharts/StackedBarChart.tsx +619 -0
- package/src/grapher/stackedCharts/StackedBarChartState.ts +80 -0
- package/src/grapher/stackedCharts/StackedBarChartThumbnail.tsx +220 -0
- package/src/grapher/stackedCharts/StackedBarSegment.tsx +87 -0
- package/src/grapher/stackedCharts/StackedBars.tsx +102 -0
- package/src/grapher/stackedCharts/StackedConstants.ts +109 -0
- package/src/grapher/stackedCharts/StackedDiscreteBarChart.tsx +270 -0
- package/src/grapher/stackedCharts/StackedDiscreteBarChartState.ts +296 -0
- package/src/grapher/stackedCharts/StackedDiscreteBarChartThumbnail.tsx +27 -0
- package/src/grapher/stackedCharts/StackedDiscreteBars.tsx +648 -0
- package/src/grapher/stackedCharts/StackedUtils.ts +142 -0
- package/src/grapher/tabs/Tabs.scss +169 -0
- package/src/grapher/tabs/Tabs.tsx +54 -0
- package/src/grapher/tabs/TabsWithDropdown.scss +62 -0
- package/src/grapher/tabs/TabsWithDropdown.tsx +114 -0
- package/src/grapher/testData/OwidTestData.sample.ts +273 -0
- package/src/grapher/testData/OwidTestData.ts +64 -0
- package/src/grapher/timeline/TimelineComponent.scss +139 -0
- package/src/grapher/timeline/TimelineComponent.tsx +658 -0
- package/src/grapher/timeline/TimelineController.ts +368 -0
- package/src/grapher/timeline/readme.md +7 -0
- package/src/grapher/tooltip/Tooltip.scss +510 -0
- package/src/grapher/tooltip/Tooltip.tsx +294 -0
- package/src/grapher/tooltip/TooltipContents.tsx +383 -0
- package/src/grapher/tooltip/TooltipProps.ts +123 -0
- package/src/grapher/tooltip/TooltipState.ts +81 -0
- package/src/grapher/verticalLabels/VerticalLabels.tsx +31 -0
- package/src/grapher/verticalLabels/VerticalLabelsState.ts +154 -0
- package/src/index.ts +226 -0
- package/src/styles/charts.scss +15 -0
- package/src/types/NominalType.ts +30 -0
- package/src/types/OwidOrigin.ts +18 -0
- package/src/types/OwidSource.ts +9 -0
- package/src/types/OwidVariable.ts +133 -0
- package/src/types/OwidVariableDisplayConfigInterface.ts +49 -0
- package/src/types/analyticsTypes.ts +54 -0
- package/src/types/dbTypes/Tags.ts +11 -0
- package/src/types/domainTypes/Archive.ts +139 -0
- package/src/types/domainTypes/Author.ts +28 -0
- package/src/types/domainTypes/ContentGraph.ts +76 -0
- package/src/types/domainTypes/CoreTableTypes.ts +305 -0
- package/src/types/domainTypes/DeployStatus.ts +23 -0
- package/src/types/domainTypes/Layout.ts +34 -0
- package/src/types/domainTypes/Posts.ts +34 -0
- package/src/types/domainTypes/Search.ts +299 -0
- package/src/types/domainTypes/Site.ts +8 -0
- package/src/types/domainTypes/StaticViz.ts +64 -0
- package/src/types/domainTypes/Toc.ts +11 -0
- package/src/types/domainTypes/Tombstone.ts +19 -0
- package/src/types/domainTypes/Various.ts +79 -0
- package/src/types/gdocTypes/Gdoc.ts +280 -0
- package/src/types/grapherTypes/BinningStrategyTypes.ts +46 -0
- package/src/types/grapherTypes/GrapherConstants.ts +53 -0
- package/src/types/grapherTypes/GrapherTypes.ts +743 -0
- package/src/types/index.ts +316 -0
- package/src/types/wordpressTypes/WordpressTypes.ts +9 -0
- package/src/utils/Bounds.ts +439 -0
- package/src/utils/BrowserUtils.ts +12 -0
- package/src/utils/FuzzySearch.ts +74 -0
- package/src/utils/MultiDimDataPageConfig.ts +31 -0
- package/src/utils/OwidVariable.ts +82 -0
- package/src/utils/PointVector.ts +97 -0
- package/src/utils/PromiseCache.ts +36 -0
- package/src/utils/PromiseSwitcher.ts +52 -0
- package/src/utils/TimeBounds.ts +130 -0
- package/src/utils/Tippy.tsx +57 -0
- package/src/utils/Util.ts +2369 -0
- package/src/utils/archival/archivalDate.ts +48 -0
- package/src/utils/dayjs.ts +32 -0
- package/src/utils/formatValue.ts +242 -0
- package/src/utils/grapherConfigUtils.ts +81 -0
- package/src/utils/image.ts +225 -0
- package/src/utils/index.ts +318 -0
- package/src/utils/isPresent.ts +5 -0
- package/src/utils/metadataHelpers.ts +329 -0
- package/src/utils/persistable/Persistable.ts +82 -0
- package/src/utils/persistable/readme.md +50 -0
- package/src/utils/regions.json +5635 -0
- package/src/utils/regions.ts +463 -0
- package/src/utils/serializers.ts +16 -0
- package/src/utils/string.ts +42 -0
- package/src/utils/urls/Url.ts +195 -0
- package/src/utils/urls/UrlMigration.ts +10 -0
- package/src/utils/urls/UrlUtils.ts +54 -0
- 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
|
+
}
|