@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,1838 @@
|
|
|
1
|
+
import * as _ from "lodash-es"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { observer } from "mobx-react"
|
|
4
|
+
import {
|
|
5
|
+
computed,
|
|
6
|
+
action,
|
|
7
|
+
reaction,
|
|
8
|
+
when,
|
|
9
|
+
IReactionDisposer,
|
|
10
|
+
makeObservable,
|
|
11
|
+
} from "mobx"
|
|
12
|
+
import cx from "classnames"
|
|
13
|
+
import a from "indefinite"
|
|
14
|
+
import {
|
|
15
|
+
isTouchDevice,
|
|
16
|
+
SortOrder,
|
|
17
|
+
isFiniteWithGuard,
|
|
18
|
+
CoreValueType,
|
|
19
|
+
getUserCountryInformation,
|
|
20
|
+
regions,
|
|
21
|
+
Tippy,
|
|
22
|
+
excludeUndefined,
|
|
23
|
+
FuzzySearch,
|
|
24
|
+
getUserNavigatorLanguagesNonEnglish,
|
|
25
|
+
getRegionAlternativeNames,
|
|
26
|
+
convertDaysSinceEpochToDate,
|
|
27
|
+
checkIsOwidIncomeGroupName,
|
|
28
|
+
checkHasMembers,
|
|
29
|
+
Region,
|
|
30
|
+
getRegionByName,
|
|
31
|
+
makeSafeForCSS,
|
|
32
|
+
} from "../../utils/index.js"
|
|
33
|
+
import {
|
|
34
|
+
Checkbox,
|
|
35
|
+
RadioButton,
|
|
36
|
+
OverlayHeader,
|
|
37
|
+
} from "../../components/index.js"
|
|
38
|
+
import {
|
|
39
|
+
faLocationArrow,
|
|
40
|
+
faArrowRightArrowLeft,
|
|
41
|
+
faFilter,
|
|
42
|
+
} from "@fortawesome/free-solid-svg-icons"
|
|
43
|
+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
|
44
|
+
import { SelectionArray } from "../selection/SelectionArray"
|
|
45
|
+
import { Flipper, Flipped } from "react-flip-toolkit"
|
|
46
|
+
import {
|
|
47
|
+
combineHistoricalAndProjectionColumns,
|
|
48
|
+
makeSelectionArray,
|
|
49
|
+
} from "../chart/ChartUtils.js"
|
|
50
|
+
import {
|
|
51
|
+
DEFAULT_GRAPHER_ENTITY_TYPE,
|
|
52
|
+
DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL,
|
|
53
|
+
POPULATION_INDICATOR_ID_USED_IN_ENTITY_SELECTOR,
|
|
54
|
+
GDP_PER_CAPITA_INDICATOR_ID_USED_IN_ENTITY_SELECTOR,
|
|
55
|
+
isPopulationVariableETLPath,
|
|
56
|
+
isWorldEntityName,
|
|
57
|
+
} from "../core/GrapherConstants"
|
|
58
|
+
import { CoreColumn, OwidTable } from "../../core-table/index.js"
|
|
59
|
+
import { SortIcon } from "../controls/SortIcon"
|
|
60
|
+
import { Dropdown } from "../controls/Dropdown"
|
|
61
|
+
import { scaleLinear, type ScaleLinear } from "d3-scale"
|
|
62
|
+
import {
|
|
63
|
+
AdditionalGrapherDataFetchFn,
|
|
64
|
+
ColumnSlug,
|
|
65
|
+
EntityName,
|
|
66
|
+
OwidColumnDef,
|
|
67
|
+
ProjectionColumnInfo,
|
|
68
|
+
Time,
|
|
69
|
+
ToleranceStrategy,
|
|
70
|
+
type EntitySelectorEvent,
|
|
71
|
+
} from "../../types/index.js"
|
|
72
|
+
import { buildVariableTable } from "../core/LegacyToOwidTable"
|
|
73
|
+
import { DrawerContext } from "../slideInDrawer/SlideInDrawer.js"
|
|
74
|
+
import * as R from "remeda"
|
|
75
|
+
import { MapConfig } from "../mapCharts/MapConfig"
|
|
76
|
+
import { match } from "ts-pattern"
|
|
77
|
+
import {
|
|
78
|
+
entityRegionTypeLabels,
|
|
79
|
+
EntityNamesByRegionType,
|
|
80
|
+
EntityRegionType,
|
|
81
|
+
EntityRegionTypeGroup,
|
|
82
|
+
isAggregateSource,
|
|
83
|
+
} from "../core/EntitiesByRegionType"
|
|
84
|
+
import { SearchField } from "../controls/SearchField"
|
|
85
|
+
import { MAP_REGION_LABELS } from "../mapCharts/MapChartConstants.js"
|
|
86
|
+
|
|
87
|
+
export type CoreColumnBySlug = Record<ColumnSlug, CoreColumn>
|
|
88
|
+
|
|
89
|
+
type EntityFilter = EntityRegionType | "all"
|
|
90
|
+
|
|
91
|
+
type ValueBySlugAndTimeAndEntityName<T> = Map<
|
|
92
|
+
ColumnSlug,
|
|
93
|
+
Map<Time, Map<EntityName, T>>
|
|
94
|
+
>
|
|
95
|
+
|
|
96
|
+
export interface EntitySelectorState {
|
|
97
|
+
searchInput: string
|
|
98
|
+
sortConfig: SortConfig
|
|
99
|
+
entityFilter: EntityFilter
|
|
100
|
+
localEntityNames?: string[]
|
|
101
|
+
interpolatedSortColumnsBySlug?: CoreColumnBySlug
|
|
102
|
+
isProjectionBySlugAndTimeAndEntityName?: ValueBySlugAndTimeAndEntityName<boolean>
|
|
103
|
+
isLoadingExternalSortColumn?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface EntitySelectorManager {
|
|
107
|
+
entitySelectorState: Partial<EntitySelectorState>
|
|
108
|
+
table: OwidTable
|
|
109
|
+
tableForSelection: OwidTable
|
|
110
|
+
selection: SelectionArray
|
|
111
|
+
entityType?: string
|
|
112
|
+
entityTypePlural?: string
|
|
113
|
+
activeColumnSlugs?: string[]
|
|
114
|
+
isEntitySelectorModalOrDrawerOpen?: boolean
|
|
115
|
+
canChangeEntity?: boolean
|
|
116
|
+
canHighlightEntities?: boolean
|
|
117
|
+
endTime?: Time
|
|
118
|
+
isOnMapTab?: boolean
|
|
119
|
+
mapConfig?: MapConfig
|
|
120
|
+
mapColumnSlug?: ColumnSlug
|
|
121
|
+
isEntityMutedInSelector?: (entityName: EntityName) => boolean
|
|
122
|
+
onSelectEntity?: (entityName: EntityName) => void
|
|
123
|
+
onDeselectEntity?: (entityName: EntityName) => void
|
|
124
|
+
onClearEntities?: () => void
|
|
125
|
+
entityRegionTypeGroups?: EntityRegionTypeGroup[]
|
|
126
|
+
entityNamesByRegionType?: EntityNamesByRegionType
|
|
127
|
+
isReady?: boolean
|
|
128
|
+
logEntitySelectorEvent: (
|
|
129
|
+
action: EntitySelectorEvent,
|
|
130
|
+
target?: string
|
|
131
|
+
) => void
|
|
132
|
+
additionalDataLoaderFn?: AdditionalGrapherDataFetchFn
|
|
133
|
+
projectionColumnInfoBySlug?: Map<ColumnSlug, ProjectionColumnInfo>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface SortConfig {
|
|
137
|
+
slug: ColumnSlug
|
|
138
|
+
order: SortOrder
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type SearchableEntity = {
|
|
142
|
+
name: string
|
|
143
|
+
sortColumnValues: Record<ColumnSlug, CoreValueType | undefined>
|
|
144
|
+
isLocal?: boolean
|
|
145
|
+
alternativeNames?: string[]
|
|
146
|
+
regionInfo?: Region
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface SortDropdownOption {
|
|
150
|
+
type:
|
|
151
|
+
| "name" // sorted by name
|
|
152
|
+
| "chart-indicator" // sorted by chart column
|
|
153
|
+
| "external-indicator" // sorted by an external indicator
|
|
154
|
+
value: string // slug
|
|
155
|
+
slug: string
|
|
156
|
+
label: string
|
|
157
|
+
formattedTime?: string
|
|
158
|
+
trackNote?: string // unused
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface FilterDropdownOption {
|
|
162
|
+
value: EntityFilter
|
|
163
|
+
label: string
|
|
164
|
+
count: number
|
|
165
|
+
trackNote?: string // unused
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const EXTERNAL_SORT_INDICATOR_DEFINITIONS = [
|
|
169
|
+
{
|
|
170
|
+
key: "population",
|
|
171
|
+
label: "Population",
|
|
172
|
+
indicatorId: POPULATION_INDICATOR_ID_USED_IN_ENTITY_SELECTOR,
|
|
173
|
+
slug: indicatorIdToSlug(
|
|
174
|
+
POPULATION_INDICATOR_ID_USED_IN_ENTITY_SELECTOR
|
|
175
|
+
),
|
|
176
|
+
// checks if a column has population data
|
|
177
|
+
isMatch: (column: CoreColumn): boolean => {
|
|
178
|
+
// check the slug first
|
|
179
|
+
const externalSlug = indicatorIdToSlug(
|
|
180
|
+
POPULATION_INDICATOR_ID_USED_IN_ENTITY_SELECTOR
|
|
181
|
+
)
|
|
182
|
+
if (column.slug === externalSlug) return true
|
|
183
|
+
|
|
184
|
+
// then check the catalog path
|
|
185
|
+
return isPopulationVariableETLPath(
|
|
186
|
+
(column.def as OwidColumnDef)?.catalogPath ?? ""
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
key: "gdpPerCapita",
|
|
192
|
+
label: "GDP per capita (int. $)",
|
|
193
|
+
indicatorId: GDP_PER_CAPITA_INDICATOR_ID_USED_IN_ENTITY_SELECTOR,
|
|
194
|
+
slug: indicatorIdToSlug(
|
|
195
|
+
GDP_PER_CAPITA_INDICATOR_ID_USED_IN_ENTITY_SELECTOR
|
|
196
|
+
),
|
|
197
|
+
// checks if a column has GDP per capita data
|
|
198
|
+
isMatch: (column: CoreColumn): boolean => {
|
|
199
|
+
// check the slug first
|
|
200
|
+
const externalSlug = indicatorIdToSlug(
|
|
201
|
+
GDP_PER_CAPITA_INDICATOR_ID_USED_IN_ENTITY_SELECTOR
|
|
202
|
+
)
|
|
203
|
+
if (column.slug === externalSlug) return true
|
|
204
|
+
|
|
205
|
+
// then check the label
|
|
206
|
+
const label = getTitleForSortColumnLabel(column)
|
|
207
|
+
// matches "gdp per capita" and content within parentheses
|
|
208
|
+
const potentialMatches =
|
|
209
|
+
label.match(/\(.*?\)|(\bgdp per capita\b)/gi) ?? []
|
|
210
|
+
// filter for "gdp per capita" matches that are not within parentheses
|
|
211
|
+
const matches = potentialMatches.filter(
|
|
212
|
+
(match) => !match.includes("(")
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return matches.length > 0
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
] as const
|
|
219
|
+
|
|
220
|
+
type ExternalSortIndicatorDefinition =
|
|
221
|
+
(typeof EXTERNAL_SORT_INDICATOR_DEFINITIONS)[number]
|
|
222
|
+
type ExternalSortIndicatorKey = ExternalSortIndicatorDefinition["key"]
|
|
223
|
+
|
|
224
|
+
const regionNamesSet = new Set(regions.map((region) => region.name))
|
|
225
|
+
|
|
226
|
+
interface EntitySelectorProps {
|
|
227
|
+
manager: EntitySelectorManager
|
|
228
|
+
selection?: SelectionArray
|
|
229
|
+
autoFocus?: boolean
|
|
230
|
+
onDismiss?: () => void
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@observer
|
|
234
|
+
export class EntitySelector extends React.Component<EntitySelectorProps> {
|
|
235
|
+
static override contextType = DrawerContext
|
|
236
|
+
declare context: React.ContextType<typeof DrawerContext>
|
|
237
|
+
|
|
238
|
+
scrollableContainer = React.createRef<HTMLDivElement>()
|
|
239
|
+
searchFieldRef = React.createRef<HTMLInputElement>()
|
|
240
|
+
contentRef = React.createRef<HTMLDivElement>()
|
|
241
|
+
|
|
242
|
+
private sortConfigByName: SortConfig = {
|
|
243
|
+
slug: this.table.entityNameSlug,
|
|
244
|
+
order: SortOrder.asc,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private disposers: IReactionDisposer[] = []
|
|
248
|
+
|
|
249
|
+
constructor(props: EntitySelectorProps) {
|
|
250
|
+
super(props)
|
|
251
|
+
makeObservable(this)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
override componentDidMount(): void {
|
|
255
|
+
void this.populateLocalEntities()
|
|
256
|
+
|
|
257
|
+
if (this.props.autoFocus && !isTouchDevice())
|
|
258
|
+
this.searchFieldRef.current?.focus()
|
|
259
|
+
|
|
260
|
+
// scroll to the top when the search input changes
|
|
261
|
+
this.disposers.push(
|
|
262
|
+
reaction(
|
|
263
|
+
() => this.searchInput,
|
|
264
|
+
() => {
|
|
265
|
+
if (this.scrollableContainer.current)
|
|
266
|
+
this.scrollableContainer.current.scrollTop = 0
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
// the initial sorting strategy depends on data,
|
|
272
|
+
// which is why we wait for Grapher to be ready
|
|
273
|
+
this.disposers.push(
|
|
274
|
+
when(
|
|
275
|
+
() => !!this.manager.isReady,
|
|
276
|
+
() => this.initSortConfig()
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
// Mdims and explorers can change the columns available for sorting, and
|
|
281
|
+
// we need to change the sort config accordingly
|
|
282
|
+
this.disposers.push(
|
|
283
|
+
reaction(
|
|
284
|
+
() => this.sortOptions,
|
|
285
|
+
() => this.updateSortConfigIfOptionHasBecomeUnavailable()
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
override componentWillUnmount(): void {
|
|
291
|
+
if (this.timeoutId) clearTimeout(this.timeoutId)
|
|
292
|
+
this.disposers.forEach((dispose) => dispose())
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
@action.bound private set(newState: Partial<EntitySelectorState>): void {
|
|
296
|
+
const correctedState = { ...newState }
|
|
297
|
+
|
|
298
|
+
if (newState.sortConfig !== undefined) {
|
|
299
|
+
const correctedSortConfig = { ...newState.sortConfig }
|
|
300
|
+
|
|
301
|
+
const shouldBeSortedByName =
|
|
302
|
+
newState.sortConfig.slug === this.table.entityNameSlug
|
|
303
|
+
|
|
304
|
+
// sort names in ascending order by default
|
|
305
|
+
if (shouldBeSortedByName && !this.isSortedByName) {
|
|
306
|
+
correctedSortConfig.order = SortOrder.asc
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// sort values in descending order by default
|
|
310
|
+
if (!shouldBeSortedByName && this.isSortedByName) {
|
|
311
|
+
correctedSortConfig.order = SortOrder.desc
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
correctedState.sortConfig = correctedSortConfig
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.manager.entitySelectorState = {
|
|
318
|
+
...this.manager.entitySelectorState,
|
|
319
|
+
...correctedState,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
getDefaultSortConfig(): SortConfig {
|
|
324
|
+
const chartIndicatorSortOptions = this.sortOptions.filter(
|
|
325
|
+
(option) => option.type === "chart-indicator"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
// default to sorting by the first chart column if there is only one
|
|
329
|
+
if (chartIndicatorSortOptions.length === 1) {
|
|
330
|
+
const { slug } = chartIndicatorSortOptions[0]
|
|
331
|
+
this.setInterpolatedSortColumnBySlug(slug)
|
|
332
|
+
return { slug, order: SortOrder.desc }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return this.sortConfigByName
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
updateSortConfigIfOptionHasBecomeUnavailable() {
|
|
339
|
+
// We don't want to update the sort config when `sortOptions` are not ready,
|
|
340
|
+
// because the new chart dimensions are currently loading
|
|
341
|
+
if (!this.manager.isReady) return
|
|
342
|
+
if (!this.manager.activeColumnSlugs?.length) return
|
|
343
|
+
|
|
344
|
+
// Check whether the current sort option is still available in the newly-updated
|
|
345
|
+
// sortOptions
|
|
346
|
+
if (
|
|
347
|
+
!this.sortOptions.find(
|
|
348
|
+
(option) => option.slug === this.sortConfig.slug
|
|
349
|
+
)
|
|
350
|
+
) {
|
|
351
|
+
this.set({ sortConfig: this.getDefaultSortConfig() })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
initSortConfig(): void {
|
|
356
|
+
this.set({ sortConfig: this.getDefaultSortConfig() })
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
resetInterpolatedMapColumn(): void {
|
|
360
|
+
const { mapColumnSlug } = this.manager
|
|
361
|
+
const sortSlug = this.sortConfig.slug
|
|
362
|
+
|
|
363
|
+
// no need to reset the map column slug if it doesn't exist or isn't set
|
|
364
|
+
if (
|
|
365
|
+
!mapColumnSlug ||
|
|
366
|
+
!this.interpolatedSortColumnsBySlug[mapColumnSlug]
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if (sortSlug === mapColumnSlug) {
|
|
371
|
+
// if the map column slug is currently selected, re-calculate its
|
|
372
|
+
// tolerance because the map and chart tab might have different
|
|
373
|
+
// tolerance settings
|
|
374
|
+
this.setInterpolatedSortColumn(
|
|
375
|
+
this.interpolateSortColumn(mapColumnSlug)
|
|
376
|
+
)
|
|
377
|
+
} else {
|
|
378
|
+
// otherwise, delete it and it will be re-calculated when necessary
|
|
379
|
+
delete this.manager.entitySelectorState
|
|
380
|
+
.interpolatedSortColumnsBySlug?.[mapColumnSlug]
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@action.bound async populateLocalEntities(): Promise<void> {
|
|
385
|
+
try {
|
|
386
|
+
const localCountryInfo = await getUserCountryInformation()
|
|
387
|
+
if (!localCountryInfo) return
|
|
388
|
+
|
|
389
|
+
const countryRegionsWithoutIncomeGroups = localCountryInfo.regions
|
|
390
|
+
? localCountryInfo.regions.filter(
|
|
391
|
+
(region) => !checkIsOwidIncomeGroupName(region)
|
|
392
|
+
)
|
|
393
|
+
: []
|
|
394
|
+
|
|
395
|
+
const userEntityCodes = [
|
|
396
|
+
localCountryInfo.code,
|
|
397
|
+
...countryRegionsWithoutIncomeGroups,
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
const userRegions = regions.filter((region) =>
|
|
401
|
+
userEntityCodes.includes(region.code)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const sortedUserRegions = _.sortBy(userRegions, (region) =>
|
|
405
|
+
userEntityCodes.indexOf(region.code)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
const localEntityNames = sortedUserRegions.map(
|
|
409
|
+
(region) => region.name
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if (localEntityNames) this.set({ localEntityNames })
|
|
413
|
+
} catch {
|
|
414
|
+
// ignore
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private setInterpolatedSortColumn(column: CoreColumn): void {
|
|
419
|
+
this.set({
|
|
420
|
+
interpolatedSortColumnsBySlug: {
|
|
421
|
+
...this.interpolatedSortColumnsBySlug,
|
|
422
|
+
[column.slug]: column,
|
|
423
|
+
},
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private setIsProjectionForSlug(
|
|
428
|
+
slug: ColumnSlug,
|
|
429
|
+
valuesByTimeAndEntityName: Map<Time, Map<EntityName, boolean>>
|
|
430
|
+
): void {
|
|
431
|
+
const { isProjectionBySlugAndTimeAndEntityName } = this
|
|
432
|
+
isProjectionBySlugAndTimeAndEntityName.set(
|
|
433
|
+
slug,
|
|
434
|
+
valuesByTimeAndEntityName
|
|
435
|
+
)
|
|
436
|
+
this.set({ isProjectionBySlugAndTimeAndEntityName })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
@computed private get toleranceOverride(): {
|
|
440
|
+
value?: number
|
|
441
|
+
strategy?: ToleranceStrategy
|
|
442
|
+
} {
|
|
443
|
+
// use map tolerance if on the map tab
|
|
444
|
+
const tolerance = this.manager.isOnMapTab
|
|
445
|
+
? this.mapConfig.timeTolerance
|
|
446
|
+
: undefined
|
|
447
|
+
const toleranceStrategy = this.manager.isOnMapTab
|
|
448
|
+
? this.mapConfig.toleranceStrategy
|
|
449
|
+
: undefined
|
|
450
|
+
|
|
451
|
+
return { value: tolerance, strategy: toleranceStrategy }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private interpolateSortColumn(slug: ColumnSlug): CoreColumn {
|
|
455
|
+
return this.inputTable
|
|
456
|
+
.interpolateColumnWithTolerance(slug, {
|
|
457
|
+
toleranceOverride: this.toleranceOverride.value,
|
|
458
|
+
toleranceStrategyOverride: this.toleranceOverride.strategy,
|
|
459
|
+
})
|
|
460
|
+
.get(slug)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private interpolateAndCombineSortColumns(
|
|
464
|
+
info: ProjectionColumnInfo
|
|
465
|
+
): OwidTable {
|
|
466
|
+
const { projectedSlug, historicalSlug } = info
|
|
467
|
+
|
|
468
|
+
// Interpolate the historical and projected columns separately
|
|
469
|
+
const table = this.table
|
|
470
|
+
.interpolateColumnWithTolerance(historicalSlug, {
|
|
471
|
+
toleranceOverride: this.toleranceOverride.value,
|
|
472
|
+
toleranceStrategyOverride: this.toleranceOverride.strategy,
|
|
473
|
+
})
|
|
474
|
+
.interpolateColumnWithTolerance(projectedSlug, {
|
|
475
|
+
toleranceOverride: this.toleranceOverride.value,
|
|
476
|
+
toleranceStrategyOverride: this.toleranceOverride.strategy,
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// Combine the interpolated columns
|
|
480
|
+
return combineHistoricalAndProjectionColumns(table, info, {
|
|
481
|
+
shouldAddIsProjectionColumn: true,
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private setInterpolatedSortColumnBySlug(slug: ColumnSlug): void {
|
|
486
|
+
if (this.interpolatedSortColumnsBySlug[slug]) return
|
|
487
|
+
|
|
488
|
+
// If the column is a projection and has an historical counterpart,
|
|
489
|
+
// then combine the projected and historical data into a single column
|
|
490
|
+
const projectionInfo = this.projectionColumnInfoByCombinedSlug.get(slug)
|
|
491
|
+
if (projectionInfo) {
|
|
492
|
+
const table = this.interpolateAndCombineSortColumns(projectionInfo)
|
|
493
|
+
|
|
494
|
+
const combinedColumn = table.get(projectionInfo.combinedSlug)
|
|
495
|
+
const isProjectionValues = table.get(
|
|
496
|
+
projectionInfo.slugForIsProjectionColumn
|
|
497
|
+
).valueByTimeAndEntityName
|
|
498
|
+
|
|
499
|
+
this.setInterpolatedSortColumn(combinedColumn)
|
|
500
|
+
this.setIsProjectionForSlug(
|
|
501
|
+
projectionInfo.combinedSlug,
|
|
502
|
+
isProjectionValues
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const column = this.interpolateSortColumn(slug)
|
|
509
|
+
this.setInterpolatedSortColumn(column)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private clearSearchInput(): void {
|
|
513
|
+
this.set({ searchInput: "" })
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private resetEntityFilter(): void {
|
|
517
|
+
this.set({ entityFilter: undefined })
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private updateSortSlug(newSlug: ColumnSlug) {
|
|
521
|
+
this.set({
|
|
522
|
+
sortConfig: {
|
|
523
|
+
slug: newSlug,
|
|
524
|
+
order: this.sortConfig.order,
|
|
525
|
+
},
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private toggleSortOrder() {
|
|
530
|
+
const newOrder =
|
|
531
|
+
this.sortConfig.order === SortOrder.asc
|
|
532
|
+
? SortOrder.desc
|
|
533
|
+
: SortOrder.asc
|
|
534
|
+
this.set({
|
|
535
|
+
sortConfig: {
|
|
536
|
+
slug: this.sortConfig.slug,
|
|
537
|
+
order: newOrder,
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
@computed private get chartHasDailyData(): boolean {
|
|
543
|
+
return this.numericalChartColumns.some(
|
|
544
|
+
(column) => column.display?.yearIsDay
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Converts the given time to be compatible with the time format
|
|
550
|
+
* of the given column.
|
|
551
|
+
*
|
|
552
|
+
* This is necessary for external sort indicators when they're loaded
|
|
553
|
+
* for charts with daily data.
|
|
554
|
+
*/
|
|
555
|
+
private toColumnCompatibleTime(time: Time, column: CoreColumn): Time {
|
|
556
|
+
const isExternal = this.externalSortIndicatorDefinitions.some(
|
|
557
|
+
(external) => column.slug === external.slug
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
// if the column comes from the chart, no conversion is needed
|
|
561
|
+
if (!isExternal) return time
|
|
562
|
+
|
|
563
|
+
// assumes that external indicators have yearly data
|
|
564
|
+
const year = this.chartHasDailyData
|
|
565
|
+
? convertDaysSinceEpochToDate(time).year()
|
|
566
|
+
: time
|
|
567
|
+
|
|
568
|
+
// clamping is necessary since external indicators might not cover
|
|
569
|
+
// the entire time range of the chart
|
|
570
|
+
return R.clamp(year, { min: column.minTime, max: column.maxTime })
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private formatTimeForSortColumnLabel(
|
|
574
|
+
time: Time,
|
|
575
|
+
column: CoreColumn
|
|
576
|
+
): string {
|
|
577
|
+
const compatibleTime = this.toColumnCompatibleTime(time, column)
|
|
578
|
+
return column.formatTime(compatibleTime)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
@computed private get manager(): EntitySelectorManager {
|
|
582
|
+
return this.props.manager
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
@computed private get endTime(): Time {
|
|
586
|
+
return this.manager.endTime ?? this.table.maxTime!
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@computed private get mapConfig(): MapConfig {
|
|
590
|
+
return this.manager.mapConfig ?? new MapConfig()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private isEntityMuted(entityName: EntityName): boolean {
|
|
594
|
+
return this.manager.isEntityMutedInSelector?.(entityName) ?? false
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
@computed private get title(): string {
|
|
598
|
+
return this.manager.isOnMapTab
|
|
599
|
+
? `Select ${this.entityType.plural}`
|
|
600
|
+
: this.manager.canHighlightEntities
|
|
601
|
+
? `Select ${this.entityType.plural}`
|
|
602
|
+
: this.manager.canChangeEntity
|
|
603
|
+
? `Choose ${a(this.entityType.singular)}`
|
|
604
|
+
: `Add/remove ${this.entityType.plural}`
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
@computed private get searchPlaceholderEntityType(): string {
|
|
608
|
+
if (isAggregateSource(this.entityFilter)) return "region"
|
|
609
|
+
|
|
610
|
+
return match(this.entityFilter)
|
|
611
|
+
.with("all", () => this.entityType.singular)
|
|
612
|
+
.with("countries", () => "country")
|
|
613
|
+
.with("continents", () => "continent")
|
|
614
|
+
.with("incomeGroups", () => "income group")
|
|
615
|
+
.with("historicalCountries", () => "country or region")
|
|
616
|
+
.exhaustive()
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
@computed private get searchInput(): string {
|
|
620
|
+
return this.manager.entitySelectorState.searchInput ?? ""
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
@computed get sortConfig(): SortConfig {
|
|
624
|
+
return (
|
|
625
|
+
this.manager.entitySelectorState.sortConfig ?? this.sortConfigByName
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
isSortSlugValid(slug: ColumnSlug): boolean {
|
|
630
|
+
return this.sortOptions.some((option) => option.value === slug)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
isEntityFilterValid(entityFilter: EntityFilter): boolean {
|
|
634
|
+
return this.filterOptions.some(
|
|
635
|
+
(option) => option.value === entityFilter
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
@computed private get entityFilter(): EntityFilter {
|
|
640
|
+
return (
|
|
641
|
+
this.manager.entitySelectorState.entityFilter ??
|
|
642
|
+
this.filterOptions[0]?.value ??
|
|
643
|
+
"all"
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
@computed private get localEntityNames(): string[] | undefined {
|
|
648
|
+
return this.manager.entitySelectorState.localEntityNames
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
@computed private get interpolatedSortColumnsBySlug(): CoreColumnBySlug {
|
|
652
|
+
return (
|
|
653
|
+
this.manager.entitySelectorState.interpolatedSortColumnsBySlug ?? {}
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
@computed
|
|
658
|
+
private get isProjectionBySlugAndTimeAndEntityName(): ValueBySlugAndTimeAndEntityName<boolean> {
|
|
659
|
+
return (
|
|
660
|
+
this.manager.entitySelectorState
|
|
661
|
+
.isProjectionBySlugAndTimeAndEntityName ?? new Map()
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
@computed private get interpolatedSortColumns(): CoreColumn[] {
|
|
666
|
+
return Object.values(this.interpolatedSortColumnsBySlug)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
@computed private get isLoadingExternalSortColumn(): boolean {
|
|
670
|
+
return (
|
|
671
|
+
this.manager.entitySelectorState.isLoadingExternalSortColumn ??
|
|
672
|
+
false
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
@computed private get inputTable(): OwidTable {
|
|
677
|
+
return this.manager.table
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
@computed private get table(): OwidTable {
|
|
681
|
+
return this.manager.tableForSelection
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
@computed private get someEntitiesAreRegions(): boolean {
|
|
685
|
+
if (!this.entitiesAreCountriesOrRegions) return false
|
|
686
|
+
return this.availableEntities.some((entity) =>
|
|
687
|
+
checkHasMembers(entity.regionInfo)
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
@computed private get entitiesAreCountriesOrRegions(): boolean {
|
|
692
|
+
// Ignore the World entity since we have charts that only have the
|
|
693
|
+
// World entity but no other countries or regions (e.g. 'World',
|
|
694
|
+
// 'Northern Hemisphere' and 'Southern hemisphere')
|
|
695
|
+
return this.availableEntityNames.some(
|
|
696
|
+
(entityName) =>
|
|
697
|
+
regionNamesSet.has(entityName) && !isWorldEntityName(entityName)
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
@computed private get supportsSortingByExternalIndicators(): boolean {
|
|
702
|
+
// If we can't dynamically load variables, don't ever the option to sort
|
|
703
|
+
// by external indicators
|
|
704
|
+
if (!this.manager.additionalDataLoaderFn) return false
|
|
705
|
+
|
|
706
|
+
// Adding external indicators like population and gdp per capita
|
|
707
|
+
// only makes sense for charts with countries or regions
|
|
708
|
+
return this.entitiesAreCountriesOrRegions
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
@computed private get numericalChartColumns(): CoreColumn[] {
|
|
712
|
+
const {
|
|
713
|
+
activeColumnSlugs = [],
|
|
714
|
+
mapColumnSlug,
|
|
715
|
+
isOnMapTab,
|
|
716
|
+
} = this.manager
|
|
717
|
+
|
|
718
|
+
const activeSlugs = isOnMapTab ? [mapColumnSlug] : activeColumnSlugs
|
|
719
|
+
|
|
720
|
+
return activeSlugs
|
|
721
|
+
.map((slug) => this.table.get(slug))
|
|
722
|
+
.filter((column) => column.hasNumberFormatting)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Map of chart columns that match external sort indicators.
|
|
727
|
+
* For example, if the chart has a column with population data,
|
|
728
|
+
* it will be used instead of the "Population" external indicator.
|
|
729
|
+
*/
|
|
730
|
+
@computed
|
|
731
|
+
private get chartColumnsByExternalSortIndicatorKey(): Partial<
|
|
732
|
+
Record<ExternalSortIndicatorKey, CoreColumn>
|
|
733
|
+
> {
|
|
734
|
+
const matchingColumns: Partial<
|
|
735
|
+
Record<ExternalSortIndicatorKey, CoreColumn>
|
|
736
|
+
> = {}
|
|
737
|
+
for (const external of EXTERNAL_SORT_INDICATOR_DEFINITIONS) {
|
|
738
|
+
const matchingColumn = this.numericalChartColumns.find((column) =>
|
|
739
|
+
external.isMatch(column)
|
|
740
|
+
)
|
|
741
|
+
if (matchingColumn) matchingColumns[external.key] = matchingColumn
|
|
742
|
+
}
|
|
743
|
+
return matchingColumns
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
@computed
|
|
747
|
+
private get externalSortIndicatorDefinitions(): ExternalSortIndicatorDefinition[] {
|
|
748
|
+
if (!this.supportsSortingByExternalIndicators) return []
|
|
749
|
+
|
|
750
|
+
// if the chart has a column that matches an external sort indicator,
|
|
751
|
+
// prefer the chart column over the external indicator
|
|
752
|
+
const matchingKeys = Object.keys(
|
|
753
|
+
this.chartColumnsByExternalSortIndicatorKey
|
|
754
|
+
)
|
|
755
|
+
return EXTERNAL_SORT_INDICATOR_DEFINITIONS.filter(
|
|
756
|
+
(external) => !matchingKeys.includes(external.key)
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
@computed private get projectionColumnInfoByCombinedSlug(): Map<
|
|
761
|
+
ColumnSlug,
|
|
762
|
+
ProjectionColumnInfo
|
|
763
|
+
> {
|
|
764
|
+
if (!this.manager.projectionColumnInfoBySlug) return new Map()
|
|
765
|
+
|
|
766
|
+
const projectionColumnInfoByCombinedSlug: Map<
|
|
767
|
+
ColumnSlug,
|
|
768
|
+
ProjectionColumnInfo
|
|
769
|
+
> = new Map()
|
|
770
|
+
|
|
771
|
+
for (const info of this.manager.projectionColumnInfoBySlug.values()) {
|
|
772
|
+
projectionColumnInfoByCombinedSlug.set(info.combinedSlug, info)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return projectionColumnInfoByCombinedSlug
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private combinedColumnHasHistoricalDataForTime(
|
|
779
|
+
slug: ColumnSlug,
|
|
780
|
+
time: Time
|
|
781
|
+
): boolean | null {
|
|
782
|
+
const isProjectionByTimeAndEntityName =
|
|
783
|
+
this.isProjectionBySlugAndTimeAndEntityName.get(slug)
|
|
784
|
+
|
|
785
|
+
// We don't have data and thus can't make a decision
|
|
786
|
+
if (!isProjectionByTimeAndEntityName) return null
|
|
787
|
+
|
|
788
|
+
const values = isProjectionByTimeAndEntityName.get(time)?.values() ?? []
|
|
789
|
+
return Array.from(values)?.some((isProjection) => !isProjection)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private makeSortColumnLabelForCombinedColumn(
|
|
793
|
+
info: ProjectionColumnInfo,
|
|
794
|
+
time: Time
|
|
795
|
+
): string {
|
|
796
|
+
const { table, isProjectionBySlugAndTimeAndEntityName } = this
|
|
797
|
+
|
|
798
|
+
const projectedLabel = getTitleForSortColumnLabel(
|
|
799
|
+
table.get(info.projectedSlug)
|
|
800
|
+
)
|
|
801
|
+
const historicalLabel = getTitleForSortColumnLabel(
|
|
802
|
+
table.get(info.historicalSlug)
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
const hasHistoricalDataForTime =
|
|
806
|
+
this.combinedColumnHasHistoricalDataForTime(info.combinedSlug, time)
|
|
807
|
+
|
|
808
|
+
// If the data for this column hasn't been computed yet, we can't
|
|
809
|
+
// determine if it has historical data, and thus which label to show.
|
|
810
|
+
// As a workaround, we check if any other (arbitrary) combined column
|
|
811
|
+
// has historical data for this time point, based on the assumption that
|
|
812
|
+
// projection columns typically share the same cut-off time.
|
|
813
|
+
if (hasHistoricalDataForTime === null) {
|
|
814
|
+
const arbitrarySlug = isProjectionBySlugAndTimeAndEntityName
|
|
815
|
+
.keys()
|
|
816
|
+
.next().value
|
|
817
|
+
|
|
818
|
+
if (arbitrarySlug) {
|
|
819
|
+
const hasHistoricalValues =
|
|
820
|
+
this.combinedColumnHasHistoricalDataForTime(
|
|
821
|
+
arbitrarySlug,
|
|
822
|
+
time
|
|
823
|
+
)
|
|
824
|
+
return hasHistoricalValues ? historicalLabel : projectedLabel
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return projectedLabel
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// If there is any historical value for the given time,
|
|
831
|
+
// we choose to show the label of the historical column
|
|
832
|
+
return hasHistoricalDataForTime ? historicalLabel : projectedLabel
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
@computed get sortOptions(): SortDropdownOption[] {
|
|
836
|
+
let options: SortDropdownOption[] = []
|
|
837
|
+
|
|
838
|
+
// the first dropdown option is always the entity name
|
|
839
|
+
options.push({
|
|
840
|
+
type: "name",
|
|
841
|
+
value: this.table.entityNameSlug,
|
|
842
|
+
slug: this.table.entityNameSlug,
|
|
843
|
+
label: "Name",
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
// add external indicators as sort options if applicable
|
|
847
|
+
if (this.supportsSortingByExternalIndicators) {
|
|
848
|
+
EXTERNAL_SORT_INDICATOR_DEFINITIONS.forEach((external) => {
|
|
849
|
+
// if the chart has a column that matches the external
|
|
850
|
+
// indicator, prefer it over the external indicator
|
|
851
|
+
const chartColumn =
|
|
852
|
+
this.chartColumnsByExternalSortIndicatorKey[external.key]
|
|
853
|
+
|
|
854
|
+
if (chartColumn) {
|
|
855
|
+
options.push({
|
|
856
|
+
type: "chart-indicator",
|
|
857
|
+
value: chartColumn.slug,
|
|
858
|
+
slug: chartColumn.slug,
|
|
859
|
+
label: getTitleForSortColumnLabel(chartColumn),
|
|
860
|
+
formattedTime: this.formatTimeForSortColumnLabel(
|
|
861
|
+
this.endTime,
|
|
862
|
+
chartColumn
|
|
863
|
+
),
|
|
864
|
+
})
|
|
865
|
+
} else {
|
|
866
|
+
const column =
|
|
867
|
+
this.interpolatedSortColumnsBySlug[external.slug]
|
|
868
|
+
options.push({
|
|
869
|
+
type: "external-indicator",
|
|
870
|
+
value: external.slug,
|
|
871
|
+
slug: external.slug,
|
|
872
|
+
label: external.label,
|
|
873
|
+
formattedTime: column
|
|
874
|
+
? this.formatTimeForSortColumnLabel(
|
|
875
|
+
this.endTime,
|
|
876
|
+
column
|
|
877
|
+
)
|
|
878
|
+
: undefined,
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
})
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// add the remaining numerical chart columns as sort options,
|
|
885
|
+
// excluding columns that match external indicators (since those
|
|
886
|
+
// have already been added)
|
|
887
|
+
const matchingSlugs = Object.values(
|
|
888
|
+
this.chartColumnsByExternalSortIndicatorKey
|
|
889
|
+
).map((column) => column.slug)
|
|
890
|
+
const columns = this.numericalChartColumns.filter(
|
|
891
|
+
(column) => !matchingSlugs.includes(column.slug)
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
// If we add data columns that combine historical and projected data,
|
|
895
|
+
// then we want to exclude the individual columns from the sort options
|
|
896
|
+
const slugsToExclude: Set<ColumnSlug> = new Set()
|
|
897
|
+
|
|
898
|
+
for (const column of columns) {
|
|
899
|
+
const formattedTime = this.formatTimeForSortColumnLabel(
|
|
900
|
+
this.endTime,
|
|
901
|
+
column
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
const projectionInfo = this.manager.projectionColumnInfoBySlug?.get(
|
|
905
|
+
column.slug
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
// Combine projected and historical data
|
|
909
|
+
if (projectionInfo) {
|
|
910
|
+
const time = this.toColumnCompatibleTime(this.endTime, column)
|
|
911
|
+
const label = this.makeSortColumnLabelForCombinedColumn(
|
|
912
|
+
projectionInfo,
|
|
913
|
+
time
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
options.push({
|
|
917
|
+
type: "chart-indicator",
|
|
918
|
+
value: projectionInfo.combinedSlug,
|
|
919
|
+
slug: projectionInfo.combinedSlug,
|
|
920
|
+
label,
|
|
921
|
+
formattedTime,
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
// We don't need a separate option for the historical data
|
|
925
|
+
// if it's part of the projection series
|
|
926
|
+
slugsToExclude.add(projectionInfo.historicalSlug)
|
|
927
|
+
} else {
|
|
928
|
+
options.push({
|
|
929
|
+
type: "chart-indicator",
|
|
930
|
+
value: column.slug,
|
|
931
|
+
slug: column.slug,
|
|
932
|
+
label: getTitleForSortColumnLabel(column),
|
|
933
|
+
formattedTime,
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
options = options.filter((option) => !slugsToExclude.has(option.value))
|
|
939
|
+
|
|
940
|
+
return options
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
@computed get sortValue(): SortDropdownOption | null {
|
|
944
|
+
return (
|
|
945
|
+
this.sortOptions.find(
|
|
946
|
+
(option) => option.slug === this.sortConfig.slug
|
|
947
|
+
) ?? null
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private isEntityNameSlug(slug: ColumnSlug): boolean {
|
|
952
|
+
return slug === this.table.entityNameSlug
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
@computed private get isSortedByName(): boolean {
|
|
956
|
+
return this.isEntityNameSlug(this.sortConfig.slug)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
@computed private get entityType(): { singular: string; plural: string } {
|
|
960
|
+
const entitiesAreCountriesOrRegions =
|
|
961
|
+
this.manager.isOnMapTab ||
|
|
962
|
+
(!this.manager.entityType && this.entitiesAreCountriesOrRegions)
|
|
963
|
+
|
|
964
|
+
if (entitiesAreCountriesOrRegions)
|
|
965
|
+
return this.someEntitiesAreRegions
|
|
966
|
+
? {
|
|
967
|
+
singular: "country or region",
|
|
968
|
+
plural: "countries and regions",
|
|
969
|
+
}
|
|
970
|
+
: { singular: "country", plural: "countries" }
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
singular: this.manager.entityType ?? DEFAULT_GRAPHER_ENTITY_TYPE,
|
|
974
|
+
plural:
|
|
975
|
+
this.manager.entityTypePlural ??
|
|
976
|
+
DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL,
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
@computed private get selectionArray(): SelectionArray {
|
|
981
|
+
return makeSelectionArray(
|
|
982
|
+
this.props.selection ?? this.manager.selection
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
@computed private get allEntitiesSelected(): boolean {
|
|
987
|
+
return (
|
|
988
|
+
this.selectionArray.numSelectedEntities ===
|
|
989
|
+
this.availableEntityNames.length
|
|
990
|
+
)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
@computed private get availableEntityNames(): string[] {
|
|
994
|
+
return this.table.availableEntityNames
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
@computed private get availableEntityNameSet(): Set<string> {
|
|
998
|
+
return this.table.availableEntityNameSet
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
@computed private get availableEntities(): SearchableEntity[] {
|
|
1002
|
+
const langs = getUserNavigatorLanguagesNonEnglish()
|
|
1003
|
+
|
|
1004
|
+
return this.availableEntityNames.map((entityName) => {
|
|
1005
|
+
const searchableEntity: SearchableEntity = {
|
|
1006
|
+
name: entityName,
|
|
1007
|
+
sortColumnValues: {},
|
|
1008
|
+
alternativeNames: getRegionAlternativeNames(entityName, langs),
|
|
1009
|
+
regionInfo: getRegionByName(entityName),
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (this.localEntityNames) {
|
|
1013
|
+
searchableEntity.isLocal =
|
|
1014
|
+
this.localEntityNames.includes(entityName)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
for (const column of this.interpolatedSortColumns) {
|
|
1018
|
+
const time = this.toColumnCompatibleTime(this.endTime, column)
|
|
1019
|
+
|
|
1020
|
+
// If we're dealing with a mixed column that has historical and
|
|
1021
|
+
// projected data for the given time, then we choose not to
|
|
1022
|
+
// show projected data since the dropdown is labelled with the
|
|
1023
|
+
// display name of the historical column.
|
|
1024
|
+
const projectionInfo =
|
|
1025
|
+
this.projectionColumnInfoByCombinedSlug.get(column.slug)
|
|
1026
|
+
if (projectionInfo) {
|
|
1027
|
+
const isProjectedValue =
|
|
1028
|
+
this.isProjectionBySlugAndTimeAndEntityName
|
|
1029
|
+
?.get(projectionInfo.combinedSlug)
|
|
1030
|
+
?.get(time)
|
|
1031
|
+
?.get(entityName)
|
|
1032
|
+
|
|
1033
|
+
if (isProjectedValue) {
|
|
1034
|
+
const hasHistoricalValues =
|
|
1035
|
+
this.combinedColumnHasHistoricalDataForTime(
|
|
1036
|
+
projectionInfo.combinedSlug,
|
|
1037
|
+
time
|
|
1038
|
+
)
|
|
1039
|
+
if (hasHistoricalValues) continue
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const row = column.owidRowByEntityNameAndTime
|
|
1044
|
+
.get(entityName)
|
|
1045
|
+
?.get(time)
|
|
1046
|
+
|
|
1047
|
+
searchableEntity.sortColumnValues[column.slug] = row?.value
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return searchableEntity
|
|
1051
|
+
})
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
@computed private get filteredAvailableEntities(): SearchableEntity[] {
|
|
1055
|
+
const { availableEntities, entityFilter } = this
|
|
1056
|
+
|
|
1057
|
+
// Sort locals and maybe World to the top if we are looking at all entites
|
|
1058
|
+
if (entityFilter === "all")
|
|
1059
|
+
return this.sortEntities(availableEntities, {
|
|
1060
|
+
sortLocalsToTop: true,
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
const entityNameSet = new Set(
|
|
1064
|
+
this.manager.entityNamesByRegionType?.get(entityFilter) ?? []
|
|
1065
|
+
)
|
|
1066
|
+
const filteredAvailableEntities = availableEntities.filter((entity) =>
|
|
1067
|
+
entityNameSet.has(entity.name)
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
return this.sortEntities(filteredAvailableEntities, {
|
|
1071
|
+
// Sort locals and maybe World to the top if looking at the long countries list, not for others
|
|
1072
|
+
sortLocalsToTop: entityFilter === "countries",
|
|
1073
|
+
})
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private sortEntities(
|
|
1077
|
+
entities: SearchableEntity[],
|
|
1078
|
+
options: { sortLocalsToTop: boolean } = {
|
|
1079
|
+
sortLocalsToTop: true,
|
|
1080
|
+
}
|
|
1081
|
+
): SearchableEntity[] {
|
|
1082
|
+
const { sortConfig } = this
|
|
1083
|
+
const byName = (e: SearchableEntity) => e.name
|
|
1084
|
+
const byValue = (e: SearchableEntity) =>
|
|
1085
|
+
e.sortColumnValues[sortConfig.slug]
|
|
1086
|
+
|
|
1087
|
+
// Name sorting
|
|
1088
|
+
if (this.isSortedByName) {
|
|
1089
|
+
// Simple name sort without local/world prioritization
|
|
1090
|
+
if (!options.sortLocalsToTop) {
|
|
1091
|
+
return _.orderBy(entities, byName, sortConfig.order)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Name sort with locals on top and World between locals and others
|
|
1095
|
+
// We include "World" here (unlike when sorting by values, see notes below) because
|
|
1096
|
+
// here it is useful.
|
|
1097
|
+
const [[worldEntity], rest] = _.partition(entities, (e) =>
|
|
1098
|
+
isWorldEntityName(e.name)
|
|
1099
|
+
)
|
|
1100
|
+
const [locals, others] = _.partition(rest, (e) => e.isLocal)
|
|
1101
|
+
|
|
1102
|
+
const sortedLocals = _.sortBy(locals, (e) =>
|
|
1103
|
+
this.localEntityNames?.indexOf(e.name)
|
|
1104
|
+
)
|
|
1105
|
+
const sortedOthers = _.orderBy(others, byName, sortConfig.order)
|
|
1106
|
+
|
|
1107
|
+
return excludeUndefined([
|
|
1108
|
+
...sortedLocals,
|
|
1109
|
+
worldEntity,
|
|
1110
|
+
...sortedOthers,
|
|
1111
|
+
])
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Value sorting: missing values go last
|
|
1115
|
+
const [withValues, withoutValues] = _.partition(entities, (e) =>
|
|
1116
|
+
isFiniteWithGuard(byValue(e))
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
let sortedWithValues: SearchableEntity[]
|
|
1120
|
+
if (options.sortLocalsToTop) {
|
|
1121
|
+
// We're not specially handling "World" here because we want to see the sorted
|
|
1122
|
+
// items and the user should understand that these are sorted. we already pull
|
|
1123
|
+
// up to three items (Germany, EU 27, Europe) up to the top and don't want to add
|
|
1124
|
+
// a fourth item in such cases that is obviously not sorted and that doesn't have
|
|
1125
|
+
// a "local" icon indicator
|
|
1126
|
+
const [localWith, otherWith] = _.partition(
|
|
1127
|
+
withValues,
|
|
1128
|
+
(e) => e.isLocal
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
// Locals: keep user-preferred order (by localEntityNames index)
|
|
1132
|
+
const sortedLocalWith = _.sortBy(localWith, (e) =>
|
|
1133
|
+
this.localEntityNames?.indexOf(e.name)
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
// Others: sort by value according to selected order
|
|
1137
|
+
const sortedOtherWith = _.orderBy(
|
|
1138
|
+
otherWith,
|
|
1139
|
+
byValue,
|
|
1140
|
+
sortConfig.order
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
sortedWithValues = excludeUndefined([
|
|
1144
|
+
...sortedLocalWith,
|
|
1145
|
+
...sortedOtherWith,
|
|
1146
|
+
])
|
|
1147
|
+
} else {
|
|
1148
|
+
sortedWithValues = _.orderBy(withValues, byValue, sortConfig.order)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const sortedWithoutValues = _.orderBy(
|
|
1152
|
+
withoutValues,
|
|
1153
|
+
byName,
|
|
1154
|
+
SortOrder.asc
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
return [...sortedWithValues, ...sortedWithoutValues]
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
@computed get isMultiMode(): boolean {
|
|
1161
|
+
return !this.manager.canChangeEntity
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
@computed get fuzzy(): FuzzySearch<SearchableEntity> {
|
|
1165
|
+
return FuzzySearch.withKeyArray(
|
|
1166
|
+
this.filteredAvailableEntities,
|
|
1167
|
+
(entity) => [entity.name, ...(entity.alternativeNames ?? [])],
|
|
1168
|
+
(entity) => entity.name
|
|
1169
|
+
)
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
@computed get searchResults(): SearchableEntity[] | undefined {
|
|
1173
|
+
if (!this.searchInput) return undefined
|
|
1174
|
+
return this.fuzzy.search(this.searchInput)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
@computed get selectedEntities(): SearchableEntity[] {
|
|
1178
|
+
const selected = this.availableEntities.filter((entity) =>
|
|
1179
|
+
this.isEntitySelected(entity)
|
|
1180
|
+
)
|
|
1181
|
+
return this.sortEntities(selected, { sortLocalsToTop: false })
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
@action.bound onTitleClick(): void {
|
|
1185
|
+
if (this.scrollableContainer.current)
|
|
1186
|
+
this.scrollableContainer.current.scrollTop = 0
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
@action.bound onSearchKeyDown(e: KeyboardEvent): void {
|
|
1190
|
+
const { searchResults } = this
|
|
1191
|
+
if (e.key === "Enter" && searchResults && searchResults.length > 0) {
|
|
1192
|
+
this.onChange(searchResults[0].name)
|
|
1193
|
+
this.clearSearchInput()
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
@action.bound onDeselectEntities(entityNames: EntityName[]): void {
|
|
1198
|
+
for (const entityName of entityNames) {
|
|
1199
|
+
this.manager.onDeselectEntity?.(entityName)
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private timeoutId?: number
|
|
1204
|
+
@action.bound onChange(entityName: EntityName): void {
|
|
1205
|
+
if (this.isMultiMode) {
|
|
1206
|
+
this.selectionArray.toggleSelection(entityName)
|
|
1207
|
+
|
|
1208
|
+
if (this.selectionArray.selectedSet.has(entityName)) {
|
|
1209
|
+
this.manager.onSelectEntity?.(entityName)
|
|
1210
|
+
this.manager.logEntitySelectorEvent?.("select", entityName)
|
|
1211
|
+
} else {
|
|
1212
|
+
this.manager.onDeselectEntity?.(entityName)
|
|
1213
|
+
this.manager.logEntitySelectorEvent?.("deselect", entityName)
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (this.selectionArray.numSelectedEntities === 0) {
|
|
1217
|
+
this.manager.onClearEntities?.()
|
|
1218
|
+
}
|
|
1219
|
+
} else {
|
|
1220
|
+
const dropEntityNames = this.selectionArray.selectedEntityNames
|
|
1221
|
+
this.selectionArray.setSelectedEntities([entityName])
|
|
1222
|
+
this.manager.onSelectEntity?.(entityName)
|
|
1223
|
+
this.manager.logEntitySelectorEvent?.("select", entityName)
|
|
1224
|
+
this.onDeselectEntities(dropEntityNames)
|
|
1225
|
+
|
|
1226
|
+
// close the modal or drawer automatically after selection
|
|
1227
|
+
if (this.manager.isEntitySelectorModalOrDrawerOpen) {
|
|
1228
|
+
this.timeoutId = window.setTimeout(() => this.close(), 200)
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
this.clearSearchInput()
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
@action.bound onClear(): void {
|
|
1236
|
+
const dropEntityNames = this.selectionArray.selectedEntityNames
|
|
1237
|
+
this.selectionArray.clearSelection()
|
|
1238
|
+
this.onDeselectEntities(dropEntityNames)
|
|
1239
|
+
this.manager.onClearEntities?.()
|
|
1240
|
+
|
|
1241
|
+
this.resetEntityFilter()
|
|
1242
|
+
|
|
1243
|
+
this.manager.logEntitySelectorEvent?.("clear")
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
@action.bound async loadAndSetExternalSortColumn(
|
|
1247
|
+
external: ExternalSortIndicatorDefinition
|
|
1248
|
+
): Promise<void> {
|
|
1249
|
+
const { slug, indicatorId } = external
|
|
1250
|
+
const { additionalDataLoaderFn } = this.manager
|
|
1251
|
+
|
|
1252
|
+
// the indicator has already been loaded
|
|
1253
|
+
if (this.interpolatedSortColumnsBySlug[slug]) return
|
|
1254
|
+
|
|
1255
|
+
// load the external indicator
|
|
1256
|
+
try {
|
|
1257
|
+
this.set({ isLoadingExternalSortColumn: true })
|
|
1258
|
+
if (additionalDataLoaderFn === undefined)
|
|
1259
|
+
throw new Error(
|
|
1260
|
+
"additionalDataLoaderFn is not set, can't load sort variables on demand"
|
|
1261
|
+
)
|
|
1262
|
+
const variable = await additionalDataLoaderFn(indicatorId)
|
|
1263
|
+
const variableTable = buildVariableTable(variable)
|
|
1264
|
+
const column = variableTable
|
|
1265
|
+
.filterByEntityNames(this.inputTable.availableEntityNames)
|
|
1266
|
+
.interpolateColumnWithTolerance(slug, {
|
|
1267
|
+
toleranceOverride: Infinity,
|
|
1268
|
+
})
|
|
1269
|
+
.get(slug)
|
|
1270
|
+
if (column) this.setInterpolatedSortColumn(column)
|
|
1271
|
+
} catch {
|
|
1272
|
+
console.error(`Failed to load variable with id ${indicatorId}`)
|
|
1273
|
+
} finally {
|
|
1274
|
+
this.set({ isLoadingExternalSortColumn: false })
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
@action.bound async onChangeSortSlug(
|
|
1279
|
+
selected: SortDropdownOption | null
|
|
1280
|
+
): Promise<void> {
|
|
1281
|
+
if (selected) {
|
|
1282
|
+
const { slug } = selected
|
|
1283
|
+
|
|
1284
|
+
// if an external indicator has been selected, load it
|
|
1285
|
+
const external = this.externalSortIndicatorDefinitions.find(
|
|
1286
|
+
(external) => external.slug === slug
|
|
1287
|
+
)
|
|
1288
|
+
if (external) await this.loadAndSetExternalSortColumn(external)
|
|
1289
|
+
|
|
1290
|
+
// apply tolerance if an indicator is selected for the first time
|
|
1291
|
+
if (!external && !this.isEntityNameSlug(slug)) {
|
|
1292
|
+
this.setInterpolatedSortColumnBySlug(slug)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
this.updateSortSlug(slug)
|
|
1296
|
+
|
|
1297
|
+
const sortByTarget = this.isEntityNameSlug(slug)
|
|
1298
|
+
? "name"
|
|
1299
|
+
: external
|
|
1300
|
+
? external.key
|
|
1301
|
+
: "value"
|
|
1302
|
+
this.manager.logEntitySelectorEvent("sortBy", sortByTarget)
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
@action.bound onChangeSortOrder(): void {
|
|
1307
|
+
this.toggleSortOrder()
|
|
1308
|
+
this.manager.logEntitySelectorEvent("sortOrder")
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
@action.bound private close(): void {
|
|
1312
|
+
// if rendered into a drawer, we use a method provided by the
|
|
1313
|
+
// `<SlideInDrawer />` component so that closing the drawer is animated
|
|
1314
|
+
if (this.context.toggleDrawerVisibility) {
|
|
1315
|
+
this.context.toggleDrawerVisibility()
|
|
1316
|
+
} else {
|
|
1317
|
+
this.manager.isEntitySelectorModalOrDrawerOpen = false
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
@computed get filterOptions(): FilterDropdownOption[] {
|
|
1322
|
+
const { entityRegionTypeGroups = [] } = this.manager
|
|
1323
|
+
|
|
1324
|
+
const options: FilterDropdownOption[] = entityRegionTypeGroups
|
|
1325
|
+
.map(({ regionType, entityNames }) => ({
|
|
1326
|
+
value: regionType,
|
|
1327
|
+
label: entityRegionTypeLabels[regionType],
|
|
1328
|
+
count: entityNames.filter((entityName) =>
|
|
1329
|
+
this.availableEntityNameSet.has(entityName)
|
|
1330
|
+
).length,
|
|
1331
|
+
}))
|
|
1332
|
+
.filter(({ count }) => count > 0)
|
|
1333
|
+
|
|
1334
|
+
return [
|
|
1335
|
+
{
|
|
1336
|
+
value: "all",
|
|
1337
|
+
label: "All",
|
|
1338
|
+
count: this.availableEntities.length,
|
|
1339
|
+
},
|
|
1340
|
+
...options,
|
|
1341
|
+
]
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
@action.bound private onChangeEntityFilter(
|
|
1345
|
+
selected: FilterDropdownOption | null
|
|
1346
|
+
): void {
|
|
1347
|
+
if (selected) {
|
|
1348
|
+
const option = selected
|
|
1349
|
+
this.set({ entityFilter: option.value })
|
|
1350
|
+
|
|
1351
|
+
this.manager.logEntitySelectorEvent("filterBy", option.value)
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
@computed private get filterValue(): FilterDropdownOption {
|
|
1356
|
+
return (
|
|
1357
|
+
this.filterOptions.find(
|
|
1358
|
+
(option) => option.value === this.entityFilter
|
|
1359
|
+
) ?? this.filterOptions[0]
|
|
1360
|
+
)
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
@computed get shouldShowFilterBar(): boolean {
|
|
1364
|
+
return (
|
|
1365
|
+
this.filterOptions.length > 1 &&
|
|
1366
|
+
this.filterOptions[0].count !== this.filterOptions[1].count
|
|
1367
|
+
)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
private renderFilterBar(): React.ReactElement {
|
|
1371
|
+
return (
|
|
1372
|
+
<div className="entity-selector__filter-bar">
|
|
1373
|
+
<Dropdown<FilterDropdownOption>
|
|
1374
|
+
options={this.filterOptions}
|
|
1375
|
+
onChange={this.onChangeEntityFilter}
|
|
1376
|
+
value={this.filterValue}
|
|
1377
|
+
renderTriggerValue={renderFilterTriggerValue}
|
|
1378
|
+
renderMenuOption={renderFilterMenuOption}
|
|
1379
|
+
aria-label="Filter by type"
|
|
1380
|
+
portalContainer={
|
|
1381
|
+
this.scrollableContainer.current ?? undefined
|
|
1382
|
+
}
|
|
1383
|
+
/>
|
|
1384
|
+
</div>
|
|
1385
|
+
)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
private renderSearchBar(): React.ReactElement {
|
|
1389
|
+
return (
|
|
1390
|
+
<div className="entity-selector__search-bar">
|
|
1391
|
+
<SearchField
|
|
1392
|
+
ref={this.searchFieldRef}
|
|
1393
|
+
value={this.searchInput}
|
|
1394
|
+
onChange={(value) => this.set({ searchInput: value })}
|
|
1395
|
+
onClear={() => this.clearSearchInput()}
|
|
1396
|
+
placeholder={`Search for ${a(
|
|
1397
|
+
this.searchPlaceholderEntityType
|
|
1398
|
+
)}`}
|
|
1399
|
+
trackNote="entity_selector_search"
|
|
1400
|
+
onKeyDown={this.onSearchKeyDown}
|
|
1401
|
+
/>
|
|
1402
|
+
</div>
|
|
1403
|
+
)
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
private renderSortBar(): React.ReactElement {
|
|
1407
|
+
return (
|
|
1408
|
+
<div className="entity-selector__sort-bar">
|
|
1409
|
+
<div className="entity-selector__sort-dropdown-and-button">
|
|
1410
|
+
<Dropdown<SortDropdownOption>
|
|
1411
|
+
className="entity-selector__sort-dropdown"
|
|
1412
|
+
menuClassName="entity-selector__sort-dropdown-menu"
|
|
1413
|
+
options={this.sortOptions}
|
|
1414
|
+
onChange={this.onChangeSortSlug}
|
|
1415
|
+
value={this.sortValue}
|
|
1416
|
+
isLoading={this.isLoadingExternalSortColumn}
|
|
1417
|
+
renderTriggerValue={renderSortTriggerValue}
|
|
1418
|
+
renderMenuOption={renderSortMenuOption}
|
|
1419
|
+
aria-label="Sort by"
|
|
1420
|
+
portalContainer={
|
|
1421
|
+
this.scrollableContainer.current ?? undefined
|
|
1422
|
+
}
|
|
1423
|
+
/>
|
|
1424
|
+
<button
|
|
1425
|
+
type="button"
|
|
1426
|
+
className="sort"
|
|
1427
|
+
onClick={this.onChangeSortOrder}
|
|
1428
|
+
>
|
|
1429
|
+
<SortIcon
|
|
1430
|
+
type={this.isSortedByName ? "text" : "numeric"}
|
|
1431
|
+
order={this.sortConfig.order}
|
|
1432
|
+
/>
|
|
1433
|
+
</button>
|
|
1434
|
+
</div>
|
|
1435
|
+
</div>
|
|
1436
|
+
)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
private renderSearchResults(): React.ReactElement {
|
|
1440
|
+
if (!this.searchResults || this.searchResults.length === 0) {
|
|
1441
|
+
return (
|
|
1442
|
+
<div className="entity-search-results grapher_body-3-regular grapher_light">
|
|
1443
|
+
There is no data for the {this.entityType.singular} you are
|
|
1444
|
+
looking for. You may want to try using different keywords or
|
|
1445
|
+
checking for typos.
|
|
1446
|
+
</div>
|
|
1447
|
+
)
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
return (
|
|
1451
|
+
<ul className="entity-search-results">
|
|
1452
|
+
{this.searchResults.map((entity) => (
|
|
1453
|
+
<li key={entity.name}>
|
|
1454
|
+
<SelectableEntity
|
|
1455
|
+
name={entity.name}
|
|
1456
|
+
type={this.isMultiMode ? "checkbox" : "radio"}
|
|
1457
|
+
checked={this.isEntitySelected(entity)}
|
|
1458
|
+
bar={this.getBarConfigForEntity(entity)}
|
|
1459
|
+
onChange={this.onChange}
|
|
1460
|
+
isLocal={entity.isLocal}
|
|
1461
|
+
isMuted={this.isEntityMuted(entity.name)}
|
|
1462
|
+
/>
|
|
1463
|
+
</li>
|
|
1464
|
+
))}
|
|
1465
|
+
</ul>
|
|
1466
|
+
)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private renderAllEntitiesInSingleMode(): React.ReactElement {
|
|
1470
|
+
const { filteredAvailableEntities, shouldShowFilterBar } = this
|
|
1471
|
+
|
|
1472
|
+
return (
|
|
1473
|
+
<>
|
|
1474
|
+
{shouldShowFilterBar && this.renderFilterBar()}
|
|
1475
|
+
<ul className={cx({ "hide-top-border": shouldShowFilterBar })}>
|
|
1476
|
+
{filteredAvailableEntities.map((entity) => (
|
|
1477
|
+
<li key={entity.name}>
|
|
1478
|
+
<SelectableEntity
|
|
1479
|
+
name={entity.name}
|
|
1480
|
+
type="radio"
|
|
1481
|
+
checked={this.isEntitySelected(entity)}
|
|
1482
|
+
bar={this.getBarConfigForEntity(entity)}
|
|
1483
|
+
onChange={this.onChange}
|
|
1484
|
+
isLocal={entity.isLocal}
|
|
1485
|
+
isMuted={this.isEntityMuted(entity.name)}
|
|
1486
|
+
/>
|
|
1487
|
+
</li>
|
|
1488
|
+
))}
|
|
1489
|
+
</ul>
|
|
1490
|
+
</>
|
|
1491
|
+
)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
@computed private get selectedSortColumn(): CoreColumn | undefined {
|
|
1495
|
+
const { sortConfig } = this
|
|
1496
|
+
if (this.isSortedByName) return undefined
|
|
1497
|
+
return this.interpolatedSortColumnsBySlug[sortConfig.slug]
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
@computed private get selectedSortColumnMaxValue(): number | undefined {
|
|
1501
|
+
const { selectedSortColumn, endTime } = this
|
|
1502
|
+
if (!selectedSortColumn) return undefined
|
|
1503
|
+
const time = this.toColumnCompatibleTime(endTime, selectedSortColumn)
|
|
1504
|
+
const values = selectedSortColumn.valuesByTime.get(time)
|
|
1505
|
+
return _.max(values)
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
@computed private get barScale(): ScaleLinear<number, number> {
|
|
1509
|
+
return scaleLinear()
|
|
1510
|
+
.domain([0, this.selectedSortColumnMaxValue ?? 1])
|
|
1511
|
+
.range([0, 1])
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private getBarConfigForEntity(
|
|
1515
|
+
entity: SearchableEntity
|
|
1516
|
+
): BarConfig | undefined {
|
|
1517
|
+
const { selectedSortColumn, barScale } = this
|
|
1518
|
+
|
|
1519
|
+
if (!selectedSortColumn) return undefined
|
|
1520
|
+
|
|
1521
|
+
const value = entity.sortColumnValues[selectedSortColumn.slug]
|
|
1522
|
+
|
|
1523
|
+
if (!isFiniteWithGuard(value)) return { formattedValue: "No data" }
|
|
1524
|
+
|
|
1525
|
+
const formattedValue =
|
|
1526
|
+
selectedSortColumn.formatValueShortWithAbbreviations(value)
|
|
1527
|
+
|
|
1528
|
+
if (value < 0) return { formattedValue, width: 0 }
|
|
1529
|
+
|
|
1530
|
+
return {
|
|
1531
|
+
formattedValue:
|
|
1532
|
+
selectedSortColumn.formatValueShortWithAbbreviations(value),
|
|
1533
|
+
width: R.clamp(barScale(value), { min: 0, max: 1 }),
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private isEntitySelected(entity: SearchableEntity): boolean {
|
|
1538
|
+
return this.selectionArray.selectedSet.has(entity.name)
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
private renderAllEntitiesInMultiMode(): React.ReactElement {
|
|
1542
|
+
const {
|
|
1543
|
+
filteredAvailableEntities,
|
|
1544
|
+
selectedEntities,
|
|
1545
|
+
shouldShowFilterBar,
|
|
1546
|
+
} = this
|
|
1547
|
+
const { numSelectedEntities, selectedEntityNames } = this.selectionArray
|
|
1548
|
+
|
|
1549
|
+
// having a "Selection" and "Available entities" section both looks odd
|
|
1550
|
+
// when all entities are currently selected and there are only a few of them
|
|
1551
|
+
const hasFewEntities = filteredAvailableEntities.length < 10
|
|
1552
|
+
const shouldHideAvailableEntities =
|
|
1553
|
+
!shouldShowFilterBar && hasFewEntities && this.allEntitiesSelected
|
|
1554
|
+
|
|
1555
|
+
const availableEntitiesTitle = this.mapConfig.is2dContinentActive()
|
|
1556
|
+
? `Countries in ${MAP_REGION_LABELS[this.mapConfig.region]}`
|
|
1557
|
+
: `All ${this.entityType.plural}`
|
|
1558
|
+
|
|
1559
|
+
return (
|
|
1560
|
+
<Flipper
|
|
1561
|
+
spring={{ stiffness: 300, damping: 33 }}
|
|
1562
|
+
flipKey={selectedEntityNames.join(",")}
|
|
1563
|
+
>
|
|
1564
|
+
<div className="entity-section">
|
|
1565
|
+
{selectedEntities.length > 0 && (
|
|
1566
|
+
<Flipped flipId="__selection" translate opacity>
|
|
1567
|
+
<div className="entity-section__header">
|
|
1568
|
+
<div className="entity-section__title grapher_body-3-regular-italic grapher_light">
|
|
1569
|
+
Selection{" "}
|
|
1570
|
+
{numSelectedEntities > 0 &&
|
|
1571
|
+
`(${numSelectedEntities})`}
|
|
1572
|
+
</div>
|
|
1573
|
+
<button type="button" onClick={this.onClear}>
|
|
1574
|
+
Clear
|
|
1575
|
+
</button>
|
|
1576
|
+
</div>
|
|
1577
|
+
</Flipped>
|
|
1578
|
+
)}
|
|
1579
|
+
<ul>
|
|
1580
|
+
{selectedEntities.map((entity, entityIndex) => (
|
|
1581
|
+
<FlippedListItem
|
|
1582
|
+
index={entityIndex}
|
|
1583
|
+
key={entity.name}
|
|
1584
|
+
flipId={`selected_${makeSafeForCSS(
|
|
1585
|
+
entity.name
|
|
1586
|
+
)}`}
|
|
1587
|
+
>
|
|
1588
|
+
<SelectableEntity
|
|
1589
|
+
name={entity.name}
|
|
1590
|
+
type="checkbox"
|
|
1591
|
+
checked={true}
|
|
1592
|
+
bar={this.getBarConfigForEntity(entity)}
|
|
1593
|
+
onChange={this.onChange}
|
|
1594
|
+
isLocal={entity.isLocal}
|
|
1595
|
+
isMuted={this.isEntityMuted(entity.name)}
|
|
1596
|
+
/>
|
|
1597
|
+
</FlippedListItem>
|
|
1598
|
+
))}
|
|
1599
|
+
</ul>
|
|
1600
|
+
</div>
|
|
1601
|
+
|
|
1602
|
+
{shouldShowFilterBar && (
|
|
1603
|
+
<Flipped flipId="__filter-bar" translate opacity>
|
|
1604
|
+
{this.renderFilterBar()}
|
|
1605
|
+
</Flipped>
|
|
1606
|
+
)}
|
|
1607
|
+
|
|
1608
|
+
{!shouldHideAvailableEntities && (
|
|
1609
|
+
<div
|
|
1610
|
+
className={cx("entity-section", {
|
|
1611
|
+
"hide-top-border": shouldShowFilterBar,
|
|
1612
|
+
})}
|
|
1613
|
+
>
|
|
1614
|
+
{!shouldShowFilterBar && (
|
|
1615
|
+
<Flipped flipId="__available" translate opacity>
|
|
1616
|
+
<div className="entity-section__title grapher_body-3-regular-italic grapher_light">
|
|
1617
|
+
{availableEntitiesTitle}
|
|
1618
|
+
</div>
|
|
1619
|
+
</Flipped>
|
|
1620
|
+
)}
|
|
1621
|
+
|
|
1622
|
+
<ul>
|
|
1623
|
+
{filteredAvailableEntities.map(
|
|
1624
|
+
(entity, entityIndex) => (
|
|
1625
|
+
<FlippedListItem
|
|
1626
|
+
index={entityIndex}
|
|
1627
|
+
key={entity.name}
|
|
1628
|
+
flipId={`available_${makeSafeForCSS(
|
|
1629
|
+
entity.name
|
|
1630
|
+
)}`}
|
|
1631
|
+
>
|
|
1632
|
+
<SelectableEntity
|
|
1633
|
+
name={entity.name}
|
|
1634
|
+
type="checkbox"
|
|
1635
|
+
checked={this.isEntitySelected(
|
|
1636
|
+
entity
|
|
1637
|
+
)}
|
|
1638
|
+
bar={this.getBarConfigForEntity(
|
|
1639
|
+
entity
|
|
1640
|
+
)}
|
|
1641
|
+
onChange={this.onChange}
|
|
1642
|
+
isLocal={entity.isLocal}
|
|
1643
|
+
isMuted={this.isEntityMuted(
|
|
1644
|
+
entity.name
|
|
1645
|
+
)}
|
|
1646
|
+
/>
|
|
1647
|
+
</FlippedListItem>
|
|
1648
|
+
)
|
|
1649
|
+
)}
|
|
1650
|
+
</ul>
|
|
1651
|
+
</div>
|
|
1652
|
+
)}
|
|
1653
|
+
</Flipper>
|
|
1654
|
+
)
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
override render(): React.ReactElement {
|
|
1658
|
+
return (
|
|
1659
|
+
<div className="entity-selector">
|
|
1660
|
+
<OverlayHeader
|
|
1661
|
+
title={this.title}
|
|
1662
|
+
onTitleClick={this.onTitleClick}
|
|
1663
|
+
onDismiss={this.close}
|
|
1664
|
+
/>
|
|
1665
|
+
|
|
1666
|
+
{this.renderSearchBar()}
|
|
1667
|
+
|
|
1668
|
+
<div ref={this.scrollableContainer} className="scrollable">
|
|
1669
|
+
{!this.searchInput &&
|
|
1670
|
+
this.sortOptions.length > 1 &&
|
|
1671
|
+
this.renderSortBar()}
|
|
1672
|
+
|
|
1673
|
+
<div
|
|
1674
|
+
ref={this.contentRef}
|
|
1675
|
+
className="entity-selector__content"
|
|
1676
|
+
>
|
|
1677
|
+
{this.searchInput
|
|
1678
|
+
? this.renderSearchResults()
|
|
1679
|
+
: this.isMultiMode
|
|
1680
|
+
? this.renderAllEntitiesInMultiMode()
|
|
1681
|
+
: this.renderAllEntitiesInSingleMode()}
|
|
1682
|
+
</div>
|
|
1683
|
+
</div>
|
|
1684
|
+
</div>
|
|
1685
|
+
)
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
type BarConfig = { formattedValue: string; width?: number }
|
|
1690
|
+
|
|
1691
|
+
function SelectableEntity({
|
|
1692
|
+
name,
|
|
1693
|
+
checked,
|
|
1694
|
+
type,
|
|
1695
|
+
bar,
|
|
1696
|
+
onChange,
|
|
1697
|
+
isLocal,
|
|
1698
|
+
isMuted,
|
|
1699
|
+
}: {
|
|
1700
|
+
name: string
|
|
1701
|
+
checked: boolean
|
|
1702
|
+
type: "checkbox" | "radio"
|
|
1703
|
+
bar?: BarConfig
|
|
1704
|
+
onChange: (entityName: EntityName) => void
|
|
1705
|
+
isLocal?: boolean
|
|
1706
|
+
isMuted?: boolean
|
|
1707
|
+
}) {
|
|
1708
|
+
const Input = {
|
|
1709
|
+
checkbox: Checkbox,
|
|
1710
|
+
radio: RadioButton,
|
|
1711
|
+
}[type]
|
|
1712
|
+
|
|
1713
|
+
const nameWords = name.split(" ")
|
|
1714
|
+
const label = isLocal ? (
|
|
1715
|
+
<span className="label-with-location-icon">
|
|
1716
|
+
{nameWords.slice(0, -1).join(" ")}{" "}
|
|
1717
|
+
<span className="label-with-location-icon label-with-location-icon--no-line-break">
|
|
1718
|
+
{nameWords[nameWords.length - 1]}
|
|
1719
|
+
<Tippy
|
|
1720
|
+
content="Your current location"
|
|
1721
|
+
theme="grapher-explanation--short"
|
|
1722
|
+
placement="top"
|
|
1723
|
+
>
|
|
1724
|
+
<FontAwesomeIcon icon={faLocationArrow} />
|
|
1725
|
+
</Tippy>
|
|
1726
|
+
</span>
|
|
1727
|
+
</span>
|
|
1728
|
+
) : (
|
|
1729
|
+
name
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
return (
|
|
1733
|
+
<div
|
|
1734
|
+
className={cx("selectable-entity", {
|
|
1735
|
+
"selectable-entity--with-bar": bar && bar.width !== undefined,
|
|
1736
|
+
"selectable-entity--muted": isMuted,
|
|
1737
|
+
})}
|
|
1738
|
+
>
|
|
1739
|
+
{bar && bar.width !== undefined && (
|
|
1740
|
+
<div className="bar" style={{ width: `${bar.width * 100}%` }} />
|
|
1741
|
+
)}
|
|
1742
|
+
<Input
|
|
1743
|
+
label={label}
|
|
1744
|
+
checked={checked}
|
|
1745
|
+
onChange={() => onChange(name)}
|
|
1746
|
+
/>
|
|
1747
|
+
{bar && (
|
|
1748
|
+
<span className="value grapher_label-1-regular">
|
|
1749
|
+
{bar.formattedValue}
|
|
1750
|
+
</span>
|
|
1751
|
+
)}
|
|
1752
|
+
</div>
|
|
1753
|
+
)
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function FlippedListItem({
|
|
1757
|
+
flipId,
|
|
1758
|
+
index = 0,
|
|
1759
|
+
children,
|
|
1760
|
+
}: {
|
|
1761
|
+
flipId: string
|
|
1762
|
+
index?: number
|
|
1763
|
+
children: React.ReactNode
|
|
1764
|
+
}) {
|
|
1765
|
+
return (
|
|
1766
|
+
<Flipped
|
|
1767
|
+
flipId={flipId}
|
|
1768
|
+
translate
|
|
1769
|
+
opacity
|
|
1770
|
+
spring={{
|
|
1771
|
+
stiffness: Math.max(300 - index, 180),
|
|
1772
|
+
damping: 33,
|
|
1773
|
+
}}
|
|
1774
|
+
>
|
|
1775
|
+
<li>{children}</li>
|
|
1776
|
+
</Flipped>
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function renderSortTriggerValue(
|
|
1781
|
+
option: SortDropdownOption | null
|
|
1782
|
+
): React.ReactNode | undefined {
|
|
1783
|
+
if (!option) return undefined
|
|
1784
|
+
return (
|
|
1785
|
+
<>
|
|
1786
|
+
<span className="label">
|
|
1787
|
+
<FontAwesomeIcon icon={faArrowRightArrowLeft} size="sm" />
|
|
1788
|
+
{"Sort by: "}
|
|
1789
|
+
</span>
|
|
1790
|
+
{option.label}
|
|
1791
|
+
{option.formattedTime && (
|
|
1792
|
+
<span className="detail">, {option.formattedTime}</span>
|
|
1793
|
+
)}
|
|
1794
|
+
</>
|
|
1795
|
+
)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function renderSortMenuOption(option: SortDropdownOption): React.ReactNode {
|
|
1799
|
+
return (
|
|
1800
|
+
<>
|
|
1801
|
+
{option.label}
|
|
1802
|
+
{option.formattedTime && (
|
|
1803
|
+
<span className="detail">, {option.formattedTime}</span>
|
|
1804
|
+
)}
|
|
1805
|
+
</>
|
|
1806
|
+
)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function renderFilterTriggerValue(
|
|
1810
|
+
option: FilterDropdownOption | null
|
|
1811
|
+
): React.ReactNode | undefined {
|
|
1812
|
+
if (!option) return undefined
|
|
1813
|
+
return (
|
|
1814
|
+
<>
|
|
1815
|
+
<span className="label">
|
|
1816
|
+
<FontAwesomeIcon icon={faFilter} size="sm" />
|
|
1817
|
+
{"Filter by type: "}
|
|
1818
|
+
</span>
|
|
1819
|
+
{option.label} <span className="detail">({option.count})</span>
|
|
1820
|
+
</>
|
|
1821
|
+
)
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function renderFilterMenuOption(option: FilterDropdownOption): React.ReactNode {
|
|
1825
|
+
return (
|
|
1826
|
+
<>
|
|
1827
|
+
{option.label} <span className="detail">({option.count})</span>
|
|
1828
|
+
</>
|
|
1829
|
+
)
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function getTitleForSortColumnLabel(column: CoreColumn): string {
|
|
1833
|
+
return column.titlePublicOrDisplayName.title
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function indicatorIdToSlug(indicatorId: number): ColumnSlug {
|
|
1837
|
+
return indicatorId.toString()
|
|
1838
|
+
}
|