vmui: sticky tooltip (#3376)

* feat: add ability to make tooltip "sticky"

* vmui: add ability to make tooltip "sticky"
This commit is contained in:
Yury Molodov 2022-11-21 23:26:53 +01:00 committed by GitHub
parent ee1479bac6
commit 82d254af08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 372 additions and 120 deletions

View File

@ -0,0 +1,181 @@
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot, { Series } from "uplot";
import { MetricResult } from "../../../api/types";
import { formatPrettyNumber, getColorLine, getLegendLabel } from "../../../utils/uplot/helpers";
import dayjs from "dayjs";
import { DATE_FULL_TIMEZONE_FORMAT } from "../../../constants/date";
import ReactDOM from "react-dom";
import get from "lodash.get";
import Button from "../../Main/Button/Button";
import { CloseIcon, DragIcon } from "../../Main/Icons";
import classNames from "classnames";
import { MouseEvent as ReactMouseEvent } from "react";
import "./style.scss";
export interface ChartTooltipProps {
id: string,
u: uPlot,
metrics: MetricResult[],
series: Series[],
unit?: string,
isSticky?: boolean,
tooltipOffset: { left: number, top: number },
tooltipIdx: { seriesIdx: number, dataIdx: number },
onClose?: (id: string) => void
}
const ChartTooltip: FC<ChartTooltipProps> = ({
u,
id,
unit = "",
metrics,
series,
tooltipIdx,
tooltipOffset,
isSticky,
onClose
}) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: -999, left: -999 });
const [moving, setMoving] = useState(false);
const [moved, setMoved] = useState(false);
const [seriesIdx, setSeriesIdx] = useState(tooltipIdx.seriesIdx);
const [dataIdx, setDataIdx] = useState(tooltipIdx.dataIdx);
const targetPortal = useMemo(() => u.root.querySelector(".u-wrap"), [u]);
const value = useMemo(() => get(u, ["data", seriesIdx, dataIdx], 0), [u, seriesIdx, dataIdx]);
const valueFormat = useMemo(() => formatPrettyNumber(value), [value]);
const dataTime = useMemo(() => u.data[0][dataIdx], [u, dataIdx]);
const date = useMemo(() => dayjs(new Date(dataTime * 1000)).format(DATE_FULL_TIMEZONE_FORMAT), [dataTime]);
const color = useMemo(() => getColorLine(series[seriesIdx]?.label || ""), [series, seriesIdx]);
const name = useMemo(() => {
const metricName = (series[seriesIdx]?.label || "").replace(/{.+}/gmi, "").trim();
return getLegendLabel(metricName);
}, []);
const fields = useMemo(() => {
const metric = metrics[seriesIdx - 1]?.metric || {};
const fields = Object.keys(metric).filter(k => k !== "__name__");
return fields.map(key => `${key}="${metric[key]}"`);
}, [metrics, seriesIdx]);
const handleClose = () => {
onClose && onClose(id);
};
const handleMouseDown = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
setMoved(true);
setMoving(true);
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
};
const handleMouseMove = (e: MouseEvent) => {
if (!moving) return;
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
};
const handleMouseUp = () => {
setMoving(false);
};
const calcPosition = () => {
if (!tooltipRef.current) return;
const topOnChart = u.valToPos((value || 0), series[seriesIdx]?.scale || "1");
const leftOnChart = u.valToPos(dataTime, "x");
const { width: tooltipWidth, height: tooltipHeight } = tooltipRef.current.getBoundingClientRect();
const { width, height } = u.over.getBoundingClientRect();
const margin = 10;
const overflowX = leftOnChart + tooltipWidth >= width ? tooltipWidth + (2 * margin) : 0;
const overflowY = topOnChart + tooltipHeight >= height ? tooltipHeight + (2 * margin) : 0;
setPosition({
top: topOnChart + tooltipOffset.top + margin - overflowY,
left: leftOnChart + tooltipOffset.left + margin - overflowX
});
};
useEffect(calcPosition, [u, value, dataTime, seriesIdx, tooltipOffset, tooltipRef]);
useEffect(() => {
setSeriesIdx(tooltipIdx.seriesIdx);
setDataIdx(tooltipIdx.dataIdx);
}, [tooltipIdx]);
useEffect(() => {
if (moving) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [moving]);
if (!targetPortal || tooltipIdx.seriesIdx < 0 || tooltipIdx.dataIdx < 0) return null;
return ReactDOM.createPortal((
<div
className={classNames({
"vm-chart-tooltip": true,
"vm-chart-tooltip_sticky": isSticky,
"vm-chart-tooltip_moved": moved
})}
ref={tooltipRef}
style={position}
>
<div className="vm-chart-tooltip-header">
<div className="vm-chart-tooltip-header__date">{date}</div>
{isSticky && (
<>
<Button
className="vm-chart-tooltip-header__drag"
variant="text"
size="small"
startIcon={<DragIcon/>}
onMouseDown={handleMouseDown}
/>
<Button
className="vm-chart-tooltip-header__close"
variant="text"
size="small"
startIcon={<CloseIcon/>}
onClick={handleClose}
/>
</>
)}
</div>
<div className="vm-chart-tooltip-data">
<div
className="vm-chart-tooltip-data__marker"
style={{ background: color }}
/>
<p>
{name}:
<b className="vm-chart-tooltip-data__value">{valueFormat}</b>
{unit}
</p>
</div>
{!!fields.length && (
<div className="vm-chart-tooltip-info">
{fields.map((f, i) => (
<div key={`${f}_${i}`}>{f}</div>
))}
</div>
)}
</div>
), targetPortal);
};
export default ChartTooltip;

View File

@ -0,0 +1,77 @@
@use "src/styles/variables" as *;
$chart-tooltip-width: 300px;
$chart-tooltip-icon-width: 25px;
$chart-tooltip-date-width: $chart-tooltip-width - (2*$chart-tooltip-icon-width) - (2*$padding-global) - $padding-small;
$chart-tooltip-x: -1 * ($padding-small + $padding-global + $chart-tooltip-date-width + ($chart-tooltip-icon-width/2));
$chart-tooltip-y: -1 * ($padding-small + ($chart-tooltip-icon-width/2));
.vm-chart-tooltip {
position: absolute;
display: grid;
gap: $padding-global;
width: $chart-tooltip-width;
padding: $padding-small;
border-radius: $border-radius-medium;
background: $color-background-tooltip;
color: $color-white;
font-size: $font-size-small;
font-weight: normal;
line-height: 150%;
word-wrap: break-word;
font-family: $font-family-monospace;
z-index: 98;
user-select: text;
pointer-events: none;
&_sticky {
background-color: $color-dove-gray;
pointer-events: auto;
z-index: 99;
}
&_moved {
position: fixed;
margin-top: $chart-tooltip-y;
margin-left: $chart-tooltip-x;
}
&-header {
display: grid;
grid-template-columns: 1fr $chart-tooltip-icon-width $chart-tooltip-icon-width;
gap: $padding-small;
align-items: center;
justify-content: center;
min-height: 25px;
&__close {
color: $color-white;
}
&__drag {
color: $color-white;
cursor: move;
}
}
&-data {
display: flex;
flex-wrap: wrap;
align-items: center;
&__value {
padding: 4px;
font-weight: bold;
}
&__marker {
width: 12px;
height: 12px;
margin-right: $padding-small;
}
}
&-info {
display: grid;
grid-gap: 4px;
}
}

View File

@ -1,9 +1,15 @@
import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
import uPlot, { AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale } from "uplot";
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot, {
AlignedData as uPlotData,
Options as uPlotOptions,
Series as uPlotSeries,
Range,
Scales,
Scale,
} from "uplot";
import { defaultOptions } from "../../../utils/uplot/helpers";
import { dragChart } from "../../../utils/uplot/events";
import { getAxes, getMinMaxBuffer } from "../../../utils/uplot/axes";
import { setTooltip } from "../../../utils/uplot/tooltip";
import { MetricResult } from "../../../api/types";
import { limitsDurations } from "../../../utils/time";
import throttle from "lodash.throttle";
@ -13,6 +19,7 @@ import { YaxisState } from "../../../state/graph/reducer";
import "uplot/dist/uPlot.min.css";
import "./style.scss";
import classNames from "classnames";
import ChartTooltip, { ChartTooltipProps } from "../ChartTooltip/ChartTooltip";
export interface LineChartProps {
metrics: MetricResult[];
@ -24,21 +31,30 @@ export interface LineChartProps {
setPeriod: ({ from, to }: {from: Date, to: Date}) => void;
container: HTMLDivElement | null
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
period, yaxis, unit, setPeriod, container }) => {
const LineChart: FC<LineChartProps> = ({
data,
series,
metrics = [],
period,
yaxis,
unit,
setPeriod,
container
}) => {
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({ min: period.start, max: period.end });
const [uPlotInst, setUPlotInst] = useState<uPlot>();
const layoutSize = useResize(container);
const tooltip = document.createElement("div");
tooltip.className = "u-tooltip";
const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = { seriesIdx: null, dataIdx: undefined };
const tooltipOffset = { left: 0, top: 0 };
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
const tooltipId = useMemo(() => `${tooltipIdx.seriesIdx}_${tooltipIdx.dataIdx}`, [tooltipIdx]);
const setScale = ({ min, max }: { min: number, max: number }): void => {
setPeriod({ from: new Date(min * 1000), to: new Date(max * 1000) });
@ -54,12 +70,13 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
const onReadyChart = (u: uPlot) => {
const factor = 0.9;
tooltipOffset.left = parseFloat(u.over.style.left);
tooltipOffset.top = parseFloat(u.over.style.top);
u.root.querySelector(".u-wrap")?.appendChild(tooltip);
setTooltipOffset({
left: parseFloat(u.over.style.left),
top: parseFloat(u.over.style.top)
});
u.over.addEventListener("mousedown", e => {
const { ctrlKey, metaKey } = e;
const leftClick = e.button === 0;
const { ctrlKey, metaKey, button } = e;
const leftClick = button === 0;
const leftClickWithMeta = leftClick && (ctrlKey || metaKey);
if (leftClickWithMeta) {
// drag pan
@ -98,21 +115,37 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
}
};
const setCursor = (u: uPlot) => {
if (tooltipIdx.dataIdx === u.cursor.idx) return;
tooltipIdx.dataIdx = u.cursor.idx || 0;
if (tooltipIdx.seriesIdx !== null && tooltipIdx.dataIdx !== undefined) {
setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit });
const handleClick = () => {
const id = `${tooltipIdx.seriesIdx}_${tooltipIdx.dataIdx}`;
const props = {
id,
unit,
series,
metrics,
tooltipIdx,
tooltipOffset,
};
if (!stickyTooltips.find(t => t.id === id)) {
const tooltipProps = JSON.parse(JSON.stringify(props));
setStickyToolTips(prev => [...prev, tooltipProps]);
}
};
const seriesFocus = (u: uPlot, sidx: (number | null)) => {
if (tooltipIdx.seriesIdx === sidx) return;
tooltipIdx.seriesIdx = sidx;
sidx && tooltipIdx.dataIdx !== undefined
? setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit })
: tooltip.style.display = "none";
const handleUnStick = (id:string) => {
setStickyToolTips(prev => prev.filter(t => t.id !== id));
};
const setCursor = (u: uPlot) => {
const dataIdx = u.cursor.idx ?? -1;
setTooltipIdx(prev => ({ ...prev, dataIdx }));
};
const seriesFocus = (u: uPlot, sidx: (number | null)) => {
const seriesIdx = sidx ?? -1;
setTooltipIdx(prev => ({ ...prev, seriesIdx }));
};
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
const getRangeY = (u: uPlot, min = 0, max = 1, axis: string): Range.MinMax => {
if (yaxis.limits.enable) return yaxis.limits.range[axis];
@ -168,6 +201,8 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
useEffect(() => setXRange({ min: period.start, max: period.end }), [period]);
useEffect(() => {
setStickyToolTips([]);
setTooltipIdx({ seriesIdx: -1, dataIdx: -1 });
if (!uPlotRef.current) return;
const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u);
@ -187,6 +222,17 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
useEffect(() => {
const show = tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1;
setShowTooltip(show);
if (show) window.addEventListener("click", handleClick);
return () => {
window.removeEventListener("click", handleClick);
};
}, [tooltipIdx, stickyTooltips]);
return (
<div
className={classNames({
@ -194,7 +240,31 @@ const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
"vm-line-chart_panning": isPanning
})}
>
<div ref={uPlotRef}/>
<div
className="vm-line-chart__u-plot"
ref={uPlotRef}
/>
{uPlotInst && showTooltip && (
<ChartTooltip
unit={unit}
u={uPlotInst}
series={series}
metrics={metrics}
tooltipIdx={tooltipIdx}
tooltipOffset={tooltipOffset}
id={tooltipId}
/>
)}
{uPlotInst && stickyTooltips.map(t => (
<ChartTooltip
{...t}
isSticky
u={uPlotInst}
key={t.id}
onClose={handleUnStick}
/>
))}
</div>
);
};

View File

@ -7,45 +7,8 @@
&_panning {
pointer-events: none;
}
}
.u-tooltip {
position: absolute;
display: none;
grid-gap: $padding-global;
max-width: 300px;
padding: $padding-small;
border-radius: $border-radius-medium;
background: $color-background-tooltip;
color: $color-white;
font-size: $font-size-small;
font-weight: normal;
line-height: 1.4;
word-wrap: break-word;
font-family: monospace;
pointer-events: none;
z-index: 100;
&-data {
display: flex;
flex-wrap: wrap;
align-items: center;
line-height: 150%;
&__value {
padding: 4px;
font-weight: bold;
}
}
&__info {
display: grid;
grid-gap: 4px;
}
&__marker {
width: 12px;
height: 12px;
margin-right: $padding-small;
&__u-plot {
position: relative;
}
}

View File

@ -14,6 +14,7 @@ interface ButtonProps {
children?: ReactNode
className?: string
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void
onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void
}
const Button: FC<ButtonProps> = ({
@ -27,6 +28,7 @@ const Button: FC<ButtonProps> = ({
className,
disabled,
onClick,
onMouseDown,
}) => {
const classesButton = classNames({
@ -45,6 +47,7 @@ const Button: FC<ButtonProps> = ({
className={classesButton}
disabled={disabled}
onClick={onClick}
onMouseDown={onMouseDown}
>
<>
{startIcon && <span className="vm-button__start-icon">{startIcon}</span>}

View File

@ -300,3 +300,12 @@ export const CopyIcon = () => (
></path>
</svg>
);
export const DragIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z"></path>
</svg>
);

View File

@ -18,6 +18,7 @@ $color-text-secondary: rgba($color-text, 0.6);
$color-text-disabled: rgba($color-text, 0.4);
$color-black: #110f0f;
$color-dove-gray: #616161;
$color-silver: #C4C4C4;
$color-alto: #D8D8D8;
$color-white: #ffffff;
@ -30,7 +31,7 @@ $color-tropical-blue: #C9E3F6;
$color-background-body: var(--color-background-body);
$color-background-block: var(--color-background-block);
$color-background-modal: rgba($color-black, 0.7);
$color-background-tooltip: rgba(97, 97, 97, 0.92);
$color-background-tooltip: rgba($color-dove-gray, 0.92);
/************* padding *************/

View File

@ -1,36 +0,0 @@
import dayjs from "dayjs";
import { SetupTooltip } from "./types";
import { getColorLine, formatPrettyNumber, getLegendLabel } from "./helpers";
import { DATE_FULL_TIMEZONE_FORMAT } from "../../constants/date";
// TODO create jsx component
export const setTooltip = ({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit = "" }: SetupTooltip): void => {
const { seriesIdx, dataIdx } = tooltipIdx;
if (seriesIdx === null || dataIdx === undefined) return;
const dataSeries = u.data[seriesIdx][dataIdx];
const dataTime = u.data[0][dataIdx];
const metric = metrics[seriesIdx - 1]?.metric || {};
const selectedSeries = series[seriesIdx];
const color = getColorLine(selectedSeries.label || "");
const { width, height } = u.over.getBoundingClientRect();
const top = u.valToPos((dataSeries || 0), series[seriesIdx]?.scale || "1");
const lft = u.valToPos(dataTime, "x");
const { width: tooltipWidth, height: tooltipHeight } = tooltip.getBoundingClientRect();
const overflowX = lft + tooltipWidth >= width;
const overflowY = top + tooltipHeight >= height;
tooltip.style.display = "grid";
tooltip.style.top = `${tooltipOffset.top + top + 10 - (overflowY ? tooltipHeight + 10 : 0)}px`;
tooltip.style.left = `${tooltipOffset.left + lft + 10 - (overflowX ? tooltipWidth + 20 : 0)}px`;
const metricName = (selectedSeries.label || "").replace(/{.+}/gmi, "").trim();
const name = getLegendLabel(metricName);
const date = dayjs(new Date(dataTime * 1000)).format(DATE_FULL_TIMEZONE_FORMAT);
const info = Object.keys(metric).filter(k => k !== "__name__").map(k => `<div><b>${k}</b>: ${metric[k]}</div>`).join("");
const marker = `<div class="u-tooltip__marker" style="background: ${color}"></div>`;
tooltip.innerHTML = `<div>${date}</div>
<div class="u-tooltip-data">
${marker}${name}: <b class="u-tooltip-data__value">${formatPrettyNumber(dataSeries)}</b> ${unit}
</div>
<div class="u-tooltip__info">${info}</div>`;
};

View File

@ -1,21 +1,4 @@
import uPlot, { Series } from "uplot";
import { MetricResult } from "../../api/types";
export interface SetupTooltip {
u: uPlot,
metrics: MetricResult[],
series: Series[],
tooltip: HTMLDivElement,
unit?: string,
tooltipOffset: {
left: number,
top: number
},
tooltipIdx: {
seriesIdx: number | null,
dataIdx: number | undefined
}
}
export interface HideSeriesArgs {
hideSeries: string[],

View File

@ -24,6 +24,7 @@ The following tip changes can be tested by building VictoriaMetrics components f
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to upload/paste JSON to investigate the trace. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3308) and [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3310).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): reduce JS bundle size from 200Kb to 100Kb. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3298).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to hide results of a particular query by clicking the `eye` icon. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3359).
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add the ability to "stick" a tooltip on the chart by clicking on a data point. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3321) and [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3376)
* FEATURE: [vmalert](https://docs.victoriametrics.com/vmalert.html): add default alert list for vmalert's metrics. See [alerts-vmalert.yml](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/deployment/docker/alerts-vmalert.yml).
* BUGFIX: [MetricsQL](https://docs.victoriametrics.com/MetricsQL.html): properly return an empty result from [limit_offset](https://docs.victoriametrics.com/MetricsQL.html#limit_offset) if the `offset` arg exceeds the number of inner time series. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3312).