diff --git a/packages/@ourworldindata/grapher/src/controls/LabeledSwitch.tsx b/packages/@ourworldindata/grapher/src/controls/LabeledSwitch.tsx index 72e954a0f4e..5a14a60bbe3 100644 --- a/packages/@ourworldindata/grapher/src/controls/LabeledSwitch.tsx +++ b/packages/@ourworldindata/grapher/src/controls/LabeledSwitch.tsx @@ -31,9 +31,8 @@ export class LabeledSwitch extends React.Component<{ {tooltip && ( diff --git a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.scss b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.scss index ae6aa9210fc..0ea66ba28b2 100644 --- a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.scss +++ b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.scss @@ -112,21 +112,6 @@ nav.controlsRow .chart-controls .settings-menu { height: 13px; padding: 0 0.333em; } - - // the tooltip triggered by hovering the circle-i - @at-root .tippy-box[data-theme="settings"] { - background: white; - color: $dark-text; - font: 400 14px/1.5 $sans-serif-font-stack; - box-shadow: 0px 4px 40px 0px rgba(0, 0, 0, 0.15); - - .tippy-content { - padding: $indent; - } - .tippy-arrow { - color: white; - } - } } .labeled-switch .labeled-switch-subtitle, diff --git a/packages/@ourworldindata/grapher/src/core/grapher.scss b/packages/@ourworldindata/grapher/src/core/grapher.scss index f7d757d42b4..d93becc8294 100644 --- a/packages/@ourworldindata/grapher/src/core/grapher.scss +++ b/packages/@ourworldindata/grapher/src/core/grapher.scss @@ -205,6 +205,25 @@ $zindex-controls-drawer: 150; z-index: $zindex-Tooltip; } +// white background tooltip for longer explanations +// (the `--short` version has a little less padding) +.tippy-box[data-theme="grapher-explanation"], +.tippy-box[data-theme="grapher-explanation--short"] { + background: white; + color: $dark-text; + font: 400 14px/1.5 $sans-serif-font-stack; + box-shadow: 0px 4px 40px 0px rgba(0, 0, 0, 0.15); + + .tippy-arrow { + color: white; + } +} +.tippy-box[data-theme="grapher-explanation"] { + .tippy-content { + padding: 15px; + } +} + .markdown-text-wrap__line { display: block; } diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss index 74ff3a18f96..286edb9004a 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss @@ -178,6 +178,22 @@ background: #ebeef2; z-index: -1; } + + .label-with-location-icon { + display: flex; + align-items: center; + + svg { + margin-left: 8px; + font-size: 0.9em; + color: #a1a1a1; + + // hide focus outline when clicked + &:focus:not(:focus-visible) { + outline: none; + } + } + } } .animated-entity { diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index 392da3224d9..28856d7b2d6 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -13,14 +13,18 @@ import { isFiniteWithGuard, CoreValueType, clamp, - sortBy, last, + getUserCountryInformation, + regions, + sortBy, + Tippy, } from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FuzzySearch } from "../controls/FuzzySearch" import { faCircleXmark, faMagnifyingGlass, + faLocationArrow, } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { SelectionArray } from "../selection/SelectionArray" @@ -41,6 +45,7 @@ import { scaleLinear, type ScaleLinear } from "d3-scale" export interface EntitySelectorState { searchInput: string sortConfig: SortConfig + localEntityNames?: string[] mostRecentlySelectedEntityName?: string } @@ -62,7 +67,7 @@ interface SortConfig { order: SortOrder } -type SearchableEntity = { name: string } & Record< +type SearchableEntity = { name: string; local?: boolean } & Record< Slug, CoreValueType | undefined > @@ -92,6 +97,8 @@ export class EntitySelector extends React.Component<{ } componentDidMount(): void { + void this.populateLocalEntities() + if (this.props.autoFocus && !isTouchDevice()) this.searchField.current?.focus() @@ -133,6 +140,32 @@ export class EntitySelector extends React.Component<{ } } + @action.bound async populateLocalEntities(): Promise { + try { + const localCountryInfo = await getUserCountryInformation() + if (!localCountryInfo) return + + const userEntityCodes = [ + localCountryInfo.code, + ...(localCountryInfo.regions ?? []), + ] + + const userRegions = regions.filter((region) => + userEntityCodes.includes(region.code) + ) + + const sortedUserRegions = sortBy(userRegions, (region) => + userEntityCodes.indexOf(region.code) + ) + + const localEntityNames = sortedUserRegions.map( + (region) => region.name + ) + + if (localEntityNames) this.set({ localEntityNames }) + } catch (err) {} + } + private clearSearchInput(): void { this.set({ searchInput: "" }) } @@ -178,6 +211,10 @@ export class EntitySelector extends React.Component<{ ) } + @computed private get localEntityNames(): string[] | undefined { + return this.manager.entitySelectorState.localEntityNames + } + @computed private get table(): OwidTable { return this.manager.tableForSelection } @@ -256,6 +293,11 @@ export class EntitySelector extends React.Component<{ return this.availableEntityNames.map((entityName) => { const searchableEntity: SearchableEntity = { name: entityName } + if (this.localEntityNames) { + searchableEntity.local = + this.localEntityNames.includes(entityName) + } + for (const column of this.sortColumns) { const rows = column.owidRowsByEntityName.get(entityName) ?? [] const sortedRows = sortBy(rows, (row) => row.time) @@ -266,13 +308,16 @@ export class EntitySelector extends React.Component<{ }) } - private sortEntities(entities: SearchableEntity[]): SearchableEntity[] { + private sortEntities( + entities: SearchableEntity[], + options: { sortLocalsToTop: boolean } = { sortLocalsToTop: true } + ): SearchableEntity[] { const { sortConfig } = this const shouldBeSortedByName = this.hasSlugName(sortConfig) - // sort by name - if (shouldBeSortedByName) { + // sort by name, ignoring local entities + if (shouldBeSortedByName && !options.sortLocalsToTop) { return orderBy( entities, (entity: SearchableEntity) => entity.name, @@ -280,6 +325,28 @@ export class EntitySelector extends React.Component<{ ) } + // sort by name, with local entities at the top + if (shouldBeSortedByName && options.sortLocalsToTop) { + const [localEntities, otherEntities] = partition( + entities, + (entity: SearchableEntity) => entity.local + ) + + const sortedLocalEntities = sortBy( + localEntities, + (entity: SearchableEntity) => + this.localEntityNames?.indexOf(entity.name) + ) + + const sortedOtherEntities = orderBy( + otherEntities, + (entity: SearchableEntity) => entity.name, + sortConfig.order + ) + + return [...sortedLocalEntities, ...sortedOtherEntities] + } + // sort by number column, with missing values at the end const [withValues, withoutValues] = partition( entities, @@ -337,7 +404,7 @@ export class EntitySelector extends React.Component<{ ) return { - selected: this.sortEntities(selected), + selected: this.sortEntities(selected, { sortLocalsToTop: false }), unselected: this.sortEntities(unselected), } } @@ -469,6 +536,7 @@ export class EntitySelector extends React.Component<{ checked={this.isEntitySelected(entity)} bar={this.getBarConfigForEntity(entity)} onChange={() => this.onChange(entity.name)} + local={entity.local} /> ))} @@ -487,6 +555,7 @@ export class EntitySelector extends React.Component<{ checked={this.isEntitySelected(entity)} bar={this.getBarConfigForEntity(entity)} onChange={() => this.onChange(entity.name)} + local={entity.local} /> ))} @@ -570,6 +639,7 @@ export class EntitySelector extends React.Component<{ onChange={() => this.onChange(entity.name) } + local={entity.local} /> @@ -610,6 +680,7 @@ export class EntitySelector extends React.Component<{ onChange={() => this.onChange(entity.name) } + local={entity.local} /> @@ -686,18 +757,35 @@ function SelectableEntity({ type, bar, onChange, + local, }: { name: React.ReactNode checked: boolean type: "checkbox" | "radio" bar?: BarConfig onChange: () => void + local?: boolean }) { const Input = { checkbox: Checkbox, radio: RadioButton, }[type] + const label = local ? ( + + {name} + + + + + ) : ( + name + ) + return (
)} - + {bar && ( {bar.formattedValue}