diff --git a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx index 429ea37a08f..1b3aca2f4ad 100644 --- a/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx +++ b/packages/@ourworldindata/grapher/src/captionedChart/CaptionedChart.tsx @@ -32,11 +32,14 @@ import { FacetChart } from "../facetChart/FacetChart" import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { - ControlsManager, - EntitySelectorToggle, - SettingsMenu, + EntitySelectionToggle, + EntitySelectionManager, +} from "../controls/EntitySelectionToggle" +import { MapProjectionMenu, -} from "../controls/Controls" + MapProjectionMenuManager, +} from "../controls/MapProjectionMenu" +import { SettingsMenu, SettingsMenuManager } from "../controls/SettingsMenu" import { FooterManager } from "../footer/FooterManager" import { HeaderManager } from "../header/HeaderManager" import { SelectionArray } from "../selection/SelectionArray" @@ -58,9 +61,11 @@ export interface CaptionedChartManager MapChartManager, FooterManager, HeaderManager, - ControlsManager, DataTableManager, - ContentSwitchersManager { + ContentSwitchersManager, + EntitySelectionManager, + MapProjectionMenuManager, + SettingsMenuManager { containerElement?: HTMLDivElement tabBounds?: Bounds fontSize?: number @@ -196,7 +201,7 @@ export class CaptionedChart extends React.Component { @computed get showControls(): boolean { return ( SettingsMenu.shouldShow(this.manager) || - EntitySelectorToggle.shouldShow(this.manager) || + EntitySelectionToggle.shouldShow(this.manager) || MapProjectionMenu.shouldShow(this.manager) ) } @@ -268,7 +273,7 @@ export class CaptionedChart extends React.Component { )}
- + svg { - display: none; - } - - .config-subtitle { - display: block; - } - } - } - - .settings-menu-contents { - .settings-menu-controls { - // - // shared button coloring & behaviors - // - button { - display: flex; - align-items: center; - color: $light-text; - background: white; - border: 1px solid $light-stroke; - font: $medium 13px/16px $lato; - letter-spacing: 0.01em; - border-radius: 4px; - padding: 7px; - height: 40px; - - &:hover { - background: $hover-fill; - cursor: pointer; - - &:not(.active) { - color: $dark-text; - } - } - - &.active, - &:active { - background: $active-fill; - border: 1px solid $active-fill; - } - - &.active { - cursor: default; - color: $active-text; - } - } - - // - // chart-type label and close button - // - .config-header { - display: flex; - justify-content: space-between; - align-items: center; - background: white; - padding: 9px $indent 3px $indent; - position: sticky; - top: 0; - z-index: 1; - - .config-title { - text-transform: uppercase; - letter-spacing: 0.1em; - color: $light-text; - font: $bold 12px/16px $lato; - } - - button.close { - position: relative; - border-radius: 50%; - height: 32px; - width: 32px; - text-align: center; - justify-content: center; - svg { - height: 14px; - width: 14px; - } - } - } - - // - // each titled block of control widgets (with optional info-circle + tooltip) - // - section { - font: $medium 14px/1.2 $lato; - color: $light-text; - padding: 1em 0; - margin: 0 $indent; - - .config-name { - font: $bold 14px/1.2 $lato; - color: $dark-text; - list-style: none; - - svg { - color: $info-icon; - 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; - } - } - } - - .config-subtitle { - font-size: 13px; - margin: 5px 0; - } - - .config-toggle { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - padding: 0.5em 0; - } - - & + section { - border-top: 1px solid $light-stroke; - } - } - - // - // resuable widgets - // - section { - // left/right pairs of options - .config-toggle { - label { - flex-basis: 100%; - color: $dark-text; - margin-bottom: 0.5em; - } - button { - width: 130px; - padding: 7px 16px; - } - } - - // on/off switch with label written to the right - .config-switch { - position: relative; - margin: 14px 0; - -webkit-user-select: none; - user-select: none; - - label { - color: $dark-text; - padding-left: 35px; - &:hover { - cursor: pointer; - } - - svg { - color: $info-icon; - height: 13px; - padding: 0 0.333em; - } - } - - .config-subtitle { - display: none; - } - - input { - position: absolute; - opacity: 0; - left: 0; - } - - .outer { - position: absolute; - left: 0; - top: 0; - content: " "; - width: 29px; - height: 16px; - background: $light-fill; - border-radius: 8px; - pointer-events: none; - .inner { - position: relative; - content: " "; - width: 10px; - height: 10px; - background: $light-text; - border-radius: 5px; - top: 3px; - left: 3px; - pointer-events: none; - transition: transform 333ms; - } - } - - &:hover { - .outer .inner { - background: $dark-text; - } - } - - input:checked + .outer { - background: $active-fill; - .inner { - background: $active-switch; - transform: translate(13px, 0); - } - } - &:hover input:checked + .outer .inner { - background: darken($active-switch, 13%); - } - } - - // vertical list of options (for selecting faceting mode) - .config-list { - display: flex; - flex-direction: column; - gap: 8px; - padding: 7px 0; - button { - width: 100%; - - .faceting-icon { - display: flex; - flex-wrap: wrap; - width: 34px; - height: 24px; - justify-content: space-between; - margin-right: 8px; - span { - // the round-rects that make up the grid - display: inline-block; - width: 100%; - height: 100%; - border-radius: 2px; - background: $light-stroke; - } - } - - &.entity span { - width: 10px; - height: 10px; - } - - &.metric span { - width: 10px; - height: 10px; - border-radius: 5px; - } - - &.active span { - background: #a4b6ca; - } - - &:hover:not(.active) span { - background: $light-fill; - } - - &:active:not(.active) span { - background: $light-text; - } - } - } - } - } - } -} - -@keyframes settings-menu-backdrop-enter { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes settings-menu-backdrop-exit { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -@keyframes settings-menu-controls-enter { - 0% { - transform: translate(301px, 0); - } - 100% { - transform: translate(0, 0); - } -} - -@keyframes settings-menu-controls-exit { - 0% { - transform: translate(0, 0); - } - 100% { - transform: translate(301px, 0); - } -} diff --git a/packages/@ourworldindata/grapher/src/controls/Controls.tsx b/packages/@ourworldindata/grapher/src/controls/Controls.tsx deleted file mode 100644 index 701bf1fb71f..00000000000 --- a/packages/@ourworldindata/grapher/src/controls/Controls.tsx +++ /dev/null @@ -1,966 +0,0 @@ -import React from "react" -import Select from "react-select" -import { createPortal } from "react-dom" -import { computed, action, observable } from "mobx" -import { observer } from "mobx-react" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" -import { - faXmark, - faGear, - faInfoCircle, - faPencilAlt, - faEye, - faRightLeft, -} from "@fortawesome/free-solid-svg-icons" -import { EntityName } from "@ourworldindata/core-table" -import { SelectionArray } from "../selection/SelectionArray" -import { ChartDimension } from "../chart/ChartDimension" -import { - GRAPHER_SETTINGS_DRAWER_ID, - ChartTypeName, - FacetAxisDomain, - FacetStrategy, - StackMode, - ScaleType, - DEFAULT_GRAPHER_ENTITY_TYPE, - DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL, -} from "../core/GrapherConstants" -import { MapConfig } from "../mapCharts/MapConfig" -import { - MapProjectionName, - MapProjectionLabels, -} from "../mapCharts/MapProjections" -import { AxisConfig } from "../axis/AxisConfig" -import { Tippy, range } from "@ourworldindata/utils" -import classnames from "classnames" - -const { - LineChart, - ScatterPlot, - StackedArea, - StackedDiscreteBar, - StackedBar, - Marimekko, -} = ChartTypeName - -export type ControlsManager = EntitySelectionManager & - MapProjectionMenuManager & - SettingsMenuManager - -export interface EntitySelectionManager { - showSelectEntitiesButton?: boolean - showChangeEntityButton?: boolean - showAddEntityButton?: boolean - entityType?: string - entityTypePlural?: string - isSelectingData?: boolean - isOnChartTab?: boolean -} - -interface EntitySelectionLabel { - icon: JSX.Element - action: string - entity: string -} - -@observer -export class EntitySelectorToggle extends React.Component<{ - manager: EntitySelectionManager -}> { - static shouldShow(manager: EntitySelectionManager): boolean { - const toggle = new EntitySelectorToggle({ manager }) - return toggle.showToggle - } - - @computed get showToggle(): boolean { - const { isOnChartTab } = this.props.manager - return !!(isOnChartTab && this.label) - } - - @computed get label(): EntitySelectionLabel | null { - const { - entityType = "", - entityTypePlural = "", - showSelectEntitiesButton, - showChangeEntityButton, - showAddEntityButton, - } = this.props.manager - - return showSelectEntitiesButton - ? { - action: "Select", - entity: entityTypePlural, - icon: , - } - : showChangeEntityButton - ? { - action: "Change", - entity: entityType, - icon: , - } - : showAddEntityButton - ? { - action: "Edit", - entity: entityTypePlural, - icon: , - } - : null - } - - render(): JSX.Element | null { - const { showToggle, label } = this - const { isSelectingData: active } = this.props.manager - - return showToggle && label ? ( -
- -
- ) : null - } -} - -export interface SettingsMenuManager { - base?: React.RefObject // the root grapher element - showConfigurationDrawer?: boolean - - // ArchieML directives - hideFacetControl?: boolean - hideRelativeToggle?: boolean - hideEntityControls?: boolean - hideZoomToggle?: boolean - hideNoDataAreaToggle?: boolean - hideFacetYDomainToggle?: boolean - hideXScaleToggle?: boolean - hideYScaleToggle?: boolean - hideTableFilterToggle?: boolean - - // chart state - type: ChartTypeName - isRelativeMode?: boolean - selection?: SelectionArray | EntityName[] - filledDimensions: ChartDimension[] - xColumnSlug?: string - xOverrideTime?: number - hasTimeline?: boolean - canToggleRelativeMode: boolean - isOnMapTab?: boolean - isOnChartTab?: boolean - isOnTableTab?: boolean - - // linear/log & align-faceted-axes - yAxis: AxisConfig - xAxis: AxisConfig - - // zoom-to-selection - zoomToSelection?: boolean - - // show no-data entities in marimekko - showNoDataArea?: boolean - - // facet by - availableFacetStrategies: FacetStrategy[] - facetStrategy?: FacetStrategy - entityType?: string - facettingLabelByYVariables?: string - - // absolute/relative units - stackMode?: StackMode - relativeToggleLabel?: string - - // show intermediate scatterplot points - compareEndPointsOnly?: boolean - - // use entity selection from chart to filter table rows - showSelectionOnlyInDataTable?: boolean -} - -@observer -export class SettingsMenu extends React.Component<{ - manager: SettingsMenuManager - top: number - bottom: number -}> { - @observable.ref active: boolean = false // set to true when the menu's display has been requested - @observable.ref visible: boolean = false // true while menu is active and during enter/exit transitions - contentRef: React.RefObject = React.createRef() // the menu contents & backdrop - - static shouldShow(manager: SettingsMenuManager): boolean { - const test = new SettingsMenu({ manager, top: 0, bottom: 0 }) - return test.showSettingsMenuToggle - } - - @computed get showYScaleToggle(): boolean | undefined { - if (this.manager.hideYScaleToggle) return false - if (this.manager.isRelativeMode) return false - if ([StackedArea, StackedBar].includes(this.manager.type)) return false // We currently do not have these charts with log scale - return this.manager.yAxis.canChangeScaleType - } - - @computed get showXScaleToggle(): boolean | undefined { - if (this.manager.hideXScaleToggle) return false - if (this.manager.isRelativeMode) return false - return this.manager.xAxis.canChangeScaleType - } - - @computed get showFacetYDomainToggle(): boolean { - // don't offer to make the y range relative if the range is discrete - return ( - !this.manager.hideFacetYDomainToggle && - this.manager.facetStrategy !== FacetStrategy.none && - this.manager.type !== StackedDiscreteBar - ) - } - - @computed get showZoomToggle(): boolean { - // TODO: - // grapher passes a SelectionArray instance but programmatically defined - // managers treat `selection` as a string[] of entity names. do we need both? - const { selection, type, hideZoomToggle } = this.manager, - entities = - selection instanceof SelectionArray - ? selection.selectedEntityNames - : Array.isArray(selection) - ? selection - : [] - - return !hideZoomToggle && type === ScatterPlot && entities.length > 0 - } - - @computed get showNoDataAreaToggle(): boolean { - return ( - !this.manager.hideNoDataAreaToggle && - this.manager.type === Marimekko && - this.manager.xColumnSlug !== undefined - ) - } - - @computed get showAbsRelToggle(): boolean { - const { type, canToggleRelativeMode, hasTimeline, xOverrideTime } = - this.manager - if (!canToggleRelativeMode) return false - if (type === ScatterPlot) - return xOverrideTime === undefined && !!hasTimeline - return [ - StackedArea, - StackedBar, - StackedDiscreteBar, - ScatterPlot, - LineChart, - Marimekko, - ].includes(type) - } - - @computed get showFacetControl(): boolean { - const { - filledDimensions, - availableFacetStrategies, - hideFacetControl, - isOnTableTab, - type, - } = this.manager - - // if there's no choice to be made, don't display a lone button - if (availableFacetStrategies.length <= 1) return false - - // heuristic: if the chart doesn't make sense unfaceted, then it probably - // also makes sense to let the user switch between entity/metric facets - if (!availableFacetStrategies.includes(FacetStrategy.none)) return true - - const showFacetControlChartType = [ - StackedArea, - StackedBar, - StackedDiscreteBar, - LineChart, - ].includes(type) - - const hasProjection = filledDimensions.some( - (dim) => dim.display.isProjection - ) - - return ( - showFacetControlChartType && - !hideFacetControl && - !hasProjection && - !isOnTableTab - ) - } - - @computed get showTableFilterToggle(): boolean { - const { hideTableFilterToggle, selection } = this.manager - const hasSelection = - selection instanceof SelectionArray - ? selection.hasSelection - : (selection?.length ?? 0) > 0 - - return hasSelection && !hideTableFilterToggle - } - - @computed get showSettingsMenuToggle(): boolean { - if (this.manager.isOnMapTab) return false - if (this.manager.isOnTableTab) return this.showTableFilterToggle - - return !!( - this.showYScaleToggle || - this.showXScaleToggle || - this.showFacetYDomainToggle || - this.showZoomToggle || - this.showNoDataAreaToggle || - this.showFacetControl || - this.showAbsRelToggle - ) - - // TODO: add a showCompareEndPointsOnlyTogggle to complement compareEndPointsOnly - } - - componentDidMount(): void { - document.addEventListener("click", this.onDocumentClick, { - capture: true, - }) - } - - componentWillUnmount(): void { - document.removeEventListener("click", this.onDocumentClick) - } - - @action.bound onDocumentClick(e: MouseEvent): void { - if ( - this.active && - this.contentRef?.current && - !this.contentRef.current.contains(e.target as Node) && - document.contains(e.target as Node) - ) - this.toggleVisibility() - } - - @action.bound toggleVisibility(e?: React.MouseEvent): void { - this.active = !this.active - if (this.active) this.visible = true - this.drawer?.classList.toggle("active", this.active) - e?.stopPropagation() - } - - @action.bound onAnimationEnd(): void { - if (!this.active) this.visible = false - } - - @computed get manager(): SettingsMenuManager { - return this.props.manager - } - - @computed get chartType(): string { - const { type } = this.manager - return type.replace(/([A-Z])/g, " $1") - } - - @computed get drawer(): Element | null { - // use the drawer `