From 934f8e8998e69f6ea76ee726a0d7f0c9e2a758f2 Mon Sep 17 00:00:00 2001 From: Igor Dykhta Date: Thu, 31 Oct 2024 17:35:29 +0200 Subject: [PATCH] [feat] Improve timeline sync filer UI (#2722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Improve time filter and sync timeline UI Signed-off-by: Ihor Dykhta Co-authored-by: Giuseppe Macrì --- src/actions/src/action-types.ts | 1 + src/actions/src/vis-state-actions.ts | 22 +++- src/components/src/bottom-widget.tsx | 1 + .../src/common/icons/arrow-down-full.tsx | 29 +++++ src/components/src/common/icons/index.tsx | 1 + .../src/common/icons/timeline-marker.tsx | 15 +-- src/components/src/common/icons/trash.tsx | 11 +- src/components/src/common/range-plot.tsx | 28 +++-- .../src/common/range-slider-subline.tsx | 80 +++++++++--- src/components/src/common/range-slider.tsx | 27 ++++- .../src/common/sync-timeline-control.tsx | 78 ++++++++++++ .../src/common/time-slider-marker.tsx | 9 +- .../filter-synced-dataset-panel.tsx | 114 +++++++++++------- src/components/src/filters/types.ts | 2 + src/components/src/index.ts | 5 +- .../layer-panel/layer-type-list-item.tsx | 4 +- src/constants/src/default-settings.ts | 7 +- src/layers/src/base-layer.ts | 2 +- src/localization/src/translations/en.ts | 2 + src/reducers/src/vis-state-updaters.ts | 78 ++++++++++-- src/reducers/src/vis-state.ts | 2 + src/schemas/src/vis-state-schema.ts | 1 + src/styles/src/base.ts | 2 + src/types/reducers.d.ts | 3 + src/types/schemas.d.ts | 1 + src/utils/src/filter-utils.ts | 30 +++-- .../components/filters/time-widget-test.js | 17 +-- 27 files changed, 435 insertions(+), 137 deletions(-) create mode 100644 src/components/src/common/icons/arrow-down-full.tsx create mode 100644 src/components/src/common/sync-timeline-control.tsx diff --git a/src/actions/src/action-types.ts b/src/actions/src/action-types.ts index df7369ecee..dfa8f59651 100644 --- a/src/actions/src/action-types.ts +++ b/src/actions/src/action-types.ts @@ -144,6 +144,7 @@ export const ActionTypes = { SET_LOCALE: `${ACTION_PREFIX}SET_LOCALE`, LAYER_FILTERED_ITEMS_CHANGE: `${ACTION_PREFIX}LAYER_FILTERED_ITEMS_CHANGE`, SYNC_TIME_FILTER_WITH_LAYER_TIMELINE: `${ACTION_PREFIX}SYNC_TIME_FILTER_WITH_LAYER_TIMELINE`, + SYNC_TIME_FILTER_TIMELINE_MODE: `${ACTION_PREFIX}SYNC_TIME_FILTER_TIMELINE_MODE`, TOGGLE_PANEL_LIST_VIEW: `${ACTION_PREFIX}TOGGLE_PANEL_LIST_VIEW`, // uiState > export image diff --git a/src/actions/src/vis-state-actions.ts b/src/actions/src/vis-state-actions.ts index 46647dc814..73ca600b71 100644 --- a/src/actions/src/vis-state-actions.ts +++ b/src/actions/src/vis-state-actions.ts @@ -20,7 +20,8 @@ import { Filter, ParsedConfig, ParsedLayer, - EffectPropsPartial + EffectPropsPartial, + SyncTimelineMode } from '@kepler.gl/types'; // TODO - import LoaderObject type from @loaders.gl/core when supported // TODO - import LoadOptions type from @loaders.gl/core when supported @@ -1573,6 +1574,25 @@ export function syncTimeFilterWithLayerTimeline( }; } +export type setTimeFilterSyncTimelineModeAction = { + id: string; + mode: SyncTimelineMode; +}; + +export function setTimeFilterSyncTimelineMode({ + id, + mode +}: setTimeFilterSyncTimelineModeAction): Merge< + setTimeFilterSyncTimelineModeAction, + {type: typeof ActionTypes.SYNC_TIME_FILTER_TIMELINE_MODE} +> { + return { + type: ActionTypes.SYNC_TIME_FILTER_TIMELINE_MODE, + id, + mode + }; +} + /** * This declaration is needed to group actions in docs */ diff --git a/src/components/src/bottom-widget.tsx b/src/components/src/bottom-widget.tsx index 9e0e3247d4..2719e90c93 100644 --- a/src/components/src/bottom-widget.tsx +++ b/src/components/src/bottom-widget.tsx @@ -203,6 +203,7 @@ export default function BottomWidgetFactory( setFilterPlot={visStateActions.setFilterPlot} setFilterAnimationTime={setTimelineValue} setFilterAnimationWindow={visStateActions.setFilterAnimationWindow} + setFilterSyncTimelineMode={visStateActions.setTimeFilterSyncTimelineMode} toggleAnimation={visStateActions.toggleFilterAnimation} updateAnimationSpeed={visStateActions.updateFilterAnimationSpeed} resetAnimation={resetAnimation} diff --git a/src/components/src/common/icons/arrow-down-full.tsx b/src/components/src/common/icons/arrow-down-full.tsx new file mode 100644 index 0000000000..24c0e01db5 --- /dev/null +++ b/src/components/src/common/icons/arrow-down-full.tsx @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright contributors to the kepler.gl project + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import Base from './base'; + +export default class ArrowDown extends Component { + static propTypes = { + /** Set the height of the icon, ex. '16px' */ + height: PropTypes.string, + style: PropTypes.any + }; + + static defaultProps = { + height: '8px', + width: '8px', + predefinedClassName: 'data-ex-icons-arrowdown-full', + viewBox: '0 0 8 4' + }; + + render() { + return ( + + + + ); + } +} diff --git a/src/components/src/common/icons/index.tsx b/src/components/src/common/icons/index.tsx index 91d2efc38e..66ae4e5bf5 100644 --- a/src/components/src/common/icons/index.tsx +++ b/src/components/src/common/icons/index.tsx @@ -11,6 +11,7 @@ export {default as AnchorWindow} from './anchor_window'; export {default as ArrowDown} from './arrow-down'; export {default as ArrowDownAlt} from './arrow-down-alt'; export {default as ArrowDownSolid} from './arrow-down-alt'; +export {default as ArrowDownFull} from './arrow-down-full'; export {default as ArrowLeft} from './arrow-left'; export {default as ArrowRight} from './arrow-right'; export {default as ArrowUpSolid} from './arrow-up-solid'; diff --git a/src/components/src/common/icons/timeline-marker.tsx b/src/components/src/common/icons/timeline-marker.tsx index 36c73b3ba9..daef6fb897 100644 --- a/src/components/src/common/icons/timeline-marker.tsx +++ b/src/components/src/common/icons/timeline-marker.tsx @@ -15,7 +15,8 @@ export default class TimelineMarker extends Component { }; static defaultProps = { - height: '16px', + height: '12px', + width: '5px', predefinedClassName: 'data-ex-icons-timeline-marker', viewBox: '0 0 5 12' }; @@ -23,16 +24,8 @@ export default class TimelineMarker extends Component { render() { return ( - - - - + + ); } diff --git a/src/components/src/common/icons/trash.tsx b/src/components/src/common/icons/trash.tsx index 02d59805ca..1a696f10c8 100644 --- a/src/components/src/common/icons/trash.tsx +++ b/src/components/src/common/icons/trash.tsx @@ -7,6 +7,7 @@ import Base, {BaseProps} from './base'; export default class Trash extends Component> { static defaultProps = { height: '16px', + viewBox: '0 0 16 16', predefinedClassName: 'data-ex-icons-trash' }; @@ -14,13 +15,9 @@ export default class Trash extends Component> { return ( - ); diff --git a/src/components/src/common/range-plot.tsx b/src/components/src/common/range-plot.tsx index 471221b536..0299affa01 100644 --- a/src/components/src/common/range-plot.tsx +++ b/src/components/src/common/range-plot.tsx @@ -191,7 +191,7 @@ export default function RangePlotFactory( } }); } else if (isLineChart(plotType) && !lineChart && !isLoading) { - // load linechart + // load line chart setIsLoading(true); setFilterPlot({ plotType: { @@ -203,19 +203,21 @@ export default function RangePlotFactory( } }, [plotType, bins, lineChart, setFilterPlot, isLoading, setIsLoading]); + const rangePlotStyle = useMemo( + () => ({ + height: `${ + isEnlarged + ? hasMobileWidth(breakPointValues) + ? theme.rangePlotContainerHLargePalm + : theme.rangePlotContainerHLarge + : theme.rangePlotContainerH + }px` + }), + [isEnlarged, theme] + ); + return ( - + {isLoading ? (
{ + const RangeSliderSubline = ({line, scaledValue}) => { + const style: CSSProperties = { + left: `${line[0]}%`, + top: '0', + width: `${line[1] - line[0]}%`, + height: '100%', + position: 'absolute', + backgroundColor: '#5558DB' + }; + + const value = scaledValue[line[2]]; + + const leftMarketStyle = useMemo( + () => ({ + left: `calc(${line[0]}% - 4px)`, + ...TIMELINE_MARKER_STYLE + }), + [line] + ); + + const rightMarketStyle = useMemo( + () => ({ + left: `calc(${line[1]}% - 4px)`, + ...TIMELINE_MARKER_STYLE + }), + [line] + ); + + const indicatorStyle = useMemo( + () => ({ + ...TIMELINE_INDICATOR_STYLE, + left: `calc(${value}% - 2px)` + }), + [value] + ); + return ( -
- -
- +
+
+ + +
); }; diff --git a/src/components/src/common/range-slider.tsx b/src/components/src/common/range-slider.tsx index dcd0a2d19e..9b20665152 100644 --- a/src/components/src/common/range-slider.tsx +++ b/src/components/src/common/range-slider.tsx @@ -9,7 +9,13 @@ import RangePlotFactory from './range-plot'; import Slider from './slider/slider'; import {Input} from './styled-components'; import RangeSliderSublineFactory from '../common/range-slider-subline'; -import {observeDimensions, unobserveDimensions, roundValToStep, clamp} from '@kepler.gl/utils'; +import { + observeDimensions, + unobserveDimensions, + roundValToStep, + clamp, + scaleSourceDomainToDestination +} from '@kepler.gl/utils'; import {LineChart, Filter, Bins} from '@kepler.gl/types'; import {Datasets} from '@kepler.gl/table'; import {ActionHandler, setFilterPlot} from '@kepler.gl/actions'; @@ -60,7 +66,7 @@ interface RangeSliderProps { step?: number; sliderHandleWidth?: number; xAxis?: ElementType; - sublines?: [number, number][]; + timelines?: number[]; timezone?: string | null; timeFormat?: string; @@ -230,7 +236,7 @@ export default function RangeSliderFactory( timeFormat, playbackControlWidth, setFilterPlot, - sublines, + timelines, animationWindow, filter, datasets @@ -239,6 +245,13 @@ export default function RangeSliderFactory( const {width} = this.state; const plotWidth = Math.max(width - Number(sliderHandleWidth), 0); const hasPlot = plotType?.type; + + const value = this.props.plotValue || this.filterValueSelector(this.props); + const scaledValue = range + ? // TODO figure out correct types for value and range + scaleSourceDomainToDestination(value as [number, number], range as [number, number]) + : [0, 0]; + return (
) : null} - {sublines?.length - ? sublines.map((line, index) => ) + {timelines?.length + ? timelines.map((line, index) => ( + + )) : null} void; + syncTimelineAnimationItems: SyncTimelineAnimationItem[]; + btnStyle: Record; +}; + +export const SYNC_TIMELINE_ANIMATION_ITEMS: Record< + string, + { + id: number; + content: React.ElementType; + tooltip: string; + } +> = { + [SYNC_TIMELINE_MODES.start]: { + id: SYNC_TIMELINE_MODES.start, + content: () => Start, + tooltip: 'tooltip.syncTimelineStart' + }, + [SYNC_TIMELINE_MODES.end]: { + id: SYNC_TIMELINE_MODES.end, + content: () => End, + tooltip: 'tooltip.syncTimelineEnd' + } +}; + +function SyncTimelineControlFactory() { + const SyncTimelineControl = ({ + syncTimelineAnimationItems = SYNC_TIMELINE_ANIMATION_ITEMS, + syncTimelineMode, + btnStyle, + setFilterSyncTimelineMode + }) => { + return ( +
+ {Object.values(syncTimelineAnimationItems) + .filter((item, index) => item.id !== syncTimelineMode) + .map(item => ( + setFilterSyncTimelineMode(item.id)} + {...btnStyle} + > + + {item.tooltip ? ( + + + + ) : null} + + ))} +
+ ); + }; + + return SyncTimelineControl; +} + +export default SyncTimelineControlFactory; diff --git a/src/components/src/common/time-slider-marker.tsx b/src/components/src/common/time-slider-marker.tsx index 633769c445..6114b35fc7 100644 --- a/src/components/src/common/time-slider-marker.tsx +++ b/src/components/src/common/time-slider-marker.tsx @@ -25,7 +25,12 @@ const TimeSliderContainer = styled.svg` fill: ${props => props.theme.axisFontColor}; } - .axis line, + .axis line { + stroke: ${props => props.theme.axisFontColor}; + shape-rendering: crispEdges; + stroke-width: 1; + } + .axis path { fill: none; stroke: ${props => props.theme.sliderBarBgd}; @@ -104,7 +109,7 @@ export function getXAxis( const ticks = Math.floor(width / (isEnlarged ? MIN_TICK_WIDTH_LARGE : MIN_TICK_WIDTH_SMALL)); const tickFormat = timezone ? getTickFormat(timezone) : null; - const xAxis = axisBottom(scale).ticks(ticks).tickSize(0).tickPadding(12); + const xAxis = axisBottom(scale).ticks(ticks).tickSize(4).tickPadding(4); if (tickFormat) { xAxis.tickFormat(tickFormat); } diff --git a/src/components/src/filters/filter-panels/filter-synced-dataset-panel.tsx b/src/components/src/filters/filter-panels/filter-synced-dataset-panel.tsx index 97a50d3e71..d1bbfb6623 100644 --- a/src/components/src/filters/filter-panels/filter-synced-dataset-panel.tsx +++ b/src/components/src/filters/filter-panels/filter-synced-dataset-panel.tsx @@ -2,24 +2,35 @@ // Copyright contributors to the kepler.gl project import React, {useCallback, useMemo} from 'react'; -import styled from 'styled-components'; +import styled, {withTheme} from 'styled-components'; import {FormattedMessage} from '@kepler.gl/localization'; import {ALL_FIELD_TYPES} from '@kepler.gl/constants'; import {getAnimatableLayers} from '@kepler.gl/utils'; import {Button} from '../../common/styled-components'; -import {Add} from '../../common/icons'; +import {Add, Trash} from '../../common/icons'; import TippyTooltip from '../../common/tippy-tooltip'; import FilterPanelHeaderFactory from '../../side-panel/filter-panel/filter-panel-header'; import SourceSelectorFactory from '../../side-panel/common/source-selector'; import SourceDataSelectorFactory from '../../side-panel/common/source-data-selector'; +import LayerTypeListItemFactory from '../../side-panel/layer-panel/layer-type-list-item'; + +const TrashIcon = styled(Trash)` + cursor: pointer; + color: ${props => props.theme.fontWhiteColor}; + margin-left: 8px; +`; const SyncedDatasetsArea = styled.div` display: grid; align-items: center; grid-auto-rows: min-content; grid-auto-flow: row; + + .side-panel-section { + margin-bottom: 0; + } `; const StyledContentTitle = styled.div` @@ -30,7 +41,7 @@ const StyledContentTitle = styled.div` const StyledSeparator = styled.div` border-left: 1px dashed ${props => props.theme.subtextColor}; height: 16px; - margin-left: 8px; + margin: 4px 0 4px 8px; `; const StyledButton = styled(Button)` @@ -71,6 +82,7 @@ function DatasetItemFactory(SourceSelector, FilterPanelHeader) { background-color: transparent; `; + // Check if this component already exists const DatasetItem = ({ dataId, datasets, @@ -111,6 +123,41 @@ function DatasetItemFactory(SourceSelector, FilterPanelHeader) { DatasetItemFactory.deps = [SourceSelectorFactory, FilterPanelHeaderFactory]; +const StyledLayerTimeline = styled.div` + display: flex; + align-items: center; +`; + +const StyledLayerList = styled.div` + flex: 1; +`; + +function LayerTimelineFactory(LayerTypeListItem) { + const StyledLayerTypeListItem = styled(LayerTypeListItem)` + background-color: ${props => props.theme.dropdownListHighlightBg}; + padding: 4px; + `; + + const LayerTimeline = ({layers, theme, onDelete}) => ( + + + {layers.map(layer => ( + + ))} + + + + ); + + return withTheme(LayerTimeline); +} + +LayerTimelineFactory.deps = [LayerTypeListItemFactory]; + function SyncedDatasetButtonFactory() { const SyncedDatasetButton = ({onAddSyncedFilter}) => (
@@ -159,36 +206,12 @@ function SyncLayerTimelineButtonFactory() { return SyncLayerTimelineButton; } -function UnsyncLayerTimelineButtonFactory() { - const UnsyncLayerTimelineButton = ({onSyncLayerTimeline}) => ( -
- - ( -
- -
- )} - > - - - - -
-
- ); - - return UnsyncLayerTimelineButton; -} - function FilterSyncedDatasetPanelFactory( DatasetItem, + LayerTimeline, SourceDataSelector, SyncedDatasetButton, - SyncLayerTimelineButton, - UnsyncLayerTimelineButton + SyncLayerTimelineButton ) { const FilterSyncedDatasetPanel = ({ datasets, @@ -203,9 +226,10 @@ function FilterSyncedDatasetPanelFactory( }) => { const datasetsWithTime = useMemo(() => getDatasetsWithTimeField(datasets), [datasets]); const filterDatasetsNum = useMemo(() => filter.dataId.length, [filter.dataId]); - const datasetsWithTimeNum = useMemo(() => Object.keys(datasetsWithTime).length, [ - datasetsWithTime - ]); + const datasetsWithTimeNum = useMemo( + () => Object.keys(datasetsWithTime).length, + [datasetsWithTime] + ); const onRemoveSyncedFilter = useCallback( valueIndex => { @@ -230,19 +254,20 @@ function FilterSyncedDatasetPanelFactory( setFilter(idx, ['dataId', 'name'], [nextId, nextName], filter.dataId.length); }, [setFilter, idx, datasetsWithTime, datasets, filter.dataId, filter.name]); - const onSyncLayerTimeline = useCallback(() => syncTimeFilterWithLayerTimeline(idx, true), [ - syncTimeFilterWithLayerTimeline, - idx - ]); + const onSyncLayerTimeline = useCallback( + () => syncTimeFilterWithLayerTimeline(idx, true), + [syncTimeFilterWithLayerTimeline, idx] + ); const onRemoveSyncWithLayerTimeline = useCallback( () => syncTimeFilterWithLayerTimeline(idx, false), [syncTimeFilterWithLayerTimeline, idx] ); - const tripLayers = useMemo(() => getAnimatableLayers(layers).filter(l => l.type === 'trip'), [ - layers - ]); + const animatableLayers = useMemo( + () => getAnimatableLayers(layers).filter(l => l.type === 'trip'), + [layers] + ); const isLinkedWithLayerTimeline = useMemo(() => filter.syncedWithLayerTimeline, [filter]); @@ -281,10 +306,13 @@ function FilterSyncedDatasetPanelFactory( )} {isLinkedWithLayerTimeline ? ( - + <> + + + ) : ( <> - {tripLayers.length ? ( + {animatableLayers.length ? ( ) : null} @@ -303,10 +331,10 @@ function FilterSyncedDatasetPanelFactory( FilterSyncedDatasetPanelFactory.deps = [ DatasetItemFactory, + LayerTimelineFactory, SourceDataSelectorFactory, SyncedDatasetButtonFactory, - SyncLayerTimelineButtonFactory, - UnsyncLayerTimelineButtonFactory + SyncLayerTimelineButtonFactory ]; export default FilterSyncedDatasetPanelFactory; diff --git a/src/components/src/filters/types.ts b/src/components/src/filters/types.ts index 29436baeff..cb4f687317 100644 --- a/src/components/src/filters/types.ts +++ b/src/components/src/filters/types.ts @@ -15,6 +15,7 @@ import { ActionHandler, setFilterAnimationTime, setFilterAnimationWindow, + setTimeFilterSyncTimelineMode, setFilterPlot, toggleFilterAnimation, updateFilterAnimationSpeed @@ -87,6 +88,7 @@ export type TimeWidgetProps = { toggleAnimation: ActionHandler; setFilterPlot: ActionHandler; setFilterAnimationWindow: ActionHandler; + setFilterSyncTimelineMode: ActionHandler; timeline: Timeline; animationConfig: AnimationConfig; }; diff --git a/src/components/src/index.ts b/src/components/src/index.ts index dab80b487d..00d9a26a44 100644 --- a/src/components/src/index.ts +++ b/src/components/src/index.ts @@ -200,6 +200,10 @@ export { } from './common/time-slider-marker'; export {default as TimeRangeSliderTimeTitleFactory} from './common/time-range-slider-time-title'; export {default as IconButton} from './common/icon-button'; +export { + SYNC_TIMELINE_ANIMATION_ITEMS, + default as SyncTimelineControlFactory +} from './common/sync-timeline-control'; // // Filters factory export {default as TimeWidgetFactory} from './filters/time-widget'; export {default as TimeWidgetTopFactory} from './filters/time-widget-top'; @@ -213,7 +217,6 @@ export { } from './filters/time-range-filter'; export {default as RangeFilterFactory} from './filters/range-filter'; - // // Editor Factory export {default as EditorFactory} from './editor/editor'; export { diff --git a/src/components/src/side-panel/layer-panel/layer-type-list-item.tsx b/src/components/src/side-panel/layer-panel/layer-type-list-item.tsx index e7c2a19fe3..439fed7d3a 100644 --- a/src/components/src/side-panel/layer-panel/layer-type-list-item.tsx +++ b/src/components/src/side-panel/layer-panel/layer-type-list-item.tsx @@ -55,9 +55,9 @@ const StyledListItem = styled.div` `; export function LayerTypeListItemFactory() { - const LayerTypeListItem: React.FC = ({value, isTile, theme}) => ( + const LayerTypeListItem: React.FC = ({value, isTile, theme, className}) => ( diff --git a/src/constants/src/default-settings.ts b/src/constants/src/default-settings.ts index e047564bfd..07e368e15f 100644 --- a/src/constants/src/default-settings.ts +++ b/src/constants/src/default-settings.ts @@ -13,7 +13,7 @@ import { scalePoint } from 'd3-scale'; import {TOOLTIP_FORMAT_TYPES} from './tooltip'; -import {RGBAColor, EffectDescription, BaseMapStyle} from '@kepler.gl/types'; +import {BaseMapStyle, EffectDescription, RGBAColor, SyncTimelineMode} from '@kepler.gl/types'; export const ACTION_PREFIX = '@@kepler.gl/'; export const KEPLER_UNFOLDED_BUCKET = 'https://studio-public-data.foursquare.com/statics/keplergl'; @@ -1441,3 +1441,8 @@ export type EffectType = | 'magnify' | 'hexagonalPixelate' | 'lightAndShadow'; + +export const SYNC_TIMELINE_MODES: Record = { + start: 0, + end: 1 +}; diff --git a/src/layers/src/base-layer.ts b/src/layers/src/base-layer.ts index 4afd3fbc47..36e7219b89 100644 --- a/src/layers/src/base-layer.ts +++ b/src/layers/src/base-layer.ts @@ -280,7 +280,7 @@ class Layer { return null; } - get name() { + get name(): string | null { return this.type; } diff --git a/src/localization/src/translations/en.ts b/src/localization/src/translations/en.ts index c771971d98..2e0068d0c4 100644 --- a/src/localization/src/translations/en.ts +++ b/src/localization/src/translations/en.ts @@ -244,6 +244,8 @@ export default { export: 'export', timeLayerSync: 'Link with the layer timeline', timeLayerUnsync: 'Unlink with the layer timeline', + syncTimelineStart: 'Start of current filter timeframe', + syncTimelineEnd: 'End of current filter timeframe', showEffectPanel: 'Show effect panel', hideEffectPanel: 'Hide effect panel', removeEffect: 'Remove effect', diff --git a/src/reducers/src/vis-state-updaters.ts b/src/reducers/src/vis-state-updaters.ts index 430f6fde7b..2d9fc8b70d 100644 --- a/src/reducers/src/vis-state-updaters.ts +++ b/src/reducers/src/vis-state-updaters.ts @@ -74,6 +74,7 @@ import { import {mergeStateFromMergers, isValidMerger} from './merger-handler'; import {Layer, LayerClasses, LAYER_ID_LENGTH} from '@kepler.gl/layers'; import { + ANIMATION_WINDOW, EDITOR_MODES, SORT_ORDER, FILTER_TYPES, @@ -83,6 +84,7 @@ import { COMPARE_TYPES, LIGHT_AND_SHADOW_EFFECT, PLOT_TYPES, + SYNC_TIMELINE_MODES, BASE_SPEED, FPS } from '@kepler.gl/constants'; @@ -942,17 +944,25 @@ export function setFilterAnimationWindowUpdater( state: VisState, {id, animationWindow}: VisStateActions.SetFilterAnimationWindowUpdaterAction ): VisState { - return { + const filter = state.filters.find(f => f.id === id); + + if (!filter) { + return state; + } + + const newFilter = { + ...filter, + animationWindow + }; + + const newState = { ...state, - filters: state.filters.map(f => - f.id === id - ? { - ...f, - animationWindow - } - : f - ) + filters: swap_(newFilter)(state.filters) }; + + const newSyncTimelineMode = getSyncAnimationMode(newFilter); + + return setTimeFilterTimelineModeUpdater(newState, {id, mode: newSyncTimelineMode}); } /** @@ -3147,16 +3157,19 @@ export function syncTimeFilterWithLayerTimelineUpdater( const {idx: filterIdx, enable = false} = action; const filter = state.filters[filterIdx] as TimeRangeFilter; - let newAnimationConfig = {...state.animationConfig}; + const newAnimationConfig = {...state.animationConfig}; const newFilterDomain = enable ? mergeTimeDomains([filter.domain, newAnimationConfig.domain as [number, number] | null]) : filter.domain; + const syncTimelineMode = getSyncAnimationMode(filter); + const newFilter = { ...filter, value: adjustValueToFilterDomain(newFilterDomain, filter), - syncedWithLayerTimeline: enable + syncedWithLayerTimeline: enable, + syncTimelineMode }; const animationConfigCurrentTime = enable @@ -3172,8 +3185,49 @@ export function syncTimeFilterWithLayerTimelineUpdater( ); } +export function setTimeFilterTimelineModeUpdater( + state: S, + action: VisStateActions.setTimeFilterSyncTimelineModeAction +) { + const {id: filterId, mode: syncTimelineMode} = action; + + const filter = state.filters.find(f => f.id === filterId) as TimeRangeFilter | undefined; + + if (!filter?.syncedWithLayerTimeline) { + return state; + } + + if (!validateSyncAnimationMode(filter, syncTimelineMode)) { + return state; + } + + const newFilter = { + ...filter, + syncTimelineMode + }; + + return { + ...state, + filters: swap_(newFilter)(state.filters) + }; +} + function getTimelineFromTrip(filter) { - return filter.value[1]; + return filter.value[filter.syncTimelineMode]; +} + +function getSyncAnimationMode(filter) { + return filter.animationWindow === ANIMATION_WINDOW.free + ? SYNC_TIMELINE_MODES.start + : SYNC_TIMELINE_MODES.end; +} + +function validateSyncAnimationMode(filter, newMode) { + if (filter.animationWindow !== ANIMATION_WINDOW.free && newMode === SYNC_TIMELINE_MODES.start) { + return false; + } + + return true; } // Find dataId from a saved visState property: diff --git a/src/reducers/src/vis-state.ts b/src/reducers/src/vis-state.ts index d71970609c..e0e31dfd8a 100644 --- a/src/reducers/src/vis-state.ts +++ b/src/reducers/src/vis-state.ts @@ -145,6 +145,8 @@ const actionHandler = { [ActionTypes.SYNC_TIME_FILTER_WITH_LAYER_TIMELINE]: visStateUpdaters.syncTimeFilterWithLayerTimelineUpdater, + [ActionTypes.SYNC_TIME_FILTER_TIMELINE_MODE]: visStateUpdaters.setTimeFilterTimelineModeUpdater, + [ActionTypes.ADD_EFFECT]: visStateUpdaters.addEffectUpdater, [ActionTypes.REORDER_EFFECT]: visStateUpdaters.reorderEffectUpdater, diff --git a/src/schemas/src/vis-state-schema.ts b/src/schemas/src/vis-state-schema.ts index 5f37d40cc2..df1ede5f33 100644 --- a/src/schemas/src/vis-state-schema.ts +++ b/src/schemas/src/vis-state-schema.ts @@ -845,6 +845,7 @@ export const filterPropsV1 = { // layer timeline syncedWithLayerTimeline: null, + syncTimelineMode: null, enabled: null, diff --git a/src/styles/src/base.ts b/src/styles/src/base.ts index d50d1823cb..6652a7d3c2 100644 --- a/src/styles/src/base.ts +++ b/src/styles/src/base.ts @@ -47,6 +47,7 @@ export const titleColorLT = '#29323C'; export const subtextColor = '#6A7485'; export const subtextColorLT = '#A0A7B4'; export const subtextColorActive = '#FFFFFF'; +export const fontWhiteColor = '#54638c'; export const panelToggleBorderColor = '#FFFFFF'; export const panelTabWidth = '30px'; @@ -1412,6 +1413,7 @@ export const theme = { subtextColor, subtextColorLT, subtextColorActive, + fontWhiteColor, panelToggleBorderColor, panelTabWidth, textTruncate, diff --git a/src/types/reducers.d.ts b/src/types/reducers.d.ts index 6b031a4119..fad4c6919e 100644 --- a/src/types/reducers.d.ts +++ b/src/types/reducers.d.ts @@ -139,6 +139,8 @@ export type MultiSelectFilter = FilterBase & value: string[]; }; +export type SyncTimelineMode = 0 | 1; + export type TimeRangeFilter = FilterBase & TimeRangeFieldDomain & { type: 'timeRange'; @@ -149,6 +151,7 @@ export type TimeRangeFilter = FilterBase & [key: string]: any; }; syncedWithLayerTimeline: boolean; + syncTimelineMode: SyncTimelineMode; animationWindow: string; invertTrendColor: boolean; }; diff --git a/src/types/schemas.d.ts b/src/types/schemas.d.ts index 5f10552ccf..0357400dd3 100644 --- a/src/types/schemas.d.ts +++ b/src/types/schemas.d.ts @@ -24,6 +24,7 @@ export type SavedFilter = { speed: Filter['speed']; layerId: Filter['layerId']; syncedWithLayerTimeline: Filter['syncedWithLayerTimeline']; + syncTimelineMode: Filter['syncTimelineMode']; }; export type MinSavedFilter = RequireFrom; export type ParsedFilter = SavedFilter | MinSavedFilter; diff --git a/src/utils/src/filter-utils.ts b/src/utils/src/filter-utils.ts index 5f49c153fd..a25b46f2d2 100644 --- a/src/utils/src/filter-utils.ts +++ b/src/utils/src/filter-utils.ts @@ -1269,20 +1269,26 @@ export function mergeFilterWithTimeline( return {filter, animationConfig}; } -export function getFilterScaledTimeline(filter: TimeRangeFilter, animationConfig: AnimationConfig) { - if (!filter.syncedWithLayerTimeline) { - return null; - } +export function scaleSourceDomainToDestination( + sourceDomain: [number, number], + destinationDomain: [number, number] +): [number, number] { // 0 -> 100: merged domains t1 - t0 === 100% filter may already have this info which is good - const domainSize = filter.domain[1] - filter.domain[0]; + const sourceDomainSize = sourceDomain[1] - sourceDomain[0]; // 10 -> 20: animationConfig domain d1 - d0 === animationConfig size - const animationConfigSize = animationConfig?.domain - ? animationConfig.domain[1] - animationConfig.domain[0] - : 0; + const destinationDomainSize = destinationDomain[1] - destinationDomain[0]; // scale animationConfig size using domain size - const scaledAnimationConfigSize = (animationConfigSize / domainSize) * 100; + const scaledSourceDomainSize = (sourceDomainSize / destinationDomainSize) * 100; // scale d0 - t0 using domain size to find starting point - const offset = animationConfig?.domain ? animationConfig.domain[0] - filter.domain[0] : 0; - const scaledOffset = (offset / domainSize) * 100; - return [[scaledOffset, scaledAnimationConfigSize + scaledOffset]]; + const offset = sourceDomain[0] - destinationDomain[0]; + const scaledOffset = (offset / destinationDomainSize) * 100; + return [scaledOffset, scaledSourceDomainSize + scaledOffset]; +} + +export function getFilterScaledTimeline(filter, animationConfig): number[] { + if (!(filter.syncedWithLayerTimeline && animationConfig?.domain)) { + return []; + } + + return scaleSourceDomainToDestination(animationConfig.domain, filter.domain); } diff --git a/test/browser/components/filters/time-widget-test.js b/test/browser/components/filters/time-widget-test.js index 7d83489ec6..58ff4bc8bf 100644 --- a/test/browser/components/filters/time-widget-test.js +++ b/test/browser/components/filters/time-widget-test.js @@ -9,7 +9,7 @@ import moment from 'moment'; import {IntlWrapper, mountWithTheme, mockHTMLElementClientSize} from 'test/helpers/component-utils'; import {setFilterAnimationTimeConfig} from '@kepler.gl/actions'; -import {visStateReducer as reducer} from '@kepler.gl/reducers'; +import {visStateReducer as reducer, DEFAULT_ANIMATION_CONFIG} from '@kepler.gl/reducers'; import { TimeWidgetFactory, @@ -57,7 +57,8 @@ const defaultProps = { onClose: nop, onToggleMinify: nop, setFilterPlot: nop, - setFilterAnimationWindow: nop + setFilterAnimationWindow: nop, + animationConfig: DEFAULT_ANIMATION_CONFIG }; // call to set filter timezone and timeformat @@ -345,7 +346,7 @@ test('Components -> TimeWidget.mount -> TimeSliderMarker', t => { // moment.utc(1474588800000) -> "2016-09-23 00:00" // moment.utc(1474617600000) -> "2016-09-23 08:00" - // Enyme cant detect element appended by d3 + // Enzyme cant detect element appended by d3 const expectedMarks = [ 'Fri 23', '01 AM', @@ -357,9 +358,10 @@ test('Components -> TimeWidget.mount -> TimeSliderMarker', t => { '07 AM', '08 AM' ]; + expectedMarks.forEach(mark => { t.ok( - d3Html.includes(`${mark}`), + d3Html.includes(`${mark}`), `should render correct time marker ${mark}` ); }); @@ -385,14 +387,15 @@ test('Components -> TimeWidget.mount -> TimeSliderMarker', t => { '03 AM', '04 AM' ]; + expectedMarks2.forEach(mark => { t.ok( - d3Html.includes(`${mark}`), + d3Html.includes(`${mark}`), `should render correct time marker ${mark}` ); }); - const inalidFilter = { + const invalidFilter = { ...StateWFilters.visState.filters[0], domain: null }; @@ -400,7 +403,7 @@ test('Components -> TimeWidget.mount -> TimeSliderMarker', t => { // set TimeWidget prop t.doesNotThrow(() => { wrapper.setProps({ - children: + children: }); }, 'mount TimeWidget with invalid filter should not fail');