Skip to content

Instantly share code, notes, and snippets.

@alettieri
Last active March 7, 2024 17:53
Show Gist options
  • Save alettieri/e5bfa8982e0cd2c38ebc6b3bf94a0462 to your computer and use it in GitHub Desktop.
Save alettieri/e5bfa8982e0cd2c38ebc6b3bf94a0462 to your computer and use it in GitHub Desktop.
Recharts Tooltip on Points
import React from 'react';
import { Typography, useTheme, Box, Theme, SxProps } from '@mui/material';
import { LineChart, Line, Legend, Tooltip, TooltipProps } from 'recharts';
import { LinePointItem } from 'recharts/types/cartesian/Line';
// Leaving out some imports for brevity...
import {
PlanItem,
PlanItemGroup,
Plan,
PlanVersionAction,
IPlanItemProjectedRule,
PlanItemProjectedCalculationChoices,
} from '../../shared/types/plan';
import { ChartLoading } from '../ChartLoading';
import { DashedIcon, LineIcon } from './';
import { PlanItemChartTooltip } from './PlanItemChartTooltip';
type OnMouseMoveHandler = (typeof LineChart.defaultProps)['onMouseMove'];
const planItemChartLegendStyles: SxProps<Theme> = {
display: 'flex',
justifyContent: 'center',
gap: 4,
'& .PlanItemChartLegend-item': {
display: 'inline-flex',
gap: 2,
fontSize: 'body3.fontSize',
alignItems: 'center',
},
'& .MuiSvgIcon-root': {
width: 8,
},
};
const getLinePointItems = (
projectedPoints?: Array<LinePointItem>,
actualPoints?: Array<LinePointItem>
) => {
if (projectedPoints && actualPoints) {
// Merge the actual and projected points together
// We want to keep the indexes aligned so we can use the index to find the correct point
return actualPoints.map((point, index) => {
if (point.x && point.y) {
return point;
}
return projectedPoints[index];
});
}
return [];
};
const PlanItemChartLegend = () => {
return (
<Box
className="PlanItemChartLegend-root"
sx={planItemChartLegendStyles}
>
<Box className="PlanItemChartLegend-item">
<LineIcon color="primary" fontSize="small" />
<Typography variant="inherit">Actuals</Typography>
</Box>
<Box display="inline-flex" className="PlanItemChartLegend-item">
<DashedIcon fontSize="small" sx={{ color: 'grey[500]' }} />
<Typography variant="inherit">Forecast</Typography>
</Box>
</Box>
);
};
const PlanItemChart = ({
planItem,
projectedRule,
group,
planId,
}: {
planItem: PlanItem;
projectedRule: IPlanItemProjectedRule;
group: PlanItemGroup;
planId: Plan['id'];
}) => {
const [toolTipIndex, updateToolTipIndex] = React.useState(0);
const theme = useTheme();
const actualRef = React.useRef<null | (Line & SVGPathElement)>(null);
const projectedRef = React.useRef<null | (Line & SVGPathElement)>(null);
const previewArgs = React.useMemo<PatchPlanVersionArg>(() => {
const updatedPlanItem: PlanItem = {
...planItem,
projected_rule: projectedRule,
};
const groups = [
{
...group,
plan_items: [updatedPlanItem],
},
];
return {
planId,
groups,
action: PlanVersionAction.Publish,
};
}, [planItem, projectedRule, planId, group]);
const { createPlanVersionPreview } = usePlanVersionPreviewQuery(
previewArgs,
Boolean(planItem) &&
projectedRule?.calculation !==
PlanItemProjectedCalculationChoices.Unforecasted,
projectedRule
);
const handleMouseMove = React.useCallback<OnMouseMoveHandler>(
(coords) => {
if (
coords.isTooltipActive &&
coords.activeTooltipIndex !== undefined
) {
updateToolTipIndex(coords.activeTooltipIndex);
}
},
[updateToolTipIndex]
);
const toolTipPosition = React.useMemo<
TooltipProps<number, string>['position']
>(() => {
const linePointItems = getLinePointItems(
projectedRef.current?.props?.points,
actualRef.current?.props?.points
);
if (linePointItems[toolTipIndex]) {
// Grab the current tooltip position based off the point index list
const point = linePointItems[toolTipIndex];
return { x: point.x, y: point.y };
}
return undefined;
}, [toolTipIndex]);
return (
<ChartLoading isLoading={createPlanVersionPreview.isFetching}>
<ResponsiveChartContainer height="110px">
<LineChart
data={createPlanVersionPreview.data?.[0]}
margin={{ right: 8, left: 8 }}
onMouseMove={handleMouseMove}
>
<Tooltip
content={<PlanItemChartTooltip />}
position={toolTipPosition}
/>
<Line
type="monotone"
dataKey="p"
stroke={theme.palette.grey[500]}
activeDot={{ r: 4 }}
dot={{ r: 2 }}
ref={projectedRef}
/>
<Line
type="monotone"
dataKey="a"
stroke={theme.palette.primary.main}
activeDot={{ r: 4 }}
dot={{ r: 2 }}
ref={actualRef}
/>
<Legend
verticalAlign="bottom"
wrapperStyle={{
bottom: 0,
fontSize: '10px',
}}
content={<PlanItemChartLegend />}
/>
</LineChart>
</ResponsiveChartContainer>
</ChartLoading>
);
};
export { PlanItemChart };
import React from 'react';
import { Typography, Box, SxProps, Theme, alpha } from '@mui/material';
import { format } from 'date-fns';
import { TooltipProps } from 'recharts';
import { IPlanVersionPreviewResponse } from '../../shared/types/plan';
import { getDateFromString } from '../../shared/utils/date-utils';
import { convertNumberToCurrency } from '../../utils/currency-utils';
const formatDateValue = (date: string) => {
return format(getDateFromString(date), 'MMM-yy');
};
const styles: SxProps<Theme> = {
'--_height': '48px',
'--_width': '68px',
'--_offsetX': '-50%',
'--_offsetY': 'calc(-1 * (var(--_height) + 18px))',
'--_tooltip-bg': (theme) => alpha(theme.palette.grey[700], 0.9),
display: 'flex',
flexDirection: 'column',
gap: 1,
textAlign: 'center',
minWidth: 'var(--_width)',
height: 'var(--_height)',
p: '10px',
backgroundColor: 'var(--_tooltip-bg)',
color: 'common.white',
fontSize: 'tooltip.fontSize',
fontWeight: 'tooltip.fontWeight',
transform: `translate(var(--_offsetX), var(--_offsetY))`,
borderRadius: 1,
position: 'relative',
overflow: 'clip-content',
'& .PlanItemChartTooltip-arrow': {
position: 'absolute',
bottom: '-7px',
left: '50%',
width: 0,
height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: '8px solid var(--_tooltip-bg)',
ml: '-8px',
},
};
export const PlanItemChartTooltip = (props: TooltipProps<number, string>) => {
const toolTipContent = React.useMemo<null | {
value: string;
date: string;
}>(() => {
if (props.payload.length > 0) {
const payload = props.payload[0];
const dataKey = payload?.dataKey as 'p' | 'a';
const dataPoint =
payload.payload as IPlanVersionPreviewResponse[0][0];
const value = Reflect.get(dataPoint, dataKey);
const date = formatDateValue(dataPoint.d);
return { value: convertNumberToCurrency(value), date };
}
return null;
}, [props.payload]);
if (toolTipContent !== null) {
return (
<Box className="PlanItemChartTooltip-root" sx={styles}>
<Typography variant="inherit">{toolTipContent.date}</Typography>
<Typography variant="inherit">
{toolTipContent.value}
</Typography>
<Box className="PlanItemChartTooltip-arrow" />
</Box>
);
}
return null;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment