mirror of
https://github.com/grafana/grafana.git
synced 2025-02-20 11:48:34 -06:00
956 lines
27 KiB
TypeScript
956 lines
27 KiB
TypeScript
import { MutableRefObject, RefObject } from 'react';
|
|
import uPlot, { Cursor } from 'uplot';
|
|
|
|
import {
|
|
DashboardCursorSync,
|
|
DataFrameType,
|
|
DataHoverClearEvent,
|
|
DataHoverEvent,
|
|
DataHoverPayload,
|
|
EventBus,
|
|
formattedValueToString,
|
|
getValueFormat,
|
|
GrafanaTheme2,
|
|
incrRoundDn,
|
|
incrRoundUp,
|
|
TimeRange,
|
|
} from '@grafana/data';
|
|
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema';
|
|
import { UPlotConfigBuilder } from '@grafana/ui';
|
|
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
|
|
import { HeatmapCellLayout } from 'app/features/transformers/calculateHeatmap/models.gen';
|
|
|
|
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
|
|
|
|
import { HeatmapData } from './fields';
|
|
import { PanelFieldConfig, YAxisConfig } from './models.gen';
|
|
|
|
interface PathbuilderOpts {
|
|
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
|
|
gap?: number | null;
|
|
hideLE?: number;
|
|
hideGE?: number;
|
|
xAlign?: -1 | 0 | 1;
|
|
yAlign?: -1 | 0 | 1;
|
|
ySizeDivisor?: number;
|
|
disp: {
|
|
fill: {
|
|
values: (u: uPlot, seriesIndex: number) => number[];
|
|
index: Array<CanvasRenderingContext2D['fillStyle']>;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface PointsBuilderOpts {
|
|
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
|
|
}
|
|
|
|
export interface HeatmapHoverEvent {
|
|
seriesIdx: number;
|
|
dataIdx: number;
|
|
pageX: number;
|
|
pageY: number;
|
|
}
|
|
|
|
export interface HeatmapZoomEvent {
|
|
xMin: number;
|
|
xMax: number;
|
|
}
|
|
|
|
interface PrepConfigOpts {
|
|
dataRef: RefObject<HeatmapData>;
|
|
theme: GrafanaTheme2;
|
|
eventBus: EventBus;
|
|
onhover?: null | ((evt?: HeatmapHoverEvent | null) => void);
|
|
onclick?: null | ((evt?: Object) => void);
|
|
onzoom?: null | ((evt: HeatmapZoomEvent) => void);
|
|
isToolTipOpen: MutableRefObject<boolean>;
|
|
timeZone: string;
|
|
getTimeRange: () => TimeRange;
|
|
palette: string[];
|
|
exemplarColor: string;
|
|
cellGap?: number | null; // in css pixels
|
|
hideLE?: number;
|
|
hideGE?: number;
|
|
yAxisConfig: YAxisConfig;
|
|
ySizeDivisor?: number;
|
|
sync?: () => DashboardCursorSync;
|
|
}
|
|
|
|
export function prepConfig(opts: PrepConfigOpts) {
|
|
const {
|
|
dataRef,
|
|
theme,
|
|
eventBus,
|
|
onhover,
|
|
onclick,
|
|
onzoom,
|
|
isToolTipOpen,
|
|
timeZone,
|
|
getTimeRange,
|
|
palette,
|
|
cellGap,
|
|
hideLE,
|
|
hideGE,
|
|
yAxisConfig,
|
|
ySizeDivisor,
|
|
sync,
|
|
} = opts;
|
|
|
|
const xScaleKey = 'x';
|
|
const xScaleUnit = 'time';
|
|
|
|
const pxRatio = devicePixelRatio;
|
|
|
|
let heatmapType = dataRef.current?.heatmap?.meta?.type;
|
|
const exemplarFillColor = theme.visualization.getColorByName(opts.exemplarColor);
|
|
|
|
let qt: Quadtree;
|
|
let hRect: Rect | null;
|
|
|
|
let builder = new UPlotConfigBuilder(timeZone);
|
|
|
|
let rect: DOMRect;
|
|
|
|
builder.addHook('init', (u) => {
|
|
u.root.querySelectorAll<HTMLElement>('.u-cursor-pt').forEach((el) => {
|
|
Object.assign(el.style, {
|
|
borderRadius: '0',
|
|
border: '1px solid white',
|
|
background: 'transparent',
|
|
});
|
|
});
|
|
|
|
onclick &&
|
|
u.over.addEventListener(
|
|
'mouseup',
|
|
(e) => {
|
|
// @ts-ignore
|
|
let isDragging: boolean = u.cursor.drag._x || u.cursor.drag._y;
|
|
|
|
if (!isDragging) {
|
|
onclick(e);
|
|
}
|
|
},
|
|
true
|
|
);
|
|
});
|
|
|
|
onzoom &&
|
|
builder.addHook('setSelect', (u) => {
|
|
onzoom({
|
|
xMin: u.posToVal(u.select.left, xScaleKey),
|
|
xMax: u.posToVal(u.select.left + u.select.width, xScaleKey),
|
|
});
|
|
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
|
|
});
|
|
|
|
// this is a tmp hack because in mode: 2, uplot does not currently call scales.x.range() for setData() calls
|
|
// scales.x.range() typically reads back from drilled-down panelProps.timeRange via getTimeRange()
|
|
builder.addHook('setData', (u) => {
|
|
//let [min, max] = (u.scales!.x!.range! as uPlot.Range.Function)(u, 0, 100, xScaleKey);
|
|
|
|
let { min: xMin, max: xMax } = u.scales!.x;
|
|
|
|
let min = getTimeRange().from.valueOf();
|
|
let max = getTimeRange().to.valueOf();
|
|
|
|
if (xMin !== min || xMax !== max) {
|
|
queueMicrotask(() => {
|
|
u.setScale(xScaleKey, { min, max });
|
|
});
|
|
}
|
|
});
|
|
|
|
// rect of .u-over (grid area)
|
|
builder.addHook('syncRect', (u, r) => {
|
|
rect = r;
|
|
});
|
|
|
|
const payload: DataHoverPayload = {
|
|
point: {
|
|
[xScaleUnit]: null,
|
|
},
|
|
data: dataRef.current?.heatmap,
|
|
};
|
|
const hoverEvent = new DataHoverEvent(payload);
|
|
|
|
let pendingOnleave: ReturnType<typeof setTimeout> | 0;
|
|
|
|
onhover &&
|
|
builder.addHook('setLegend', (u) => {
|
|
if (u.cursor.idxs != null) {
|
|
for (let i = 0; i < u.cursor.idxs.length; i++) {
|
|
const sel = u.cursor.idxs[i];
|
|
if (sel != null) {
|
|
const { left, top } = u.cursor;
|
|
payload.rowIndex = sel;
|
|
payload.point[xScaleUnit] = u.posToVal(left!, xScaleKey);
|
|
eventBus.publish(hoverEvent);
|
|
|
|
if (!isToolTipOpen.current) {
|
|
if (pendingOnleave) {
|
|
clearTimeout(pendingOnleave);
|
|
pendingOnleave = 0;
|
|
}
|
|
onhover({
|
|
seriesIdx: i,
|
|
dataIdx: sel,
|
|
pageX: rect.left + left!,
|
|
pageY: rect.top + top!,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isToolTipOpen.current) {
|
|
// if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms)
|
|
if (!pendingOnleave) {
|
|
pendingOnleave = setTimeout(() => {
|
|
onhover(null);
|
|
payload.rowIndex = undefined;
|
|
payload.point[xScaleUnit] = null;
|
|
eventBus.publish(hoverEvent);
|
|
}, 100);
|
|
}
|
|
}
|
|
});
|
|
|
|
builder.addHook('drawClear', (u) => {
|
|
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
|
|
|
qt.clear();
|
|
|
|
// force-clear the path cache to cause drawBars() to rebuild new quadtree
|
|
u.series.forEach((s, i) => {
|
|
if (i > 0) {
|
|
// @ts-ignore
|
|
s._paths = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
builder.setMode(2);
|
|
|
|
builder.addScale({
|
|
scaleKey: xScaleKey,
|
|
isTime: true,
|
|
orientation: ScaleOrientation.Horizontal,
|
|
direction: ScaleDirection.Right,
|
|
// TODO: expand by x bucket size and layout
|
|
range: () => {
|
|
return [getTimeRange().from.valueOf(), getTimeRange().to.valueOf()];
|
|
},
|
|
});
|
|
|
|
builder.addAxis({
|
|
scaleKey: xScaleKey,
|
|
placement: AxisPlacement.Bottom,
|
|
isTime: true,
|
|
theme: theme,
|
|
timeZone,
|
|
});
|
|
|
|
const yField = dataRef.current?.heatmap?.fields[1]!;
|
|
if (!yField) {
|
|
return builder; // early abort (avoids error)
|
|
}
|
|
|
|
// eslint-ignore @typescript-eslint/no-explicit-any
|
|
const yFieldConfig = yField.config?.custom as PanelFieldConfig | undefined;
|
|
const yScale = yFieldConfig?.scaleDistribution ?? { type: ScaleDistribution.Linear };
|
|
const yAxisReverse = Boolean(yAxisConfig.reverse);
|
|
const isSparseHeatmap = heatmapType === DataFrameType.HeatmapCells && !isHeatmapCellsDense(dataRef.current?.heatmap!);
|
|
const shouldUseLogScale = yScale.type !== ScaleDistribution.Linear || isSparseHeatmap;
|
|
const isOrdinalY = readHeatmapRowsCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null;
|
|
|
|
// random to prevent syncing y in other heatmaps
|
|
// TODO: try to match TimeSeries y keygen algo to sync with TimeSeries panels (when not isOrdinalY)
|
|
const yScaleKey = 'y_' + (Math.random() + 1).toString(36).substring(7);
|
|
|
|
builder.addScale({
|
|
scaleKey: yScaleKey,
|
|
isTime: false,
|
|
// distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet
|
|
orientation: ScaleOrientation.Vertical,
|
|
direction: yAxisReverse ? ScaleDirection.Down : ScaleDirection.Up,
|
|
// should be tweakable manually
|
|
distribution: shouldUseLogScale ? ScaleDistribution.Log : ScaleDistribution.Linear,
|
|
log: yScale.log ?? 2,
|
|
range:
|
|
// sparse already accounts for le/ge by explicit yMin & yMax cell bounds, so no need to expand y range
|
|
isSparseHeatmap
|
|
? (u, dataMin, dataMax) => {
|
|
let scaleMin: number | null, scaleMax: number | null;
|
|
|
|
[scaleMin, scaleMax] = shouldUseLogScale
|
|
? uPlot.rangeLog(dataMin, dataMax, (yScale.log ?? 2) as unknown as uPlot.Scale.LogBase, true)
|
|
: [dataMin, dataMax];
|
|
|
|
if (shouldUseLogScale && !isOrdinalY) {
|
|
let yExp = u.scales[yScaleKey].log!;
|
|
let log = yExp === 2 ? Math.log2 : Math.log10;
|
|
|
|
let { min: explicitMin, max: explicitMax } = yAxisConfig;
|
|
|
|
// guard against <= 0
|
|
if (explicitMin != null && explicitMin > 0) {
|
|
// snap to magnitude
|
|
let minLog = log(explicitMin);
|
|
scaleMin = yExp ** incrRoundDn(minLog, 1);
|
|
}
|
|
|
|
if (explicitMax != null && explicitMax > 0) {
|
|
let maxLog = log(explicitMax);
|
|
scaleMax = yExp ** incrRoundUp(maxLog, 1);
|
|
}
|
|
}
|
|
|
|
return [scaleMin, scaleMax];
|
|
}
|
|
: // dense and ordinal only have one of yMin|yMax|y, so expand range by one cell in the direction of le/ge/unknown
|
|
(u, dataMin, dataMax) => {
|
|
let scaleMin = dataMin,
|
|
scaleMax = dataMax;
|
|
|
|
let { min: explicitMin, max: explicitMax } = yAxisConfig;
|
|
|
|
// logarithmic expansion
|
|
if (shouldUseLogScale) {
|
|
let yExp = u.scales[yScaleKey].log!;
|
|
|
|
let minExpanded = false;
|
|
let maxExpanded = false;
|
|
|
|
let log = yExp === 2 ? Math.log2 : Math.log10;
|
|
|
|
if (ySizeDivisor !== 1) {
|
|
let minLog = log(dataMin);
|
|
let maxLog = log(dataMax);
|
|
|
|
if (!Number.isInteger(minLog)) {
|
|
scaleMin = yExp ** incrRoundDn(minLog, 1);
|
|
minExpanded = true;
|
|
}
|
|
|
|
if (!Number.isInteger(maxLog)) {
|
|
scaleMax = yExp ** incrRoundUp(maxLog, 1);
|
|
maxExpanded = true;
|
|
}
|
|
}
|
|
|
|
if (dataRef.current?.yLayout === HeatmapCellLayout.le) {
|
|
if (!minExpanded) {
|
|
scaleMin /= yExp;
|
|
}
|
|
} else if (dataRef.current?.yLayout === HeatmapCellLayout.ge) {
|
|
if (!maxExpanded) {
|
|
scaleMax *= yExp;
|
|
}
|
|
} else {
|
|
scaleMin /= yExp / 2;
|
|
scaleMax *= yExp / 2;
|
|
}
|
|
|
|
if (!isOrdinalY) {
|
|
// guard against <= 0
|
|
if (explicitMin != null && explicitMin > 0) {
|
|
// snap down to magnitude
|
|
let minLog = log(explicitMin);
|
|
scaleMin = yExp ** incrRoundDn(minLog, 1);
|
|
}
|
|
|
|
if (explicitMax != null && explicitMax > 0) {
|
|
let maxLog = log(explicitMax);
|
|
scaleMax = yExp ** incrRoundUp(maxLog, 1);
|
|
}
|
|
}
|
|
}
|
|
// linear expansion
|
|
else {
|
|
let bucketSize = dataRef.current?.yBucketSize;
|
|
|
|
if (bucketSize === 0) {
|
|
bucketSize = 1;
|
|
}
|
|
|
|
if (bucketSize) {
|
|
if (dataRef.current?.yLayout === HeatmapCellLayout.le) {
|
|
scaleMin -= bucketSize!;
|
|
} else if (dataRef.current?.yLayout === HeatmapCellLayout.ge) {
|
|
scaleMax += bucketSize!;
|
|
} else {
|
|
scaleMin -= bucketSize! / 2;
|
|
scaleMax += bucketSize! / 2;
|
|
}
|
|
} else {
|
|
// how to expand scale range if inferred non-regular or log buckets?
|
|
}
|
|
|
|
if (!isOrdinalY) {
|
|
scaleMin = explicitMin ?? scaleMin;
|
|
scaleMax = explicitMax ?? scaleMax;
|
|
}
|
|
}
|
|
|
|
return [scaleMin, scaleMax];
|
|
},
|
|
});
|
|
|
|
const dispY = yField.display ?? getValueFormat('short');
|
|
|
|
builder.addAxis({
|
|
scaleKey: yScaleKey,
|
|
show: yAxisConfig.axisPlacement !== AxisPlacement.Hidden,
|
|
placement: yAxisConfig.axisPlacement || AxisPlacement.Left,
|
|
size: yAxisConfig.axisWidth || null,
|
|
label: yAxisConfig.axisLabel,
|
|
theme: theme,
|
|
formatValue: (v, decimals) => formattedValueToString(dispY(v, decimals)),
|
|
splits: isOrdinalY
|
|
? (self: uPlot) => {
|
|
const meta = readHeatmapRowsCustomMeta(dataRef.current?.heatmap);
|
|
if (!meta.yOrdinalDisplay) {
|
|
return [0, 1]; //?
|
|
}
|
|
let splits = meta.yOrdinalDisplay.map((v, idx) => idx);
|
|
|
|
switch (dataRef.current?.yLayout) {
|
|
case HeatmapCellLayout.le:
|
|
splits.unshift(-1);
|
|
break;
|
|
case HeatmapCellLayout.ge:
|
|
splits.push(splits.length);
|
|
break;
|
|
}
|
|
|
|
// Skip labels when the height is too small
|
|
if (self.height < 60) {
|
|
splits = [splits[0], splits[splits.length - 1]];
|
|
} else {
|
|
while (splits.length > 3 && (self.height - 15) / splits.length < 10) {
|
|
splits = splits.filter((v, idx) => idx % 2 === 0); // remove half the items
|
|
}
|
|
}
|
|
return splits;
|
|
}
|
|
: undefined,
|
|
values: isOrdinalY
|
|
? (self: uPlot, splits) => {
|
|
const meta = readHeatmapRowsCustomMeta(dataRef.current?.heatmap);
|
|
if (meta.yOrdinalDisplay) {
|
|
return splits.map((v) =>
|
|
v < 0
|
|
? meta.yMinDisplay ?? '' // Check prometheus style labels
|
|
: meta.yOrdinalDisplay[v] ?? ''
|
|
);
|
|
}
|
|
return splits;
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
const pathBuilder = isSparseHeatmap ? heatmapPathsSparse : heatmapPathsDense;
|
|
|
|
// heatmap layer
|
|
builder.addSeries({
|
|
facets: [
|
|
{
|
|
scale: xScaleKey,
|
|
auto: true,
|
|
sorted: 1,
|
|
},
|
|
{
|
|
scale: yScaleKey,
|
|
auto: true,
|
|
},
|
|
],
|
|
pathBuilder: pathBuilder({
|
|
each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
|
|
qt.add({
|
|
x: x - u.bbox.left,
|
|
y: y - u.bbox.top,
|
|
w: xSize,
|
|
h: ySize,
|
|
sidx: seriesIdx,
|
|
didx: dataIdx,
|
|
});
|
|
},
|
|
gap: cellGap,
|
|
hideLE,
|
|
hideGE,
|
|
xAlign:
|
|
dataRef.current?.xLayout === HeatmapCellLayout.le
|
|
? -1
|
|
: dataRef.current?.xLayout === HeatmapCellLayout.ge
|
|
? 1
|
|
: 0,
|
|
yAlign: ((dataRef.current?.yLayout === HeatmapCellLayout.le
|
|
? -1
|
|
: dataRef.current?.yLayout === HeatmapCellLayout.ge
|
|
? 1
|
|
: 0) * (yAxisReverse ? -1 : 1)) as -1 | 0 | 1,
|
|
ySizeDivisor,
|
|
disp: {
|
|
fill: {
|
|
values: (u, seriesIdx) => {
|
|
let countFacetIdx = !isSparseHeatmap ? 2 : 3;
|
|
return valuesToFills(
|
|
u.data[seriesIdx][countFacetIdx] as unknown as number[],
|
|
palette,
|
|
dataRef.current?.minValue!,
|
|
dataRef.current?.maxValue!
|
|
);
|
|
},
|
|
index: palette,
|
|
},
|
|
},
|
|
}),
|
|
theme,
|
|
scaleKey: '', // facets' scales used (above)
|
|
});
|
|
|
|
// exemplars layer
|
|
builder.addSeries({
|
|
facets: [
|
|
{
|
|
scale: xScaleKey,
|
|
auto: true,
|
|
sorted: 1,
|
|
},
|
|
{
|
|
scale: yScaleKey,
|
|
auto: true,
|
|
},
|
|
],
|
|
pathBuilder: heatmapPathsPoints(
|
|
{
|
|
each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => {
|
|
qt.add({
|
|
x: x - u.bbox.left,
|
|
y: y - u.bbox.top,
|
|
w: xSize,
|
|
h: ySize,
|
|
sidx: seriesIdx,
|
|
didx: dataIdx,
|
|
});
|
|
},
|
|
},
|
|
exemplarFillColor
|
|
),
|
|
theme,
|
|
scaleKey: '', // facets' scales used (above)
|
|
});
|
|
|
|
const cursor: Cursor = {
|
|
drag: {
|
|
x: true,
|
|
y: false,
|
|
setScale: false,
|
|
},
|
|
dataIdx: (u, seriesIdx) => {
|
|
if (seriesIdx === 1) {
|
|
hRect = null;
|
|
|
|
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)) {
|
|
hRect = o;
|
|
}
|
|
});
|
|
}
|
|
|
|
return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
|
|
},
|
|
points: {
|
|
fill: 'rgba(255,255,255, 0.3)',
|
|
bbox: (u, seriesIdx) => {
|
|
let isHovered = hRect && seriesIdx === hRect.sidx;
|
|
|
|
return {
|
|
left: isHovered ? hRect!.x / pxRatio : -10,
|
|
top: isHovered ? hRect!.y / pxRatio : -10,
|
|
width: isHovered ? hRect!.w / pxRatio : 0,
|
|
height: isHovered ? hRect!.h / pxRatio : 0,
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
if (sync && sync() !== DashboardCursorSync.Off) {
|
|
cursor.sync = {
|
|
key: '__global_',
|
|
scales: [xScaleKey, yScaleKey],
|
|
filters: {
|
|
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
|
|
if (x < 0) {
|
|
payload.point[xScaleUnit] = null;
|
|
eventBus.publish(new DataHoverClearEvent());
|
|
} else {
|
|
payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
|
|
eventBus.publish(hoverEvent);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
},
|
|
};
|
|
|
|
builder.setSync();
|
|
}
|
|
|
|
builder.setCursor(cursor);
|
|
|
|
return builder;
|
|
}
|
|
|
|
const CRISP_EDGES_GAP_MIN = 4;
|
|
|
|
export function heatmapPathsDense(opts: PathbuilderOpts) {
|
|
const { disp, each, gap = 1, hideLE = -Infinity, hideGE = Infinity, xAlign = 1, yAlign = 1, ySizeDivisor = 1 } = 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
|
|
) => {
|
|
let d = u.data[seriesIdx];
|
|
const xs = d[0] as unknown as number[];
|
|
const ys = d[1] as unknown as number[];
|
|
const counts = d[2] as unknown as number[];
|
|
const dlen = xs.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());
|
|
|
|
// detect x and y bin qtys by detecting layout repetition in x & y data
|
|
let yBinQty = dlen - ys.lastIndexOf(ys[0]);
|
|
let xBinQty = dlen / yBinQty;
|
|
let yBinIncr = ys[1] - ys[0] || scaleY.max! - scaleY.min!;
|
|
let xBinIncr = xs[yBinQty] - xs[0];
|
|
|
|
// uniform tile sizes based on zoom level
|
|
let xSize: number;
|
|
let ySize: number;
|
|
|
|
if (scaleX.distr === 3) {
|
|
xSize = Math.abs(valToPosX(xs[0] * scaleX.log!, scaleX, xDim, xOff) - valToPosX(xs[0], scaleX, xDim, xOff));
|
|
} else {
|
|
xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff));
|
|
}
|
|
|
|
if (scaleY.distr === 3) {
|
|
ySize =
|
|
Math.abs(valToPosY(ys[0] * scaleY.log!, scaleY, yDim, yOff) - valToPosY(ys[0], scaleY, yDim, yOff)) /
|
|
ySizeDivisor;
|
|
} else {
|
|
ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)) / ySizeDivisor;
|
|
}
|
|
|
|
// clamp min tile size to 1px
|
|
xSize = Math.max(1, round(xSize - cellGap));
|
|
ySize = Math.max(1, round(ySize - cellGap));
|
|
|
|
// bucket agg direction
|
|
// let xCeil = false;
|
|
// let yCeil = false;
|
|
|
|
let xOffset = xAlign === -1 ? -xSize : xAlign === 0 ? -xSize / 2 : 0;
|
|
let yOffset = yAlign === 1 ? -ySize : yAlign === 0 ? -ySize / 2 : 0;
|
|
|
|
// pre-compute x and y offsets
|
|
let cys = ys.slice(0, yBinQty).map((y) => round(valToPosY(y, scaleY, yDim, yOff) + yOffset));
|
|
let cxs = Array.from({ length: xBinQty }, (v, i) =>
|
|
round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset)
|
|
);
|
|
|
|
for (let i = 0; i < dlen; i++) {
|
|
if (counts[i] > hideLE && counts[i] < hideGE) {
|
|
let cx = cxs[~~(i / yBinQty)];
|
|
let cy = cys[i % yBinQty];
|
|
|
|
let fillPath = fillPaths[fills[i]];
|
|
|
|
rect(fillPath, cx, cy, xSize, ySize);
|
|
|
|
each(u, 1, i, cx, cy, 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();
|
|
|
|
return null;
|
|
}
|
|
);
|
|
|
|
return null;
|
|
};
|
|
}
|
|
|
|
export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: string) {
|
|
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 points = new Path2D();
|
|
let fillPaths = [points];
|
|
let fillPalette = [exemplarColor ?? 'rgba(255,0,255,0.7)'];
|
|
|
|
for (let i = 0; i < dataX.length; i++) {
|
|
let yVal = dataY[i]!;
|
|
yVal -= 0.5; // center vertically in bucket (when tiles are le)
|
|
// y-randomize vertically to distribute exemplars in same bucket at same time
|
|
let randSign = Math.round(Math.random()) * 2 - 1;
|
|
yVal += randSign * 0.5 * Math.random();
|
|
|
|
let x = valToPosX(dataX[i], scaleX, xDim, xOff);
|
|
let y = valToPosY(yVal, scaleY, yDim, yOff);
|
|
let w = 8;
|
|
let h = 8;
|
|
|
|
rect(points, x - w / 2, y - h / 2, w, h);
|
|
|
|
opts.each(u, seriesIdx, i, x - w / 2, y - h / 2, w, h);
|
|
}
|
|
|
|
u.ctx.save();
|
|
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();
|
|
}
|
|
);
|
|
|
|
return null;
|
|
};
|
|
}
|
|
// accepts xMax, yMin, yMax, count
|
|
// xbinsize? x tile sizes are uniform?
|
|
export function heatmapPathsSparse(opts: PathbuilderOpts) {
|
|
const { disp, each, gap = 1, hideLE = -Infinity, hideGE = Infinity } = 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] <= hideLE || counts[i] >= hideGE) {
|
|
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;
|
|
|
|
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 boundedMinMax = (
|
|
values: number[],
|
|
minValue?: number,
|
|
maxValue?: number,
|
|
hideLE = -Infinity,
|
|
hideGE = Infinity
|
|
) => {
|
|
if (minValue == null) {
|
|
minValue = Infinity;
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
if (values[i] > hideLE && values[i] < hideGE) {
|
|
minValue = Math.min(minValue, values[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (maxValue == null) {
|
|
maxValue = -Infinity;
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
if (values[i] > hideLE && values[i] < hideGE) {
|
|
maxValue = Math.max(maxValue, values[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [minValue, maxValue];
|
|
};
|
|
|
|
export const valuesToFills = (values: number[], palette: string[], minValue: number, maxValue: number) => {
|
|
let range = Math.max(maxValue - minValue, 1);
|
|
|
|
let paletteSize = palette.length;
|
|
|
|
let indexedFills = Array(values.length);
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
indexedFills[i] =
|
|
values[i] < minValue
|
|
? 0
|
|
: values[i] > maxValue
|
|
? paletteSize - 1
|
|
: Math.min(paletteSize - 1, Math.floor((paletteSize * (values[i] - minValue)) / range));
|
|
}
|
|
|
|
return indexedFills;
|
|
};
|