Skip to content

Commit

Permalink
Merge d457505 into 59b70e9
Browse files Browse the repository at this point in the history
  • Loading branch information
sternetj committed Dec 19, 2023
2 parents 59b70e9 + d457505 commit 00bd6ba
Show file tree
Hide file tree
Showing 7 changed files with 540 additions and 8 deletions.
29 changes: 27 additions & 2 deletions src/components/MyData/SleepChart/DailyChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { VictoryChart, VictoryAxis } from 'victory-native';
import { VictoryLabelProps, Tuple } from 'victory-core';
import {
Expand All @@ -22,6 +22,7 @@ import {
import { useCommonChartProps } from '../useCommonChartProps';
import uniqBy from 'lodash/unionBy';
import type { SleepChartData } from './useSleepChartData';
import { DataSelector, SleepDataToolTipPreparer } from './DataSelector';
import ViewShot from 'react-native-view-shot';
import { t } from 'i18next';
import { useVictoryTheme } from '../useVictoryTheme';
Expand All @@ -34,10 +35,11 @@ import { ActivityIndicatorView } from '../../ActivityIndicatorView';

type Props = SleepChartData & {
viewShotRef: React.RefObject<ViewShot>;
onBlockScrollChange: (shouldBlock: boolean) => void;
};

export const DailyChart = (props: Props) => {
const { viewShotRef } = props;
const { viewShotRef, onBlockScrollChange } = props;
const { xDomain, sleepData, isFetching } = props;
const common = useCommonChartProps();
const { sleepAnalysisTheme: theme } = useVictoryTheme();
Expand Down Expand Up @@ -103,6 +105,24 @@ export const DailyChart = (props: Props) => {
);
}, [xDomain]);

const prepareTooltipData = useCallback<SleepDataToolTipPreparer>(
(x) => {
const closestPoint = orderBy(data, 'x').find(
(datum) => datum.x <= x && datum.x + datum.width >= x,
);

return (
closestPoint && {
color: closestPoint.fill ?? '',
header: format(xDomain.invert(x), 'h:mm aa'),
title: closestPoint.sleepTypeName,
subtitle: t('sleep-analysis-stage', 'Stage'),
}
);
},
[data, xDomain],
);

return (
<View>
<ViewShot ref={viewShotRef} options={{ format: 'png' }}>
Expand Down Expand Up @@ -147,6 +167,11 @@ export const DailyChart = (props: Props) => {
<View style={styles.loadingContainer}>
{<ActivityIndicatorView animating={isFetching} />}
</View>

<DataSelector
onBlockScrollChange={onBlockScrollChange}
prepareTooltipData={prepareTooltipData}
/>
</View>
);
};
Expand Down
201 changes: 201 additions & 0 deletions src/components/MyData/SleepChart/DataSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { NativeTouchEvent, View } from 'react-native';
import { useCommonChartProps } from '../useCommonChartProps';
import {
G,
Rect,
Svg,
Text,
Circle,
RectProps,
TextProps,
} from 'react-native-svg';
import { differenceInMilliseconds } from 'date-fns';
import { createStyles } from '../../BrandConfigProvider';
import { useStyles } from '../../../hooks';

export type SleepDataToolTipPreparer = (x: number) =>
| undefined
| {
header: string;
title: string;
subtitle: string;
color: string;
x?: number;
y?: number;
hideTooltip?: boolean;
};

type Props = {
selectionDelayMs?: number;
prepareTooltipData: SleepDataToolTipPreparer;
onBlockScrollChange: (shouldBlock: boolean) => void;
};

export type Selection = {
date: Date;
};

const tooltipOffset = 10;
const tooltipWidth = 115;

export const DataSelector = (props: Props) => {
const { selectionDelayMs = 500 } = props;
const { onBlockScrollChange, prepareTooltipData } = props;
const common = useCommonChartProps();
const [touchStarted, setTouchStarted] = useState<Date>();
const [pendingSelection, setPendingSelection] = useState<NativeTouchEvent>();
const [selection, setSelection] = useState<NativeTouchEvent>();
const { styles } = useStyles(defaultStyles);

useEffect(() => {
let id: number;
if (pendingSelection) {
id = setTimeout(() => {
onBlockScrollChange(true);
setSelection(pendingSelection);
}, selectionDelayMs) as any as number;
}

return () => clearTimeout(id);
}, [pendingSelection, onBlockScrollChange, selectionDelayMs]);

const clearSelection = useCallback(() => {
onBlockScrollChange(false);
setSelection(undefined);
setPendingSelection(undefined);
}, [onBlockScrollChange]);

const tooltip = useMemo(() => {
if (!selection) {
return;
}
const minX = common.padding.left;
const maxX = common.plotAreaWidth;

const tooltipData = {
x: selection.locationX,
y: selection.locationY,
...prepareTooltipData(Math.min(Math.max(selection.locationX, 0), maxX)),
};

const x = minX + Math.min(Math.max(tooltipData.x, 0), maxX);

return {
...tooltipData,
x,
y: common.padding.top,
height: common.height - common.padding.bottom - common.padding.top,
tooltipX: Math.min(
Math.max(x - tooltipOffset, 0),
maxX - tooltipWidth / 2,
),
displayTooltip: !!tooltipData && !tooltipData.hideTooltip,
};
}, [selection, prepareTooltipData, common]);

return (
<>
{tooltip && (
<Svg
width={common.width}
height={common.height}
style={styles.selectionContainer}
>
<G y={tooltip.y} x={tooltip.x}>
<Rect
x={-Number(styles.selectionLine?.width ?? 0) / 2}
height={tooltip.height}
{...styles.selectionLine}
/>
</G>
{tooltip.displayTooltip && (
<G x={tooltip.tooltipX} y={tooltip.y}>
<Rect
width={tooltipWidth}
height={62}
rx={6}
{...styles.tooltip}
/>
<Text x={8} y={17} {...styles.tooltipHeader}>
{tooltip.header}
</Text>
<G y={37} x={8}>
<Circle x={5} y={-5} r={5} fill={tooltip.color} />
<Text {...styles.tooltipTitle} x={16} fontSize={16}>
{tooltip.title}
</Text>
<Text {...styles.tooltipSubtitle} x={16} y={16}>
{tooltip.subtitle}
</Text>
</G>
</G>
)}
</Svg>
)}

<View
testID="sleep-chart-data-selector"
onStartShouldSetResponder={(event) => {
setTouchStarted(new Date(event.nativeEvent.timestamp));
setPendingSelection(event.nativeEvent);
return false;
}}
onMoveShouldSetResponder={(e) => {
if (
!touchStarted ||
differenceInMilliseconds(
new Date(e.nativeEvent.timestamp),
touchStarted,
) < selectionDelayMs
) {
clearSelection();
return false;
}

return !!e.nativeEvent.touches.length;
}}
onResponderGrant={() => onBlockScrollChange(true)}
onResponderMove={(event) => setSelection(event.nativeEvent)}
onResponderRelease={clearSelection}
onTouchEnd={clearSelection}
style={{
position: 'absolute',
top: common.padding.top / 2,
bottom: common.padding.bottom / 2,
left: common.padding.left,
width: common.plotAreaWidth,
}}
/>
</>
);
};

const defaultStyles = createStyles('SleepAnalysisTooltip', (theme) => ({
selectionContainer: {
position: 'absolute',
bottom: 0,
},
selectionLine: {
width: 2,
fill: theme.colors.scrim,
} as RectProps,
tooltip: {
fill: theme.colors.onPrimaryContainer,
opacity: 1,
} as RectProps,
tooltipHeader: {
fill: theme.colors.primaryContainer,
} as TextProps,
tooltipTitle: {
fill: 'white',
} as TextProps,
tooltipSubtitle: {
fill: 'white',
} as TextProps,
}));

declare module '@styles' {
interface ComponentStyles
extends ComponentNamedStyles<typeof defaultStyles> {}
}
51 changes: 48 additions & 3 deletions src/components/MyData/SleepChart/MultiDayChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { VictoryChart, VictoryAxis, VictoryBar } from 'victory-native';
import { VictoryLabelProps } from 'victory-core';
import {
Expand All @@ -16,6 +16,7 @@ import { G, Circle, Text, SvgProps } from 'react-native-svg';
import { useCommonChartProps } from '../useCommonChartProps';
import type { SleepChartData } from './useSleepChartData';
import { useVictoryTheme } from '../useVictoryTheme';
import { DataSelector, SleepDataToolTipPreparer } from './DataSelector';
import ViewShot from 'react-native-view-shot';
import { t } from 'i18next';
import { useStyles } from '../../../hooks';
Expand All @@ -26,11 +27,12 @@ import { ActivityIndicatorView } from '../../ActivityIndicatorView';
type Props = SleepChartData & {
domainPadding?: number;
viewShotRef: React.RefObject<ViewShot>;
onBlockScrollChange: (shouldBlock: boolean) => void;
};

export const MultiDayChart = (props: Props) => {
const { viewShotRef } = props;
const { dateRange, sleepData, isFetching } = props;
const { viewShotRef, onBlockScrollChange } = props;
const { dateRange, sleepData, isFetching, xDomain } = props;
const common = useCommonChartProps();
const theme = useVictoryTheme();
const { styles } = useStyles(defaultStyles);
Expand Down Expand Up @@ -101,6 +103,44 @@ export const MultiDayChart = (props: Props) => {
return new Array(4).fill(0).map((_, i) => ((i + 1) * maxY) / 4);
}, [data]);

const prepareTooltipData = useCallback<SleepDataToolTipPreparer>(
(x) => {
const domain = isYear ? [0, 11] : dateRange;
const getDateValue = (d: Date) =>
xDomainWithPadding(isYear ? d.getMonth() : d);

const xDomainWithPadding = xDomain
.copy()
.range([domainPadding, xDomain.range()[1] - domainPadding])
.domain(domain);

const closestPoint = data.reduce(
(closest, datum) =>
Math.abs(getDateValue(datum.x) - x) <
Math.abs(getDateValue(closest.x) - x)
? datum
: closest,
data[0],
);

return {
color: styles.data?.color ?? '',
header: closestPoint.period,
title: t(
'sleep-analysis-sleep-amount',
'{{hours}}h {{minutes}}m',
closestPoint,
),
subtitle: isYear
? t('sleep-analysis-average-duration', 'Avg Duration')
: t('sleep-analysis-sleep-duration', 'Sleep Duration'),
x: getDateValue(closestPoint?.x),
hideTooltip: !closestPoint?.y,
};
},
[data, xDomain, dateRange, isYear, styles, domainPadding],
);

return (
<View>
<ViewShot ref={viewShotRef} options={{ format: 'png' }}>
Expand Down Expand Up @@ -152,6 +192,11 @@ export const MultiDayChart = (props: Props) => {
<View style={styles.loadingContainer}>
<ActivityIndicatorView animating={isFetching} />
</View>

<DataSelector
onBlockScrollChange={onBlockScrollChange}
prepareTooltipData={prepareTooltipData}
/>
</View>
);
};
Expand Down
Loading

0 comments on commit 00bd6ba

Please sign in to comment.