From e1eef8998ba5c0a12f93a3cc84dfc41ec2000ad7 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 11 Aug 2023 16:17:37 +0100 Subject: [PATCH] Add dataset options menu Includes opacity and dataset remove button --- .../components/exploration/constants.ts | 1 + .../exploration/dataset-list-item.tsx | 2 + .../exploration/dataset-options.tsx | 100 ++++++++++++++++++ app/scripts/components/exploration/hooks.ts | 54 +++++++++- app/scripts/styles/drop-menu-item-button.tsx | 49 ++++++--- app/scripts/styles/range-slider.tsx | 73 +++++++++++++ package.json | 1 + yarn.lock | 13 +++ 8 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 app/scripts/components/exploration/dataset-options.tsx create mode 100644 app/scripts/styles/range-slider.tsx diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index 563b17841..19ef16650 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -30,6 +30,7 @@ export interface TimelineDataset { settings: { // user defined settings like visibility, opacity isVisible?: boolean; + opacity?: number; }; } diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index cd721fa54..1cb3059da 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -38,6 +38,7 @@ import { } from './dataset-list-item-status'; import { DatasetChart } from './dataset-chart'; import { activeAnalysisMetricsAtom, isAnalysisAtom } from './atoms'; +import DatasetOptions from './dataset-options'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -170,6 +171,7 @@ export function DatasetListItem(props: DatasetListItemProps) { {dataset.data.title} + {!isError ? ( setVisible((v) => !v)}> {isVisible ? ( diff --git a/app/scripts/components/exploration/dataset-options.tsx b/app/scripts/components/exploration/dataset-options.tsx new file mode 100644 index 000000000..70033bbeb --- /dev/null +++ b/app/scripts/components/exploration/dataset-options.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { PrimitiveAtom, useAtomValue, useSetAtom } from 'jotai'; +import 'react-range-slider-input/dist/style.css'; +import styled from 'styled-components'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; +import { Button } from '@devseed-ui/button'; +import { CollecticonCog, CollecticonTrashBin } from '@devseed-ui/collecticons'; +import { Overline } from '@devseed-ui/typography'; + +import { useTimelineDatasetSettings } from './hooks'; +import { TimelineDataset } from './constants'; +import { timelineDatasetsAtom } from './atoms'; + +import DropMenuItemButton from '$styles/drop-menu-item-button'; +import { SliderInput, SliderInputProps } from '$styles/range-slider'; + +interface DatasetOptionsProps { + datasetAtom: PrimitiveAtom; +} + +export default function DatasetOptions(props: DatasetOptionsProps) { + const { datasetAtom } = props; + + const setDatasets = useSetAtom(timelineDatasetsAtom); + const dataset = useAtomValue(datasetAtom); + const [getSettings, setSetting] = useTimelineDatasetSettings(datasetAtom); + + const opacity = (getSettings('opacity') ?? 100) as number; + + return ( + ( + + )} + > + View options + +
  • + setSetting('opacity', v)} + /> +
  • +
    + +
  • + { + setDatasets((datasets) => + datasets.filter((d) => d.data.id !== dataset.data.id) + ); + }} + > + Remove dataset + +
  • +
    +
    + ); +} + +const OpacityControlWrapper = styled.div` + padding: ${glsp(0.5, 1)}; + display: flex; + flex-flow: column; + gap: ${glsp(0.25)}; +`; + +const OpacityControlElements = styled.div` + display: flex; + gap: ${glsp(0.5)}; + align-items: center; +`; + +const OpacityValue = styled.span` + font-size: 0.75rem; + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; + width: 2rem; + text-align: right; +`; + +function OpacityControl(props: SliderInputProps) { + const { value, onInput } = props; + + return ( + + Opacity + + + {value} + + + ); +} diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index a681c0018..1086afbe5 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { extent, scaleTime } from 'd3'; import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; import { focusAtom } from 'jotai-optics'; @@ -109,6 +109,58 @@ export function useTimelineDatasetAtom(id: string) { return datasetAtom as PrimitiveAtom; } +type TimelineDatasetSettingsReturn = [ + ( + prop: keyof TimelineDataset['settings'] + ) => TimelineDataset['settings'][keyof TimelineDataset['settings']], + ( + prop: keyof TimelineDataset['settings'], + value: + | TimelineDataset['settings'][keyof TimelineDataset['settings']] + | (( + prev: TimelineDataset['settings'][keyof TimelineDataset['settings']] + ) => TimelineDataset['settings'][keyof TimelineDataset['settings']]) + ) => void +]; + +/** + * Hook to get/set the settings of a dataset. + * + * @param datasetAtom Single dataset atom. + * @returns State getter/setter for the dataset settings. + * + * @example + * const [get, set] = useTimelineDatasetSettings(datasetAtom); + * const isVisible = get('isVisible'); + * set('isVisible', !isVisible); + * set('isVisible', (prev) => !prev); + * + */ +export function useTimelineDatasetSettings( + datasetAtom: PrimitiveAtom +): TimelineDatasetSettingsReturn { + const settingsAtom = useMemo(() => { + return focusAtom(datasetAtom, (optic) => optic.prop('settings')); + }, [datasetAtom]); + + const [value, set] = useAtom(settingsAtom); + + const setter = useCallback( + (prop, value) => { + set((prev) => { + const currValue = prev[prop]; + const newValue = typeof value === 'function' ? value(currValue) : value; + return { ...prev, [prop]: newValue }; + }); + }, + [set] + ); + + const getter = useCallback((prop) => value[prop], [value]); + + return [getter, setter]; +} + /** * Hook to get/set the visibility of a dataset. * @param datasetAtom Single dataset atom. diff --git a/app/scripts/styles/drop-menu-item-button.tsx b/app/scripts/styles/drop-menu-item-button.tsx index 0dfe18166..99e88a160 100644 --- a/app/scripts/styles/drop-menu-item-button.tsx +++ b/app/scripts/styles/drop-menu-item-button.tsx @@ -1,27 +1,52 @@ import styled, { css } from 'styled-components'; -import { DropMenuItem } from '@devseed-ui/dropdown'; -import { rgba, themeVal } from '@devseed-ui/theme-provider'; +import { DropMenuItem, DropMenuItemProps } from '@devseed-ui/dropdown'; +import { themeVal } from '@devseed-ui/theme-provider'; -const rgbaFixed = rgba as any; +interface DropMenuItemButtonProps extends DropMenuItemProps { + variation?: + | 'base' + | 'primary' + | 'secondary' + | 'danger' + | 'success' + | 'warning' + | 'info'; +} const DropMenuItemButton = styled(DropMenuItem).attrs({ as: 'button', 'data-dropdown': 'click.close' -})` +})` background: none; border: none; width: 100%; cursor: pointer; text-align: left; - ${({ active }) => - active && - css` - &, - &:visited { - background-color: ${rgbaFixed(themeVal('color.link'), 0.08)}; - } - `} + ${(props) => renderVariation(props)} `; export default DropMenuItemButton; + +function renderVariation(props: DropMenuItemButtonProps) { + const { variation = 'base', active } = props; + + return css` + color: ${themeVal(`color.${variation}`)}; + + &:hover { + color: ${themeVal(`color.${variation}`)}; + background-color: ${themeVal(`color.${variation}-50a`)}; + } + + /* Print & when prop is passed */ + ${active && '&,'} + &.active { + background-color: ${themeVal(`color.${variation}-100a`)}; + } + + &:focus-visible { + outline-color: ${themeVal(`color.${variation}-200a`)}; + } + `; +} diff --git a/app/scripts/styles/range-slider.tsx b/app/scripts/styles/range-slider.tsx new file mode 100644 index 000000000..5f5740760 --- /dev/null +++ b/app/scripts/styles/range-slider.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import styled from 'styled-components'; +import RangeSlider from 'react-range-slider-input'; +import 'react-range-slider-input/dist/style.css'; + +import { themeVal } from '@devseed-ui/theme-provider'; + +export interface RangeSliderInputProps { + id?: string; + className?: string; + min?: number; + max?: number; + step?: number; + defaultValue?: [number, number]; + value: [number, number]; + onInput: (v: [number, number]) => void; + // onThumbDragStart; + // onThumbDragEnd; + // onRangeDragStart; + // onRangeDragEnd; + disabled?: boolean; + rangeSlideDisabled?: boolean; + thumbsDisabled?: [boolean, boolean]; + orientation?: 'horizontal' | 'vertical'; +} + +export const RangeSliderInput = styled(RangeSlider)` + && { + background: ${themeVal('color.base-200')}; + border-radius: ${themeVal('shape.rounded')}; + + .range-slider__range { + border-radius: ${themeVal('shape.rounded')}; + background: ${themeVal('color.primary')}; + } + + .range-slider__thumb { + transition: background 160ms ease-in-out; + background: ${themeVal('color.surface')}; + box-shadow: ${themeVal('boxShadow.elevationD')}; + width: 1.25rem; + height: 1.25rem; + + &:hover { + background: ${themeVal('color.base-50')}; + } + } + + .range-slider__thumb[data-lower] { + width: 0; + } + } +`; + +export interface SliderInputProps + extends Omit { + value: number; + onInput: (v: number) => void; +} + +export function SliderInput(props: SliderInputProps) { + const { value, onInput, ...rest } = props; + + return ( + onInput(v[1])} + thumbsDisabled={[true, false]} + rangeSlideDisabled + /> + ); +} diff --git a/package.json b/package.json index a6569899c..bebbe2805 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "react-indiana-drag-scroll": "^2.2.0", "react-lazyload": "^3.2.0", "react-nl2br": "^1.0.2", + "react-range-slider-input": "^3.0.7", "react-resizable-panels": "^0.0.45", "react-router": "^6.0.0", "react-router-dom": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index efd854c79..428295249 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4905,6 +4905,11 @@ core-js-compat@^3.21.0, core-js-compat@^3.22.1: browserslist "^4.21.1" semver "7.0.0" +core-js@^3.22.4: + version "3.32.0" + resolved "http://verdaccio.ds.io:4873/core-js/-/core-js-3.32.0.tgz#7643d353d899747ab1f8b03d2803b0312a0fb3b6" + integrity sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww== + core-util-is@~1.0.0: version "1.0.3" resolved "http://verdaccio.ds.io:4873/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -10661,6 +10666,14 @@ react-nl2br@^1.0.2: resolved "http://verdaccio.ds.io:4873/react-nl2br/-/react-nl2br-1.0.4.tgz#20079e2660b9e9a5293b115466e3749abeed6d87" integrity sha512-KQ+uwmjYk3B04xDl0hus2OeGoqkR8qkoyDBtsIqVacOUMNeaP0W+r/+anded2ehii/FlgFqEuu0T72CJLWFp4A== +react-range-slider-input@^3.0.7: + version "3.0.7" + resolved "http://verdaccio.ds.io:4873/react-range-slider-input/-/react-range-slider-input-3.0.7.tgz#88ceb118b33d7eb0550cec1f77fc3e60e0f880f9" + integrity sha512-yAJDDMUNkILOcZSCLCVbwgnoAM3v0AfdDysTCMXDwY/+ZRNRlv98TyHbVCwPFEd7qiI8Ca/stKb0GAy//NybYw== + dependencies: + clsx "^1.1.1" + core-js "^3.22.4" + react-refresh@^0.9.0: version "0.9.0" resolved "http://verdaccio.ds.io:4873/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf"