heatmap.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import { map } from 'rxjs';
  2. import {
  3. ArrayVector,
  4. DataFrame,
  5. DataTransformerID,
  6. FieldType,
  7. incrRoundUp,
  8. incrRoundDn,
  9. SynchronousDataTransformerInfo,
  10. DataFrameType,
  11. getFieldDisplayName,
  12. Field,
  13. getValueFormat,
  14. formattedValueToString,
  15. durationToMilliseconds,
  16. parseDuration,
  17. } from '@grafana/data';
  18. import { ScaleDistribution } from '@grafana/schema';
  19. import { HeatmapCellLayout, HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen';
  20. import { niceLinearIncrs, niceTimeIncrs } from './utils';
  21. export interface HeatmapTransformerOptions extends HeatmapCalculationOptions {
  22. /** the raw values will still exist in results after transformation */
  23. keepOriginalData?: boolean;
  24. }
  25. export const heatmapTransformer: SynchronousDataTransformerInfo<HeatmapTransformerOptions> = {
  26. id: DataTransformerID.heatmap,
  27. name: 'Create heatmap',
  28. description: 'calculate heatmap from source data',
  29. defaultOptions: {},
  30. operator: (options) => (source) => source.pipe(map((data) => heatmapTransformer.transformer(options)(data))),
  31. transformer: (options: HeatmapTransformerOptions) => {
  32. return (data: DataFrame[]) => {
  33. const v = calculateHeatmapFromData(data, options);
  34. if (options.keepOriginalData) {
  35. return [v, ...data];
  36. }
  37. return [v];
  38. };
  39. },
  40. };
  41. function parseNumeric(v?: string | null) {
  42. return v === '+Inf' ? Infinity : v === '-Inf' ? -Infinity : +(v ?? 0);
  43. }
  44. export function sortAscStrInf(aName?: string | null, bName?: string | null) {
  45. return parseNumeric(aName) - parseNumeric(bName);
  46. }
  47. export interface HeatmapRowsCustomMeta {
  48. /** This provides the lookup values */
  49. yOrdinalDisplay: string[];
  50. yOrdinalLabel?: string[];
  51. yMatchWithLabel?: string;
  52. yMinDisplay?: string;
  53. }
  54. /** simple utility to get heatmap metadata from a frame */
  55. export function readHeatmapRowsCustomMeta(frame?: DataFrame): HeatmapRowsCustomMeta {
  56. return (frame?.meta?.custom ?? {}) as HeatmapRowsCustomMeta;
  57. }
  58. export function isHeatmapCellsDense(frame: DataFrame) {
  59. let foundY = false;
  60. for (let field of frame.fields) {
  61. // dense heatmap frames can only have one of these fields
  62. switch (field.name) {
  63. case 'y':
  64. case 'yMin':
  65. case 'yMax':
  66. if (foundY) {
  67. return false;
  68. }
  69. foundY = true;
  70. }
  71. }
  72. return foundY;
  73. }
  74. export interface RowsHeatmapOptions {
  75. frame: DataFrame;
  76. value?: string; // the field value name
  77. unit?: string;
  78. decimals?: number;
  79. layout?: HeatmapCellLayout;
  80. }
  81. /** Given existing buckets, create a values style frame */
  82. // Assumes frames have already been sorted ASC and de-accumulated.
  83. export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame {
  84. // TODO: handle null-filling w/ fields[0].config.interval?
  85. const xField = opts.frame.fields[0];
  86. const xValues = xField.values.toArray();
  87. const yFields = opts.frame.fields.filter((f, idx) => f.type === FieldType.number && idx > 0);
  88. // similar to initBins() below
  89. const len = xValues.length * yFields.length;
  90. const xs = new Array(len);
  91. const ys = new Array(len);
  92. const counts2 = new Array(len);
  93. const counts = yFields.map((field) => field.values.toArray().slice());
  94. // transpose
  95. counts.forEach((bucketCounts, bi) => {
  96. for (let i = 0; i < bucketCounts.length; i++) {
  97. counts2[counts.length * i + bi] = bucketCounts[i];
  98. }
  99. });
  100. const bucketBounds = Array.from({ length: yFields.length }, (v, i) => i);
  101. // fill flat/repeating array
  102. for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) {
  103. ys[i] = bucketBounds[yi];
  104. if (yi === 0 && i >= bucketBounds.length) {
  105. xi++;
  106. }
  107. xs[i] = xValues[xi];
  108. }
  109. // this name determines whether cells are drawn above, below, or centered on the values
  110. let ordinalFieldName = yFields[0].labels?.le != null ? 'yMax' : 'y';
  111. switch (opts.layout) {
  112. case HeatmapCellLayout.le:
  113. ordinalFieldName = 'yMax';
  114. break;
  115. case HeatmapCellLayout.ge:
  116. ordinalFieldName = 'yMin';
  117. break;
  118. case HeatmapCellLayout.unknown:
  119. ordinalFieldName = 'y';
  120. break;
  121. }
  122. const custom: HeatmapRowsCustomMeta = {
  123. yOrdinalDisplay: yFields.map((f) => getFieldDisplayName(f, opts.frame)),
  124. yMatchWithLabel: Object.keys(yFields[0].labels ?? {})[0],
  125. };
  126. if (custom.yMatchWithLabel) {
  127. custom.yOrdinalLabel = yFields.map((f) => f.labels?.[custom.yMatchWithLabel!] ?? '');
  128. if (custom.yMatchWithLabel === 'le') {
  129. custom.yMinDisplay = '0.0';
  130. }
  131. }
  132. // Format the labels as a value
  133. // TODO: this leaves the internally prepended '0.0' without this formatting treatment
  134. if (opts.unit?.length || opts.decimals != null) {
  135. const fmt = getValueFormat(opts.unit ?? 'short');
  136. if (custom.yMinDisplay) {
  137. custom.yMinDisplay = formattedValueToString(fmt(0, opts.decimals));
  138. }
  139. custom.yOrdinalDisplay = custom.yOrdinalDisplay.map((name) => {
  140. let num = +name;
  141. if (!Number.isNaN(num)) {
  142. return formattedValueToString(fmt(num, opts.decimals));
  143. }
  144. return name;
  145. });
  146. }
  147. return {
  148. length: xs.length,
  149. refId: opts.frame.refId,
  150. meta: {
  151. type: DataFrameType.HeatmapCells,
  152. custom,
  153. },
  154. fields: [
  155. {
  156. name: 'xMax',
  157. type: xField.type,
  158. values: new ArrayVector(xs),
  159. config: xField.config,
  160. },
  161. {
  162. name: ordinalFieldName,
  163. type: FieldType.number,
  164. values: new ArrayVector(ys),
  165. config: {
  166. unit: 'short', // ordinal lookup
  167. },
  168. },
  169. {
  170. name: opts.value?.length ? opts.value : 'Value',
  171. type: FieldType.number,
  172. values: new ArrayVector(counts2),
  173. config: yFields[0].config,
  174. display: yFields[0].display,
  175. },
  176. ],
  177. };
  178. }
  179. // Sorts frames ASC by numeric bucket name and de-accumulates values in each frame's Value field [1]
  180. // similar to Prometheus result_transformer.ts -> transformToHistogramOverTime()
  181. export function prepBucketFrames(frames: DataFrame[]): DataFrame[] {
  182. frames = frames.slice();
  183. // sort ASC by frame.name (Prometheus bucket bound)
  184. // or use frame.fields[1].config.displayNameFromDS ?
  185. frames.sort((a, b) => sortAscStrInf(a.name, b.name));
  186. // cumulative counts
  187. const counts = frames.map((frame) => frame.fields[1].values.toArray().slice());
  188. // de-accumulate
  189. counts.reverse();
  190. counts.forEach((bucketCounts, bi) => {
  191. if (bi < counts.length - 1) {
  192. for (let i = 0; i < bucketCounts.length; i++) {
  193. bucketCounts[i] -= counts[bi + 1][i];
  194. }
  195. }
  196. });
  197. counts.reverse();
  198. return frames.map((frame, i) => ({
  199. ...frame,
  200. fields: [
  201. frame.fields[0],
  202. {
  203. ...frame.fields[1],
  204. values: new ArrayVector(counts[i]),
  205. },
  206. ],
  207. }));
  208. }
  209. export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame {
  210. //console.time('calculateHeatmapFromData');
  211. let xs: number[] = [];
  212. let ys: number[] = [];
  213. // optimization
  214. //let xMin = Infinity;
  215. //let xMax = -Infinity;
  216. let xField: Field | undefined = undefined;
  217. let yField: Field | undefined = undefined;
  218. for (let frame of frames) {
  219. // TODO: assumes numeric timestamps, ordered asc, without nulls
  220. const x = frame.fields.find((f) => f.type === FieldType.time);
  221. if (!x) {
  222. continue;
  223. }
  224. if (!xField) {
  225. xField = x; // the first X
  226. }
  227. const xValues = x.values.toArray();
  228. for (let field of frame.fields) {
  229. if (field !== x && field.type === FieldType.number) {
  230. xs = xs.concat(xValues);
  231. ys = ys.concat(field.values.toArray());
  232. if (!yField) {
  233. yField = field;
  234. }
  235. }
  236. }
  237. }
  238. if (!xField || !yField) {
  239. throw 'no heatmap fields found';
  240. }
  241. if (!xs.length || !ys.length) {
  242. throw 'no values found';
  243. }
  244. const xBucketsCfg = options.xBuckets ?? {};
  245. const yBucketsCfg = options.yBuckets ?? {};
  246. if (xBucketsCfg.scale?.type === ScaleDistribution.Log) {
  247. throw 'X axis only supports linear buckets';
  248. }
  249. const scaleDistribution = options.yBuckets?.scale ?? {
  250. type: ScaleDistribution.Linear,
  251. };
  252. const heat2d = heatmap(xs, ys, {
  253. xSorted: true,
  254. xTime: xField.type === FieldType.time,
  255. xMode: xBucketsCfg.mode,
  256. xSize: durationToMilliseconds(parseDuration(xBucketsCfg.value ?? '')),
  257. yMode: yBucketsCfg.mode,
  258. ySize: yBucketsCfg.value ? +yBucketsCfg.value : undefined,
  259. yLog: scaleDistribution?.type === ScaleDistribution.Log ? (scaleDistribution?.log as any) : undefined,
  260. });
  261. const frame = {
  262. length: heat2d.x.length,
  263. name: getFieldDisplayName(yField),
  264. meta: {
  265. type: DataFrameType.HeatmapCells,
  266. },
  267. fields: [
  268. {
  269. name: 'xMin',
  270. type: xField.type,
  271. values: new ArrayVector(heat2d.x),
  272. config: xField.config,
  273. },
  274. {
  275. name: 'yMin',
  276. type: FieldType.number,
  277. values: new ArrayVector(heat2d.y),
  278. config: {
  279. ...yField.config, // keep units from the original source
  280. custom: {
  281. scaleDistribution,
  282. },
  283. },
  284. },
  285. {
  286. name: 'Count',
  287. type: FieldType.number,
  288. values: new ArrayVector(heat2d.count),
  289. config: {
  290. unit: 'short', // always integer
  291. },
  292. },
  293. ],
  294. };
  295. //console.timeEnd('calculateHeatmapFromData');
  296. return frame;
  297. }
  298. interface HeatmapOpts {
  299. // default is 10% of data range, snapped to a "nice" increment
  300. xMode?: HeatmapCalculationMode;
  301. yMode?: HeatmapCalculationMode;
  302. xSize?: number;
  303. ySize?: number;
  304. // use Math.ceil instead of Math.floor for bucketing
  305. xCeil?: boolean;
  306. yCeil?: boolean;
  307. // log2 or log10 buckets
  308. xLog?: 2 | 10;
  309. yLog?: 2 | 10;
  310. xTime?: boolean;
  311. yTime?: boolean;
  312. // optimization hints for known data ranges (sorted, pre-scanned, etc)
  313. xMin?: number;
  314. xMax?: number;
  315. yMin?: number;
  316. yMax?: number;
  317. xSorted?: boolean;
  318. ySorted?: boolean;
  319. }
  320. // TODO: handle NaN, Inf, -Inf, null, undefined values in xs & ys
  321. function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) {
  322. let len = xs.length;
  323. let xSorted = opts?.xSorted ?? false;
  324. let ySorted = opts?.ySorted ?? false;
  325. // find x and y limits to pre-compute buckets struct
  326. let minX = xSorted ? xs[0] : Infinity;
  327. let minY = ySorted ? ys[0] : Infinity;
  328. let maxX = xSorted ? xs[len - 1] : -Infinity;
  329. let maxY = ySorted ? ys[len - 1] : -Infinity;
  330. for (let i = 0; i < len; i++) {
  331. if (!xSorted) {
  332. minX = Math.min(minX, xs[i]);
  333. maxX = Math.max(maxX, xs[i]);
  334. }
  335. if (!ySorted) {
  336. minY = Math.min(minY, ys[i]);
  337. maxY = Math.max(maxY, ys[i]);
  338. }
  339. }
  340. let yExp = opts?.yLog;
  341. if (yExp && (minY <= 0 || maxY <= 0)) {
  342. throw 'Log Y axes cannot have values <= 0';
  343. }
  344. //let scaleX = opts?.xLog === 10 ? Math.log10 : opts?.xLog === 2 ? Math.log2 : (v: number) => v;
  345. //let scaleY = opts?.yLog === 10 ? Math.log10 : opts?.yLog === 2 ? Math.log2 : (v: number) => v;
  346. let xBinIncr = opts?.xSize ?? 0;
  347. let yBinIncr = opts?.ySize ?? 0;
  348. let xMode = opts?.xMode;
  349. let yMode = opts?.yMode;
  350. // fall back to 10 buckets if invalid settings
  351. if (!Number.isFinite(xBinIncr) || xBinIncr <= 0) {
  352. xMode = HeatmapCalculationMode.Count;
  353. xBinIncr = 20;
  354. }
  355. if (!Number.isFinite(yBinIncr) || yBinIncr <= 0) {
  356. yMode = HeatmapCalculationMode.Count;
  357. yBinIncr = 10;
  358. }
  359. if (xMode === HeatmapCalculationMode.Count) {
  360. // TODO: optionally use view range min/max instead of data range for bucket sizing
  361. let approx = (maxX - minX) / Math.max(xBinIncr - 1, 1);
  362. // nice-ify
  363. let xIncrs = opts?.xTime ? niceTimeIncrs : niceLinearIncrs;
  364. let xIncrIdx = xIncrs.findIndex((bucketSize) => bucketSize > approx) - 1;
  365. xBinIncr = xIncrs[Math.max(xIncrIdx, 0)];
  366. }
  367. if (yMode === HeatmapCalculationMode.Count) {
  368. // TODO: optionally use view range min/max instead of data range for bucket sizing
  369. let approx = (maxY - minY) / Math.max(yBinIncr - 1, 1);
  370. // nice-ify
  371. let yIncrs = opts?.yTime ? niceTimeIncrs : niceLinearIncrs;
  372. let yIncrIdx = yIncrs.findIndex((bucketSize) => bucketSize > approx) - 1;
  373. yBinIncr = yIncrs[Math.max(yIncrIdx, 0)];
  374. }
  375. // console.log({
  376. // yBinIncr,
  377. // xBinIncr,
  378. // });
  379. let binX = opts?.xCeil ? (v: number) => incrRoundUp(v, xBinIncr) : (v: number) => incrRoundDn(v, xBinIncr);
  380. let binY = opts?.yCeil ? (v: number) => incrRoundUp(v, yBinIncr) : (v: number) => incrRoundDn(v, yBinIncr);
  381. if (yExp) {
  382. yBinIncr = 1 / (opts?.ySize ?? 1); // sub-divides log exponents
  383. let yLog = yExp === 2 ? Math.log2 : Math.log10;
  384. binY = opts?.yCeil ? (v: number) => incrRoundUp(yLog(v), yBinIncr) : (v: number) => incrRoundDn(yLog(v), yBinIncr);
  385. }
  386. let minXBin = binX(minX);
  387. let maxXBin = binX(maxX);
  388. let minYBin = binY(minY);
  389. let maxYBin = binY(maxY);
  390. let xBinQty = Math.round((maxXBin - minXBin) / xBinIncr) + 1;
  391. let yBinQty = Math.round((maxYBin - minYBin) / yBinIncr) + 1;
  392. let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr, yExp);
  393. for (let i = 0; i < len; i++) {
  394. const xi = (binX(xs[i]) - minXBin) / xBinIncr;
  395. const yi = (binY(ys[i]) - minYBin) / yBinIncr;
  396. const ci = xi * yBinQty + yi;
  397. counts[ci]++;
  398. }
  399. return {
  400. x: xs2,
  401. y: ys2,
  402. count: counts,
  403. };
  404. }
  405. function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number, yExp?: number) {
  406. const len = xQty * yQty;
  407. const xs = new Array<number>(len);
  408. const ys = new Array<number>(len);
  409. const counts = new Array<number>(len);
  410. for (let i = 0, yi = 0, x = xMin; i < len; yi = ++i % yQty) {
  411. counts[i] = 0;
  412. if (yExp) {
  413. ys[i] = yExp ** (yMin + yi * yIncr);
  414. } else {
  415. ys[i] = yMin + yi * yIncr;
  416. }
  417. if (yi === 0 && i >= yQty) {
  418. x += xIncr;
  419. }
  420. xs[i] = x;
  421. }
  422. return [xs, ys, counts];
  423. }