From 82d254af082a653970af5e4de50e98a121c1e0c4 Mon Sep 17 00:00:00 2001
From: Yury Molodov <yurymolodov@gmail.com>
Date: Mon, 21 Nov 2022 23:26:53 +0100
Subject: [PATCH] vmui: sticky tooltip (#3376)

* feat: add ability to make tooltip "sticky"

* vmui: add ability to make tooltip "sticky"
---
 .../Chart/ChartTooltip/ChartTooltip.tsx       | 181 ++++++++++++++++++
 .../components/Chart/ChartTooltip/style.scss  |  77 ++++++++
 .../components/Chart/LineChart/LineChart.tsx  | 124 +++++++++---
 .../src/components/Chart/LineChart/style.scss |  41 +---
 .../src/components/Main/Button/Button.tsx     |   3 +
 .../vmui/src/components/Main/Icons/index.tsx  |   9 +
 .../packages/vmui/src/styles/variables.scss   |   3 +-
 .../packages/vmui/src/utils/uplot/tooltip.ts  |  36 ----
 .../packages/vmui/src/utils/uplot/types.ts    |  17 --
 docs/CHANGELOG.md                             |   1 +
 10 files changed, 372 insertions(+), 120 deletions(-)
 create mode 100644 app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx
 create mode 100644 app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss
 delete mode 100644 app/vmui/packages/vmui/src/utils/uplot/tooltip.ts

diff --git a/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx
new file mode 100644
index 000000000..099771946
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/ChartTooltip.tsx
@@ -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;
diff --git a/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss
new file mode 100644
index 000000000..9c8cedc94
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Chart/ChartTooltip/style.scss
@@ -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;
+  }
+}
diff --git a/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx b/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx
index b8c37503f..1e7e4ff7c 100644
--- a/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx
+++ b/app/vmui/packages/vmui/src/components/Chart/LineChart/LineChart.tsx
@@ -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>
   );
 };
diff --git a/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss b/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss
index d1b3b26de..30186608f 100644
--- a/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss
+++ b/app/vmui/packages/vmui/src/components/Chart/LineChart/style.scss
@@ -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;
   }
 }
diff --git a/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx b/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
index 09d5c9edd..ad54f6168 100644
--- a/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
@@ -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>}
diff --git a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
index 68ab7a9bf..cd7ea24dc 100644
--- a/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
@@ -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>
+);
diff --git a/app/vmui/packages/vmui/src/styles/variables.scss b/app/vmui/packages/vmui/src/styles/variables.scss
index 094acc677..a3c55ae3d 100644
--- a/app/vmui/packages/vmui/src/styles/variables.scss
+++ b/app/vmui/packages/vmui/src/styles/variables.scss
@@ -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 *************/
diff --git a/app/vmui/packages/vmui/src/utils/uplot/tooltip.ts b/app/vmui/packages/vmui/src/utils/uplot/tooltip.ts
deleted file mode 100644
index 970da5b5e..000000000
--- a/app/vmui/packages/vmui/src/utils/uplot/tooltip.ts
+++ /dev/null
@@ -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>`;
-};
diff --git a/app/vmui/packages/vmui/src/utils/uplot/types.ts b/app/vmui/packages/vmui/src/utils/uplot/types.ts
index 604b86549..e46850228 100644
--- a/app/vmui/packages/vmui/src/utils/uplot/types.ts
+++ b/app/vmui/packages/vmui/src/utils/uplot/types.ts
@@ -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[],
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 51149a0dc..8f922c3a1 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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).