heatmap_data_converter.ts 12 KB

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