Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow multi hover on metric graphs (v2) #1077

Merged
merged 8 commits into from
Mar 15, 2023
7 changes: 7 additions & 0 deletions packages/front-end/components/HomePage/NorthStar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const NorthStar: FC<{

const [openNorthStarModal, setOpenNorthStarModal] = useState(false);

const [northstarHoverDate, setNorthstarHoverDate] = useState<number | null>(null);
const onNorthstarHoverCallback = (ret: {d: number | null}) => {
setNorthstarHoverDate(ret.d);
};

const nameMap = new Map<string, string>();
experiments.forEach((e) => {
nameMap.set(e.id, e.name);
Expand Down Expand Up @@ -78,6 +83,8 @@ const NorthStar: FC<{
metricId={mid}
window={northStar?.window}
resolution={northStar?.resolution ?? "week"}
hoverDate={northstarHoverDate}
onHoverCallback={onNorthstarHoverCallback}
/>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ const NorthStarMetricDisplay = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
window,
resolution,
hoverDate,
onHoverCallback,
}: {
metricId: string;
window?: number | string;
resolution?: string;
hoverDate?: number | null;
onHoverCallback?: (ret: { d: number | null }) => void;
}): React.ReactElement => {
const { project } = useDefinitions();
const permissions = usePermissions();
Expand Down Expand Up @@ -69,6 +73,8 @@ const NorthStarMetricDisplay = ({
smoothBy={resolution === "week" ? "week" : "day"}
height={300}
method={metric.type !== "binomial" ? "avg" : "sum"}
onHover={onHoverCallback}
hoverDate={hoverDate}
/>
</div>
) : (
Expand Down
295 changes: 177 additions & 118 deletions packages/front-end/components/Metrics/DateGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Link from "next/link";
import { MetricType } from "back-end/types/metric";
import { FC, useState, useMemo, Fragment } from "react";
import { FC, useState, useMemo, Fragment, useEffect } from "react";
import { ParentSizeModern } from "@visx/responsive";
import { Group } from "@visx/group";
import { GridColumns, GridRows } from "@visx/grid";
Expand Down Expand Up @@ -67,6 +67,9 @@ const DateGraph: FC<{
showStdDev?: boolean;
experiments?: Partial<ExperimentInterfaceStringDates>[];
height?: number;
margin?: [number, number, number, number];
onHover?: (ret: { d: number | null }) => void;
hoverDate?: number | null;
}> = ({
type,
smoothBy = "day",
Expand All @@ -75,6 +78,9 @@ const DateGraph: FC<{
showStdDev = true,
experiments = [],
height = 220,
margin = [15, 15, 30, 80],
onHover,
hoverDate,
}) => {
const data = useMemo(
() =>
Expand Down Expand Up @@ -113,85 +119,11 @@ const DateGraph: FC<{
[dates, smoothBy, method]
);

const getTooltipData = (mx: number, width: number, yScale): TooltipData => {
const innerWidth = width - margin[1] - margin[3] + width / data.length - 1;
const px = mx / innerWidth;
const index = Math.max(
Math.min(Math.round(px * data.length), data.length - 1),
0
);
const d = data[index];
const x = (data.length > 0 ? index / data.length : 0) * innerWidth;
const y = yScale(d.v) ?? 0;
return { x, y, d };
};

const getTooltipContents = (d: Datapoint) => {
if (!d || d.oor) return null;
return (
<>
{type === "binomial" ? (
<div className={styles.val}>{d.c.toLocaleString()}</div>
) : (
<>
<div className={styles.val}>
{method === "sum" ? `Σ` : `μ`}:{" "}
{formatConversionRate(type, d.v as number)}
{smoothBy === "week" && (
<sub style={{ fontWeight: "normal", fontSize: 8 }}>smooth</sub>
)}
</div>
{"s" in d && method === "avg" && (
<div className={styles.secondary}>
{`σ`}: {formatConversionRate(type, d.s)}
{smoothBy === "week" && (
<sub style={{ fontWeight: "normal", fontSize: 8 }}>
smooth
</sub>
)}
</div>
)}
<div className={styles.secondary}>
<em>n</em>: {d.c.toLocaleString()}
</div>
</>
)}
<div className={styles.date}>{date(d.d as Date)}</div>
</>
);
};

const { containerRef, containerBounds } = useTooltipInPortal({
scroll: true,
detectBounds: true,
});

const {
showTooltip,
hideTooltip,
tooltipOpen,
tooltipData,
tooltipLeft = 0,
tooltipTop = 0,
} = useTooltip<TooltipData>();

const margin = [15, 15, 30, 80];
const dateNums = data.map((d) => getValidDate(d.d).getTime());
const min = Math.min(...dateNums);
const max = Math.max(...dateNums);

const [toolTipTimer, setToolTipTimer] = useState<null | ReturnType<
typeof setTimeout
>>(null);
const [
highlightExp,
setHighlightExp,
] = useState<null | ExperimentDisplayData>(null);
const toolTipDelay = 600;

// in future we might want to mark the different phases or percent traffic in this as different colors
const experimentDates: ExperimentDisplayData[] = [];
const bands = new Map();
const toolTipDelay = 600;

if (experiments && experiments.length > 0) {
experiments.forEach((e) => {
Expand Down Expand Up @@ -277,54 +209,181 @@ const DateGraph: FC<{
});
}

const { containerRef, containerBounds } = useTooltipInPortal({
scroll: true,
detectBounds: true,
});

const width = (containerBounds?.width || 0) + margin[1] + margin[3];
const dateNums = data.map((d) => getValidDate(d.d).getTime());
const min = Math.min(...dateNums);
const max = Math.max(...dateNums);
const yMax = height - margin[0] - margin[2];
const xMax = containerBounds?.width || 0;
const numXTicks = width > 768 ? 7 : 4;
const numYTicks = 5;
const axisHeight = 30;
const minGraphHeight = 100;
const expBarHeight = 10;
const expBarMargin = 4;
const expHeight = bands.size * (expBarHeight + expBarMargin);
let graphHeight = yMax - expHeight;
if (graphHeight < minGraphHeight) {
height += minGraphHeight - (yMax - expHeight);
graphHeight = minGraphHeight;
}
const xScale = scaleTime({
domain: [min, max],
range: [0, xMax],
round: true,
});
const yScale = scaleLinear<number>({
domain: [
0,
Math.max(
...data.map((d) =>
type === "binomial" ? d.c : Math.min(d.v * 2, d.v + (d.s ?? 0) * 2)
)
),
],
range: [graphHeight, 0],
round: true,
});

useEffect(() => {
if (!hoverDate) {
hideTooltip();
return;
}
const datapoint = getDatapointFromDate(hoverDate);
if (!datapoint) {
hideTooltip();
return;
}
const tooltipData = getTooltipDataFromDatapoint(datapoint);
if (!tooltipData) {
hideTooltip();
return;
}
showTooltip({
tooltipLeft: tooltipData.x,
tooltipTop: tooltipData.y,
tooltipData: tooltipData,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoverDate]);
tinahollygb marked this conversation as resolved.
Show resolved Hide resolved

const getDateFromX = (x: number) => {
const innerWidth = width - margin[1] - margin[3] + width / data.length - 1;
const px = x / innerWidth;
const index = Math.max(
Math.min(Math.round(px * data.length), data.length - 1),
0
);
const datapoint = data[index];
return getValidDate(datapoint.d).getTime();
};

const getDatapointFromDate = (date: number) => {
// find the closest datapoint to the date
const datapoint = data.reduce((acc, cur) => {
const curDate = getValidDate(cur.d).getTime();
const accDate = getValidDate(acc.d).getTime();
return Math.abs(curDate - date) < Math.abs(accDate - date) ? cur : acc;
});
// if it's within 1 day, return it
if (Math.abs(getValidDate(datapoint.d).getTime() - date) < 86400000) {
return datapoint;
}
return null;
};

const getTooltipDataFromDatapoint = (datapoint: Datapoint) => {
const index = data.indexOf(datapoint);
if (index === -1) {
return null;
}
const innerWidth = width - margin[1] - margin[3] + width / data.length - 1;
const x = (data.length > 0 ? index / data.length : 0) * innerWidth;
const y = (yScale(datapoint.v) ?? 0) as number;
return { x, y, d: datapoint };
};

const getTooltipContents = (d: Datapoint) => {
if (!d || d.oor) return null;
return (
<>
{type === "binomial" ? (
<div className={styles.val}>{d.c.toLocaleString()}</div>
) : (
<>
<div className={styles.val}>
{method === "sum" ? `Σ` : `μ`}:{" "}
{formatConversionRate(type, d.v as number)}
{smoothBy === "week" && (
<sub style={{ fontWeight: "normal", fontSize: 8 }}>smooth</sub>
)}
</div>
{"s" in d && method === "avg" && (
<div className={styles.secondary}>
{`σ`}: {formatConversionRate(type, d.s)}
{smoothBy === "week" && (
<sub style={{ fontWeight: "normal", fontSize: 8 }}>
smooth
</sub>
)}
</div>
)}
<div className={styles.secondary}>
<em>n</em>: {d.c.toLocaleString()}
</div>
</>
)}
<div className={styles.date}>{date(d.d as Date)}</div>
</>
);
};

const {
showTooltip,
hideTooltip,
tooltipOpen,
tooltipData,
tooltipLeft = 0,
tooltipTop = 0,
} = useTooltip<TooltipData>();

const [toolTipTimer, setToolTipTimer] = useState<null | ReturnType<
typeof setTimeout
>>(null);
const [
highlightExp,
setHighlightExp,
] = useState<null | ExperimentDisplayData>(null);

return (
<ParentSizeModern style={{ position: "relative" }}>
{({ width }) => {
const yMax = height - margin[0] - margin[2];
// const yMax = height - margin[0] - margin[2];
const xMax = width - margin[1] - margin[3];
const numXTicks = width > 768 ? 7 : 4;
const numYTicks = 5;
const axisHeight = 30;
const minGraphHeight = 100;
const expBarHeight = 10;
const expBarMargin = 4;
const expHeight = bands.size * (expBarHeight + expBarMargin);
let graphHeight = yMax - expHeight;
if (graphHeight < minGraphHeight) {
height += minGraphHeight - (yMax - expHeight);
graphHeight = minGraphHeight;
}

const xScale = scaleTime({
domain: [min, max],
range: [0, xMax],
round: true,
});
const yScale = scaleLinear<number>({
domain: [
0,
Math.max(
...data.map((d) =>
type === "binomial"
? d.c
: Math.min(d.v * 2, d.v + (d.s ?? 0) * 2)
)
),
],
range: [graphHeight, 0],
round: true,
});

const handlePointer = (event: React.PointerEvent<HTMLDivElement>) => {
const handlePointerMove = (
event: React.PointerEvent<HTMLDivElement>
) => {
// coordinates should be relative to the container in which Tooltip is rendered
const containerX =
("clientX" in event ? event.clientX : 0) - containerBounds.left;
const data = getTooltipData(containerX, width, yScale);
showTooltip({
tooltipLeft: data.x,
tooltipTop: data.y,
tooltipData: data,
});
const date = getDateFromX(containerX);
if (onHover) {
onHover({ d: date });
}
};

const handlePointerLeave = () => {
hideTooltip();
if (onHover) {
onHover({ d: null });
}
};

return (
Expand All @@ -338,8 +397,8 @@ const DateGraph: FC<{
marginLeft: margin[3],
marginTop: margin[0],
}}
onPointerMove={handlePointer}
onPointerLeave={hideTooltip}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
{tooltipOpen && !tooltipData?.d?.oor && (
<>
Expand Down