heatmap_data_converter.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. import _ from 'lodash';
  2. import { TimeSeries } from 'app/core/core';
  3. import { Bucket, HeatmapCard, HeatmapCardStats, YBucket, XBucket } from './types';
  4. const VALUE_INDEX = 0;
  5. const TIME_INDEX = 1;
  6. /**
  7. * Convert histogram represented by the list of series to heatmap object.
  8. * @param seriesList List of time series
  9. */
  10. function histogramToHeatmap(seriesList: TimeSeries[]) {
  11. const heatmap: any = {};
  12. for (let i = 0; i < seriesList.length; i++) {
  13. const series = seriesList[i];
  14. const bound = i;
  15. if (isNaN(bound)) {
  16. return heatmap;
  17. }
  18. for (const point of series.datapoints) {
  19. const count = point[VALUE_INDEX];
  20. const time = point[TIME_INDEX];
  21. if (!_.isNumber(count)) {
  22. continue;
  23. }
  24. let bucket = heatmap[time];
  25. if (!bucket) {
  26. bucket = heatmap[time] = { x: time, buckets: {} };
  27. }
  28. bucket.buckets[bound] = {
  29. y: bound,
  30. count: count,
  31. bounds: {
  32. top: null,
  33. bottom: bound,
  34. },
  35. values: [],
  36. points: [],
  37. };
  38. }
  39. }
  40. return heatmap;
  41. }
  42. /**
  43. * Sort series representing histogram by label value.
  44. */
  45. function sortSeriesByLabel(s1: { label: string }, s2: { label: string }) {
  46. let label1, label2;
  47. try {
  48. // fail if not integer. might happen with bad queries
  49. label1 = parseHistogramLabel(s1.label);
  50. label2 = parseHistogramLabel(s2.label);
  51. } catch (err) {
  52. console.log(err.message || err);
  53. return 0;
  54. }
  55. if (label1 > label2) {
  56. return 1;
  57. }
  58. if (label1 < label2) {
  59. return -1;
  60. }
  61. return 0;
  62. }
  63. function parseHistogramLabel(label: string): number {
  64. if (label === '+Inf' || label === 'inf') {
  65. return +Infinity;
  66. }
  67. const value = Number(label);
  68. if (isNaN(value)) {
  69. throw new Error(`Error parsing histogram label: ${label} is not a number`);
  70. }
  71. return value;
  72. }
  73. /**
  74. * Convert buckets into linear array of "cards" - objects, represented heatmap elements.
  75. * @param {Object} buckets
  76. * @return {Object} Array of "card" objects and stats
  77. */
  78. function convertToCards(buckets: any, hideZero = false): { cards: HeatmapCard[]; cardStats: HeatmapCardStats } {
  79. let min = 0,
  80. max = 0;
  81. const cards: HeatmapCard[] = [];
  82. _.forEach(buckets, xBucket => {
  83. _.forEach(xBucket.buckets, yBucket => {
  84. const card: HeatmapCard = {
  85. x: xBucket.x,
  86. y: yBucket.y,
  87. yBounds: yBucket.bounds,
  88. values: yBucket.values,
  89. count: yBucket.count,
  90. };
  91. if (!hideZero || card.count !== 0) {
  92. cards.push(card);
  93. }
  94. if (cards.length === 1) {
  95. min = yBucket.count;
  96. max = yBucket.count;
  97. }
  98. min = yBucket.count < min ? yBucket.count : min;
  99. max = yBucket.count > max ? yBucket.count : max;
  100. });
  101. });
  102. const cardStats = { min, max };
  103. return { cards, cardStats };
  104. }
  105. /**
  106. * Special method for log scales. When series converted into buckets with log scale,
  107. * for simplification, 0 values are converted into 0, not into -Infinity. On the other hand, we mean
  108. * that all values less than series minimum, is 0 values, and we create special "minimum" bucket for
  109. * that values (actually, there're no values less than minimum, so this bucket is empty).
  110. * 8-16| | ** | | * | **|
  111. * 4-8| * |* *|* |** *| * |
  112. * 2-4| * *| | ***| |* |
  113. * 1-2|* | | | | | This bucket contains minimum series value
  114. * 0.5-1|____|____|____|____|____| This bucket should be displayed as 0 on graph
  115. * 0|____|____|____|____|____| This bucket is for 0 values (should actually be -Infinity)
  116. * So we should merge two bottom buckets into one (0-value bucket).
  117. *
  118. * @param {Object} buckets Heatmap buckets
  119. * @param {Number} minValue Minimum series value
  120. * @return {Object} Transformed buckets
  121. */
  122. function mergeZeroBuckets(buckets: any, minValue: number) {
  123. _.forEach(buckets, xBucket => {
  124. const yBuckets = xBucket.buckets;
  125. const emptyBucket: any = {
  126. bounds: { bottom: 0, top: 0 },
  127. values: [],
  128. points: [],
  129. count: 0,
  130. };
  131. const nullBucket = yBuckets[0] || emptyBucket;
  132. const minBucket = yBuckets[minValue] || emptyBucket;
  133. const newBucket: any = {
  134. y: 0,
  135. bounds: { bottom: minValue, top: minBucket.bounds.top || minValue },
  136. values: [],
  137. points: [],
  138. count: 0,
  139. };
  140. newBucket.points = nullBucket.points.concat(minBucket.points);
  141. newBucket.values = nullBucket.values.concat(minBucket.values);
  142. newBucket.count = newBucket.values.length;
  143. if (newBucket.count === 0) {
  144. return;
  145. }
  146. delete yBuckets[minValue];
  147. yBuckets[0] = newBucket;
  148. });
  149. return buckets;
  150. }
  151. /**
  152. * Convert set of time series into heatmap buckets
  153. * @return {Object} Heatmap object:
  154. * {
  155. * xBucketBound_1: {
  156. * x: xBucketBound_1,
  157. * buckets: {
  158. * yBucketBound_1: {
  159. * y: yBucketBound_1,
  160. * bounds: {bottom, top}
  161. * values: [val_1, val_2, ..., val_K],
  162. * points: [[val_Y, val_X, series_name], ..., [...]],
  163. * seriesStat: {seriesName_1: val_1, seriesName_2: val_2}
  164. * },
  165. * ...
  166. * yBucketBound_M: {}
  167. * },
  168. * values: [val_1, val_2, ..., val_K],
  169. * points: [
  170. * [val_Y, val_X, series_name], (point_1)
  171. * ...
  172. * [...] (point_K)
  173. * ]
  174. * },
  175. * xBucketBound_2: {},
  176. * ...
  177. * xBucketBound_N: {}
  178. * }
  179. */
  180. function convertToHeatMap(seriesList: TimeSeries[], yBucketSize: number, xBucketSize: number, logBase = 1) {
  181. const heatmap = {};
  182. for (const series of seriesList) {
  183. const datapoints = series.datapoints;
  184. const seriesName = series.label;
  185. // Slice series into X axis buckets
  186. // | | ** | | * | **|
  187. // | * |* *|* |** *| * |
  188. // |** *| | ***| |* |
  189. // |____|____|____|____|____|_
  190. //
  191. _.forEach(datapoints, point => {
  192. const bucketBound = getBucketBound(point[TIME_INDEX], xBucketSize);
  193. pushToXBuckets(heatmap, point, bucketBound, seriesName);
  194. });
  195. }
  196. // Slice X axis buckets into Y (value) buckets
  197. // | **| |2|,
  198. // | * | --\ |1|,
  199. // |* | --/ |1|,
  200. // |____| |0|
  201. //
  202. _.forEach(heatmap, (xBucket: any) => {
  203. if (logBase !== 1) {
  204. xBucket.buckets = convertToLogScaleValueBuckets(xBucket, yBucketSize, logBase);
  205. } else {
  206. xBucket.buckets = convertToValueBuckets(xBucket, yBucketSize);
  207. }
  208. });
  209. return heatmap;
  210. }
  211. function pushToXBuckets(buckets: any, point: any[], bucketNum: number, seriesName: string) {
  212. const value = point[VALUE_INDEX];
  213. if (value === null || value === undefined || isNaN(value)) {
  214. return;
  215. }
  216. // Add series name to point for future identification
  217. const pointExt = _.concat(point, seriesName);
  218. if (buckets[bucketNum] && buckets[bucketNum].values) {
  219. buckets[bucketNum].values.push(value);
  220. buckets[bucketNum].points.push(pointExt);
  221. } else {
  222. buckets[bucketNum] = {
  223. x: bucketNum,
  224. values: [value],
  225. points: [pointExt],
  226. };
  227. }
  228. }
  229. function pushToYBuckets(
  230. buckets: Bucket,
  231. bucketNum: number,
  232. value: any,
  233. point: string[],
  234. bounds: { bottom: number; top: number }
  235. ) {
  236. let count = 1;
  237. // Use the 3rd argument as scale/count
  238. if (point.length > 3) {
  239. count = parseInt(point[2], 10);
  240. }
  241. if (buckets[bucketNum]) {
  242. buckets[bucketNum].values.push(value);
  243. buckets[bucketNum].points.push(point);
  244. buckets[bucketNum].count += count;
  245. } else {
  246. buckets[bucketNum] = {
  247. y: bucketNum,
  248. bounds: bounds,
  249. values: [value],
  250. points: [point],
  251. count: count,
  252. };
  253. }
  254. }
  255. function getValueBucketBound(value: any, yBucketSize: number, logBase: number) {
  256. if (logBase === 1) {
  257. return getBucketBound(value, yBucketSize);
  258. } else {
  259. return getLogScaleBucketBound(value, yBucketSize, logBase);
  260. }
  261. }
  262. /**
  263. * Find bucket for given value (for linear scale)
  264. */
  265. function getBucketBounds(value: number, bucketSize: number) {
  266. let bottom, top;
  267. bottom = Math.floor(value / bucketSize) * bucketSize;
  268. top = (Math.floor(value / bucketSize) + 1) * bucketSize;
  269. return { bottom, top };
  270. }
  271. function getBucketBound(value: number, bucketSize: number) {
  272. const bounds = getBucketBounds(value, bucketSize);
  273. return bounds.bottom;
  274. }
  275. function convertToValueBuckets(xBucket: { values: any; points: any }, bucketSize: number) {
  276. const values = xBucket.values;
  277. const points = xBucket.points;
  278. const buckets = {};
  279. _.forEach(values, (val, index) => {
  280. const bounds = getBucketBounds(val, bucketSize);
  281. const bucketNum = bounds.bottom;
  282. pushToYBuckets(buckets, bucketNum, val, points[index], bounds);
  283. });
  284. return buckets;
  285. }
  286. /**
  287. * Find bucket for given value (for log scales)
  288. */
  289. function getLogScaleBucketBounds(value: number, yBucketSplitFactor: number, logBase: number) {
  290. let top, bottom;
  291. if (value === 0) {
  292. return { bottom: 0, top: 0 };
  293. }
  294. const valueLog = logp(value, logBase);
  295. let pow, powTop;
  296. if (yBucketSplitFactor === 1 || !yBucketSplitFactor) {
  297. pow = Math.floor(valueLog);
  298. powTop = pow + 1;
  299. } else {
  300. const additionalBucketSize = 1 / yBucketSplitFactor;
  301. let additionalLog = valueLog - Math.floor(valueLog);
  302. additionalLog = Math.floor(additionalLog / additionalBucketSize) * additionalBucketSize;
  303. pow = Math.floor(valueLog) + additionalLog;
  304. powTop = pow + additionalBucketSize;
  305. }
  306. bottom = Math.pow(logBase, pow);
  307. top = Math.pow(logBase, powTop);
  308. return { bottom, top };
  309. }
  310. function getLogScaleBucketBound(value: number, yBucketSplitFactor: number, logBase: number) {
  311. const bounds = getLogScaleBucketBounds(value, yBucketSplitFactor, logBase);
  312. return bounds.bottom;
  313. }
  314. function convertToLogScaleValueBuckets(
  315. xBucket: { values: any; points: any },
  316. yBucketSplitFactor: number,
  317. logBase: number
  318. ) {
  319. const values = xBucket.values;
  320. const points = xBucket.points;
  321. const buckets = {};
  322. _.forEach(values, (val, index) => {
  323. const bounds = getLogScaleBucketBounds(val, yBucketSplitFactor, logBase);
  324. const bucketNum = bounds.bottom;
  325. pushToYBuckets(buckets, bucketNum, val, points[index], bounds);
  326. });
  327. return buckets;
  328. }
  329. /**
  330. * Logarithm for custom base
  331. * @param value
  332. * @param base logarithm base
  333. */
  334. function logp(value: number, base: number) {
  335. return Math.log(value) / Math.log(base);
  336. }
  337. /**
  338. * Calculate size of Y bucket from given buckets bounds.
  339. * @param bounds Array of Y buckets bounds
  340. * @param logBase Logarithm base
  341. */
  342. function calculateBucketSize(bounds: number[], logBase = 1): number {
  343. let bucketSize = Infinity;
  344. if (bounds.length === 0) {
  345. return 0;
  346. } else if (bounds.length === 1) {
  347. return bounds[0];
  348. } else {
  349. bounds = _.sortBy(bounds);
  350. for (let i = 1; i < bounds.length; i++) {
  351. const distance = getDistance(bounds[i], bounds[i - 1], logBase);
  352. bucketSize = distance < bucketSize ? distance : bucketSize;
  353. }
  354. }
  355. return bucketSize;
  356. }
  357. /**
  358. * Calculate distance between two numbers in given scale (linear or logarithmic).
  359. * @param a
  360. * @param b
  361. * @param logBase
  362. */
  363. function getDistance(a: number, b: number, logBase = 1): number {
  364. if (logBase === 1) {
  365. // Linear distance
  366. return Math.abs(b - a);
  367. } else {
  368. // logarithmic distance
  369. const ratio = Math.max(a, b) / Math.min(a, b);
  370. return logp(ratio, logBase);
  371. }
  372. }
  373. /**
  374. * Compare two heatmap data objects
  375. * @param objA
  376. * @param objB
  377. */
  378. function isHeatmapDataEqual(objA: any, objB: any): boolean {
  379. let isEql = !emptyXOR(objA, objB);
  380. _.forEach(objA, (xBucket: XBucket, x) => {
  381. if (objB[x]) {
  382. if (emptyXOR(xBucket.buckets, objB[x].buckets)) {
  383. isEql = false;
  384. return false;
  385. }
  386. _.forEach(xBucket.buckets, (yBucket: YBucket, y) => {
  387. if (objB[x].buckets && objB[x].buckets[y]) {
  388. if (objB[x].buckets[y].values) {
  389. isEql = _.isEqual(_.sortBy(yBucket.values), _.sortBy(objB[x].buckets[y].values));
  390. if (!isEql) {
  391. return false;
  392. } else {
  393. return true;
  394. }
  395. } else {
  396. isEql = false;
  397. return false;
  398. }
  399. } else {
  400. isEql = false;
  401. return false;
  402. }
  403. });
  404. if (!isEql) {
  405. return false;
  406. } else {
  407. return true;
  408. }
  409. } else {
  410. isEql = false;
  411. return false;
  412. }
  413. });
  414. return isEql;
  415. }
  416. function emptyXOR(foo: any, bar: any): boolean {
  417. return (_.isEmpty(foo) || _.isEmpty(bar)) && !(_.isEmpty(foo) && _.isEmpty(bar));
  418. }
  419. export {
  420. convertToHeatMap,
  421. histogramToHeatmap,
  422. convertToCards,
  423. mergeZeroBuckets,
  424. getValueBucketBound,
  425. isHeatmapDataEqual,
  426. calculateBucketSize,
  427. sortSeriesByLabel,
  428. };