timeline.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. import uPlot, { Cursor, Series } from 'uplot';
  2. import { GrafanaTheme2, TimeRange } from '@grafana/data';
  3. import { alpha } from '@grafana/data/src/themes/colorManipulator';
  4. import { VisibilityMode } from '@grafana/schema';
  5. import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG';
  6. import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
  7. import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
  8. import { TimelineFieldConfig, TimelineMode, TimelineValueAlignment } from './types';
  9. const { round, min, ceil } = Math;
  10. const textPadding = 2;
  11. const pxRatio = devicePixelRatio;
  12. const laneDistr = SPACE_BETWEEN;
  13. type WalkCb = (idx: number, offPx: number, dimPx: number) => void;
  14. function walk(rowHeight: number, yIdx: number | null, count: number, dim: number, draw: WalkCb) {
  15. distribute(count, rowHeight, laneDistr, yIdx, (i, offPct, dimPct) => {
  16. let laneOffPx = dim * offPct;
  17. let laneWidPx = dim * dimPct;
  18. draw(i, laneOffPx, laneWidPx);
  19. });
  20. }
  21. interface TimelineBoxRect extends Rect {
  22. fillColor: string;
  23. }
  24. /**
  25. * @internal
  26. */
  27. export interface TimelineCoreOptions {
  28. mode: TimelineMode;
  29. alignValue?: TimelineValueAlignment;
  30. numSeries: number;
  31. rowHeight: number;
  32. colWidth?: number;
  33. theme: GrafanaTheme2;
  34. showValue: VisibilityMode;
  35. mergeValues?: boolean;
  36. isDiscrete: (seriesIdx: number) => boolean;
  37. getValueColor: (seriesIdx: number, value: any) => string;
  38. label: (seriesIdx: number) => string;
  39. getTimeRange: () => TimeRange;
  40. formatValue?: (seriesIdx: number, value: any) => string;
  41. getFieldConfig: (seriesIdx: number) => TimelineFieldConfig;
  42. onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
  43. onLeave: () => void;
  44. }
  45. /**
  46. * @internal
  47. */
  48. export function getConfig(opts: TimelineCoreOptions) {
  49. const {
  50. mode,
  51. numSeries,
  52. isDiscrete,
  53. rowHeight = 0,
  54. colWidth = 0,
  55. showValue,
  56. mergeValues = false,
  57. theme,
  58. label,
  59. formatValue,
  60. alignValue = 'left',
  61. getTimeRange,
  62. getValueColor,
  63. getFieldConfig,
  64. onHover,
  65. onLeave,
  66. } = opts;
  67. let qt: Quadtree;
  68. const hoverMarks = Array(numSeries)
  69. .fill(null)
  70. .map(() => {
  71. let mark = document.createElement('div');
  72. mark.classList.add('bar-mark');
  73. mark.style.position = 'absolute';
  74. mark.style.background = 'rgba(255,255,255,0.2)';
  75. return mark;
  76. });
  77. // Needed for to calculate text positions
  78. let boxRectsBySeries: TimelineBoxRect[][];
  79. const resetBoxRectsBySeries = (count: number) => {
  80. boxRectsBySeries = Array(numSeries)
  81. .fill(null)
  82. .map((v) => Array(count).fill(null));
  83. };
  84. const font = `500 ${Math.round(12 * devicePixelRatio)}px ${theme.typography.fontFamily}`;
  85. const hovered: Array<Rect | null> = Array(numSeries).fill(null);
  86. const size = [colWidth, Infinity];
  87. const gapFactor = 1 - size[0];
  88. const maxWidth = (size[1] ?? Infinity) * pxRatio;
  89. const fillPaths: Map<CanvasRenderingContext2D['fillStyle'], Path2D> = new Map();
  90. const strokePaths: Map<CanvasRenderingContext2D['strokeStyle'], Path2D> = new Map();
  91. function drawBoxes(ctx: CanvasRenderingContext2D) {
  92. fillPaths.forEach((fillPath, fillStyle) => {
  93. ctx.fillStyle = fillStyle;
  94. ctx.fill(fillPath);
  95. });
  96. strokePaths.forEach((strokePath, strokeStyle) => {
  97. ctx.strokeStyle = strokeStyle;
  98. ctx.stroke(strokePath);
  99. });
  100. fillPaths.clear();
  101. strokePaths.clear();
  102. }
  103. function putBox(
  104. ctx: CanvasRenderingContext2D,
  105. rect: uPlot.RectH,
  106. xOff: number,
  107. yOff: number,
  108. left: number,
  109. top: number,
  110. boxWidth: number,
  111. boxHeight: number,
  112. strokeWidth: number,
  113. seriesIdx: number,
  114. valueIdx: number,
  115. value: any,
  116. discrete: boolean
  117. ) {
  118. // do not render super small boxes
  119. if (boxWidth < 1) {
  120. return;
  121. }
  122. const valueColor = getValueColor(seriesIdx + 1, value);
  123. const fieldConfig = getFieldConfig(seriesIdx);
  124. const fillColor = getFillColor(fieldConfig, valueColor);
  125. boxRectsBySeries[seriesIdx][valueIdx] = {
  126. x: round(left - xOff),
  127. y: round(top - yOff),
  128. w: boxWidth,
  129. h: boxHeight,
  130. sidx: seriesIdx + 1,
  131. didx: valueIdx,
  132. // for computing label contrast
  133. fillColor,
  134. };
  135. if (discrete) {
  136. let fillStyle = fillColor;
  137. let fillPath = fillPaths.get(fillStyle);
  138. if (fillPath == null) {
  139. fillPaths.set(fillStyle, (fillPath = new Path2D()));
  140. }
  141. rect(fillPath, left, top, boxWidth, boxHeight);
  142. if (strokeWidth) {
  143. let strokeStyle = valueColor;
  144. let strokePath = strokePaths.get(strokeStyle);
  145. if (strokePath == null) {
  146. strokePaths.set(strokeStyle, (strokePath = new Path2D()));
  147. }
  148. rect(
  149. strokePath,
  150. left + strokeWidth / 2,
  151. top + strokeWidth / 2,
  152. boxWidth - strokeWidth,
  153. boxHeight - strokeWidth
  154. );
  155. }
  156. } else {
  157. ctx.beginPath();
  158. rect(ctx, left, top, boxWidth, boxHeight);
  159. ctx.fillStyle = fillColor;
  160. ctx.fill();
  161. if (strokeWidth) {
  162. ctx.beginPath();
  163. rect(ctx, left + strokeWidth / 2, top + strokeWidth / 2, boxWidth - strokeWidth, boxHeight - strokeWidth);
  164. ctx.strokeStyle = valueColor;
  165. ctx.lineWidth = strokeWidth;
  166. ctx.stroke();
  167. }
  168. }
  169. }
  170. const drawPaths: Series.PathBuilder = (u, sidx, idx0, idx1) => {
  171. uPlot.orient(
  172. u,
  173. sidx,
  174. (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
  175. let strokeWidth = round((series.width || 0) * pxRatio);
  176. let discrete = isDiscrete(sidx);
  177. u.ctx.save();
  178. rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
  179. u.ctx.clip();
  180. walk(rowHeight, sidx - 1, numSeries, yDim, (iy, y0, height) => {
  181. if (mode === TimelineMode.Changes) {
  182. for (let ix = 0; ix < dataY.length; ix++) {
  183. let yVal = dataY[ix];
  184. if (yVal != null) {
  185. let left = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff));
  186. let nextIx = ix;
  187. while (
  188. ++nextIx < dataY.length &&
  189. (dataY[nextIx] === undefined || (mergeValues && dataY[nextIx] === yVal))
  190. ) {}
  191. // to now (not to end of chart)
  192. let right =
  193. nextIx === dataY.length
  194. ? xOff + xDim + strokeWidth
  195. : Math.round(valToPosX(dataX[nextIx], scaleX, xDim, xOff));
  196. putBox(
  197. u.ctx,
  198. rect,
  199. xOff,
  200. yOff,
  201. left,
  202. round(yOff + y0),
  203. right - left,
  204. round(height),
  205. strokeWidth,
  206. iy,
  207. ix,
  208. yVal,
  209. discrete
  210. );
  211. ix = nextIx - 1;
  212. }
  213. }
  214. } else if (mode === TimelineMode.Samples) {
  215. let colWid = valToPosX(dataX[1], scaleX, xDim, xOff) - valToPosX(dataX[0], scaleX, xDim, xOff);
  216. let gapWid = colWid * gapFactor;
  217. let barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth);
  218. let xShift = barWid / 2;
  219. //let xShift = align === 1 ? 0 : align === -1 ? barWid : barWid / 2;
  220. for (let ix = idx0; ix <= idx1; ix++) {
  221. if (dataY[ix] != null) {
  222. // TODO: all xPos can be pre-computed once for all series in aligned set
  223. let left = valToPosX(dataX[ix], scaleX, xDim, xOff);
  224. putBox(
  225. u.ctx,
  226. rect,
  227. xOff,
  228. yOff,
  229. round(left - xShift),
  230. round(yOff + y0),
  231. barWid,
  232. round(height),
  233. strokeWidth,
  234. iy,
  235. ix,
  236. dataY[ix],
  237. discrete
  238. );
  239. }
  240. }
  241. }
  242. });
  243. if (discrete) {
  244. u.ctx.lineWidth = strokeWidth;
  245. drawBoxes(u.ctx);
  246. }
  247. u.ctx.restore();
  248. }
  249. );
  250. return null;
  251. };
  252. const drawPoints: Series.Points.Show =
  253. formatValue == null || showValue === VisibilityMode.Never
  254. ? false
  255. : (u, sidx, i0, i1) => {
  256. u.ctx.save();
  257. u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
  258. u.ctx.clip();
  259. u.ctx.font = font;
  260. u.ctx.textAlign = mode === TimelineMode.Changes ? alignValue : 'center';
  261. u.ctx.textBaseline = 'middle';
  262. uPlot.orient(
  263. u,
  264. sidx,
  265. (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => {
  266. let strokeWidth = round((series.width || 0) * pxRatio);
  267. let y = round(yOff + yMids[sidx - 1]);
  268. for (let ix = 0; ix < dataY.length; ix++) {
  269. if (dataY[ix] != null) {
  270. const boxRect = boxRectsBySeries[sidx - 1][ix];
  271. // Todo refine this to better know when to not render text (when values do not fit)
  272. if (!boxRect || (showValue === VisibilityMode.Auto && boxRect.w < 25)) {
  273. continue;
  274. }
  275. if (boxRect.x >= xDim) {
  276. continue; // out of view
  277. }
  278. // center-aligned
  279. let x = round(boxRect.x + xOff + boxRect.w / 2);
  280. const txt = formatValue(sidx, dataY[ix]);
  281. if (mode === TimelineMode.Changes) {
  282. if (alignValue === 'left') {
  283. x = round(boxRect.x + xOff + strokeWidth + textPadding);
  284. } else if (alignValue === 'right') {
  285. x = round(boxRect.x + xOff + boxRect.w - strokeWidth - textPadding);
  286. }
  287. }
  288. // TODO: cache by fillColor to avoid setting ctx for label
  289. u.ctx.fillStyle = theme.colors.getContrastText(boxRect.fillColor, 3);
  290. u.ctx.fillText(txt, x, y);
  291. }
  292. }
  293. }
  294. );
  295. u.ctx.restore();
  296. return false;
  297. };
  298. const init = (u: uPlot) => {
  299. let over = u.over;
  300. over.style.overflow = 'hidden';
  301. hoverMarks.forEach((m) => {
  302. over.appendChild(m);
  303. });
  304. };
  305. const drawClear = (u: uPlot) => {
  306. qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
  307. qt.clear();
  308. resetBoxRectsBySeries(u.data[0].length);
  309. // force-clear the path cache to cause drawBars() to rebuild new quadtree
  310. u.series.forEach((s) => {
  311. // @ts-ignore
  312. s._paths = null;
  313. });
  314. };
  315. function setHoverMark(i: number, o: Rect | null) {
  316. let h = hoverMarks[i];
  317. if (o) {
  318. h.style.display = '';
  319. h.style.left = round(o.x / pxRatio) + 'px';
  320. h.style.top = round(o.y / pxRatio) + 'px';
  321. h.style.width = round(o.w / pxRatio) + 'px';
  322. h.style.height = round(o.h / pxRatio) + 'px';
  323. } else {
  324. h.style.display = 'none';
  325. }
  326. hovered[i] = o;
  327. }
  328. let hoveredAtCursor: Rect | undefined;
  329. function hoverMulti(cx: number, cy: number) {
  330. let foundAtCursor: Rect | undefined;
  331. for (let i = 0; i < numSeries; i++) {
  332. let found: Rect | undefined;
  333. if (cx >= 0) {
  334. let cy2 = yMids[i];
  335. qt.get(cx, cy2, 1, 1, (o) => {
  336. if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) {
  337. found = o;
  338. if (Math.abs(cy - cy2) <= o.h / 2) {
  339. foundAtCursor = o;
  340. }
  341. }
  342. });
  343. }
  344. if (found) {
  345. if (found !== hovered[i]) {
  346. setHoverMark(i, found);
  347. }
  348. } else if (hovered[i] != null) {
  349. setHoverMark(i, null);
  350. }
  351. }
  352. if (foundAtCursor) {
  353. if (foundAtCursor !== hoveredAtCursor) {
  354. hoveredAtCursor = foundAtCursor;
  355. onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
  356. }
  357. } else if (hoveredAtCursor) {
  358. hoveredAtCursor = undefined;
  359. onLeave();
  360. }
  361. }
  362. function hoverOne(cx: number, cy: number) {
  363. let foundAtCursor: Rect | undefined;
  364. qt.get(cx, cy, 1, 1, (o) => {
  365. if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
  366. foundAtCursor = o;
  367. }
  368. });
  369. if (foundAtCursor) {
  370. setHoverMark(0, foundAtCursor);
  371. if (foundAtCursor !== hoveredAtCursor) {
  372. hoveredAtCursor = foundAtCursor;
  373. onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
  374. }
  375. } else if (hoveredAtCursor) {
  376. setHoverMark(0, null);
  377. hoveredAtCursor = undefined;
  378. onLeave();
  379. }
  380. }
  381. const doHover = mode === TimelineMode.Changes ? hoverMulti : hoverOne;
  382. const setCursor = (u: uPlot) => {
  383. let cx = round(u.cursor.left! * pxRatio);
  384. let cy = round(u.cursor.top! * pxRatio);
  385. // if quadtree is empty, fill it
  386. if (!qt.o.length && qt.q == null) {
  387. for (const seriesRects of boxRectsBySeries) {
  388. for (const rect of seriesRects) {
  389. rect && qt.add(rect);
  390. }
  391. }
  392. }
  393. doHover(cx, cy);
  394. };
  395. // hide y crosshair & hover points
  396. const cursor: Partial<Cursor> = {
  397. y: false,
  398. x: mode === TimelineMode.Changes,
  399. points: { show: false },
  400. };
  401. const yMids: number[] = Array(numSeries).fill(0);
  402. const ySplits: number[] = Array(numSeries).fill(0);
  403. return {
  404. cursor,
  405. xSplits:
  406. mode === TimelineMode.Samples
  407. ? (u: uPlot, axisIdx: number, scaleMin: number, scaleMax: number, foundIncr: number, foundSpace: number) => {
  408. let splits = [];
  409. let dataIncr = u.data[0][1] - u.data[0][0];
  410. let skipFactor = ceil(foundIncr / dataIncr);
  411. for (let i = 0; i < u.data[0].length; i += skipFactor) {
  412. let v = u.data[0][i];
  413. if (v >= scaleMin && v <= scaleMax) {
  414. splits.push(v);
  415. }
  416. }
  417. return splits;
  418. }
  419. : null,
  420. xRange: (u: uPlot) => {
  421. const r = getTimeRange();
  422. let min = r.from.valueOf();
  423. let max = r.to.valueOf();
  424. if (mode === TimelineMode.Samples) {
  425. let colWid = u.data[0][1] - u.data[0][0];
  426. let scalePad = colWid / 2;
  427. if (min <= u.data[0][0]) {
  428. min = u.data[0][0] - scalePad;
  429. }
  430. let lastIdx = u.data[0].length - 1;
  431. if (max >= u.data[0][lastIdx]) {
  432. max = u.data[0][lastIdx] + scalePad;
  433. }
  434. }
  435. return [min, max] as uPlot.Range.MinMax;
  436. },
  437. ySplits: (u: uPlot) => {
  438. walk(rowHeight, null, numSeries, u.bbox.height, (iy, y0, hgt) => {
  439. // vertical midpoints of each series' timeline (stored relative to .u-over)
  440. yMids[iy] = round(y0 + hgt / 2);
  441. ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, FIXED_UNIT);
  442. });
  443. return ySplits;
  444. },
  445. yValues: (u: uPlot, splits: number[]) => splits.map((v, i) => label(i + 1)),
  446. yRange: [0, 1] as uPlot.Range.MinMax,
  447. // pathbuilders
  448. drawPaths,
  449. drawPoints,
  450. // hooks
  451. init,
  452. drawClear,
  453. setCursor,
  454. };
  455. }
  456. function getFillColor(fieldConfig: TimelineFieldConfig, color: string) {
  457. // if #rgba with pre-existing alpha. ignore fieldConfig.fillOpacity
  458. // e.g. thresholds with opacity
  459. if (color[0] === '#' && color.length === 9) {
  460. return color;
  461. }
  462. const opacityPercent = (fieldConfig.fillOpacity ?? 100) / 100;
  463. return alpha(color, opacityPercent);
  464. }