vmui: chart refactoring to enhance code structure (#4830)

(cherry picked from commit 8287749c05)
This commit is contained in:
Yury Molodov 2023-08-21 15:35:47 +02:00 committed by hagen1778
parent f48962e834
commit 931c63f602
No known key found for this signature in database
GPG Key ID: 3BF75F3741CA9640
33 changed files with 899 additions and 943 deletions

View File

@ -1,6 +1,6 @@
import { seriesBarsPlugin } from "../../../utils/uplot/plugin"; import { seriesBarsPlugin } from "../../../utils/uplot/plugin";
import { barDisp, getBarSeries } from "../../../utils/uplot/series"; import { barDisp, getBarSeries } from "../../../utils/uplot";
import { Fill, Stroke } from "../../../utils/uplot/types"; import { Fill, Stroke } from "../../../types";
import { PaddingSide, Series } from "uplot"; import { PaddingSide, Series } from "uplot";

View File

@ -0,0 +1,178 @@
import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
import { MouseEvent as ReactMouseEvent } from "react";
import useEventListener from "../../../hooks/useEventListener";
import ReactDOM from "react-dom";
import classNames from "classnames";
import uPlot from "uplot";
import Button from "../../Main/Button/Button";
import { CloseIcon, DragIcon } from "../../Main/Icons";
import { SeriesItemStats } from "../../../types";
export interface ChartTooltipProps {
u?: uPlot;
id: string;
title?: string;
dates: string[];
value: string | number | null;
point: { top: number, left: number };
unit?: string;
stats?: SeriesItemStats;
isSticky?: boolean;
info?: string;
marker?: string;
show?: boolean;
onClose?: (id: string) => void;
}
const ChartTooltip: FC<ChartTooltipProps> = ({
u,
id,
title,
dates,
value,
point,
unit = "",
info,
stats,
isSticky,
marker,
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 handleClose = () => {
onClose && onClose(id);
};
const handleMouseDown = (e: ReactMouseEvent) => {
setMoved(true);
setMoving(true);
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
};
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!moving) return;
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
}, [moving]);
const handleMouseUp = () => {
setMoving(false);
};
const calcPosition = () => {
if (!tooltipRef.current || !u) return;
const { top, left } = point;
const uPlotPosition = {
left: parseFloat(u.over.style.left),
top: parseFloat(u.over.style.top)
};
const {
width: uPlotWidth,
height: uPlotHeight
} = u.over.getBoundingClientRect();
const {
width: tooltipWidth,
height: tooltipHeight
} = tooltipRef.current.getBoundingClientRect();
const margin = 10;
const overflowX = left + tooltipWidth >= uPlotWidth ? tooltipWidth + (2 * margin) : 0;
const overflowY = top + tooltipHeight >= uPlotHeight ? tooltipHeight + (2 * margin) : 0;
const position = {
top: top + uPlotPosition.top + margin - overflowY,
left: left + uPlotPosition.left + margin - overflowX
};
if (position.left < 0) position.left = 20;
if (position.top < 0) position.top = 20;
setPosition(position);
};
useEffect(calcPosition, [u, value, point, tooltipRef]);
useEventListener("mousemove", handleMouseMove);
useEventListener("mouseup", handleMouseUp);
if (!u) 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">
{title && (
<div className="vm-chart-tooltip-header__title">
{title}
</div>
)}
<div className="vm-chart-tooltip-header__date">
{dates.map((date, i) => <span key={i}>{date}</span>)}
</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">
{marker && (
<span
className="vm-chart-tooltip-data__marker"
style={{ background: marker }}
/>
)}
<div>
<p className="vm-chart-tooltip-data__value">
<b>{value}</b>{unit}
</p>
{stats && (
<p className="vm-chart-tooltip-data__stats">
{Object.keys(stats).filter(key => key !== "last").map((key, i) => (
<span key={i}>
{key}:<b>{stats[key as keyof SeriesItemStats]}</b>
</span>
)
)}
</p>
)}
</div>
</div>
<div className="vm-chart-tooltip-info">
{info}
</div>
</div>
), u.root);
};
export default ChartTooltip;

View File

@ -0,0 +1,26 @@
import React, { FC } from "preact/compat";
import ChartTooltip, { ChartTooltipProps } from "./ChartTooltip";
import "./style.scss";
interface LineTooltipHook {
showTooltip: boolean;
tooltipProps: ChartTooltipProps;
stickyTooltips: ChartTooltipProps[];
handleUnStick: (id: string) => void;
}
const ChartTooltipWrapper: FC<LineTooltipHook> = ({ showTooltip, tooltipProps, stickyTooltips, handleUnStick }) => (
<>
{showTooltip && tooltipProps && <ChartTooltip {...tooltipProps}/>}
{stickyTooltips.map(t => (
<ChartTooltip
{...t}
isSticky
key={t.id}
onClose={handleUnStick}
/>
))}
</>
);
export default ChartTooltipWrapper;

View File

@ -1,4 +1,5 @@
@use "src/styles/variables" as *; @use "src/styles/variables" as *;
$chart-tooltip-width: 325px; $chart-tooltip-width: 325px;
$chart-tooltip-icon-width: 25px; $chart-tooltip-icon-width: 25px;
$chart-tooltip-half-icon: calc($chart-tooltip-icon-width/2); $chart-tooltip-half-icon: calc($chart-tooltip-icon-width/2);
@ -53,12 +54,10 @@ $chart-tooltip-y: -1 * ($padding-small + $chart-tooltip-half-icon);
} }
&__date { &__date {
&_range {
display: grid; display: grid;
gap: 2px; gap: 2px;
} }
} }
}
&-data { &-data {
display: grid; display: grid;
@ -68,6 +67,13 @@ $chart-tooltip-y: -1 * ($padding-small + $chart-tooltip-half-icon);
word-break: break-all; word-break: break-all;
line-height: $font-size; line-height: $font-size;
&__stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $padding-small;
}
&__marker { &__marker {
width: 12px; width: 12px;
height: 12px; height: 12px;

View File

@ -1,142 +0,0 @@
import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
import uPlot from "uplot";
import ReactDOM from "react-dom";
import Button from "../../../Main/Button/Button";
import { CloseIcon, DragIcon } from "../../../Main/Icons";
import classNames from "classnames";
import { MouseEvent as ReactMouseEvent } from "react";
import "../../Line/ChartTooltip/style.scss";
import useEventListener from "../../../../hooks/useEventListener";
export interface TooltipHeatmapProps {
cursor: {left: number, top: number}
startDate: string,
endDate: string,
bucket: string,
value: number,
valueFormat: string
}
export interface ChartTooltipHeatmapProps extends TooltipHeatmapProps {
id: string,
u: uPlot,
unit?: string,
isSticky?: boolean,
tooltipOffset: { left: number, top: number },
onClose?: (id: string) => void
}
const ChartTooltipHeatmap: FC<ChartTooltipHeatmapProps> = ({
u,
id,
unit = "",
cursor,
tooltipOffset,
isSticky,
onClose,
startDate,
endDate,
bucket,
valueFormat,
value
}) => {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: -999, left: -999 });
const [moving, setMoving] = useState(false);
const [moved, setMoved] = useState(false);
const handleClose = () => {
onClose && onClose(id);
};
const handleMouseDown = (e: ReactMouseEvent) => {
setMoved(true);
setMoving(true);
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
};
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!moving) return;
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
}, [moving]);
const handleMouseUp = () => {
setMoving(false);
};
const calcPosition = () => {
if (!tooltipRef.current) return;
const topOnChart = cursor.top;
const leftOnChart = cursor.left;
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, cursor, tooltipOffset, tooltipRef]);
useEventListener("mousemove", handleMouseMove);
useEventListener("mouseup", handleMouseUp);
if (!cursor?.left || !cursor?.top || !value) 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 vm-chart-tooltip-header__date_range">
<span>{startDate}</span>
<span>{endDate}</span>
</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">
<p>
value: <b className="vm-chart-tooltip-data__value">{valueFormat}</b>{unit}
</p>
</div>
<div className="vm-chart-tooltip-info">
{bucket}
</div>
</div>
), u.root);
};
export default ChartTooltipHeatmap;

View File

@ -1,49 +1,44 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat"; import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot, { import uPlot, {
AlignedData as uPlotData, AlignedData as uPlotData,
Options as uPlotOptions, Options as uPlotOptions,
Range
} from "uplot"; } from "uplot";
import { defaultOptions, sizeAxis } from "../../../../utils/uplot/helpers";
import { dragChart } from "../../../../utils/uplot/events";
import { getAxes } from "../../../../utils/uplot/axes";
import { MetricResult } from "../../../../api/types"; import { MetricResult } from "../../../../api/types";
import { dateFromSeconds, formatDateForNativeInput, limitsDurations } from "../../../../utils/time";
import throttle from "lodash.throttle";
import { TimeParams } from "../../../../types"; import { TimeParams } from "../../../../types";
import { YaxisState } from "../../../../state/graph/reducer";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import classNames from "classnames"; import classNames from "classnames";
import dayjs from "dayjs";
import { useAppState } from "../../../../state/common/StateContext"; import { useAppState } from "../../../../state/common/StateContext";
import { heatmapPaths } from "../../../../utils/uplot/heatmap"; import {
import { DATE_FULL_TIMEZONE_FORMAT } from "../../../../constants/date"; heatmapPaths,
import ChartTooltipHeatmap, { handleDestroy,
ChartTooltipHeatmapProps, getDefaultOptions,
TooltipHeatmapProps sizeAxis,
} from "../ChartTooltipHeatmap/ChartTooltipHeatmap"; getAxes,
setSelect,
} from "../../../../utils/uplot";
import { ElementSize } from "../../../../hooks/useElementSize"; import { ElementSize } from "../../../../hooks/useElementSize";
import useEventListener from "../../../../hooks/useEventListener"; import useReadyChart from "../../../../hooks/uplot/useReadyChart";
import useZoomChart from "../../../../hooks/uplot/useZoomChart";
import usePlotScale from "../../../../hooks/uplot/usePlotScale";
import useHeatmapTooltip from "../../../../hooks/uplot/useHeatmapTooltip";
import ChartTooltipWrapper from "../../ChartTooltip";
import { ChartTooltipProps } from "../../ChartTooltip/ChartTooltip";
export interface HeatmapChartProps { export interface HeatmapChartProps {
metrics: MetricResult[]; metrics: MetricResult[];
data: uPlotData; data: uPlotData;
period: TimeParams; period: TimeParams;
yaxis: YaxisState;
unit?: string; unit?: string;
setPeriod: ({ from, to }: { from: Date, to: Date }) => void; setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
layoutSize: ElementSize, layoutSize: ElementSize,
height?: number; height?: number;
onChangeLegend: (val: TooltipHeatmapProps) => void; onChangeLegend: (val: ChartTooltipProps) => void;
} }
enum typeChartUpdate {xRange = "xRange", yRange = "yRange"}
const HeatmapChart: FC<HeatmapChartProps> = ({ const HeatmapChart: FC<HeatmapChartProps> = ({
data, data,
metrics = [], metrics = [],
period, period,
yaxis,
unit, unit,
setPeriod, setPeriod,
layoutSize, layoutSize,
@ -53,144 +48,39 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const uPlotRef = useRef<HTMLDivElement>(null); 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 [uPlotInst, setUPlotInst] = useState<uPlot>();
const [startTouchDistance, setStartTouchDistance] = useState(0);
const [tooltipProps, setTooltipProps] = useState<TooltipHeatmapProps | null>(null); const { xRange, setPlotScale } = usePlotScale({ period, setPeriod });
const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 }); const { onReadyChart, isPanning } = useReadyChart(setPlotScale);
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipHeatmapProps[]>([]); useZoomChart({ uPlotInst, xRange, setPlotScale });
const tooltipId = useMemo(() => { const {
return `${tooltipProps?.bucket}_${tooltipProps?.startDate}`; stickyTooltips,
}, [tooltipProps]); handleUnStick,
getTooltipProps,
setCursor,
resetTooltips
} = useHeatmapTooltip({ u: uPlotInst, metrics, unit });
const setScale = ({ min, max }: { min: number, max: number }): void => { const tooltipProps = useMemo(() => getTooltipProps(), [getTooltipProps]);
if (isNaN(min) || isNaN(max)) return;
setPeriod({
from: dayjs(min * 1000).toDate(),
to: dayjs(max * 1000).toDate()
});
};
const throttledSetScale = useCallback(throttle(setScale, 500), []);
const setPlotScale = ({ min, max }: { min: number, max: number }) => {
const delta = (max - min) * 1000;
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
setXRange({ min, max });
throttledSetScale({ min, max });
};
const onReadyChart = (u: uPlot) => { const getHeatmapAxes = () => {
const factor = 0.9; const baseAxes = getAxes([{}], unit);
setTooltipOffset({
left: parseFloat(u.over.style.left),
top: parseFloat(u.over.style.top)
});
u.over.addEventListener("mousedown", e => { return [
const { ctrlKey, metaKey, button } = e; ...baseAxes,
const leftClick = button === 0; {
const leftClickWithMeta = leftClick && (ctrlKey || metaKey); scale: "y",
if (leftClickWithMeta) { stroke: baseAxes[0].stroke,
// drag pan font: baseAxes[0].font,
dragChart({ u, e, setPanning, setPlotScale, factor }); size: sizeAxis,
splits: metrics.map((m, i) => i),
values: metrics.map(m => m.metric.vmrange),
} }
}); ];
u.over.addEventListener("touchstart", e => {
dragChart({ u, e, setPanning, setPlotScale, factor });
});
u.over.addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const { width } = u.over.getBoundingClientRect();
const zoomPos = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const xVal = u.posToVal(zoomPos, "x");
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange;
u.batch(() => setPlotScale({ min, max }));
});
}; };
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const { target, ctrlKey, metaKey, key } = e;
const isInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
if (!uPlotInst || isInput) return;
const minus = key === "-";
const plus = key === "+" || key === "=";
if ((minus || plus) && !(ctrlKey || metaKey)) {
e.preventDefault();
const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1);
setPlotScale({
min: xRange.min + factor,
max: xRange.max - factor
});
}
}, [uPlotInst, xRange]);
const handleClick = useCallback(() => {
if (!tooltipProps?.value) return;
const id = `${tooltipProps?.bucket}_${tooltipProps?.startDate}`;
const props = {
id,
unit,
tooltipOffset,
...tooltipProps
};
if (!stickyTooltips.find(t => t.id === id)) {
const res = JSON.parse(JSON.stringify(props));
setStickyToolTips(prev => [...prev, res]);
}
}, [stickyTooltips, tooltipProps, tooltipOffset, unit]);
const handleUnStick = (id: string) => {
setStickyToolTips(prev => prev.filter(t => t.id !== id));
};
const setCursor = (u: uPlot) => {
const left = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const top = u.cursor.top && u.cursor.top > 0 ? u.cursor.top : 0;
const xArr = (u.data[1][0] || []) as number[];
if (!Array.isArray(xArr)) return;
const xVal = u.posToVal(left, "x");
const yVal = u.posToVal(top, "y");
const xIdx = xArr.findIndex((t, i) => xVal >= t && xVal < xArr[i + 1]) || -1;
const second = xArr[xIdx + 1];
const result = metrics[Math.round(yVal)];
if (!result) {
setTooltipProps(null);
return;
}
const [endTime = 0, value = ""] = result.values.find(v => v[0] === second) || [];
const valueFormat = `${+value}%`;
const startTime = xArr[xIdx];
const startDate = dayjs(startTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
const endDate = dayjs(endTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
setTooltipProps({
cursor: { left, top },
startDate,
endDate,
bucket: result?.metric?.vmrange || "",
value: +value,
valueFormat: valueFormat,
});
};
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
const axes = getAxes( [{}], unit);
const options: uPlotOptions = { const options: uPlotOptions = {
...defaultOptions, ...getDefaultOptions({ width: layoutSize.width, height }),
mode: 2, mode: 2,
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
series: [ series: [
{}, {},
{ {
@ -210,17 +100,7 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
], ],
}, },
], ],
axes: [ axes: getHeatmapAxes(),
...axes,
{
scale: "y",
stroke: axes[0].stroke,
font: axes[0].font,
size: sizeAxis,
splits: metrics.map((m, i) => i),
values: metrics.map(m => m.metric.vmrange),
}
],
scales: { scales: {
x: { x: {
time: true, time: true,
@ -228,87 +108,35 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
y: { y: {
log: 2, log: 2,
time: false, time: false,
range: (self, initMin, initMax) => [initMin - 1, initMax + 1] range: (u, initMin, initMax) => [initMin - 1, initMax + 1]
} }
}, },
width: layoutSize.width || 400,
height: height || 500,
plugins: [{ hooks: { ready: onReadyChart, setCursor } }],
hooks: { hooks: {
setSelect: [ ready: [onReadyChart],
(u) => { setCursor: [setCursor],
const min = u.posToVal(u.select.left, "x"); setSelect: [setSelect(setPlotScale)],
const max = u.posToVal(u.select.left + u.select.width, "x"); destroy: [handleDestroy],
setPlotScale({ min, max });
}
]
}, },
}; };
const updateChart = (type: typeChartUpdate): void => {
if (!uPlotInst) return;
switch (type) {
case typeChartUpdate.xRange:
uPlotInst.scales.x.range = getRangeX;
break;
}
if (!isPanning) uPlotInst.redraw();
};
useEffect(() => setXRange({ min: period.start, max: period.end }), [period]);
useEffect(() => { useEffect(() => {
setStickyToolTips([]); resetTooltips();
setTooltipProps(null);
const isValidData = data[0] === null && Array.isArray(data[1]); const isValidData = data[0] === null && Array.isArray(data[1]);
if (!uPlotRef.current || !layoutSize.width || !layoutSize.height || !isValidData) return; if (!uPlotRef.current || !isValidData) return;
const u = new uPlot(options, data, uPlotRef.current); const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u); setUPlotInst(u);
setXRange({ min: period.start, max: period.end });
return u.destroy; return u.destroy;
}, [uPlotRef.current, layoutSize, height, isDarkTheme, data]); }, [uPlotRef, data, isDarkTheme]);
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
setStartTouchDistance(Math.sqrt(dx * dx + dy * dy));
};
const handleTouchMove = useCallback((e: TouchEvent) => {
if (e.touches.length !== 2 || !uPlotInst) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const endTouchDistance = Math.sqrt(dx * dx + dy * dy);
const diffDistance = startTouchDistance - endTouchDistance;
const max = (uPlotInst.scales.x.max || xRange.max);
const min = (uPlotInst.scales.x.min || xRange.min);
const dur = max - min;
const dir = (diffDistance > 0 ? -1 : 1);
const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({
min: min + zoomFactor,
max: max - zoomFactor
}));
}, [uPlotInst, startTouchDistance, xRange]);
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
useEffect(() => { useEffect(() => {
if (tooltipProps) onChangeLegend(tooltipProps); if (!uPlotInst) return;
}, [tooltipProps]); uPlotInst.setSize({ width: layoutSize.width || 400, height: height || 500 });
uPlotInst.redraw();
}, [height, layoutSize]);
useEventListener("click", handleClick); useEffect(() => {
useEventListener("keydown", handleKeyDown); onChangeLegend(tooltipProps);
useEventListener("touchmove", handleTouchMove); }, [tooltipProps]);
useEventListener("touchstart", handleTouchStart);
return ( return (
<div <div
@ -325,25 +153,13 @@ const HeatmapChart: FC<HeatmapChartProps> = ({
className="vm-line-chart__u-plot" className="vm-line-chart__u-plot"
ref={uPlotRef} ref={uPlotRef}
/> />
{uPlotInst && tooltipProps && (
<ChartTooltipHeatmap
{...tooltipProps}
unit={unit}
u={uPlotInst}
tooltipOffset={tooltipOffset}
id={tooltipId}
/>
)}
{uPlotInst && stickyTooltips.map(t => ( <ChartTooltipWrapper
<ChartTooltipHeatmap showTooltip={!!tooltipProps.show}
{...t} tooltipProps={tooltipProps}
isSticky stickyTooltips={stickyTooltips}
u={uPlotInst} handleUnStick={handleUnStick}
key={t.id}
onClose={handleUnStick}
/> />
))}
</div> </div>
); );
}; };

View File

@ -1,39 +1,39 @@
import React, { FC, useEffect, useState } from "preact/compat"; import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import { gradMetal16 } from "../../../../utils/uplot/heatmap"; import { gradMetal16 } from "../../../../utils/uplot";
import { SeriesItem, LegendItemType } from "../../../../types";
import "./style.scss"; import "./style.scss";
import { TooltipHeatmapProps } from "../ChartTooltipHeatmap/ChartTooltipHeatmap";
import { SeriesItem } from "../../../../utils/uplot/series";
import LegendItem from "../../Line/Legend/LegendItem/LegendItem"; import LegendItem from "../../Line/Legend/LegendItem/LegendItem";
import { LegendItemType } from "../../../../utils/uplot/types"; import { ChartTooltipProps } from "../../ChartTooltip/ChartTooltip";
interface LegendHeatmapProps { interface LegendHeatmapProps {
min: number min: number
max: number max: number
legendValue: TooltipHeatmapProps | null, legendValue: ChartTooltipProps | null,
series: SeriesItem[] series: SeriesItem[]
} }
const LegendHeatmap: FC<LegendHeatmapProps> = ( const LegendHeatmap: FC<LegendHeatmapProps> = ({
{
min, min,
max, max,
legendValue, legendValue,
series, series
} }) => {
) => {
const [percent, setPercent] = useState(0); const [percent, setPercent] = useState(0);
const [valueFormat, setValueFormat] = useState(""); const [valueFormat, setValueFormat] = useState("");
const [minFormat, setMinFormat] = useState(""); const [minFormat, setMinFormat] = useState("");
const [maxFormat, setMaxFormat] = useState(""); const [maxFormat, setMaxFormat] = useState("");
const value = useMemo(() => {
return parseFloat(String(legendValue?.value || 0).replace("%", ""));
}, [legendValue]);
useEffect(() => { useEffect(() => {
const value = legendValue?.value || 0;
setPercent(value ? (value - min) / (max - min) * 100 : 0); setPercent(value ? (value - min) / (max - min) * 100 : 0);
setValueFormat(value ? `${value}%` : ""); setValueFormat(value ? `${value}%` : "");
setMinFormat(`${min}%`); setMinFormat(`${min}%`);
setMaxFormat(`${max}%`); setMaxFormat(`${max}%`);
}, [legendValue, min, max]); }, [value, min, max]);
return ( return (
<div className="vm-legend-heatmap__wrapper"> <div className="vm-legend-heatmap__wrapper">
@ -42,7 +42,7 @@ const LegendHeatmap: FC<LegendHeatmapProps> = (
className="vm-legend-heatmap-gradient" className="vm-legend-heatmap-gradient"
style={{ background: `linear-gradient(to right, ${gradMetal16.join(", ")})` }} style={{ background: `linear-gradient(to right, ${gradMetal16.join(", ")})` }}
> >
{!!legendValue?.value && ( {!!value && (
<div <div
className="vm-legend-heatmap-gradient__value" className="vm-legend-heatmap-gradient__value"
style={{ left: `${percent}%` }} style={{ left: `${percent}%` }}

View File

@ -1,180 +0,0 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot from "uplot";
import { MetricResult } from "../../../../api/types";
import { formatPrettyNumber } 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";
import { SeriesItem } from "../../../../utils/uplot/series";
import useEventListener from "../../../../hooks/useEventListener";
export interface ChartTooltipProps {
id: string,
u: uPlot,
metricItem: MetricResult,
seriesItem: SeriesItem,
unit?: string,
isSticky?: boolean,
showQueryNum?: boolean,
tooltipOffset: { left: number, top: number },
tooltipIdx: { seriesIdx: number, dataIdx: number },
onClose?: (id: string) => void
}
const ChartTooltip: FC<ChartTooltipProps> = ({
u,
id,
unit = "",
metricItem,
seriesItem,
tooltipIdx,
tooltipOffset,
isSticky,
showQueryNum,
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 value = get(u, ["data", seriesIdx, dataIdx], 0);
const valueFormat = formatPrettyNumber(value, get(u, ["scales", "1", "min"], 0), get(u, ["scales", "1", "max"], 1));
const dataTime = u.data[0][dataIdx];
const date = dayjs(dataTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
const color = `${seriesItem?.stroke}`;
const calculations = seriesItem?.calculations || {};
const group = metricItem?.group || 0;
const fullMetricName = useMemo(() => {
const metric = metricItem?.metric || {};
const labelNames = Object.keys(metric).filter(x => x != "__name__");
const labels = labelNames.map(key => `${key}=${JSON.stringify(metric[key])}`);
let metricName = metric["__name__"] || "";
if (labels.length > 0) {
metricName += "{" + labels.join(",") + "}";
}
return metricName;
}, [metricItem]);
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 = useCallback((e: MouseEvent) => {
if (!moving) return;
const { clientX, clientY } = e;
setPosition({ top: clientY, left: clientX });
}, [moving]);
const handleMouseUp = () => {
setMoving(false);
};
const calcPosition = () => {
if (!tooltipRef.current) return;
const topOnChart = u.valToPos((value || 0), seriesItem?.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;
const position = {
top: topOnChart + tooltipOffset.top + margin - overflowY,
left: leftOnChart + tooltipOffset.left + margin - overflowX
};
if (position.left < 0) position.left = 20;
if (position.top < 0) position.top = 20;
setPosition(position);
};
useEffect(calcPosition, [u, value, dataTime, seriesIdx, tooltipOffset, tooltipRef]);
useEffect(() => {
setSeriesIdx(tooltipIdx.seriesIdx);
setDataIdx(tooltipIdx.dataIdx);
}, [tooltipIdx]);
useEventListener("mousemove", handleMouseMove);
useEventListener("mouseup", handleMouseUp);
if (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">
{showQueryNum && (<div>Query {group}</div>)}
{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 }}
/>
<div>
<b>{valueFormat}{unit}</b><br/>
median:<b>{calculations.median}</b>, min:<b>{calculations.min}</b>, max:<b>{calculations.max}</b>
</div>
</div>
<div className="vm-chart-tooltip-info">
{fullMetricName}
</div>
</div>
), u.root);
};
export default ChartTooltip;

View File

@ -1,5 +1,5 @@
import React, { FC, useMemo } from "preact/compat"; import React, { FC, useMemo } from "preact/compat";
import { LegendItemType } from "../../../../utils/uplot/types"; import { LegendItemType } from "../../../../types";
import LegendItem from "./LegendItem/LegendItem"; import LegendItem from "./LegendItem/LegendItem";
import Accordion from "../../../Main/Accordion/Accordion"; import Accordion from "../../../Main/Accordion/Accordion";
import "./style.scss"; import "./style.scss";

View File

@ -1,6 +1,6 @@
import React, { FC, useMemo } from "preact/compat"; import React, { FC, useMemo } from "preact/compat";
import { MouseEvent } from "react"; import { MouseEvent } from "react";
import { LegendItemType } from "../../../../../utils/uplot/types"; import { LegendItemType } from "../../../../../types";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import { getFreeFields } from "./helpers"; import { getFreeFields } from "./helpers";

View File

@ -1,4 +1,4 @@
import { LegendItemType } from "../../../../../utils/uplot/types"; import { LegendItemType } from "../../../../../types";
export const getFreeFields = (legend: LegendItemType) => { export const getFreeFields = (legend: LegendItemType) => {
const keys = Object.keys(legend.freeFormFields).filter(f => f !== "__name__"); const keys = Object.keys(legend.freeFormFields).filter(f => f !== "__name__");

View File

@ -1,26 +1,33 @@
import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat"; import React, { FC, useEffect, useRef, useState } from "preact/compat";
import uPlot, { import uPlot, {
AlignedData as uPlotData, AlignedData as uPlotData,
Options as uPlotOptions, Options as uPlotOptions,
Series as uPlotSeries, Series as uPlotSeries,
} from "uplot"; } from "uplot";
import { defaultOptions } from "../../../../utils/uplot/helpers"; import {
import { dragChart } from "../../../../utils/uplot/events"; getDefaultOptions,
import { getAxes } from "../../../../utils/uplot/axes"; addSeries,
delSeries,
getRangeX,
getRangeY,
getScales,
handleDestroy,
getAxes,
setSelect
} from "../../../../utils/uplot";
import { MetricResult } from "../../../../api/types"; import { MetricResult } from "../../../../api/types";
import { dateFromSeconds, formatDateForNativeInput, limitsDurations } from "../../../../utils/time";
import { TimeParams } from "../../../../types"; import { TimeParams } from "../../../../types";
import { YaxisState } from "../../../../state/graph/reducer"; import { YaxisState } from "../../../../state/graph/reducer";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import ChartTooltip, { ChartTooltipProps } from "../ChartTooltip/ChartTooltip";
import dayjs from "dayjs";
import { useAppState } from "../../../../state/common/StateContext"; import { useAppState } from "../../../../state/common/StateContext";
import { SeriesItem } from "../../../../utils/uplot/series";
import { ElementSize } from "../../../../hooks/useElementSize"; import { ElementSize } from "../../../../hooks/useElementSize";
import useEventListener from "../../../../hooks/useEventListener"; import useReadyChart from "../../../../hooks/uplot/useReadyChart";
import { getRangeX, getRangeY, getScales } from "../../../../utils/uplot/scales"; import useZoomChart from "../../../../hooks/uplot/useZoomChart";
import usePlotScale from "../../../../hooks/uplot/usePlotScale";
import useLineTooltip from "../../../../hooks/uplot/useLineTooltip";
import ChartTooltipWrapper from "../../ChartTooltip";
export interface LineChartProps { export interface LineChartProps {
metrics: MetricResult[]; metrics: MetricResult[];
@ -48,208 +55,41 @@ const LineChart: FC<LineChartProps> = ({
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const uPlotRef = useRef<HTMLDivElement>(null); 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 [uPlotInst, setUPlotInst] = useState<uPlot>();
const [startTouchDistance, setStartTouchDistance] = useState(0);
const [showTooltip, setShowTooltip] = useState(false); const { xRange, setPlotScale } = usePlotScale({ period, setPeriod });
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 }); const { onReadyChart, isPanning } = useReadyChart(setPlotScale);
const [tooltipOffset, setTooltipOffset] = useState({ left: 0, top: 0 }); useZoomChart({ uPlotInst, xRange, setPlotScale });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]); const {
showTooltip,
const setPlotScale = ({ min, max }: { min: number, max: number }) => { stickyTooltips,
const delta = (max - min) * 1000; handleUnStick,
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return; getTooltipProps,
setXRange({ min, max }); seriesFocus,
setPeriod({ setCursor,
from: dayjs(min * 1000).toDate(), resetTooltips
to: dayjs(max * 1000).toDate() } = useLineTooltip({ u: uPlotInst, metrics, series, unit });
});
};
const onReadyChart = (u: uPlot): void => {
const factor = 0.9;
setTooltipOffset({
left: parseFloat(u.over.style.left),
top: parseFloat(u.over.style.top)
});
u.over.addEventListener("mousedown", e => {
const { ctrlKey, metaKey, button } = e;
const leftClick = button === 0;
const leftClickWithMeta = leftClick && (ctrlKey || metaKey);
if (leftClickWithMeta) {
// drag pan
dragChart({ u, e, setPanning, setPlotScale, factor });
}
});
u.over.addEventListener("touchstart", e => {
dragChart({ u, e, setPanning, setPlotScale, factor });
});
u.over.addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const { width } = u.over.getBoundingClientRect();
const zoomPos = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const xVal = u.posToVal(zoomPos, "x");
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange;
u.batch(() => setPlotScale({ min, max }));
});
};
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const { target, ctrlKey, metaKey, key } = e;
const isInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
if (!uPlotInst || isInput) return;
const minus = key === "-";
const plus = key === "+" || key === "=";
if ((minus || plus) && !(ctrlKey || metaKey)) {
e.preventDefault();
const factor = (xRange.max - xRange.min) / 10 * (plus ? 1 : -1);
setPlotScale({
min: xRange.min + factor,
max: xRange.max - factor
});
}
}, [uPlotInst, xRange]);
const getChartProps = useCallback(() => {
const { seriesIdx, dataIdx } = tooltipIdx;
const id = `${seriesIdx}_${dataIdx}`;
const metricItem = metrics[seriesIdx-1];
const seriesItem = series[seriesIdx] as SeriesItem;
const groups = new Set(metrics.map(m => m.group));
const showQueryNum = groups.size > 1;
return {
id,
unit,
seriesItem,
metricItem,
tooltipIdx,
tooltipOffset,
showQueryNum,
};
}, [uPlotInst, metrics, series, tooltipIdx, tooltipOffset, unit]);
const handleClick = useCallback(() => {
if (!showTooltip) return;
const props = getChartProps();
if (!stickyTooltips.find(t => t.id === props.id)) {
setStickyToolTips(prev => [...prev, props as ChartTooltipProps]);
}
}, [getChartProps, stickyTooltips, showTooltip]);
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 addSeries = (u: uPlot, series: uPlotSeries[]) => {
series.forEach((s) => {
u.addSeries(s);
});
};
const delSeries = (u: uPlot) => {
for (let i = u.series.length - 1; i >= 0; i--) {
u.delSeries(i);
}
};
const delHooks = (u: uPlot) => {
Object.keys(u.hooks).forEach(hook => {
u.hooks[hook as keyof uPlot.Hooks.Arrays] = [];
});
};
const handleDestroy = (u: uPlot) => {
delSeries(u);
delHooks(u);
u.setData([]);
};
const setSelect = (u: uPlot) => {
const min = u.posToVal(u.select.left, "x");
const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({ min, max });
};
const options: uPlotOptions = { const options: uPlotOptions = {
...defaultOptions, ...getDefaultOptions({ width: layoutSize.width, height }),
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
series, series,
axes: getAxes([{}, { scale: "1" }], unit), axes: getAxes([{}, { scale: "1" }], unit),
scales: getScales(yaxis, xRange), scales: getScales(yaxis, xRange),
width: layoutSize.width || 400,
height: height || 500,
hooks: { hooks: {
ready: [onReadyChart], ready: [onReadyChart],
setSeries: [seriesFocus], setSeries: [seriesFocus],
setCursor: [setCursor], setCursor: [setCursor],
setSelect: [setSelect], setSelect: [setSelect(setPlotScale)],
destroy: [handleDestroy], destroy: [handleDestroy],
}, },
}; };
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
setStartTouchDistance(Math.sqrt(dx * dx + dy * dy));
};
const handleTouchMove = useCallback((e: TouchEvent) => {
if (e.touches.length !== 2 || !uPlotInst) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const endTouchDistance = Math.sqrt(dx * dx + dy * dy);
const diffDistance = startTouchDistance - endTouchDistance;
const max = (uPlotInst.scales.x.max || xRange.max);
const min = (uPlotInst.scales.x.min || xRange.min);
const dur = max - min;
const dir = (diffDistance > 0 ? -1 : 1);
const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({
min: min + zoomFactor,
max: max - zoomFactor
}));
}, [uPlotInst, startTouchDistance, xRange]);
useEffect(() => { useEffect(() => {
setXRange({ min: period.start, max: period.end }); resetTooltips();
}, [period]);
useEffect(() => {
setStickyToolTips([]);
setTooltipIdx({ seriesIdx: -1, dataIdx: -1 });
if (!uPlotRef.current) return; if (!uPlotRef.current) return;
if (uPlotInst) uPlotInst.destroy(); if (uPlotInst) uPlotInst.destroy();
const u = new uPlot(options, data, uPlotRef.current); const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u); setUPlotInst(u);
setXRange({ min: period.start, max: period.end });
return u.destroy; return u.destroy;
}, [uPlotRef, isDarkTheme]); }, [uPlotRef, isDarkTheme]);
@ -287,15 +127,6 @@ const LineChart: FC<LineChartProps> = ({
uPlotInst.redraw(); uPlotInst.redraw();
}, [height, layoutSize]); }, [height, layoutSize]);
useEffect(() => {
setShowTooltip(tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1);
}, [tooltipIdx]);
useEventListener("click", handleClick);
useEventListener("keydown", handleKeyDown);
useEventListener("touchmove", handleTouchMove);
useEventListener("touchstart", handleTouchStart);
return ( return (
<div <div
className={classNames({ className={classNames({
@ -311,22 +142,12 @@ const LineChart: FC<LineChartProps> = ({
className="vm-line-chart__u-plot" className="vm-line-chart__u-plot"
ref={uPlotRef} ref={uPlotRef}
/> />
{uPlotInst && showTooltip && ( <ChartTooltipWrapper
<ChartTooltip showTooltip={showTooltip}
{...getChartProps()} tooltipProps={getTooltipProps()}
u={uPlotInst} stickyTooltips={stickyTooltips}
handleUnStick={handleUnStick}
/> />
)}
{uPlotInst && stickyTooltips.map(t => (
<ChartTooltip
{...t}
isSticky
u={uPlotInst}
key={t.id}
onClose={handleUnStick}
/>
))}
</div> </div>
); );
}; };

View File

@ -8,11 +8,12 @@ import {
getHideSeries, getHideSeries,
getLegendItem, getLegendItem,
getSeriesItemContext, getSeriesItemContext,
SeriesItem normalizeData,
} from "../../../utils/uplot/series"; getLimitsYAxis,
import { getLimitsYAxis, getMinMaxBuffer, getTimeSeries } from "../../../utils/uplot/axes"; getMinMaxBuffer,
import { LegendItemType } from "../../../utils/uplot/types"; getTimeSeries,
import { TimeParams } from "../../../types"; } from "../../../utils/uplot";
import { TimeParams, SeriesItem, LegendItemType } from "../../../types";
import { AxisRange, YaxisState } from "../../../state/graph/reducer"; import { AxisRange, YaxisState } from "../../../state/graph/reducer";
import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../utils/math"; import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../utils/math";
import classNames from "classnames"; import classNames from "classnames";
@ -20,10 +21,9 @@ import { useTimeState } from "../../../state/time/TimeStateContext";
import HeatmapChart from "../../Chart/Heatmap/HeatmapChart/HeatmapChart"; import HeatmapChart from "../../Chart/Heatmap/HeatmapChart/HeatmapChart";
import "./style.scss"; import "./style.scss";
import { promValueToNumber } from "../../../utils/metric"; import { promValueToNumber } from "../../../utils/metric";
import { normalizeData } from "../../../utils/uplot/heatmap";
import useDeviceDetect from "../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { TooltipHeatmapProps } from "../../Chart/Heatmap/ChartTooltipHeatmap/ChartTooltipHeatmap";
import useElementSize from "../../../hooks/useElementSize"; import useElementSize from "../../../hooks/useElementSize";
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
export interface GraphViewProps { export interface GraphViewProps {
data?: MetricResult[]; data?: MetricResult[];
@ -66,7 +66,7 @@ const GraphView: FC<GraphViewProps> = ({
const [series, setSeries] = useState<uPlotSeries[]>([]); const [series, setSeries] = useState<uPlotSeries[]>([]);
const [legend, setLegend] = useState<LegendItemType[]>([]); const [legend, setLegend] = useState<LegendItemType[]>([]);
const [hideSeries, setHideSeries] = useState<string[]>([]); const [hideSeries, setHideSeries] = useState<string[]>([]);
const [legendValue, setLegendValue] = useState<TooltipHeatmapProps | null>(null); const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null);
const getSeriesItem = useMemo(() => { const getSeriesItem = useMemo(() => {
return getSeriesItemContext(data, hideSeries, alias); return getSeriesItemContext(data, hideSeries, alias);
@ -199,7 +199,6 @@ const GraphView: FC<GraphViewProps> = ({
data={dataChart} data={dataChart}
metrics={data} metrics={data}
period={period} period={period}
yaxis={yaxis}
unit={unit} unit={unit}
setPeriod={setPeriod} setPeriod={setPeriod}
layoutSize={containerSize} layoutSize={containerSize}

View File

@ -0,0 +1,65 @@
import { useRef } from "preact/compat";
import uPlot from "uplot";
import { SetMinMax } from "../../types";
interface DragHookArgs {
dragSpeed: number,
setPanning: (enable: boolean) => void,
setPlotScale: SetMinMax
}
interface DragArgs {
e: MouseEvent | TouchEvent,
u: uPlot,
}
const isMouseEvent = (e: MouseEvent | TouchEvent): e is MouseEvent => e instanceof MouseEvent;
const getClientX = (e: MouseEvent | TouchEvent) => isMouseEvent(e) ? e.clientX : e.touches[0].clientX;
const useDragChart = ({ dragSpeed = 0.85, setPanning, setPlotScale }: DragHookArgs) => {
const dragState = useRef({
leftStart: 0,
xUnitsPerPx: 0,
scXMin: 0,
scXMax: 0,
});
const mouseMove = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
const clientX = getClientX(e);
const { leftStart, xUnitsPerPx, scXMin, scXMax } = dragState.current;
const dx = xUnitsPerPx * ((clientX - leftStart) * dragSpeed);
setPlotScale({ min: scXMin - dx, max: scXMax - dx });
};
const mouseUp = () => {
setPanning(false);
document.removeEventListener("mousemove", mouseMove);
document.removeEventListener("mouseup", mouseUp);
document.removeEventListener("touchmove", mouseMove);
document.removeEventListener("touchend", mouseUp);
};
const mouseDown = () => {
document.addEventListener("mousemove", mouseMove);
document.addEventListener("mouseup", mouseUp);
document.addEventListener("touchmove", mouseMove);
document.addEventListener("touchend", mouseUp);
};
return ({ e, u }: DragArgs): void => {
e.preventDefault();
setPanning(true);
dragState.current = {
leftStart: getClientX(e),
xUnitsPerPx: u.posToVal(1, "x") - u.posToVal(0, "x"),
scXMin: u.scales.x.min || 0,
scXMax: u.scales.x.max || 0,
};
mouseDown();
};
};
export default useDragChart;

View File

@ -0,0 +1,82 @@
import uPlot from "uplot";
import { useCallback, useState } from "preact/compat";
import { ChartTooltipProps } from "../../components/Chart/ChartTooltip/ChartTooltip";
import dayjs from "dayjs";
import { DATE_FULL_TIMEZONE_FORMAT } from "../../constants/date";
import { MetricResult } from "../../api/types";
import useEventListener from "../useEventListener";
import get from "lodash.get";
interface LineTooltipHook {
u?: uPlot;
metrics: MetricResult[];
unit?: string;
}
const useLineTooltip = ({ u, metrics, unit }: LineTooltipHook) => {
const [point, setPoint] = useState({ left: 0, top: 0 });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
const resetTooltips = () => {
setStickyToolTips([]);
setPoint({ left: 0, top: 0 });
};
const setCursor = (u: uPlot) => {
const left = u.cursor.left || 0;
const top = u.cursor.top || 0;
setPoint({ left, top });
};
const getTooltipProps = useCallback((): ChartTooltipProps => {
const { left, top } = point;
const xArr = (get(u, ["data", 1, 0], []) || []) as number[];
const xVal = u ? u.posToVal(left, "x") : 0;
const yVal = u ? u.posToVal(top, "y") : 0;
const xIdx = xArr.findIndex((t, i) => xVal >= t && xVal < xArr[i + 1]) || -1;
const second = xArr[xIdx + 1];
const result = metrics[Math.round(yVal)] || { values: [] };
const [endTime = 0, value = ""] = result.values.find(v => v[0] === second) || [];
const startTime = xArr[xIdx];
const startDate = dayjs(startTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
const endDate = dayjs(endTime * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT);
const bucket = result?.metric?.vmrange || "";
return {
unit,
point,
u: u,
id: `${bucket}_${startDate}`,
dates: [startDate, endDate],
value: `${value}%`,
info: bucket,
show: +value > 0
};
}, [u, point, metrics, unit]);
const handleClick = useCallback(() => {
const props = getTooltipProps();
if (!props.show) return;
if (!stickyTooltips.find(t => t.id === props.id)) {
setStickyToolTips(prev => [...prev, props]);
}
}, [getTooltipProps, stickyTooltips]);
const handleUnStick = (id: string) => {
setStickyToolTips(prev => prev.filter(t => t.id !== id));
};
useEventListener("click", handleClick);
return {
stickyTooltips,
handleUnStick,
getTooltipProps,
setCursor,
resetTooltips
};
};
export default useLineTooltip;

View File

@ -0,0 +1,101 @@
import uPlot, { Series as uPlotSeries } from "uplot";
import { useCallback, useEffect, useState } from "preact/compat";
import { ChartTooltipProps } from "../../components/Chart/ChartTooltip/ChartTooltip";
import { SeriesItem } from "../../types";
import get from "lodash.get";
import dayjs from "dayjs";
import { DATE_FULL_TIMEZONE_FORMAT } from "../../constants/date";
import { formatPrettyNumber, getMetricName } from "../../utils/uplot";
import { MetricResult } from "../../api/types";
import useEventListener from "../useEventListener";
interface LineTooltipHook {
u?: uPlot;
metrics: MetricResult[];
series: uPlotSeries[];
unit?: string;
}
const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
const resetTooltips = () => {
setStickyToolTips([]);
setTooltipIdx({ seriesIdx: -1, dataIdx: -1 });
};
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 getTooltipProps = useCallback((): ChartTooltipProps => {
const { seriesIdx, dataIdx } = tooltipIdx;
const metricItem = metrics[seriesIdx - 1];
const seriesItem = series[seriesIdx] as SeriesItem;
const groups = new Set(metrics.map(m => m.group));
const group = metricItem?.group || 0;
const value = get(u, ["data", seriesIdx, dataIdx], 0);
const min = get(u, ["scales", "1", "min"], 0);
const max = get(u, ["scales", "1", "max"], 1);
const date = get(u, ["data", 0, dataIdx], 0);
const point = {
top: u ? u.valToPos((value || 0), seriesItem?.scale || "1") : 0,
left: u ? u.valToPos(date, "x") : 0,
};
return {
unit,
point,
u: u,
id: `${seriesIdx}_${dataIdx}`,
title: groups.size > 1 ? `Query ${group}` : "",
dates: [date ? dayjs(date * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT) : "-"],
value: formatPrettyNumber(value, min, max),
info: getMetricName(metricItem),
stats: seriesItem?.calculations,
marker: `${seriesItem?.stroke}`,
};
}, [u, tooltipIdx, metrics, series, unit]);
const handleClick = useCallback(() => {
if (!showTooltip) return;
const props = getTooltipProps();
if (!stickyTooltips.find(t => t.id === props.id)) {
setStickyToolTips(prev => [...prev, props]);
}
}, [getTooltipProps, stickyTooltips, showTooltip]);
const handleUnStick = (id: string) => {
setStickyToolTips(prev => prev.filter(t => t.id !== id));
};
useEffect(() => {
setShowTooltip(tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1);
}, [tooltipIdx]);
useEventListener("click", handleClick);
return {
showTooltip,
stickyTooltips,
handleUnStick,
getTooltipProps,
seriesFocus,
setCursor,
resetTooltips,
};
};
export default useLineTooltip;

View File

@ -0,0 +1,34 @@
import { MinMax } from "../../types";
import { limitsDurations } from "../../utils/time";
import { useEffect, useState } from "preact/compat";
import { TimeParams } from "../../types";
import dayjs from "dayjs";
interface PlotScaleHook {
setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
period: TimeParams;
}
const usePlotScale = ({ period, setPeriod }: PlotScaleHook) => {
const [xRange, setXRange] = useState({ min: period.start, max: period.end });
const setPlotScale = ({ min, max }: MinMax) => {
const delta = (max - min) * 1000;
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
setPeriod({
from: dayjs(min * 1000).toDate(),
to: dayjs(max * 1000).toDate()
});
};
useEffect(() => {
setXRange({ min: period.start, max: period.end });
}, [period]);
return {
xRange,
setPlotScale
};
};
export default usePlotScale;

View File

@ -0,0 +1,53 @@
import { useState } from "preact/compat";
import uPlot from "uplot";
import useDragChart from "./useDragChart";
import { SetMinMax } from "../../types";
const isLiftClickWithMeta = (e: MouseEvent) => {
const { ctrlKey, metaKey, button } = e;
const leftClick = button === 0;
return leftClick && (ctrlKey || metaKey);
};
// coefficient for drag speed; the higher the value, the faster the graph moves.
const dragSpeed = 0.9;
const useReadyChart = (setPlotScale: SetMinMax) => {
const [isPanning, setPanning] = useState(false);
const dragChart = useDragChart({ dragSpeed, setPanning, setPlotScale });
const onReadyChart = (u: uPlot): void => {
const handleInteractionStart = (e: MouseEvent | TouchEvent) => {
const dragByMouse = e instanceof MouseEvent && isLiftClickWithMeta(e);
const dragByTouch = e instanceof TouchEvent && e.touches.length > 1;
if (dragByMouse || dragByTouch) {
dragChart({ u, e });
}
};
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const { width } = u.over.getBoundingClientRect();
const zoomPos = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const xVal = u.posToVal(zoomPos, "x");
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
const nxRange = e.deltaY < 0 ? oxRange * dragSpeed : oxRange / dragSpeed;
const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange;
u.batch(() => setPlotScale({ min, max }));
};
u.over.addEventListener("mousedown", handleInteractionStart);
u.over.addEventListener("touchstart", handleInteractionStart);
u.over.addEventListener("wheel", handleWheel);
};
return {
onReadyChart,
isPanning,
};
};
export default useReadyChart;

View File

@ -0,0 +1,66 @@
import { useCallback, useState } from "preact/compat";
import useEventListener from "../useEventListener";
import { MinMax, SetMinMax } from "../../types";
interface ZoomChartHook {
uPlotInst?: uPlot;
xRange: MinMax;
setPlotScale: SetMinMax;
}
const calculateDistance = (touches: TouchList) => {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
};
const useZoomChart = ({ uPlotInst, xRange, setPlotScale }: ZoomChartHook) => {
const [startTouchDistance, setStartTouchDistance] = useState(0);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const { target, ctrlKey, metaKey, key } = e;
const isInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
if (!uPlotInst || isInput) return;
const isPlus = key === "+" || key === "=";
const isMinus = key === "-";
const isNotControlKey = !(ctrlKey || metaKey);
if ((isMinus || isPlus) && isNotControlKey) {
e.preventDefault();
const factor = (xRange.max - xRange.min) / 10 * (isPlus ? 1 : -1);
setPlotScale({ min: xRange.min + factor, max: xRange.max - factor });
}
}, [uPlotInst, xRange]);
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
setStartTouchDistance(calculateDistance(e.touches));
}
};
const handleTouchMove = useCallback((e: TouchEvent) => {
if (!uPlotInst || e.touches.length !== 2) return;
e.preventDefault();
const endTouchDistance = calculateDistance(e.touches);
const diffDistance = startTouchDistance - endTouchDistance;
const max = (uPlotInst.scales.x.max || xRange.max);
const min = (uPlotInst.scales.x.min || xRange.min);
const dur = max - min;
const dir = (diffDistance > 0 ? -1 : 1);
const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({ min: min + zoomFactor, max: max - zoomFactor }));
}, [uPlotInst, startTouchDistance, xRange]);
useEventListener("keydown", handleKeyDown);
useEventListener("touchmove", handleTouchMove);
useEventListener("touchstart", handleTouchStart);
return null;
};
export default useZoomChart;

View File

@ -83,11 +83,13 @@ export const useFetchQuery = ({
setFetchQueue([...fetchQueue, controller]); setFetchQueue([...fetchQueue, controller]);
try { try {
const isDisplayChart = displayType === "chart"; const isDisplayChart = displayType === "chart";
let seriesLimit = showAllSeries ? Infinity : (+stateSeriesLimits[displayType] || Infinity); const defaultLimit = showAllSeries ? Infinity : (+stateSeriesLimits[displayType] || Infinity);
let seriesLimit = defaultLimit;
const tempData: MetricBase[] = []; const tempData: MetricBase[] = [];
const tempTraces: Trace[] = []; const tempTraces: Trace[] = [];
let counter = 1; let counter = 1;
let totalLength = 0; let totalLength = 0;
let isHistogramResult = false;
for await (const url of fetchUrl) { for await (const url of fetchUrl) {
@ -115,9 +117,8 @@ export const useFetchQuery = ({
tempTraces.push(trace); tempTraces.push(trace);
} }
const isHistogramResult = isDisplayChart && isHistogramData(resp.data.result); isHistogramResult = isDisplayChart && isHistogramData(resp.data.result);
if (resp.data.result.length) setIsHistogram(isHistogramResult); seriesLimit = isHistogramResult ? Infinity : Math.max(totalLength, defaultLimit);
if (isHistogramResult) seriesLimit = Infinity;
const freeTempSize = seriesLimit - tempData.length; const freeTempSize = seriesLimit - tempData.length;
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => { resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
d.group = counter; d.group = counter;
@ -136,6 +137,7 @@ export const useFetchQuery = ({
setWarning(totalLength > seriesLimit ? limitText : ""); setWarning(totalLength > seriesLimit ? limitText : "");
isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]); isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]);
setTraces(tempTraces); setTraces(tempTraces);
setIsHistogram(totalLength ? isHistogramResult: false);
} catch (e) { } catch (e) {
if (e instanceof Error && e.name !== "AbortError") { if (e instanceof Error && e.name !== "AbortError") {
setError(`${e.name}: ${e.message}`); setError(`${e.name}: ${e.message}`);

View File

@ -6,7 +6,7 @@ import { useQueryState } from "../../../state/query/QueryStateContext";
import { displayTypeTabs } from "../DisplayTypeSwitch"; import { displayTypeTabs } from "../DisplayTypeSwitch";
import { compactObject } from "../../../utils/object"; import { compactObject } from "../../../utils/object";
import { useGraphState } from "../../../state/graph/GraphStateContext"; import { useGraphState } from "../../../state/graph/GraphStateContext";
import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject"; import { useSearchParams } from "react-router-dom";
export const useSetQueryParams = () => { export const useSetQueryParams = () => {
const { tenantId } = useAppState(); const { tenantId } = useAppState();
@ -14,7 +14,7 @@ export const useSetQueryParams = () => {
const { query } = useQueryState(); const { query } = useQueryState();
const { duration, relativeTime, period: { date, step } } = useTimeState(); const { duration, relativeTime, period: { date, step } } = useTimeState();
const { customStep } = useGraphState(); const { customStep } = useGraphState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject(); const [, setSearchParams] = useSearchParams();
const setSearchParamsFromState = () => { const setSearchParamsFromState = () => {
const params: Record<string, unknown> = {}; const params: Record<string, unknown> = {};
@ -30,7 +30,7 @@ export const useSetQueryParams = () => {
if ((step !== customStep) && customStep) params[`${group}.step_input`] = customStep; if ((step !== customStep) && customStep) params[`${group}.step_input`] = customStep;
}); });
setSearchParamsFromKeys(compactObject(params) as Record<string, string>); setSearchParams(compactObject(params) as Record<string, string>);
}; };
useEffect(setSearchParamsFromState, [tenantId, displayType, query, duration, relativeTime, date, step, customStep]); useEffect(setSearchParamsFromState, [tenantId, displayType, query, duration, relativeTime, date, step, customStep]);

View File

@ -2,7 +2,7 @@ import React, { FC, useEffect, useMemo, KeyboardEvent } from "react";
import { useFetchTopQueries } from "./hooks/useFetchTopQueries"; import { useFetchTopQueries } from "./hooks/useFetchTopQueries";
import Spinner from "../../components/Main/Spinner/Spinner"; import Spinner from "../../components/Main/Spinner/Spinner";
import TopQueryPanel from "./TopQueryPanel/TopQueryPanel"; import TopQueryPanel from "./TopQueryPanel/TopQueryPanel";
import { formatPrettyNumber } from "../../utils/uplot/helpers"; import { formatPrettyNumber } from "../../utils/uplot";
import { isSupportedDuration } from "../../utils/time"; import { isSupportedDuration } from "../../utils/time";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { TopQueryStats } from "../../types"; import { TopQueryStats } from "../../types";

View File

@ -1,4 +1,5 @@
import { MetricBase } from "../api/types"; import { MetricBase } from "../api/types";
export * from "./uplot";
declare global { declare global {
interface Window { interface Window {

View File

@ -1,4 +1,16 @@
import uPlot, { Series } from "uplot"; import { Axis, Series } from "uplot";
export interface SeriesItemStats {
min: string,
max: string,
median: string,
last: string
}
export interface SeriesItem extends Series {
freeFormFields: {[key: string]: string};
calculations: SeriesItemStats
}
export interface HideSeriesArgs { export interface HideSeriesArgs {
hideSeries: string[], hideSeries: string[],
@ -7,13 +19,9 @@ export interface HideSeriesArgs {
series: Series[] series: Series[]
} }
export interface DragArgs { export type MinMax = { min: number, max: number }
e: MouseEvent | TouchEvent,
u: uPlot, export type SetMinMax = ({ min, max }: MinMax) => void
factor: number,
setPanning: (enable: boolean) => void,
setPlotScale: ({ min, max }: { min: number, max: number }) => void
}
export interface LegendItemType { export interface LegendItemType {
group: number; group: number;
@ -53,3 +61,7 @@ export interface Fill {
} }
export type ArrayRGB = [number, number, number] export type ArrayRGB = [number, number, number]
export interface AxisExtend extends Axis {
_size?: number;
}

View File

@ -1,4 +1,4 @@
import { ArrayRGB } from "./uplot/types"; import { ArrayRGB } from "../types";
export const baseContrastColors = [ export const baseContrastColors = [
"#e54040", "#e54040",

View File

@ -2,9 +2,10 @@ import uPlot, { Axis, Series } from "uplot";
import { getMaxFromArray, getMinFromArray } from "../math"; import { getMaxFromArray, getMinFromArray } from "../math";
import { getSecondsFromDuration, roundToMilliseconds } from "../time"; import { getSecondsFromDuration, roundToMilliseconds } from "../time";
import { AxisRange } from "../../state/graph/reducer"; import { AxisRange } from "../../state/graph/reducer";
import { formatTicks, sizeAxis } from "./helpers"; import { formatTicks, getTextWidth } from "./helpers";
import { TimeParams } from "../../types"; import { TimeParams } from "../../types";
import { getCssVariable } from "../theme"; import { getCssVariable } from "../theme";
import { AxisExtend } from "../../types";
// see https://github.com/leeoniya/uPlot/tree/master/docs#axis--grid-opts // see https://github.com/leeoniya/uPlot/tree/master/docs#axis--grid-opts
const timeValues = [ const timeValues = [
@ -77,3 +78,16 @@ export const getLimitsYAxis = (values: { [key: string]: number[] }, buffer: bool
result[key] = buffer ? getMinMaxBuffer(min, max) : [min, max]; result[key] = buffer ? getMinMaxBuffer(min, max) : [min, max];
return result; return result;
}; };
export const sizeAxis = (u: uPlot, values: string[], axisIdx: number, cycleNum: number): number => {
const axis = u.axes[axisIdx] as AxisExtend;
if (cycleNum > 1) return axis._size || 60;
let axisSize = 6 + (axis?.ticks?.size || 0) + (axis.gap || 0);
const longestVal = (values ?? []).reduce((acc, val) => val?.length > acc.length ? val : acc, "");
if (longestVal != "") axisSize += getTextWidth(longestVal, "10px Arial");
return Math.ceil(axisSize);
};

View File

@ -1,34 +0,0 @@
import { DragArgs } from "./types";
export const dragChart = ({ e, factor = 0.85, u, setPanning, setPlotScale }: DragArgs): void => {
e.preventDefault();
const isMouseEvent = e instanceof MouseEvent;
setPanning(true);
const leftStart = isMouseEvent ? e.clientX : e.touches[0].clientX;
const xUnitsPerPx = u.posToVal(1, "x") - u.posToVal(0, "x");
const scXMin = u.scales.x.min || 0;
const scXMax = u.scales.x.max || 0;
const mouseMove = (e: MouseEvent | TouchEvent) => {
const isMouseEvent = e instanceof MouseEvent;
if (!isMouseEvent && e.touches.length > 1) return;
e.preventDefault();
const clientX = isMouseEvent ? e.clientX : e.touches[0].clientX;
const dx = xUnitsPerPx * ((clientX - leftStart) * factor);
setPlotScale({ min: scXMin - dx, max: scXMax - dx });
};
const mouseUp = () => {
setPanning(false);
document.removeEventListener("mousemove", mouseMove);
document.removeEventListener("mouseup", mouseUp);
document.removeEventListener("touchmove", mouseMove);
document.removeEventListener("touchend", mouseUp);
};
document.addEventListener("mousemove", mouseMove);
document.addEventListener("mouseup", mouseUp);
document.addEventListener("touchmove", mouseMove);
document.addEventListener("touchend", mouseUp);
};

View File

@ -1,27 +1,5 @@
import uPlot, { Axis } from "uplot"; import uPlot from "uplot";
import { MetricResult } from "../../api/types";
export const defaultOptions = {
legend: {
show: false
},
cursor: {
drag: {
x: true,
y: false
},
focus: {
prox: 30
},
points: {
size: 5.6,
width: 1.4
},
bind: {
click: (): null => null,
dblclick: (): null => null,
},
},
};
export const formatTicks = (u: uPlot, ticks: number[], unit = ""): string[] => { export const formatTicks = (u: uPlot, ticks: number[], unit = ""): string[] => {
const min = ticks[0]; const min = ticks[0];
@ -32,7 +10,11 @@ export const formatTicks = (u: uPlot, ticks: number[], unit = ""): string[] => {
return ticks.map(v => `${formatPrettyNumber(v, min, max)} ${unit}`); return ticks.map(v => `${formatPrettyNumber(v, min, max)} ${unit}`);
}; };
export const formatPrettyNumber = (n: number | null | undefined, min: number | null | undefined, max: number | null | undefined): string => { export const formatPrettyNumber = (
n: number | null | undefined,
min: number | null | undefined,
max: number | null | undefined
): string => {
if (n === undefined || n === null) { if (n === undefined || n === null) {
return ""; return "";
} }
@ -59,11 +41,7 @@ export const formatPrettyNumber = (n: number | null | undefined, min: number | n
}); });
}; };
interface AxisExtend extends Axis { export const getTextWidth = (val: string, font: string): number => {
_size?: number;
}
const getTextWidth = (val: string, font: string): number => {
const span = document.createElement("span"); const span = document.createElement("span");
span.innerText = val; span.innerText = val;
span.style.cssText = `position: absolute; z-index: -1; pointer-events: none; opacity: 0; font: ${font}`; span.style.cssText = `position: absolute; z-index: -1; pointer-events: none; opacity: 0; font: ${font}`;
@ -73,17 +51,17 @@ const getTextWidth = (val: string, font: string): number => {
return width; return width;
}; };
export const sizeAxis = (u: uPlot, values: string[], axisIdx: number, cycleNum: number): number => { export const getDashLine = (group: number): number[] => {
const axis = u.axes[axisIdx] as AxisExtend; return group <= 1 ? [] : [group*4, group*1.2];
if (cycleNum > 1) return axis._size || 60;
let axisSize = 6 + (axis?.ticks?.size || 0) + (axis.gap || 0);
const longestVal = (values ?? []).reduce((acc, val) => val?.length > acc.length ? val : acc, "");
if (longestVal != "") axisSize += getTextWidth(longestVal, "10px Arial");
return Math.ceil(axisSize);
}; };
export const getDashLine = (group: number): number[] => group <= 1 ? [] : [group*4, group*1.2]; export const getMetricName = (metricItem: MetricResult) => {
const metric = metricItem?.metric || {};
const labelNames = Object.keys(metric).filter(x => x != "__name__");
const labels = labelNames.map(key => `${key}=${JSON.stringify(metric[key])}`);
let metricName = metric["__name__"] || "";
if (labels.length > 0) {
metricName += "{" + labels.join(",") + "}";
}
return metricName;
};

View File

@ -0,0 +1,7 @@
import uPlot from "uplot";
export const delHooks = (u: uPlot) => {
Object.keys(u.hooks).forEach(hook => {
u.hooks[hook as keyof uPlot.Hooks.Arrays] = [];
});
};

View File

@ -0,0 +1,7 @@
export * from "./axes";
export * from "./heatmap";
export * from "./helpers";
export * from "./hooks";
export * from "./instnance";
export * from "./scales";
export * from "./series";

View File

@ -0,0 +1,43 @@
import uPlot, { Options as uPlotOptions } from "uplot";
import { delSeries } from "./series";
import { delHooks } from "./hooks";
import dayjs from "dayjs";
import { dateFromSeconds, formatDateForNativeInput } from "../time";
interface InstanceOptions {
width?: number,
height?: number
}
export const getDefaultOptions = ({ width = 400, height = 500 }: InstanceOptions): uPlotOptions => ({
width,
height,
series: [],
tzDate: ts => dayjs(formatDateForNativeInput(dateFromSeconds(ts))).local().toDate(),
legend: {
show: false
},
cursor: {
drag: {
x: true,
y: false
},
focus: {
prox: 30
},
points: {
size: 5.6,
width: 1.4
},
bind: {
click: (): null => null,
dblclick: (): null => null,
},
},
});
export const handleDestroy = (u: uPlot) => {
delSeries(u);
delHooks(u);
u.setData([]);
};

View File

@ -1,22 +1,16 @@
import uPlot, { Range, Scale, Scales } from "uplot"; import uPlot, { Range, Scale, Scales } from "uplot";
import { getMinMaxBuffer } from "./axes"; import { getMinMaxBuffer } from "./axes";
import { YaxisState } from "../../state/graph/reducer"; import { YaxisState } from "../../state/graph/reducer";
import { MinMax, SetMinMax } from "../../types";
interface XRangeType { export const getRangeX = ({ min, max }: MinMax): Range.MinMax => [min, max];
min: number,
max: number
}
export const getRangeX = (xRange: XRangeType): Range.MinMax => {
return [xRange.min, xRange.max];
};
export const getRangeY = (u: uPlot, min = 0, max = 1, axis: string, yaxis: YaxisState): Range.MinMax => { export const getRangeY = (u: uPlot, min = 0, max = 1, axis: string, yaxis: YaxisState): Range.MinMax => {
if (yaxis.limits.enable) return yaxis.limits.range[axis]; if (yaxis.limits.enable) return yaxis.limits.range[axis];
return getMinMaxBuffer(min, max); return getMinMaxBuffer(min, max);
}; };
export const getScales = (yaxis: YaxisState, xRange: XRangeType): Scales => { export const getScales = (yaxis: YaxisState, xRange: MinMax): Scales => {
const scales: { [key: string]: { range: Scale.Range } } = { x: { range: () => getRangeX(xRange) } }; const scales: { [key: string]: { range: Scale.Range } } = { x: { range: () => getRangeX(xRange) } };
const ranges = Object.keys(yaxis.limits.range); const ranges = Object.keys(yaxis.limits.range);
(ranges.length ? ranges : ["1"]).forEach(axis => { (ranges.length ? ranges : ["1"]).forEach(axis => {
@ -24,3 +18,9 @@ export const getScales = (yaxis: YaxisState, xRange: XRangeType): Scales => {
}); });
return scales; return scales;
}; };
export const setSelect = (setPlotScale: SetMinMax) => (u: uPlot) => {
const min = u.posToVal(u.select.left, "x");
const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({ min, max });
};

View File

@ -1,22 +1,11 @@
import { MetricResult } from "../../api/types"; import { MetricResult } from "../../api/types";
import { Series } from "uplot"; import uPlot, { Series as uPlotSeries } from "uplot";
import { getNameForMetric, promValueToNumber } from "../metric"; import { getNameForMetric, promValueToNumber } from "../metric";
import { BarSeriesItem, Disp, Fill, LegendItemType, Stroke } from "./types"; import { HideSeriesArgs, BarSeriesItem, Disp, Fill, LegendItemType, Stroke, SeriesItem } from "../../types";
import { HideSeriesArgs } from "./types";
import { baseContrastColors, getColorFromString } from "../color"; import { baseContrastColors, getColorFromString } from "../color";
import { getMedianFromArray, getMaxFromArray, getMinFromArray, getLastFromArray } from "../math"; import { getMedianFromArray, getMaxFromArray, getMinFromArray, getLastFromArray } from "../math";
import { formatPrettyNumber } from "./helpers"; import { formatPrettyNumber } from "./helpers";
export interface SeriesItem extends Series {
freeFormFields: {[key: string]: string};
calculations: {
min: string,
max: string,
median: string,
last: string
}
}
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[]) => { export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[]) => {
const colorState: {[key: string]: string} = {}; const colorState: {[key: string]: string} = {};
const calculations = data.map(d => { const calculations = data.map(d => {
@ -108,3 +97,15 @@ export const barDisp = (stroke: Stroke, fill: Fill): Disp => {
fill: fill fill: fill
}; };
}; };
export const delSeries = (u: uPlot) => {
for (let i = u.series.length - 1; i >= 0; i--) {
u.delSeries(i);
}
};
export const addSeries = (u: uPlot, series: uPlotSeries[]) => {
series.forEach((s) => {
u.addSeries(s);
});
};