mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Timeline & StatusGrid: cleanups (#34250)
* remove text alignment, per-box hover for grid, fix mergeValues * unconditionally set spanNulls = -1 * fix stroke width offset math * split multi-hover, so only single mark overlays in grid mode * restore alignValue in state-timeline * better descriptions, maybe * init field.config.custom if necessary * don't show last out-of-view value Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
@@ -11,11 +11,11 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT
|
||||
mode: TimelineMode;
|
||||
rowHeight: number;
|
||||
showValue: BarValueVisibility;
|
||||
alignValue: TimelineValueAlignment;
|
||||
alignValue?: TimelineValueAlignment;
|
||||
colWidth?: number;
|
||||
}
|
||||
|
||||
const propsToDiff = ['mode', 'rowHeight', 'colWidth', 'showValue', 'alignValue'];
|
||||
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue'];
|
||||
|
||||
export class TimelineChart extends React.Component<TimelineProps> {
|
||||
static contextType = PanelContextRoot;
|
||||
|
||||
@@ -41,12 +41,17 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Stat
|
||||
})
|
||||
.setPanelOptions((builder) => {
|
||||
builder
|
||||
.addBooleanSwitch({
|
||||
path: 'mergeValues',
|
||||
name: 'Merge equal consecutive values',
|
||||
defaultValue: defaultPanelOptions.mergeValues,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'showValue',
|
||||
name: 'Show values',
|
||||
settings: {
|
||||
options: [
|
||||
//{ value: BarValueVisibility.Auto, label: 'Auto' },
|
||||
{ value: BarValueVisibility.Auto, label: 'Auto' },
|
||||
{ value: BarValueVisibility.Always, label: 'Always' },
|
||||
{ value: BarValueVisibility.Never, label: 'Never' },
|
||||
],
|
||||
@@ -55,7 +60,7 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Stat
|
||||
})
|
||||
.addRadio({
|
||||
path: 'alignValue',
|
||||
name: 'Align value',
|
||||
name: 'Align values',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: 'left', label: 'Left' },
|
||||
@@ -65,11 +70,6 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Stat
|
||||
},
|
||||
defaultValue: defaultPanelOptions.alignValue,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'mergeValues',
|
||||
name: 'Merge equal consecutive values',
|
||||
defaultValue: defaultPanelOptions.mergeValues,
|
||||
})
|
||||
.addSliderInput({
|
||||
path: 'rowHeight',
|
||||
name: 'Row height',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"description": "State over time",
|
||||
"description": "State changes and durations",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
|
||||
@@ -9,6 +9,8 @@ import tinycolor from 'tinycolor2';
|
||||
|
||||
const { round, min, ceil } = Math;
|
||||
|
||||
const textPadding = 2;
|
||||
|
||||
const pxRatio = devicePixelRatio;
|
||||
|
||||
const laneDistr = SPACE_BETWEEN;
|
||||
@@ -25,8 +27,6 @@ function walk(rowHeight: number, yIdx: number | null, count: number, dim: number
|
||||
}
|
||||
|
||||
interface TimelineBoxRect extends Rect {
|
||||
left: number;
|
||||
strokeWidth: number;
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
@@ -35,12 +35,12 @@ interface TimelineBoxRect extends Rect {
|
||||
*/
|
||||
export interface TimelineCoreOptions {
|
||||
mode: TimelineMode;
|
||||
alignValue?: TimelineValueAlignment;
|
||||
numSeries: number;
|
||||
rowHeight: number;
|
||||
colWidth?: number;
|
||||
theme: GrafanaTheme2;
|
||||
showValue: BarValueVisibility;
|
||||
alignValue: TimelineValueAlignment;
|
||||
isDiscrete: (seriesIdx: number) => boolean;
|
||||
getValueColor: (seriesIdx: number, value: any) => string;
|
||||
label: (seriesIdx: number) => string;
|
||||
@@ -62,10 +62,10 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
rowHeight = 0,
|
||||
colWidth = 0,
|
||||
showValue,
|
||||
alignValue,
|
||||
theme,
|
||||
label,
|
||||
formatValue,
|
||||
alignValue = 'left',
|
||||
getTimeRange,
|
||||
getValueColor,
|
||||
getFieldConfig,
|
||||
@@ -150,9 +150,7 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
h: boxHeight,
|
||||
sidx: seriesIdx + 1,
|
||||
didx: valueIdx,
|
||||
// These two are needed for later text positioning
|
||||
left: left,
|
||||
strokeWidth,
|
||||
// for computing label contrast
|
||||
fillColor,
|
||||
});
|
||||
|
||||
@@ -235,7 +233,7 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
yOff,
|
||||
left,
|
||||
round(yOff + y0),
|
||||
right - left - 2,
|
||||
right - left,
|
||||
round(height),
|
||||
strokeWidth,
|
||||
iy,
|
||||
@@ -279,7 +277,10 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
}
|
||||
});
|
||||
|
||||
discrete && drawBoxes(u.ctx);
|
||||
if (discrete) {
|
||||
u.ctx.lineWidth = strokeWidth;
|
||||
drawBoxes(u.ctx);
|
||||
}
|
||||
|
||||
u.ctx.restore();
|
||||
}
|
||||
@@ -297,28 +298,15 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
u.ctx.clip();
|
||||
|
||||
u.ctx.font = font;
|
||||
u.ctx.textAlign = alignValue;
|
||||
u.ctx.textAlign = mode === TimelineMode.Changes ? alignValue : 'center';
|
||||
u.ctx.textBaseline = 'middle';
|
||||
|
||||
uPlot.orient(
|
||||
u,
|
||||
sidx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect
|
||||
) => {
|
||||
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => {
|
||||
let strokeWidth = round((series.width || 0) * pxRatio);
|
||||
|
||||
let y = round(yOff + yMids[sidx - 1]);
|
||||
|
||||
for (let ix = 0; ix < dataY.length; ix++) {
|
||||
@@ -326,14 +314,29 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
const boxRect = boxRectsBySeries[sidx - 1][ix];
|
||||
|
||||
// Todo refine this to better know when to not render text (when values do not fit)
|
||||
if (!boxRect || boxRect.w < 20) {
|
||||
if (!boxRect || (showValue === BarValueVisibility.Auto && boxRect.w < 25)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = getTextPositionOffet(boxRect, alignValue);
|
||||
if (boxRect.x >= xDim) {
|
||||
continue; // out of view
|
||||
}
|
||||
|
||||
// center-aligned
|
||||
let x = round(boxRect.x + xOff + boxRect.w / 2);
|
||||
const txt = formatValue(sidx, dataY[ix]);
|
||||
|
||||
if (mode === TimelineMode.Changes) {
|
||||
if (alignValue === 'left') {
|
||||
x = round(boxRect.x + xOff + strokeWidth + textPadding);
|
||||
} else if (alignValue === 'right') {
|
||||
x = round(boxRect.x + xOff + boxRect.w - strokeWidth - textPadding);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: cache by fillColor to avoid setting ctx for label
|
||||
u.ctx.fillStyle = theme.colors.getContrastText(boxRect.fillColor, 3);
|
||||
u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y);
|
||||
u.ctx.fillText(txt, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,14 +368,28 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
});
|
||||
};
|
||||
|
||||
const setCursor = (u: uPlot) => {
|
||||
let cx = round(u.cursor!.left! * pxRatio);
|
||||
function setHoverMark(i: number, o: Rect | null) {
|
||||
let h = hoverMarks[i];
|
||||
|
||||
if (o) {
|
||||
h.style.display = '';
|
||||
h.style.left = round(o!.x / pxRatio) + 'px';
|
||||
h.style.top = round(o!.y / pxRatio) + 'px';
|
||||
h.style.width = round(o!.w / pxRatio) + 'px';
|
||||
h.style.height = round(o!.h / pxRatio) + 'px';
|
||||
} else {
|
||||
h.style.display = 'none';
|
||||
}
|
||||
|
||||
hovered[i] = o;
|
||||
}
|
||||
|
||||
function hoverMulti(cx: number, cy: number) {
|
||||
for (let i = 0; i < numSeries; i++) {
|
||||
let found: Rect | null = null;
|
||||
|
||||
if (cx >= 0) {
|
||||
let cy = yMids[i];
|
||||
cy = yMids[i];
|
||||
|
||||
qt.get(cx, cy, 1, 1, (o) => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
@@ -381,28 +398,44 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
let h = hoverMarks[i];
|
||||
|
||||
if (found) {
|
||||
if (found !== hovered[i]) {
|
||||
hovered[i] = found;
|
||||
|
||||
h.style.display = '';
|
||||
h.style.left = round(found!.x / pxRatio) + 'px';
|
||||
h.style.top = round(found!.y / pxRatio) + 'px';
|
||||
h.style.width = round(found!.w / pxRatio) + 'px';
|
||||
h.style.height = round(found!.h / pxRatio) + 'px';
|
||||
setHoverMark(i, found);
|
||||
}
|
||||
} else if (hovered[i] != null) {
|
||||
h.style.display = 'none';
|
||||
hovered[i] = null;
|
||||
setHoverMark(i, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hoverOne(cx: number, cy: number) {
|
||||
let found: Rect | null = null;
|
||||
|
||||
qt.get(cx, cy, 1, 1, (o) => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
found = o;
|
||||
}
|
||||
});
|
||||
|
||||
if (found) {
|
||||
setHoverMark(0, found);
|
||||
} else if (hovered[0] != null) {
|
||||
setHoverMark(0, null);
|
||||
}
|
||||
}
|
||||
|
||||
const doHover = mode === TimelineMode.Changes ? hoverMulti : hoverOne;
|
||||
|
||||
const setCursor = (u: uPlot) => {
|
||||
let cx = round(u.cursor!.left! * pxRatio);
|
||||
let cy = round(u.cursor!.top! * pxRatio);
|
||||
doHover(cx, cy);
|
||||
};
|
||||
|
||||
// hide y crosshair & hover points
|
||||
const cursor: Partial<Cursor> = {
|
||||
y: false,
|
||||
x: mode === TimelineMode.Changes,
|
||||
points: { show: false },
|
||||
};
|
||||
|
||||
@@ -480,19 +513,6 @@ export function getConfig(opts: TimelineCoreOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function getTextPositionOffet(rect: TimelineBoxRect, alignValue: TimelineValueAlignment) {
|
||||
// left or right aligned values shift 2 pixels inside edge
|
||||
const textPadding = alignValue === 'left' ? 2 : alignValue === 'right' ? -2 : 0;
|
||||
const { left, w, strokeWidth } = rect;
|
||||
|
||||
return (
|
||||
left +
|
||||
strokeWidth / 2 +
|
||||
(alignValue === 'center' ? w / 2 - strokeWidth / 2 : alignValue === 'right' ? w - strokeWidth / 2 : 0) +
|
||||
textPadding
|
||||
);
|
||||
}
|
||||
|
||||
function getFillColor(fieldConfig: TimelineFieldConfig, color: string) {
|
||||
const opacityPercent = (fieldConfig.fillOpacity ?? 100) / 100;
|
||||
return tinycolor(color).setAlpha(opacityPercent).toString();
|
||||
|
||||
@@ -9,9 +9,13 @@ export interface TimelineOptions {
|
||||
legend: VizLegendOptions;
|
||||
showValue: BarValueVisibility;
|
||||
rowHeight: number;
|
||||
|
||||
// only used for "samples" mode (status-grid)
|
||||
colWidth?: number;
|
||||
alignValue: TimelineValueAlignment;
|
||||
// only used in "changes" mode (state-timeline)
|
||||
mergeValues?: boolean;
|
||||
// only used in "changes" mode (state-timeline)
|
||||
alignValue?: TimelineValueAlignment;
|
||||
}
|
||||
|
||||
export type TimelineValueAlignment = 'center' | 'left' | 'right';
|
||||
@@ -28,9 +32,9 @@ export interface TimelineFieldConfig extends HideableFieldConfig {
|
||||
* @alpha
|
||||
*/
|
||||
export const defaultPanelOptions: Partial<TimelineOptions> = {
|
||||
showValue: BarValueVisibility.Always,
|
||||
mergeValues: true,
|
||||
showValue: BarValueVisibility.Auto,
|
||||
alignValue: 'left',
|
||||
mergeValues: true,
|
||||
rowHeight: 0.9,
|
||||
};
|
||||
|
||||
@@ -46,6 +50,8 @@ export const defaultTimelineFieldConfig: TimelineFieldConfig = {
|
||||
* @alpha
|
||||
*/
|
||||
export enum TimelineMode {
|
||||
// state-timeline
|
||||
Changes = 'changes',
|
||||
// status-grid
|
||||
Samples = 'samples',
|
||||
}
|
||||
|
||||
@@ -250,6 +250,9 @@ export function prepareTimelineFields(
|
||||
case FieldType.number:
|
||||
case FieldType.boolean:
|
||||
case FieldType.string:
|
||||
// magic value for join() to leave nulls alone
|
||||
(field.config.custom = field.config.custom ?? {}).spanNulls = -1;
|
||||
|
||||
if (mergeValues) {
|
||||
let merged = unsetSameFutureValues(field.values.toArray());
|
||||
if (merged) {
|
||||
|
||||
@@ -41,7 +41,6 @@ export const StatusGridPanel: React.FC<TimelinePanelProps> = ({
|
||||
{...options}
|
||||
// hardcoded
|
||||
mode={TimelineMode.Samples}
|
||||
alignValue="center"
|
||||
>
|
||||
{(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
|
||||
</TimelineChart>
|
||||
|
||||
@@ -46,12 +46,12 @@ export const plugin = new PanelPlugin<StatusPanelOptions, StatusFieldConfig>(Sta
|
||||
name: 'Show values',
|
||||
settings: {
|
||||
options: [
|
||||
//{ value: BarValueVisibility.Auto, label: 'Auto' },
|
||||
{ value: BarValueVisibility.Auto, label: 'Auto' },
|
||||
{ value: BarValueVisibility.Always, label: 'Always' },
|
||||
{ value: BarValueVisibility.Never, label: 'Never' },
|
||||
],
|
||||
},
|
||||
defaultValue: BarValueVisibility.Always,
|
||||
defaultValue: BarValueVisibility.Auto,
|
||||
})
|
||||
.addSliderInput({
|
||||
path: 'rowHeight',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"description": "System status map",
|
||||
"description": "Periodic status history",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
|
||||
Reference in New Issue
Block a user