diff --git a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx index 842f1b2febc..328fb389228 100644 --- a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx +++ b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx @@ -1,9 +1,19 @@ import React, { useEffect, useRef } from 'react'; -import { DataFrameView, Field, FieldType, formattedValueToString, getFieldDisplayName, LinkModel } from '@grafana/data'; +import { + DataFrameType, + DataFrameView, + Field, + FieldType, + formattedValueToString, + getFieldDisplayName, + LinkModel, +} from '@grafana/data'; import { LinkButton, VerticalGroup } from '@grafana/ui'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DataHoverView } from '../geomap/components/DataHoverView'; + import { BucketLayout, HeatmapData } from './fields'; import { HeatmapHoverEvent } from './utils'; @@ -177,6 +187,14 @@ export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => { return
EXEMPLARS: {JSON.stringify(exemplarIndex)}
; }; + if (data.heatmap?.meta?.type === DataFrameType.HeatmapSparse) { + return ( +
+ +
+ ); + } + return ( <>
diff --git a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx index 7814e421a1e..b10672a459a 100644 --- a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data'; +import { DataFrameType, GrafanaTheme2, PanelProps, reduceField, ReducerID, TimeRange } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { Portal, @@ -16,7 +16,7 @@ import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { HeatmapHoverView } from './HeatmapHoverView'; -import { HeatmapData, prepareHeatmapData } from './fields'; +import { prepareHeatmapData } from './fields'; import { PanelOptions } from './models.gen'; import { quantizeScheme } from './palettes'; import { HeatmapHoverEvent, prepConfig } from './utils'; @@ -76,7 +76,7 @@ export const HeatmapPanel: React.FC = ({ ); // ugh - const dataRef = useRef(info); + const dataRef = useRef(info); dataRef.current = info; const builder = useMemo(() => { @@ -103,13 +103,15 @@ export const HeatmapPanel: React.FC = ({ return null; } - const field = info.heatmap.fields[2]; - const { min, max } = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] }); + let heatmapType = dataRef.current?.heatmap?.meta?.type; + let countFieldIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3; + const countField = info.heatmap.fields[countFieldIdx]; + + const { min, max } = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] }); let hoverValue: number | undefined = undefined; if (hover && info.heatmap.fields) { - const countField = info.heatmap.fields[2]; - hoverValue = countField?.values.get(hover.index); + hoverValue = countField.values.get(hover.index); } return ( diff --git a/public/app/plugins/panel/heatmap-new/fields.ts b/public/app/plugins/panel/heatmap-new/fields.ts index ed2fa8bca83..7deab890255 100644 --- a/public/app/plugins/panel/heatmap-new/fields.ts +++ b/public/app/plugins/panel/heatmap-new/fields.ts @@ -68,6 +68,11 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), exemplars, theme); } + let sparseCellsHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapSparse); + if (sparseCellsHeatmap) { + return getSparseHeatmapData(sparseCellsHeatmap, exemplars, theme); + } + // Find a well defined heatmap let scanlinesHeatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapScanlines); if (scanlinesHeatmap) { @@ -84,12 +89,8 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme }; } - let first = frames[0]; - if (first.meta?.type === DataFrameType.HeatmapSparse) { - return getSparseHeatmapData(first, exemplars, theme); - } - if (source === HeatmapSourceMode.Data) { + let first = frames[0]; if (first.meta?.type !== DataFrameType.HeatmapScanlines) { first = bucketsToScanlines(frames[0]); } diff --git a/public/app/plugins/panel/heatmap-new/models.gen.ts b/public/app/plugins/panel/heatmap-new/models.gen.ts index 71ddfb346de..a8c2302a528 100644 --- a/public/app/plugins/panel/heatmap-new/models.gen.ts +++ b/public/app/plugins/panel/heatmap-new/models.gen.ts @@ -30,7 +30,7 @@ export interface HeatmapColorOptions { fill: string; // when opacity mode, the target color scale: HeatmapColorScale; // for opacity mode exponent: number; // when scale== sqrt - steps: number; // 2-256 + steps: number; // 2-128 // Clamp the colors to the value range field?: string; @@ -80,7 +80,7 @@ export const defaultPanelOptions: PanelOptions = { show: true, yHistogram: false, }, - cellGap: 3, + cellGap: 1, }; export interface PanelFieldConfig extends HideableFieldConfig { diff --git a/public/app/plugins/panel/heatmap-new/utils.ts b/public/app/plugins/panel/heatmap-new/utils.ts index 11539a30693..148083f63d0 100644 --- a/public/app/plugins/panel/heatmap-new/utils.ts +++ b/public/app/plugins/panel/heatmap-new/utils.ts @@ -1,8 +1,8 @@ import { MutableRefObject, RefObject } from 'react'; import uPlot from 'uplot'; -import { GrafanaTheme2, TimeRange } from '@grafana/data'; -import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema'; +import { DataFrameType, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema'; import { UPlotConfigBuilder } from '@grafana/ui'; import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; @@ -63,6 +63,10 @@ export function prepConfig(opts: PrepConfigOpts) { hideThreshold, } = opts; + const pxRatio = devicePixelRatio; + + let heatmapType = dataRef.current?.heatmap?.meta?.type; + let qt: Quadtree; let hRect: Rect | null; @@ -191,23 +195,34 @@ export function prepConfig(opts: PrepConfigOpts) { theme: theme, }); + const shouldUseLogScale = heatmapType === DataFrameType.HeatmapSparse; + builder.addScale({ scaleKey: 'y', isTime: false, // distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet orientation: ScaleOrientation.Vertical, direction: ScaleDirection.Up, - range: (u, dataMin, dataMax) => { - let bucketSize = dataRef.current?.yBucketSize; + // should be tweakable manually + distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear, + log: 2, + range: shouldUseLogScale + ? undefined + : (u, dataMin, dataMax) => { + let bucketSize = dataRef.current?.yBucketSize; - if (dataRef.current?.yLayout === BucketLayout.le) { - dataMin -= bucketSize!; - } else { - dataMax += bucketSize!; - } + if (bucketSize) { + if (dataRef.current?.yLayout === BucketLayout.le) { + dataMin -= bucketSize!; + } else { + dataMax += bucketSize!; + } + } else { + // how to expand scale range if inferred non-regular or log buckets? + } - return [dataMin, dataMax]; - }, + return [dataMin, dataMax]; + }, }); const hasLabeledY = dataRef.current?.yAxisValues != null; @@ -247,6 +262,8 @@ export function prepConfig(opts: PrepConfigOpts) { : undefined, }); + let pathBuilder = heatmapType === DataFrameType.HeatmapScanlines ? heatmapPathsDense : heatmapPathsSparse; + builder.addSeries({ facets: [ { @@ -259,7 +276,7 @@ export function prepConfig(opts: PrepConfigOpts) { auto: true, }, ], - pathBuilder: heatmapPaths({ + pathBuilder: pathBuilder({ each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => { qt.add({ x: x - u.bbox.left, @@ -276,7 +293,10 @@ export function prepConfig(opts: PrepConfigOpts) { yCeil: dataRef.current?.yLayout === BucketLayout.le, disp: { fill: { - values: (u, seriesIdx) => countsToFills(u, seriesIdx, palette), + values: (u, seriesIdx) => { + let countFacetIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3; + return countsToFills(u.data[seriesIdx][countFacetIdx] as unknown as number[], palette); + }, index: palette, }, }, @@ -295,8 +315,8 @@ export function prepConfig(opts: PrepConfigOpts) { if (seriesIdx === 1) { hRect = null; - let cx = u.cursor.left! * devicePixelRatio; - let cy = u.cursor.top! * devicePixelRatio; + let cx = u.cursor.left! * pxRatio; + let cy = u.cursor.top! * pxRatio; qt.get(cx, cy, 1, 1, (o) => { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { @@ -313,10 +333,10 @@ export function prepConfig(opts: PrepConfigOpts) { let isHovered = hRect && seriesIdx === hRect.sidx; return { - left: isHovered ? hRect!.x / devicePixelRatio : -10, - top: isHovered ? hRect!.y / devicePixelRatio : -10, - width: isHovered ? hRect!.w / devicePixelRatio : 0, - height: isHovered ? hRect!.h / devicePixelRatio : 0, + left: isHovered ? hRect!.x / pxRatio : -10, + top: isHovered ? hRect!.y / pxRatio : -10, + width: isHovered ? hRect!.w / pxRatio : 0, + height: isHovered ? hRect!.h / pxRatio : 0, }; }, }, @@ -325,8 +345,16 @@ export function prepConfig(opts: PrepConfigOpts) { return builder; } -export function heatmapPaths(opts: PathbuilderOpts) { - const { disp, each, gap, hideThreshold = 0, xCeil = false, yCeil = false } = opts; +const CRISP_EDGES_GAP_MIN = 4; + +export function heatmapPathsDense(opts: PathbuilderOpts) { + const { disp, each, gap = 1, hideThreshold = 0, xCeil = false, yCeil = false } = opts; + + const pxRatio = devicePixelRatio; + + const round = gap! >= CRISP_EDGES_GAP_MIN ? Math.round : (v: number) => v; + + const cellGap = Math.round(gap! * pxRatio); return (u: uPlot, seriesIdx: number) => { uPlot.orient( @@ -372,15 +400,9 @@ export function heatmapPaths(opts: PathbuilderOpts) { let xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff)); let ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)); - const autoGapFactor = 0.05; - - // tile gap control - let xGap = gap != null ? gap * devicePixelRatio : Math.max(0, autoGapFactor * Math.min(xSize, ySize)); - let yGap = xGap; - // clamp min tile size to 1px - xSize = Math.max(1, Math.round(xSize - xGap)); - ySize = Math.max(1, Math.round(ySize - yGap)); + xSize = Math.max(1, round(xSize - cellGap)); + ySize = Math.max(1, round(ySize - cellGap)); // bucket agg direction // let xCeil = false; @@ -390,9 +412,9 @@ export function heatmapPaths(opts: PathbuilderOpts) { let yOffset = yCeil ? 0 : -ySize; // pre-compute x and y offsets - let cys = ys.slice(0, yBinQty).map((y) => Math.round(valToPosY(y, scaleY, yDim, yOff) + yOffset)); + let cys = ys.slice(0, yBinQty).map((y) => round(valToPosY(y, scaleY, yDim, yOff) + yOffset)); let cxs = Array.from({ length: xBinQty }, (v, i) => - Math.round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset) + round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset) ); for (let i = 0; i < dlen; i++) { @@ -431,9 +453,136 @@ export function heatmapPaths(opts: PathbuilderOpts) { }; } -export const countsToFills = (u: uPlot, seriesIdx: number, palette: string[]) => { - let counts = u.data[seriesIdx][2] as unknown as number[]; +// accepts xMax, yMin, yMax, count +// xbinsize? x tile sizes are uniform? +export function heatmapPathsSparse(opts: PathbuilderOpts) { + const { disp, each, gap = 1, hideThreshold = 0 } = opts; + const pxRatio = devicePixelRatio; + + const round = gap! >= CRISP_EDGES_GAP_MIN ? Math.round : (v: number) => v; + + const cellGap = Math.round(gap! * pxRatio); + + return (u: uPlot, seriesIdx: number) => { + uPlot.orient( + u, + seriesIdx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + arc + ) => { + //console.time('heatmapPathsSparse'); + + let d = u.data[seriesIdx]; + const xMaxs = d[0] as unknown as number[]; // xMax, do we get interval? + const yMins = d[1] as unknown as number[]; + const yMaxs = d[2] as unknown as number[]; + const counts = d[3] as unknown as number[]; + const dlen = xMaxs.length; + + // fill colors are mapped from interpolating densities / counts along some gradient + // (should be quantized to 64 colors/levels max. e.g. 16) + let fills = disp.fill.values(u, seriesIdx); + let fillPalette = disp.fill.index ?? [...new Set(fills)]; + + let fillPaths = fillPalette.map((color) => new Path2D()); + + // cache all tile bounds + let xOffs = new Map(); + let yOffs = new Map(); + + for (let i = 0; i < xMaxs.length; i++) { + let xMax = xMaxs[i]; + let yMin = yMins[i]; + let yMax = yMaxs[i]; + + if (!xOffs.has(xMax)) { + xOffs.set(xMax, round(valToPosX(xMax, scaleX, xDim, xOff))); + } + + if (!yOffs.has(yMin)) { + yOffs.set(yMin, round(valToPosY(yMin, scaleY, yDim, yOff))); + } + + if (!yOffs.has(yMax)) { + yOffs.set(yMax, round(valToPosY(yMax, scaleY, yDim, yOff))); + } + } + + // uniform x size (interval, step) + let xSizeUniform = xOffs.get(xMaxs.find((v) => v !== xMaxs[0])) - xOffs.get(xMaxs[0]); + + for (let i = 0; i < dlen; i++) { + if (counts[i] <= hideThreshold) { + continue; + } + + let xMax = xMaxs[i]; + let yMin = yMins[i]; + let yMax = yMaxs[i]; + + let xMaxPx = xOffs.get(xMax); // xSize is from interval, or inferred delta? + let yMinPx = yOffs.get(yMin); + let yMaxPx = yOffs.get(yMax); + + let xSize = xSizeUniform; + let ySize = yMinPx - yMaxPx; + + // clamp min tile size to 1px + xSize = Math.max(1, xSize - cellGap); + ySize = Math.max(1, ySize - cellGap); + + let x = xMaxPx; + let y = yMinPx; + + // filter out 0 counts and out of view + // if ( + // xs[i] + xBinIncr >= scaleX.min! && + // xs[i] - xBinIncr <= scaleX.max! && + // ys[i] + yBinIncr >= scaleY.min! && + // ys[i] - yBinIncr <= scaleY.max! + // ) { + let fillPath = fillPaths[fills[i]]; + + rect(fillPath, x, y, xSize, ySize); + + each(u, 1, i, x, y, xSize, ySize); + // } + } + + u.ctx.save(); + // u.ctx.globalAlpha = 0.8; + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + fillPaths.forEach((p, i) => { + u.ctx.fillStyle = fillPalette[i]; + u.ctx.fill(p); + }); + u.ctx.restore(); + + //console.timeEnd('heatmapPathsSparse'); + + return null; + } + ); + }; +} + +export const countsToFills = (counts: number[], palette: string[]) => { // TODO: integrate 1e-9 hideThreshold? const hideThreshold = 0;