diff --git a/packages/@ourworldindata/components/src/Checkbox.scss b/packages/@ourworldindata/components/src/Checkbox.scss index 7791038ecf7..20b781e17c9 100644 --- a/packages/@ourworldindata/components/src/Checkbox.scss +++ b/packages/@ourworldindata/components/src/Checkbox.scss @@ -3,7 +3,7 @@ $light-stroke: #dadada; $hover-stroke: #a4b6ca; - $active-fill: #dbe5f0; + $active-fill: #a4b6ca; position: relative; @@ -27,7 +27,7 @@ pointer-events: none; border-radius: 2px; border: 1px solid $light-stroke; - color: $dark-text; + color: #fff; display: flex; align-items: center; @@ -36,7 +36,6 @@ svg { font-size: 10px; padding-left: 0.75px; - color: $active-text; } } diff --git a/packages/@ourworldindata/components/src/Checkbox.tsx b/packages/@ourworldindata/components/src/Checkbox.tsx index 207a9439003..022bc2426f1 100644 --- a/packages/@ourworldindata/components/src/Checkbox.tsx +++ b/packages/@ourworldindata/components/src/Checkbox.tsx @@ -4,7 +4,7 @@ import { faCheck } from "@fortawesome/free-solid-svg-icons" export class Checkbox extends React.Component<{ checked: boolean - onChange: React.ChangeEventHandler + onChange?: React.ChangeEventHandler label: React.ReactNode }> { render(): JSX.Element { diff --git a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts index 3c9199a295c..e4e86d850cf 100644 --- a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts +++ b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts @@ -928,3 +928,7 @@ export const ColumnTypeMap = { const _ColumnTypeMap: { [key in ColumnTypeNames]: unknown } = ColumnTypeMap + +export function isColumnWithNumberFormatting(column: CoreColumn): boolean { + return column instanceof AbstractColumnWithNumberFormatting +} diff --git a/packages/@ourworldindata/core-table/src/OwidTable.ts b/packages/@ourworldindata/core-table/src/OwidTable.ts index 8a65d486b0f..5a93b99859d 100644 --- a/packages/@ourworldindata/core-table/src/OwidTable.ts +++ b/packages/@ourworldindata/core-table/src/OwidTable.ts @@ -189,7 +189,7 @@ export class OwidTable extends CoreTable { } // Does a stable sort by time. You can refer to this table for fast time filtering. - @imemo private get sortedByTime(): this { + @imemo get sortedByTime(): this { if (this.timeColumn.isMissing) return this return this.sortBy([this.timeColumn.slug]) } diff --git a/packages/@ourworldindata/core-table/src/index.ts b/packages/@ourworldindata/core-table/src/index.ts index b2bd9e9c71e..bd1f4888afd 100644 --- a/packages/@ourworldindata/core-table/src/index.ts +++ b/packages/@ourworldindata/core-table/src/index.ts @@ -14,6 +14,7 @@ export { ColumnTypeMap, AbstractCoreColumn, TimeColumn, + isColumnWithNumberFormatting, } from "./CoreTableColumns.js" export { OwidTable, BlankOwidTable } from "./OwidTable.js" diff --git a/packages/@ourworldindata/grapher/src/controls/Dropdown.scss b/packages/@ourworldindata/grapher/src/controls/Dropdown.scss new file mode 100644 index 00000000000..0873f9f73bd --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/Dropdown.scss @@ -0,0 +1,86 @@ +.grapher-dropdown { + $option-checkmark: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0xMS4wMTU2IDAuOTg0Mzc1QzExLjMyMDMgMS4yNjU2MiAxMS4zMjAzIDEuNzU3ODEgMTEuMDE1NiAyLjAzOTA2TDUuMDE1NjIgOC4wMzkwNkM0LjczNDM4IDguMzQzNzUgNC4yNDIxOSA4LjM0Mzc1IDMuOTYwOTQgOC4wMzkwNkwwLjk2MDkzOCA1LjAzOTA2QzAuNjU2MjUgNC43NTc4MSAwLjY1NjI1IDQuMjY1NjIgMC45NjA5MzggMy45ODQzOEMxLjI0MjE5IDMuNjc5NjkgMS43MzQzOCAzLjY3OTY5IDIuMDE1NjIgMy45ODQzOEw0LjQ3NjU2IDYuNDQ1MzFMOS45NjA5NCAwLjk4NDM3NUMxMC4yNDIyIDAuNjc5Njg4IDEwLjczNDQgMC42Nzk2ODggMTEuMDE1NiAwLjk4NDM3NVoiIGZpbGw9IiMxRDNENjMiLz4KPC9zdmc+"; + $menu-caret-up: "data:image/svg+xml;base64, PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI1IiB2aWV3Qm94PSIwIDAgOCA1IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMC40NjA5MzggMy43MzQzOEwzLjQzNzUgMC43MzQzNzVDMy42MDE1NiAwLjU5Mzc1IDMuNzg5MDYgMC41IDQgMC41QzQuMTg3NSAwLjUgNC4zNzUgMC41OTM3NSA0LjUxNTYyIDAuNzM0Mzc1TDcuNDkyMTkgMy43MzQzOEM3LjcwMzEyIDMuOTQ1MzEgNy43NzM0NCA0LjI3MzQ0IDcuNjU2MjUgNC41NTQ2OUM3LjUzOTA2IDQuODM1OTQgNy4yODEyNSA1IDYuOTc2NTYgNUgxQzAuNjk1MzEyIDUgMC40MTQwNjIgNC44MzU5NCAwLjI5Njg3NSA0LjU1NDY5QzAuMTc5Njg4IDQuMjczNDQgMC4yNSAzLjk0NTMxIDAuNDYwOTM4IDMuNzM0MzhaIiBmaWxsPSIjNUI1QjVCIi8+Cjwvc3ZnPg=="; + $menu-caret-down: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI1IiB2aWV3Qm94PSIwIDAgOCA1IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNy41MTU2MiAxLjI4OTA2TDQuNTM5MDYgNC4yODkwNkM0LjM3NSA0LjQyOTY5IDQuMTg3NSA0LjUgNCA0LjVDMy43ODkwNiA0LjUgMy42MDE1NiA0LjQyOTY5IDMuNDYwOTQgNC4yODkwNkwwLjQ4NDM3NSAxLjI4OTA2QzAuMjUgMS4wNzgxMiAwLjE3OTY4OCAwLjc1IDAuMjk2ODc1IDAuNDY4NzVDMC40MTQwNjIgMC4xODc1IDAuNjk1MzEyIDAgMSAwSDYuOTc2NTZDNy4yODEyNSAwIDcuNTM5MDYgMC4xODc1IDcuNjU2MjUgMC40Njg3NUM3Ljc3MzQ0IDAuNzUgNy43MjY1NiAxLjA3ODEyIDcuNTE1NjIgMS4yODkwNloiIGZpbGw9IiM1QjVCNUIiLz4KPC9zdmc+Cg=="; + + $medium: 400; + $lato: $sans-serif-font-stack; + + $light-stroke: #e7e7e7; + $active-stroke: #a4b6ca; + + $active-fill: #dbe5f0; + $hover-fill: #f2f2f2; + $selected-fill: #c7ced7; + + $light-text: #858585; + + font: $medium 13px/16px $lato; + + .control { + min-height: auto; + font: $medium 13px/16px $lato; + letter-spacing: 0.01em; + display: flex; + align-items: center; + border: 1px solid $light-stroke; + border-radius: 4px; + padding: 7px; + color: $dark-text; + + &:hover { + background: $hover-fill; + cursor: pointer; + } + + &:after { + content: " "; + background: url($menu-caret-down) no-repeat center; + width: 16px; + height: 16px; + } + + &.active { + border-color: $active-stroke; + &:after { + background: url($menu-caret-up) no-repeat center; + } + } + } + + .menu { + margin-top: 3px; + border-radius: 4px; + background: white; + box-shadow: 0px 4px 40px 0px rgba(0, 0, 0, 0.15); + z-index: $zindex-controls-popup; + + .option { + padding: 8px 18px; + &:hover { + cursor: pointer; + background: $hover-fill; + } + &:active, + &.active { + color: $active-text; + background: $active-fill; + } + &.active { + position: relative; + &:hover { + background: $selected-fill; + } + &:after { + content: " "; + background: url($option-checkmark) no-repeat; + width: 12px; + height: 9px; + position: absolute; + right: 18px; + bottom: 11px; + } + } + } + } +} diff --git a/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx b/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx new file mode 100644 index 00000000000..ec3b019f3b9 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx @@ -0,0 +1,26 @@ +import React from "react" +import Select, { Props } from "react-select" + +export function Dropdown(props: Props): React.JSX.Element { + return ( + - state.menuIsOpen ? "active control" : "control", - option: (state) => - state.isSelected ? "active option" : "option", - menu: () => "menu", - }} /> ) : null diff --git a/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx b/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx index 10117e869e1..93b789d6108 100644 --- a/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx +++ b/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx @@ -2,7 +2,7 @@ import React from "react" export class RadioButton extends React.Component<{ checked: boolean - onChange: React.ChangeEventHandler + onChange?: React.ChangeEventHandler label: React.ReactNode group?: string }> { diff --git a/packages/@ourworldindata/grapher/src/core/grapher.scss b/packages/@ourworldindata/grapher/src/core/grapher.scss index b810dad4c96..f7d757d42b4 100644 --- a/packages/@ourworldindata/grapher/src/core/grapher.scss +++ b/packages/@ourworldindata/grapher/src/core/grapher.scss @@ -89,6 +89,7 @@ $zindex-controls-drawer: 150; @import "../../../components/src/Checkbox.scss"; @import "../closeButton/CloseButton.scss"; @import "../controls/RadioButton.scss"; +@import "../controls/Dropdown.scss"; .grapher_dark { color: $dark-text; diff --git a/packages/@ourworldindata/grapher/src/core/typography.scss b/packages/@ourworldindata/grapher/src/core/typography.scss index 149e3b11d66..0ba748d14c4 100644 --- a/packages/@ourworldindata/grapher/src/core/typography.scss +++ b/packages/@ourworldindata/grapher/src/core/typography.scss @@ -134,3 +134,12 @@ .grapher_label-2-regular { @include grapher_label-2-regular; } + +@mixin grapher_label-2-medium { + @include grapher_label-2-regular; + font-weight: 500; +} + +.grapher_label-2-medium { + @include grapher_label-2-medium; +} diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss index 8d1b8a02a26..fb59d698304 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss @@ -19,7 +19,65 @@ background-color: #fff; z-index: $zindex-header; padding: 0 var(--padding) 8px var(--padding); + margin-bottom: 8px; + } + + .entity-selector__sort-bar { + padding: 0 var(--padding); + display: flex; + align-items: center; margin-bottom: 16px; + + .grapher-dropdown { + flex-grow: 1; + } + + .label { + flex-shrink: 0; + margin-right: 8px; + } + + button.sort { + flex-shrink: 0; + margin-left: 16px; + + $size: 32px; + + display: flex; + align-items: center; + justify-content: center; + + position: relative; + height: $size; + width: $size; + padding: 7px; + + color: $dark-text; + background: #f2f2f2; + border: none; + border-radius: 4px; + + svg { + height: 14px; + width: 14px; + } + + &:hover:not(:disabled) { + background: #e7e7e7; + cursor: pointer; + } + + &:active:not(:disabled) { + color: #1d3d63; + background: #dbe5f0; + border: 1px solid #dbe5f0; + } + + &:disabled { + background: #f2f2f2; + color: #a1a1a1; + } + } } .entity-selector__footer { @@ -70,7 +128,7 @@ } .entity-selector__content { - margin: -8px var(--padding) ($footer-height + 8px) var(--padding); + margin: 0 var(--padding) ($footer-height + 8px) var(--padding); } &.entity-selector--single { @@ -79,19 +137,42 @@ } } - .entity-search-results { + .entity-section + .entity-section { margin-top: 16px; } - .section-title { + .entity-section__title { letter-spacing: 0.01em; - margin-top: 16px; margin-bottom: 8px; } .selectable-entity { padding: 9px 8px 9px 16px; + display: flex; + justify-content: space-between; + position: relative; cursor: pointer; + + mark { + color: #4e4e4e; + background: transparent; + font-weight: 600; + } + + .value { + color: #a1a1a1; + white-space: nowrap; + margin-left: 8px; + } + + .bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: #ebeef2; + z-index: -1; + } } .animated-entity { diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index 326068486a2..9af114d7206 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -5,9 +5,16 @@ import cx from "classnames" import a from "indefinite" import { isTouchDevice, - sortBy, partition, capitalize, + SortOrder, + orderBy, + keyBy, + isFiniteWithGuard, + CoreValueType, + clamp, + sortBy, + last, } from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FuzzySearch } from "../controls/FuzzySearch" @@ -25,28 +32,52 @@ import { DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL, GRAPHER_SCROLLABLE_CONTAINER_CLASS, } from "../core/GrapherConstants" +import { + CoreColumn, + OwidTable, + isColumnWithNumberFormatting, +} from "@ourworldindata/core-table" +import { SortIcon } from "../controls/SortIcon" +import { Dropdown } from "../controls/Dropdown" +import { scaleLinear, type ScaleLinear } from "d3-scale" export interface EntitySelectorState { searchInput: string + sortConfig: SortConfig mostRecentlySelectedEntityName?: string } export interface EntitySelectorManager { entitySelectorState: Partial + tableForSelection: OwidTable selection: SelectionArray canChangeEntity: boolean entitiesAreCountryLike?: boolean entityType?: string entityTypePlural?: string + activeColumnSlugs?: string[] } -interface SearchableEntity { - name: string +type Slug = string + +interface SortConfig { + slug: Slug + order: SortOrder } +type SearchableEntity = { name: string } & Record< + Slug, + CoreValueType | undefined +> + interface PartitionedEntities { - selected: string[] - unselected: string[] + selected: SearchableEntity[] + unselected: SearchableEntity[] +} + +interface DropdownOption { + value: string + label: string } @observer @@ -58,6 +89,11 @@ export class EntitySelector extends React.Component<{ container: React.RefObject = React.createRef() searchField: React.RefObject = React.createRef() + private defaultSortConfig = { + slug: this.table.entityNameSlug, + order: SortOrder.asc, + } + componentDidMount(): void { if (this.props.autoFocus && !isTouchDevice()) this.searchField.current?.focus() @@ -75,10 +111,28 @@ export class EntitySelector extends React.Component<{ ) } - private set(state: Partial): void { + private set(newState: Partial): void { + const correctedState = { ...newState } + + if (newState.sortConfig !== undefined) { + const correctedSortConfig = { ...newState.sortConfig } + + // sort names in ascending order by default + if (this.hasSlugName(newState.sortConfig) && !this.isSortedByName) { + correctedSortConfig.order = SortOrder.asc + } + + // sort values in descending order by default + if (!this.hasSlugName(newState.sortConfig) && this.isSortedByName) { + correctedSortConfig.order = SortOrder.desc + } + + correctedState.sortConfig = correctedSortConfig + } + this.manager.entitySelectorState = { ...this.manager.entitySelectorState, - ...state, + ...correctedState, } } @@ -86,6 +140,28 @@ export class EntitySelector extends React.Component<{ this.set({ searchInput: "" }) } + private updateSortSlug(newSlug: Slug) { + this.set({ + sortConfig: { + slug: newSlug, + order: this.sortConfig.order, + }, + }) + } + + private toggleSortOrder() { + const newOrder = + this.sortConfig.order === SortOrder.asc + ? SortOrder.desc + : SortOrder.asc + this.set({ + sortConfig: { + slug: this.sortConfig.slug, + order: newOrder, + }, + }) + } + @computed private get manager(): EntitySelectorManager { return this.props.manager } @@ -98,6 +174,67 @@ export class EntitySelector extends React.Component<{ return this.manager.entitySelectorState.mostRecentlySelectedEntityName } + @computed private get sortConfig(): SortConfig { + return ( + this.manager.entitySelectorState.sortConfig ?? + this.defaultSortConfig + ) + } + + @computed private get table(): OwidTable { + return this.manager.tableForSelection + } + + @computed private get sortColumns(): CoreColumn[] { + const activeSlugs = this.manager.activeColumnSlugs ?? [] + + const sortColumns = activeSlugs + .map((slug) => this.table.get(slug)) + .filter((column) => isColumnWithNumberFormatting(column)) + + return [this.table.entityNameColumn, ...sortColumns] + } + + @computed private get sortColumnsBySlug(): Record { + return keyBy(this.sortColumns, (column: CoreColumn) => column.slug) + } + + @computed get sortOptions(): DropdownOption[] { + const options: DropdownOption[] = [] + + const getDropdownLabel = (column: CoreColumn): string => { + if (column.slug === this.table.entityNameSlug) return "Name" + return column.displayName || column.nonEmptyName + } + + options.push( + ...this.sortColumns.map((column) => { + return { + value: column.slug, + label: getDropdownLabel(column), + } + }) + ) + + return options + } + + @computed get sortValue(): DropdownOption | null { + return ( + this.sortOptions.find( + (option) => option.value === this.sortConfig.slug + ) ?? null + ) + } + + private hasSlugName(sortConfig: SortConfig): boolean { + return sortConfig.slug === this.table.entityNameSlug + } + + @computed private get isSortedByName(): boolean { + return this.hasSlugName(this.sortConfig) + } + @computed private get entityType(): string { return this.manager.entityType || DEFAULT_GRAPHER_ENTITY_TYPE } @@ -112,12 +249,60 @@ export class EntitySelector extends React.Component<{ return makeSelectionArray(this.manager.selection) } - @computed get sortedAvailableEntities(): string[] { - return sortBy(this.selectionArray.availableEntityNames) + @computed private get availableEntityNames(): string[] { + return this.table.availableEntityNames } - @computed private get searchableEntities(): SearchableEntity[] { - return this.sortedAvailableEntities.map((name) => ({ name })) + @computed private get availableEntities(): SearchableEntity[] { + return this.availableEntityNames.map((entityName) => { + const searchableEntity: SearchableEntity = { name: entityName } + + for (const column of this.sortColumns) { + const rows = column.owidRowsByEntityName.get(entityName) ?? [] + const sortedRows = sortBy(rows, (row) => row.time) + searchableEntity[column.slug] = last(sortedRows)?.value + } + + return searchableEntity + }) + } + + private sortEntities(entities: SearchableEntity[]): SearchableEntity[] { + const { sortConfig } = this + + const shouldBeSortedByName = this.hasSlugName(sortConfig) + + // sort by name + if (shouldBeSortedByName) { + return orderBy( + entities, + (entity: SearchableEntity) => entity.name, + sortConfig.order + ) + } + + // sort by number column, with missing values at the end + const [withValues, withoutValues] = partition( + entities, + (entity: SearchableEntity) => + isFiniteWithGuard(entity[sortConfig.slug]) + ) + const sortedEntitiesWithValues = orderBy( + withValues, + (entity: SearchableEntity) => entity[sortConfig.slug], + sortConfig.order + ) + const sortedEntitiesWithoutValues = orderBy( + withoutValues, + (entity: SearchableEntity) => entity.name, + SortOrder.asc + ) + + return [...sortedEntitiesWithValues, ...sortedEntitiesWithoutValues] + } + + @computed private get sortedAvailableEntities(): SearchableEntity[] { + return this.sortEntities(this.availableEntities) } @computed get isMultiMode(): boolean { @@ -125,13 +310,12 @@ export class EntitySelector extends React.Component<{ } @computed get fuzzy(): FuzzySearch { - return new FuzzySearch(this.searchableEntities, "name") + return new FuzzySearch(this.sortedAvailableEntities, "name") } @computed get searchResults(): SearchableEntity[] | undefined { - return this.searchInput - ? this.fuzzy.search(this.searchInput) - : undefined + if (!this.searchInput) return undefined + return this.fuzzy.search(this.searchInput) } @computed get partitionedSearchResults(): PartitionedEntities | undefined { @@ -140,20 +324,26 @@ export class EntitySelector extends React.Component<{ if (!searchResults) return undefined const [selected, unselected] = partition( - searchResults.map((entity) => entity.name), - (name) => this.isEntitySelected(name) + searchResults, + (entity: SearchableEntity) => this.isEntitySelected(entity) ) - return { selected, unselected } + return { + selected, + unselected, + } } @computed get partitionedEntities(): PartitionedEntities { const [selected, unselected] = partition( this.sortedAvailableEntities, - (name) => this.isEntitySelected(name) + (entity: SearchableEntity) => this.isEntitySelected(entity) ) - return { selected, unselected } + return { + selected: this.sortEntities(selected), + unselected: this.sortEntities(unselected), + } } @computed get partitionedVisibleEntities(): PartitionedEntities { @@ -183,7 +373,20 @@ export class EntitySelector extends React.Component<{ @action.bound onClear(): void { const { partitionedVisibleEntities: visibleEntities } = this - this.selectionArray.deselectEntities(visibleEntities.selected) + this.selectionArray.deselectEntities( + visibleEntities.selected.map((entity) => entity.name) + ) + } + + @action.bound onChangeSortSlug(selected: unknown): void { + if (selected) { + const selectedOption = selected as DropdownOption + this.updateSortSlug(selectedOption.value) + } + } + + @action.bound onChangeSortOrder(): void { + this.toggleSortOrder() } private renderSearchBar(): JSX.Element { @@ -198,7 +401,9 @@ export class EntitySelector extends React.Component<{ ref={this.searchField} type="search" placeholder={`Search for ${a(this.entityType)}`} - value={this.searchInput} + value={ + this.manager.entitySelectorState.searchInput ?? "" + } onChange={action((e): void => { this.set({ searchInput: e.currentTarget.value, @@ -224,6 +429,29 @@ export class EntitySelector extends React.Component<{ ) } + private renderSortBar(): JSX.Element { + return ( +
+ Sort by + + +
+ ) + } + private renderSearchResults(): JSX.Element { if (!this.searchResults || this.searchResults.length === 0) { return ( @@ -237,13 +465,14 @@ export class EntitySelector extends React.Component<{ return (
    - {this.searchResults.map(({ name }) => ( -
  • + {this.searchResults.map((entity) => ( +
  • this.onChange(name)} + checked={this.isEntitySelected(entity)} + bar={this.getBarConfigForEntity(entity)} + onChange={() => this.onChange(entity.name)} />
  • ))} @@ -254,13 +483,14 @@ export class EntitySelector extends React.Component<{ private renderAllEntitiesInSingleMode(): JSX.Element { return (
      - {this.sortedAvailableEntities.map((name) => ( -
    • + {this.sortedAvailableEntities.map((entity) => ( +
    • this.onChange(name)} + checked={this.isEntitySelected(entity)} + bar={this.getBarConfigForEntity(entity)} + onChange={() => this.onChange(entity.name)} />
    • ))} @@ -268,8 +498,37 @@ export class EntitySelector extends React.Component<{ ) } - private isEntitySelected(name: string): boolean { - return this.selectionArray.selectedSet.has(name) + @computed private get displayColumn(): CoreColumn | undefined { + const { sortConfig } = this + if (this.hasSlugName(sortConfig)) return undefined + return this.sortColumnsBySlug[sortConfig.slug] + } + + @computed private get barScale(): ScaleLinear { + return scaleLinear() + .domain([0, this.displayColumn?.maxValue ?? 1]) + .range([0, 1]) + } + + private getBarConfigForEntity( + entity: SearchableEntity + ): BarConfig | undefined { + const { displayColumn, barScale } = this + + if (!displayColumn) return undefined + + const value = entity[displayColumn.slug] + + if (!isFiniteWithGuard(value)) return { formattedValue: "No data" } + + return { + formattedValue: displayColumn.formatValueShort(value), + width: clamp(barScale(value), 0, 1), + } + } + + private isEntitySelected(entity: SearchableEntity): boolean { + return this.selectionArray.selectedSet.has(entity.name) } private renderAllEntitiesInMultiMode(): JSX.Element { @@ -283,62 +542,84 @@ export class EntitySelector extends React.Component<{ }} flipKey={this.selectionArray.selectedEntityNames.join(",")} > - {selected.length > 0 && ( - -
      - Selection -
      -
      - )} -
        - {selected.map((name) => ( - -
      • - this.onChange(name)} - /> -
      • +
        + {selected.length > 0 && ( + +
        + Selection +
        - ))} -
      - - {selected.length > 0 && unselected.length > 0 && ( - -
      - {capitalize(this.entityTypePlural)} -
      -
      - )} - -
        - {unselected.map((name) => ( - -
      • + {selected.map((entity) => ( + - this.onChange(name)} - /> -
      • +
      • + + this.onChange(entity.name) + } + /> +
      • +
        + ))} +
      + + +
      + {selected.length > 0 && unselected.length > 0 && ( + +
      + {capitalize(this.entityTypePlural)} +
      - ))} -
    + )} + +
      + {unselected.map((entity) => ( + +
    • + + this.onChange(entity.name) + } + /> +
    • +
      + ))} +
    + ) } @@ -375,6 +656,10 @@ export class EntitySelector extends React.Component<{ > {this.renderSearchBar()} + {!this.searchInput && + this.sortOptions.length > 1 && + this.renderSortBar()} +
    {this.searchInput ? this.renderSearchResults() @@ -389,15 +674,19 @@ export class EntitySelector extends React.Component<{ } } +type BarConfig = { formattedValue: string; width?: number } + function SelectableEntity({ name, checked, type, + bar, onChange, }: { name: React.ReactNode checked: boolean type: "checkbox" | "radio" + bar?: BarConfig onChange: () => void }) { const Input = { @@ -414,7 +703,15 @@ function SelectableEntity({ onChange() }} > + {bar && bar.width !== undefined && ( +
    + )} + {bar && ( + + {bar.formattedValue} + + )}
    ) } diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index ab436ae43e9..45c8c6aa7ab 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1820,6 +1820,7 @@ export function checkIsAuthor(x: unknown): x is OwidGdocAuthorInterface { const type = get(x, "content.type") return type === OwidGdocType.Author } + /** * Returns the cartesian product of the given arrays. * @@ -1844,3 +1845,7 @@ export function isElementHidden(element: Element | null): boolean { return true return isElementHidden(element.parentElement) } + +export function isFiniteWithGuard(value: unknown): value is number { + return isFinite(value as any) +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index 36296ecf04a..cd97dc240a1 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -119,6 +119,7 @@ export { checkIsAuthor, cartesian, isElementHidden, + isFiniteWithGuard, } from "./Util.js" export {