Skip to content

Commit 501f8be

Browse files
authored
[E&A] Add logic for tracking out-of-view Timeline playheads (#1067)
**Related Ticket:** #1052 ### Description of Changes This PR continues the E&A timeline changes with the following updates: 1. Logic to track which timeline playheads are out-of-view and display static (non-clickable) indicators for them 2. Revamped playheads to simplify style control and changed their content to show the corresponding date and labels Designs: https://www.figma.com/design/9INQauBWhiRxvOWDGhRrxO/US-GHG-Center?node-id=1128-2306&t=XQg28Qtma2dgRiS5-4 ### Notes & Questions About Changes There are a couple of details from the designs that I did not include in this PR due to some questions I have. I would appreciate your feedback, especially since @faustoperez is unavailable: 1. The designs show the timeline header dates vertically centered, as shown below: <img width="258" alt="Screenshot 2024-07-24 at 15 08 39" src="https://github.com/user-attachments/assets/4ac4ab9a-1dd9-4af8-b446-56e8e0df3788"> Currently, our header dates are displayed on two lines, and we sometimes show longer text like "Sun 05 Apr 2009": <img width="173" alt="Screenshot 2024-07-24 at 15 16 37" src="https://github.com/user-attachments/assets/b28d0e9b-dd6a-4c60-93f2-8a047393375a"> Changing these to be vertically centered and in one line can sometimes result in a cluttered view when zooming, as shown below: <img width="211" alt="Screenshot 2024-07-24 at 13 48 52" src="https://github.com/user-attachments/assets/e8893f7a-c4a4-4ec5-9a07-807cc5514d0d"> 2. Clicking on a playhead toggles the corresponding date picker. If you intend to drag the playhead and accidentally click on it, the calendar toggles, which can be a bit inconvenient. I plan to open a separate PR for this for some team feedback ### Validation / Testing The validation / testing cases are outlined in the designs: https://www.figma.com/design/9INQauBWhiRxvOWDGhRrxO/US-GHG-Center?node-id=1128-2306&t=XQg28Qtma2dgRiS5-4
2 parents b651ab7 + cd00501 commit 501f8be

File tree

7 files changed

+428
-138
lines changed

7 files changed

+428
-138
lines changed

app/scripts/components/datasets/s-explore/panel-date-widget.tsx

+1-14
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
ToolbarIconButton,
1313
VerticalDivider
1414
} from '@devseed-ui/toolbar';
15-
import { format } from 'date-fns';
1615
import {
1716
PanelWidget,
1817
PanelWidgetBody,
@@ -26,6 +25,7 @@ import { TimeDensity } from '$context/layer-data';
2625
import { mod } from '$utils/utils';
2726
import DateSliderControl from '$components/common/dateslider';
2827
import { prepareDateSliderData } from '$components/common/dateslider/utils';
28+
import { formatDate } from '$components/exploration/data-utils';
2929

3030
function getDatePickerView(timeDensity?: TimeDensity) {
3131
const view = {
@@ -51,19 +51,6 @@ interface PanelDateWidgetProps {
5151
children?: ReactNode;
5252
}
5353

54-
export const formatDate = (date: Date | null, view: string) => {
55-
if (!date) return 'Date';
56-
57-
switch (view) {
58-
case 'decade':
59-
return format(date, 'yyyy');
60-
case 'year':
61-
return format(date, 'MMM yyyy');
62-
default:
63-
return format(date, 'MMM do, yyyy');
64-
}
65-
};
66-
6754
export function PanelDateWidget(props: PanelDateWidgetProps) {
6855
const {
6956
title,

app/scripts/components/exploration/components/timeline/date-axis.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const GridLine = styled.line`
1818
`;
1919

2020
const DateAxisSVG = styled.svg`
21+
border-top: 1.5px solid #dde0e3;
22+
2123
text {
2224
font-size: 0.75rem;
2325
fill: ${themeVal('color.base')};

app/scripts/components/exploration/components/timeline/timeline-controls.tsx

+150-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import React, { useMemo } from 'react';
2-
import styled from 'styled-components';
1+
import React, { memo, useMemo } from 'react';
2+
import styled, { css } from 'styled-components';
33
import { useAtom } from 'jotai';
44
import { endOfYear, startOfYear } from 'date-fns';
55
import { scaleTime, ScaleTime } from 'd3';
66

7-
import { glsp } from '@devseed-ui/theme-provider';
7+
import { glsp, themeVal } from '@devseed-ui/theme-provider';
88
import { Toolbar, ToolbarGroup, VerticalDivider } from '@devseed-ui/toolbar';
99

10+
import { isEqual } from 'lodash';
1011
import { DateAxis } from './date-axis';
1112
import { TimelineZoomControls } from './timeline-zoom-controls';
1213
import { TimelineDatePicker } from './timeline-datepicker';
14+
import { TimelineHead } from './timeline';
1315
import {
1416
selectedCompareDateAtom,
1517
selectedDateAtom,
@@ -20,6 +22,7 @@ import { CollecticonCalendarMinus } from '$components/common/icons/calendar-minu
2022
import { CollecticonCalendarPlus } from '$components/common/icons/calendar-plus';
2123
import { TipToolbarIconButton } from '$components/common/tip-button';
2224
import useAois from '$components/common/map/controls/hooks/use-aois';
25+
import { formatDate } from '$components/exploration/data-utils';
2326

2427
const TimelineControlsSelf = styled.div`
2528
width: 100%;
@@ -36,6 +39,7 @@ const ControlsToolbar = styled.div`
3639
display: flex;
3740
justify-content: space-between;
3841
padding: ${glsp(1.5, 1, 0.5, 1)};
42+
position: relative;
3943
4044
${ToolbarGroup /* sc-selector */}:last-child:not(:first-child) {
4145
margin-left: auto;
@@ -60,6 +64,7 @@ const ToolbarFullWidth = styled(Toolbar)`
6064
interface TimelineControlsProps {
6165
xScaled?: ScaleTime<number, number>;
6266
width: number;
67+
outOfViewHeads?: TimelineHead[];
6368
onZoom: (zoom: number) => void;
6469
}
6570

@@ -87,8 +92,144 @@ export function TimelineDateAxis(props: Omit<TimelineControlsProps, "onZoom">) {
8792
);
8893
}
8994

95+
export const TIMELINE_PLAYHEAD_COLOR_PRIMARY = '#8b8b8b';
96+
export const TIMELINE_PLAYHEAD_COLOR_SECONDARY = '#333333';
97+
export const TIMELINE_PLAYHEAD_COLOR_TEXT = '#ffffff';
98+
export const TIMELINE_PLAYHEAD_COLOR_LABEL = '#cccccc';
99+
100+
const TimelineHeadIndicatorsWrapper = styled.div`
101+
position: absolute;
102+
bottom: -30px;
103+
width: 100%;
104+
`;
105+
106+
interface PlayheadProps {
107+
secondary?: boolean;
108+
}
109+
110+
const TimelinePlayheadBase = styled.div<PlayheadProps>`
111+
background-color: ${TIMELINE_PLAYHEAD_COLOR_PRIMARY};
112+
color: ${TIMELINE_PLAYHEAD_COLOR_TEXT};
113+
padding: ${glsp(0.15)} ${glsp(0.30)};
114+
border-radius: ${themeVal('shape.rounded')};
115+
font-size: 0.75rem;
116+
position: relative;
117+
width: max-content;
118+
font-weight: ${themeVal('type.base.regular')};
119+
120+
&::after, &::before {
121+
content: '';
122+
position: absolute;
123+
bottom: 1px;
124+
width: 0;
125+
height: 0;
126+
border-top: 11.5px solid transparent;
127+
border-bottom: 11.5px solid transparent;
128+
}
129+
`;
130+
131+
const PlayheadArrow = css<PlayheadProps>`
132+
&::after, &::before {
133+
content: '';
134+
position: absolute;
135+
bottom: 1px;
136+
width: 0;
137+
height: 0;
138+
border-top: 11.5px solid transparent;
139+
border-bottom: 11.5px solid transparent;
140+
}
141+
`;
142+
143+
const LeftPlayheadArrow = css<PlayheadProps>`
144+
${PlayheadArrow}
145+
&::after {
146+
border-right: 8px solid ${props => props.secondary ? TIMELINE_PLAYHEAD_COLOR_PRIMARY : TIMELINE_PLAYHEAD_COLOR_SECONDARY};
147+
}
148+
`;
149+
150+
const RightPlayheadArrow = css<PlayheadProps>`
151+
${PlayheadArrow}
152+
&::before {
153+
border-left: 8px solid ${props => props.secondary ? TIMELINE_PLAYHEAD_COLOR_PRIMARY : TIMELINE_PLAYHEAD_COLOR_SECONDARY};
154+
}
155+
`;
156+
157+
const TimelinePlayheadLeftIndicator = styled(TimelinePlayheadBase)`
158+
background-color: ${props => props.secondary ? TIMELINE_PLAYHEAD_COLOR_PRIMARY : TIMELINE_PLAYHEAD_COLOR_SECONDARY};
159+
${LeftPlayheadArrow}
160+
&::after {
161+
left: ${props => props.secondary ? '-28%' : '-8%'};
162+
}
163+
`;
164+
165+
const TimelinePlayheadRightIndicator = styled(TimelinePlayheadBase)`
166+
background-color: ${props => props.secondary ? TIMELINE_PLAYHEAD_COLOR_PRIMARY : TIMELINE_PLAYHEAD_COLOR_SECONDARY};
167+
${RightPlayheadArrow}
168+
&::before {
169+
right: ${props => props.secondary ? '-28%' : '-8%'};
170+
}
171+
`;
172+
173+
const TimelineHeadIndicatorsBase = styled.div`
174+
position: absolute;
175+
bottom: 1px;
176+
display: flex;
177+
gap: ${glsp(1)};
178+
`;
179+
180+
const TimelineHeadLeftIndicators = styled(TimelineHeadIndicatorsBase)`
181+
left: 0;
182+
`;
183+
184+
const TimelineHeadRightIndicators = styled(TimelineHeadIndicatorsBase)`
185+
right: 125px;
186+
flex-direction: row-reverse;
187+
`;
188+
189+
export const TimelineHeadIndicators = memo(({ outOfViewHeads }: { outOfViewHeads: TimelineHead[] }) => {
190+
// Filter the out-of-view heads to get those that are out to the left
191+
const leftHeads = outOfViewHeads.filter(head => head.outDirection === 'left');
192+
// Filter the out-of-view heads to get those that are out to the right
193+
const rightHeads = outOfViewHeads.filter(head => head.outDirection === 'right');
194+
195+
return (
196+
<>
197+
{/* If there are any heads out to the left, render the left indicators */}
198+
{leftHeads.length > 0 && (
199+
<TimelineHeadLeftIndicators>
200+
<TimelinePlayheadLeftIndicator>
201+
<span>{formatDate(leftHeads[0].date)}</span>
202+
</TimelinePlayheadLeftIndicator>
203+
{leftHeads.length > 1 &&
204+
<TimelinePlayheadLeftIndicator secondary>
205+
+{leftHeads.length - 1}
206+
</TimelinePlayheadLeftIndicator>}
207+
</TimelineHeadLeftIndicators>
208+
)}
209+
{/* If there are any heads out to the right, render the right indicators */}
210+
{rightHeads.length > 0 && (
211+
<TimelineHeadRightIndicators>
212+
<TimelinePlayheadRightIndicator>
213+
<span>{formatDate(rightHeads[rightHeads.length - 1].date)}</span>
214+
</TimelinePlayheadRightIndicator>
215+
{rightHeads.length > 1 &&
216+
<TimelinePlayheadRightIndicator secondary>
217+
+{rightHeads.length - 1}
218+
</TimelinePlayheadRightIndicator>}
219+
</TimelineHeadRightIndicators>
220+
)}
221+
</>
222+
);
223+
}, (prevProps, nextProps) => {
224+
// React.memo does a shallow comparison of props, so we need to supply
225+
// a custom comparison function to compare the outOfViewHead objects
226+
return isEqual(prevProps.outOfViewHeads, nextProps.outOfViewHeads);
227+
});
228+
229+
TimelineHeadIndicators.displayName = 'TimelineHeadIndicators';
230+
90231
export function TimelineControls(props: TimelineControlsProps) {
91-
const { xScaled, width, onZoom } = props;
232+
const { xScaled, width, outOfViewHeads, onZoom } = props;
92233

93234
const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom);
94235
const [selectedCompareDay, setSelectedCompareDay] = useAtom(
@@ -103,6 +244,11 @@ export function TimelineControls(props: TimelineControlsProps) {
103244
return (
104245
<TimelineControlsSelf>
105246
<ControlsToolbar>
247+
{outOfViewHeads && outOfViewHeads.length > 0 && (
248+
<TimelineHeadIndicatorsWrapper>
249+
<TimelineHeadIndicators outOfViewHeads={outOfViewHeads} />
250+
</TimelineHeadIndicatorsWrapper>
251+
)}
106252
<ToolbarFullWidth>
107253
<ToolbarGroup>
108254
{!selectedInterval && (

0 commit comments

Comments
 (0)