utils.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import React from 'react';
  2. import uPlot from 'uplot';
  3. import {
  4. ArrayVector,
  5. DataFrame,
  6. DashboardCursorSync,
  7. DataHoverPayload,
  8. DataHoverEvent,
  9. DataHoverClearEvent,
  10. FALLBACK_COLOR,
  11. Field,
  12. FieldColorModeId,
  13. FieldConfig,
  14. FieldType,
  15. formattedValueToString,
  16. getFieldDisplayName,
  17. getValueFormat,
  18. GrafanaTheme2,
  19. getActiveThreshold,
  20. Threshold,
  21. getFieldConfigWithMinMax,
  22. ThresholdsMode,
  23. TimeRange,
  24. } from '@grafana/data';
  25. import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
  26. import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
  27. import {
  28. FIXED_UNIT,
  29. SeriesVisibilityChangeMode,
  30. UPlotConfigBuilder,
  31. UPlotConfigPrepFn,
  32. VizLegendItem,
  33. } from '@grafana/ui';
  34. import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold';
  35. import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue';
  36. import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
  37. import { preparePlotData2, getStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
  38. import { getConfig, TimelineCoreOptions } from './timeline';
  39. import { TimelineFieldConfig, TimelineOptions } from './types';
  40. const defaultConfig: TimelineFieldConfig = {
  41. lineWidth: 0,
  42. fillOpacity: 80,
  43. };
  44. export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
  45. if (event.ctrlKey || event.metaKey || event.shiftKey) {
  46. return SeriesVisibilityChangeMode.AppendToSelection;
  47. }
  48. return SeriesVisibilityChangeMode.ToggleSelection;
  49. }
  50. export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
  51. frame,
  52. theme,
  53. timeZone,
  54. getTimeRange,
  55. mode,
  56. eventBus,
  57. sync,
  58. rowHeight,
  59. colWidth,
  60. showValue,
  61. alignValue,
  62. mergeValues,
  63. getValueColor,
  64. }) => {
  65. const builder = new UPlotConfigBuilder(timeZone);
  66. const xScaleUnit = 'time';
  67. const xScaleKey = 'x';
  68. const isDiscrete = (field: Field) => {
  69. const mode = field.config?.color?.mode;
  70. return !(mode && field.display && mode.startsWith('continuous-'));
  71. };
  72. const getValueColorFn = (seriesIdx: number, value: any) => {
  73. const field = frame.fields[seriesIdx];
  74. if (
  75. field.state?.origin?.fieldIndex !== undefined &&
  76. field.state?.origin?.frameIndex !== undefined &&
  77. getValueColor
  78. ) {
  79. return getValueColor(field.state?.origin?.frameIndex, field.state?.origin?.fieldIndex, value);
  80. }
  81. return FALLBACK_COLOR;
  82. };
  83. const opts: TimelineCoreOptions = {
  84. // should expose in panel config
  85. mode: mode!,
  86. numSeries: frame.fields.length - 1,
  87. isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
  88. mergeValues,
  89. rowHeight: rowHeight!,
  90. colWidth: colWidth,
  91. showValue: showValue!,
  92. alignValue,
  93. theme,
  94. label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
  95. getFieldConfig: (seriesIdx) => frame.fields[seriesIdx].config.custom,
  96. getValueColor: getValueColorFn,
  97. getTimeRange,
  98. // hardcoded formatter for state values
  99. formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
  100. onHover: (seriesIndex, valueIndex) => {
  101. hoveredSeriesIdx = seriesIndex;
  102. hoveredDataIdx = valueIndex;
  103. shouldChangeHover = true;
  104. },
  105. onLeave: () => {
  106. hoveredSeriesIdx = null;
  107. hoveredDataIdx = null;
  108. shouldChangeHover = true;
  109. },
  110. };
  111. let shouldChangeHover = false;
  112. let hoveredSeriesIdx: number | null = null;
  113. let hoveredDataIdx: number | null = null;
  114. const coreConfig = getConfig(opts);
  115. const payload: DataHoverPayload = {
  116. point: {
  117. [xScaleUnit]: null,
  118. [FIXED_UNIT]: null,
  119. },
  120. data: frame,
  121. };
  122. builder.addHook('init', coreConfig.init);
  123. builder.addHook('drawClear', coreConfig.drawClear);
  124. builder.addHook('setCursor', coreConfig.setCursor);
  125. // in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
  126. // which fires after the above setCursor hook, so can take advantage of hoveringOver
  127. // already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
  128. const interpolateTooltip: PlotTooltipInterpolator = (
  129. updateActiveSeriesIdx,
  130. updateActiveDatapointIdx,
  131. updateTooltipPosition
  132. ) => {
  133. if (shouldChangeHover) {
  134. if (hoveredSeriesIdx != null) {
  135. updateActiveSeriesIdx(hoveredSeriesIdx);
  136. updateActiveDatapointIdx(hoveredDataIdx);
  137. }
  138. shouldChangeHover = false;
  139. }
  140. updateTooltipPosition(hoveredSeriesIdx == null);
  141. };
  142. builder.setTooltipInterpolator(interpolateTooltip);
  143. builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
  144. builder.setCursor(coreConfig.cursor);
  145. builder.addScale({
  146. scaleKey: xScaleKey,
  147. isTime: true,
  148. orientation: ScaleOrientation.Horizontal,
  149. direction: ScaleDirection.Right,
  150. range: coreConfig.xRange,
  151. });
  152. builder.addScale({
  153. scaleKey: FIXED_UNIT, // y
  154. isTime: false,
  155. orientation: ScaleOrientation.Vertical,
  156. direction: ScaleDirection.Up,
  157. range: coreConfig.yRange,
  158. });
  159. builder.addAxis({
  160. scaleKey: xScaleKey,
  161. isTime: true,
  162. splits: coreConfig.xSplits!,
  163. placement: AxisPlacement.Bottom,
  164. timeZone,
  165. theme,
  166. grid: { show: true },
  167. });
  168. builder.addAxis({
  169. scaleKey: FIXED_UNIT, // y
  170. isTime: false,
  171. placement: AxisPlacement.Left,
  172. splits: coreConfig.ySplits,
  173. values: coreConfig.yValues,
  174. grid: { show: false },
  175. ticks: { show: false },
  176. gap: 16,
  177. theme,
  178. });
  179. let seriesIndex = 0;
  180. for (let i = 0; i < frame.fields.length; i++) {
  181. if (i === 0) {
  182. continue;
  183. }
  184. const field = frame.fields[i];
  185. const config = field.config as FieldConfig<TimelineFieldConfig>;
  186. const customConfig: TimelineFieldConfig = {
  187. ...defaultConfig,
  188. ...config.custom,
  189. };
  190. field.state!.seriesIndex = seriesIndex++;
  191. // const scaleKey = config.unit || FIXED_UNIT;
  192. // const colorMode = getFieldColorModeForField(field);
  193. builder.addSeries({
  194. scaleKey: FIXED_UNIT,
  195. pathBuilder: coreConfig.drawPaths,
  196. pointsBuilder: coreConfig.drawPoints,
  197. //colorMode,
  198. lineWidth: customConfig.lineWidth,
  199. fillOpacity: customConfig.fillOpacity,
  200. theme,
  201. show: !customConfig.hideFrom?.viz,
  202. thresholds: config.thresholds,
  203. // The following properties are not used in the uPlot config, but are utilized as transport for legend config
  204. dataFrameFieldIndex: field.state?.origin,
  205. });
  206. }
  207. if (sync && sync() !== DashboardCursorSync.Off) {
  208. let cursor: Partial<uPlot.Cursor> = {};
  209. cursor.sync = {
  210. key: '__global_',
  211. filters: {
  212. pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
  213. if (sync && sync() === DashboardCursorSync.Off) {
  214. return false;
  215. }
  216. payload.rowIndex = dataIdx;
  217. if (x < 0 && y < 0) {
  218. payload.point[xScaleUnit] = null;
  219. payload.point[FIXED_UNIT] = null;
  220. eventBus.publish(new DataHoverClearEvent());
  221. } else {
  222. payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
  223. payload.point.panelRelY = y > 0 ? y / h : 1; // used for old graph panel to position tooltip
  224. payload.down = undefined;
  225. eventBus.publish(new DataHoverEvent(payload));
  226. }
  227. return true;
  228. },
  229. },
  230. //TODO: remove any once https://github.com/leeoniya/uPlot/pull/611 got merged or the typing is fixed
  231. scales: [xScaleKey, null as any],
  232. };
  233. builder.setSync();
  234. builder.setCursor(cursor);
  235. }
  236. return builder;
  237. };
  238. export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
  239. const names = new Map<string, number>();
  240. for (let i = 0; i < frame.fields.length; i++) {
  241. names.set(getFieldDisplayName(frame.fields[i], frame), i);
  242. }
  243. return names;
  244. }
  245. /**
  246. * If any sequential duplicate values exist, this will return a new array
  247. * with the future values set to undefined.
  248. *
  249. * in: 1, 1,undefined, 1,2, 2,null,2,3
  250. * out: 1,undefined,undefined,undefined,2,undefined,null,2,3
  251. */
  252. export function unsetSameFutureValues(values: any[]): any[] | undefined {
  253. let prevVal = values[0];
  254. let clone: any[] | undefined = undefined;
  255. for (let i = 1; i < values.length; i++) {
  256. let value = values[i];
  257. if (value === null) {
  258. prevVal = null;
  259. } else {
  260. if (value === prevVal) {
  261. if (!clone) {
  262. clone = [...values];
  263. }
  264. clone[i] = undefined;
  265. } else if (value != null) {
  266. prevVal = value;
  267. }
  268. }
  269. }
  270. return clone;
  271. }
  272. function getSpanNulls(field: Field) {
  273. let spanNulls = field.config.custom?.spanNulls;
  274. // magic value for join() to leave nulls alone instead of expanding null ranges
  275. // should be set to -1 when spanNulls = null|undefined|false|0, which is "retain nulls, without expanding"
  276. // Infinity is not optimal here since it causes spanNulls to be more expensive than simply removing all nulls unconditionally
  277. return !spanNulls ? -1 : spanNulls === true ? Infinity : spanNulls;
  278. }
  279. /**
  280. * Merge values by the threshold
  281. */
  282. export function mergeThresholdValues(field: Field, theme: GrafanaTheme2): Field | undefined {
  283. const thresholds = field.config.thresholds;
  284. if (field.type !== FieldType.number || !thresholds || !thresholds.steps.length) {
  285. return undefined;
  286. }
  287. const items = getThresholdItems(field.config, theme);
  288. if (items.length !== thresholds.steps.length) {
  289. return undefined; // should not happen
  290. }
  291. const thresholdToText = new Map<Threshold, string>();
  292. const textToColor = new Map<string, string>();
  293. for (let i = 0; i < items.length; i++) {
  294. thresholdToText.set(thresholds.steps[i], items[i].label);
  295. textToColor.set(items[i].label, items[i].color!);
  296. }
  297. let input = field.values.toArray();
  298. const vals = new Array<String | undefined>(field.values.length);
  299. if (thresholds.mode === ThresholdsMode.Percentage) {
  300. const { min, max } = getFieldConfigWithMinMax(field);
  301. const delta = max! - min!;
  302. input = input.map((v) => {
  303. if (v == null) {
  304. return v;
  305. }
  306. return ((v - min!) / delta) * 100;
  307. });
  308. }
  309. for (let i = 0; i < vals.length; i++) {
  310. const v = input[i];
  311. if (v == null) {
  312. vals[i] = v;
  313. } else {
  314. vals[i] = thresholdToText.get(getActiveThreshold(v, thresholds.steps));
  315. }
  316. }
  317. return {
  318. ...field,
  319. config: {
  320. ...field.config,
  321. custom: {
  322. ...field.config.custom,
  323. spanNulls: getSpanNulls(field),
  324. },
  325. },
  326. type: FieldType.string,
  327. values: new ArrayVector(vals),
  328. display: (value: string) => ({
  329. text: value,
  330. color: textToColor.get(value),
  331. numeric: NaN,
  332. }),
  333. };
  334. }
  335. // This will return a set of frames with only graphable values included
  336. export function prepareTimelineFields(
  337. series: DataFrame[] | undefined,
  338. mergeValues: boolean,
  339. timeRange: TimeRange,
  340. theme: GrafanaTheme2
  341. ): { frames?: DataFrame[]; warn?: string } {
  342. if (!series?.length) {
  343. return { warn: 'No data in response' };
  344. }
  345. let hasTimeseries = false;
  346. const frames: DataFrame[] = [];
  347. for (let frame of series) {
  348. let isTimeseries = false;
  349. let changed = false;
  350. let maybeSortedFrame = maybeSortFrame(
  351. frame,
  352. frame.fields.findIndex((f) => f.type === FieldType.time)
  353. );
  354. let nulledFrame = applyNullInsertThreshold({
  355. frame: maybeSortedFrame,
  356. refFieldPseudoMin: timeRange.from.valueOf(),
  357. refFieldPseudoMax: timeRange.to.valueOf(),
  358. });
  359. if (nulledFrame !== frame) {
  360. changed = true;
  361. }
  362. const fields: Field[] = [];
  363. for (let field of nullToValue(nulledFrame).fields) {
  364. switch (field.type) {
  365. case FieldType.time:
  366. isTimeseries = true;
  367. hasTimeseries = true;
  368. fields.push(field);
  369. break;
  370. case FieldType.number:
  371. if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) {
  372. const f = mergeThresholdValues(field, theme);
  373. if (f) {
  374. fields.push(f);
  375. changed = true;
  376. continue;
  377. }
  378. }
  379. case FieldType.boolean:
  380. case FieldType.string:
  381. field = {
  382. ...field,
  383. config: {
  384. ...field.config,
  385. custom: {
  386. ...field.config.custom,
  387. spanNulls: getSpanNulls(field),
  388. },
  389. },
  390. };
  391. fields.push(field);
  392. break;
  393. default:
  394. changed = true;
  395. }
  396. }
  397. if (isTimeseries && fields.length > 1) {
  398. hasTimeseries = true;
  399. if (changed) {
  400. frames.push({
  401. ...maybeSortedFrame,
  402. fields,
  403. });
  404. } else {
  405. frames.push(maybeSortedFrame);
  406. }
  407. }
  408. }
  409. if (!hasTimeseries) {
  410. return { warn: 'Data does not have a time field' };
  411. }
  412. if (!frames.length) {
  413. return { warn: 'No graphable fields' };
  414. }
  415. return { frames };
  416. }
  417. export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] {
  418. const items: VizLegendItem[] = [];
  419. const thresholds = fieldConfig.thresholds;
  420. if (!thresholds || !thresholds.steps.length) {
  421. return items;
  422. }
  423. const steps = thresholds.steps;
  424. const disp = getValueFormat(thresholds.mode === ThresholdsMode.Percentage ? 'percent' : fieldConfig.unit ?? '');
  425. const fmt = (v: number) => formattedValueToString(disp(v));
  426. for (let i = 1; i <= steps.length; i++) {
  427. const step = steps[i - 1];
  428. items.push({
  429. label: i === 1 ? `< ${fmt(step.value)}` : `${fmt(step.value)}+`,
  430. color: theme.visualization.getColorByName(step.color),
  431. yAxis: 1,
  432. });
  433. }
  434. return items;
  435. }
  436. export function prepareTimelineLegendItems(
  437. frames: DataFrame[] | undefined,
  438. options: VizLegendOptions,
  439. theme: GrafanaTheme2
  440. ): VizLegendItem[] | undefined {
  441. if (!frames || options.displayMode === 'hidden') {
  442. return undefined;
  443. }
  444. return getFieldLegendItem(allNonTimeFields(frames), theme);
  445. }
  446. export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLegendItem[] | undefined {
  447. if (!fields.length) {
  448. return undefined;
  449. }
  450. const items: VizLegendItem[] = [];
  451. const fieldConfig = fields[0].config;
  452. const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed;
  453. const thresholds = fieldConfig.thresholds;
  454. // If thresholds are enabled show each step in the legend
  455. if (colorMode === FieldColorModeId.Thresholds && thresholds?.steps && thresholds.steps.length > 1) {
  456. return getThresholdItems(fieldConfig, theme);
  457. }
  458. // If thresholds are enabled show each step in the legend
  459. if (colorMode.startsWith('continuous')) {
  460. return undefined; // eventually a color bar
  461. }
  462. let stateColors: Map<string, string | undefined> = new Map();
  463. fields.forEach((field) => {
  464. field.values.toArray().forEach((v) => {
  465. let state = field.display!(v);
  466. if (state.color) {
  467. stateColors.set(state.text, state.color!);
  468. }
  469. });
  470. });
  471. stateColors.forEach((color, label) => {
  472. if (label.length > 0) {
  473. items.push({
  474. label: label!,
  475. color: theme.visualization.getColorByName(color ?? FALLBACK_COLOR),
  476. yAxis: 1,
  477. });
  478. }
  479. });
  480. return items;
  481. }
  482. function allNonTimeFields(frames: DataFrame[]): Field[] {
  483. const fields: Field[] = [];
  484. for (const frame of frames) {
  485. for (const field of frame.fields) {
  486. if (field.type !== FieldType.time) {
  487. fields.push(field);
  488. }
  489. }
  490. }
  491. return fields;
  492. }
  493. export function findNextStateIndex(field: Field, datapointIdx: number) {
  494. let end;
  495. let rightPointer = datapointIdx + 1;
  496. if (rightPointer >= field.values.length) {
  497. return null;
  498. }
  499. const startValue = field.values.get(datapointIdx);
  500. while (end === undefined) {
  501. if (rightPointer >= field.values.length) {
  502. return null;
  503. }
  504. const rightValue = field.values.get(rightPointer);
  505. if (rightValue === undefined || rightValue === startValue) {
  506. rightPointer++;
  507. } else {
  508. end = rightPointer;
  509. }
  510. }
  511. return end;
  512. }
  513. /**
  514. * Returns the precise duration of a time range passed in milliseconds.
  515. * This function calculates with 30 days month and 365 days year.
  516. * adapted from https://gist.github.com/remino/1563878
  517. * @param milliSeconds The duration in milliseconds
  518. * @returns A formated string of the duration
  519. */
  520. export function fmtDuration(milliSeconds: number): string {
  521. if (milliSeconds < 0 || Number.isNaN(milliSeconds)) {
  522. return '';
  523. }
  524. let yr: number, mo: number, wk: number, d: number, h: number, m: number, s: number, ms: number;
  525. s = Math.floor(milliSeconds / 1000);
  526. m = Math.floor(s / 60);
  527. s = s % 60;
  528. h = Math.floor(m / 60);
  529. m = m % 60;
  530. d = Math.floor(h / 24);
  531. h = h % 24;
  532. yr = Math.floor(d / 365);
  533. if (yr > 0) {
  534. d = d % 365;
  535. }
  536. mo = Math.floor(d / 30);
  537. if (mo > 0) {
  538. d = d % 30;
  539. }
  540. wk = Math.floor(d / 7);
  541. if (wk > 0) {
  542. d = d % 7;
  543. }
  544. ms = Math.round((milliSeconds % 1000) * 1000) / 1000;
  545. return (
  546. yr > 0
  547. ? yr + 'y ' + (mo > 0 ? mo + 'mo ' : '') + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
  548. : mo > 0
  549. ? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
  550. : wk > 0
  551. ? wk + 'w ' + (d > 0 ? d + 'd ' : '')
  552. : d > 0
  553. ? d + 'd ' + (h > 0 ? h + 'h ' : '')
  554. : h > 0
  555. ? h + 'h ' + (m > 0 ? m + 'm ' : '')
  556. : m > 0
  557. ? m + 'm ' + (s > 0 ? s + 's ' : '')
  558. : s > 0
  559. ? s + 's ' + (ms > 0 ? ms + 'ms ' : '')
  560. : ms > 0
  561. ? ms + 'ms '
  562. : '0'
  563. ).trim();
  564. }