diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index f3ca30eb860e87..23f79c1bbb4805 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -514,6 +514,10 @@ Shows the Timelion tutorial to users when they first open the Timelion app. Used for calculating automatic intervals in visualizations, this is the number of buckets to try to represent. +[[timelion-legacyChartsLibrary]]`timelion:legacyChartsLibrary`:: +Enables the legacy charts library for timelion charts in Visualize. + + [float] [[kibana-visualization-settings]] ==== Visualization diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index fb748d1e766613..1f6b549c0110c2 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -228,6 +228,7 @@ export class DocLinksService { indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, kibanaSearchSettings: `${KIBANA_DOCS}advanced-options.html#kibana-search-settings`, visualizationSettings: `${KIBANA_DOCS}advanced-options.html#kibana-visualization-settings`, + timelionSettings: `${KIBANA_DOCS}advanced-options.html#kibana-timelion-settings`, }, ml: { guide: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/index.html`, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index a7dd5461adf462..ace26dc9e62950 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -80,6 +80,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'text', _meta: { description: 'Non-default value of setting.' }, }, + 'timelion:legacyChartsLibrary': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'timelion:target_buckets': { type: 'long', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index a47acf25a2cc17..95faaee88fa613 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -45,6 +45,7 @@ export interface UsageStats { 'visualization:tileMap:maxPrecision': number; 'csv:separator': string; 'visualization:tileMap:WMSdefaults': string; + 'timelion:legacyChartsLibrary': boolean; 'timelion:target_buckets': number; 'timelion:max_buckets': number; 'timelion:es.timefield': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index a5d4668a4296f9..6947b6cc3ce384 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7258,6 +7258,12 @@ "description": "Non-default value of setting." } }, + "timelion:legacyChartsLibrary": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "timelion:target_buckets": { "type": "long", "_meta": { diff --git a/src/plugins/timelion/public/app.js b/src/plugins/timelion/public/app.js index 6db88ad65a1d1f..4a4b2be679dd35 100644 --- a/src/plugins/timelion/public/app.js +++ b/src/plugins/timelion/public/app.js @@ -21,7 +21,7 @@ import { registerListenEventListener, watchMultiDecorator, } from '../../kibana_legacy/public'; -import { getTimezone } from '../../vis_type_timelion/public'; +import { _LEGACY_ as visTypeTimelion } from '../../vis_type_timelion/public'; import { initCellsDirective } from './directives/cells/cells'; import { initFullscreenDirective } from './directives/fullscreen/fullscreen'; import { initFixedElementDirective } from './directives/fixed_element'; @@ -144,7 +144,7 @@ export function initTimelionApp(app, deps) { $scope.updatedSheets = []; const savedVisualizations = deps.plugins.visualizations.savedVisualizationsLoader; - const timezone = getTimezone(deps.core.uiSettings); + const timezone = visTypeTimelion.getTimezone(deps.core.uiSettings); const defaultExpression = '.es(*)'; diff --git a/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js b/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js index 2abfd2b1e7c7a9..0bc5897c49d6f4 100644 --- a/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js +++ b/src/plugins/timelion/public/directives/timelion_expression_input_helpers.js @@ -7,7 +7,7 @@ */ import _ from 'lodash'; -import { parseTimelionExpressionAsync } from '../../../vis_type_timelion/public'; +import { _LEGACY_ as visTypeTimelion } from '../../../vis_type_timelion/public'; export const SUGGESTION_TYPE = { ARGUMENTS: 'arguments', @@ -180,7 +180,7 @@ async function extractSuggestionsFromParsedResult( export async function suggest(expression, functionList, cursorPosition, argValueSuggestions) { try { - const result = await parseTimelionExpressionAsync(expression); + const result = await visTypeTimelion.parseTimelionExpressionAsync(expression); return await extractSuggestionsFromParsedResult( result, cursorPosition, diff --git a/src/plugins/timelion/public/index.scss b/src/plugins/timelion/public/index.scss index b93e99bf9bcc43..7a4259b2a17c8a 100644 --- a/src/plugins/timelion/public/index.scss +++ b/src/plugins/timelion/public/index.scss @@ -15,4 +15,4 @@ // styles for timelion visualization are lazy loaded only while a vis is opened // this will duplicate styles only if both Timelion app and timelion visualization are loaded // could be left here as it is since the Timelion app is deprecated -@import '../../vis_type_timelion/public/components/timelion_vis.scss'; +@import '../../vis_type_timelion/public/legacy/timelion_vis.scss'; diff --git a/src/plugins/timelion/public/panels/timechart/schema.ts b/src/plugins/timelion/public/panels/timechart/schema.ts index 7178ba135c4c56..dc26adc6ea5f56 100644 --- a/src/plugins/timelion/public/panels/timechart/schema.ts +++ b/src/plugins/timelion/public/panels/timechart/schema.ts @@ -11,13 +11,7 @@ import $ from 'jquery'; import moment from 'moment-timezone'; // @ts-ignore import observeResize from '../../lib/observe_resize'; -import { - calculateInterval, - DEFAULT_TIME_FORMAT, - tickFormatters, - xaxisFormatterProvider, - generateTicksProvider, -} from '../../../../vis_type_timelion/public'; +import { _LEGACY_ as visTypeTimelion } from '../../../../vis_type_timelion/public'; import { TimelionVisualizationDependencies } from '../../application'; const DEBOUNCE_DELAY = 50; @@ -37,9 +31,9 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { help: 'Draw a timeseries chart', render($scope: any, $elem: any) { const template = '
'; - const formatters = tickFormatters() as any; - const getxAxisFormatter = xaxisFormatterProvider(uiSettings); - const generateTicks = generateTicksProvider(); + const formatters = visTypeTimelion.tickFormatters() as any; + const getxAxisFormatter = visTypeTimelion.xaxisFormatterProvider(uiSettings); + const generateTicks = visTypeTimelion.generateTicksProvider(); // TODO: I wonder if we should supply our own moment that sets this every time? // could just use angular's injection to provide a moment service? @@ -226,7 +220,7 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { if (legendCaption) { legendCaption.text( moment(pos.x).format( - _.get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT) + _.get(dataset, '[0]._global.legend.timeFormat', visTypeTimelion.DEFAULT_TIME_FORMAT) ) ); } @@ -289,7 +283,7 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { // Get the X-axis tick format const time = timefilter.timefilter.getBounds() as any; - const interval = calculateInterval( + const interval = visTypeTimelion.calculateInterval( time.min.valueOf(), time.max.valueOf(), uiSettings.get('timelion:target_buckets') || 200, diff --git a/src/plugins/vis_type_timelion/common/constants.ts b/src/plugins/vis_type_timelion/common/constants.ts new file mode 100644 index 00000000000000..a97bdd855107ce --- /dev/null +++ b/src/plugins/vis_type_timelion/common/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const UI_SETTINGS = { + LEGACY_CHARTS_LIBRARY: 'timelion:legacyChartsLibrary', + ES_TIMEFIELD: 'timelion:es.timefield', + DEFAULT_INDEX: 'timelion:es.default_index', + TARGET_BUCKETS: 'timelion:target_buckets', + MAX_BUCKETS: 'timelion:max_buckets', + MIN_INTERVAL: 'timelion:min_interval', + GRAPHITE_URL: 'timelion:graphite.url', + QUANDL_KEY: 'timelion:quandl.key', +}; diff --git a/src/plugins/vis_type_timelion/common/vis_data.ts b/src/plugins/vis_type_timelion/common/vis_data.ts new file mode 100644 index 00000000000000..e3041f43a8f193 --- /dev/null +++ b/src/plugins/vis_type_timelion/common/vis_data.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface VisSeries { + yaxis?: number; + label: string; + lines?: { + show?: boolean; + lineWidth?: number; + fill?: number; + steps?: number; + }; + points?: { + show?: boolean; + symbol?: 'cross' | 'x' | 'circle' | 'square' | 'diamond' | 'plus' | 'triangle'; + fillColor?: string; + fill?: number; + radius?: number; + lineWidth?: number; + }; + bars: { + lineWidth?: number; + fill?: number; + }; + color?: string; + data: Array>; + stack: boolean; +} diff --git a/src/plugins/vis_type_timelion/kibana.json b/src/plugins/vis_type_timelion/kibana.json index bbd52e3e1bec56..bf537f4ffbc76a 100644 --- a/src/plugins/vis_type_timelion/kibana.json +++ b/src/plugins/vis_type_timelion/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions"], + "requiredPlugins": ["visualizations", "data", "expressions", "charts"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { "name": "Kibana App", diff --git a/src/plugins/vis_type_timelion/public/components/series/area.tsx b/src/plugins/vis_type_timelion/public/components/series/area.tsx new file mode 100644 index 00000000000000..589a488d3acad4 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/components/series/area.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { AreaSeries, ScaleType, CurveType, AreaSeriesStyle, PointShape } from '@elastic/charts'; +import type { VisSeries } from '../../../common/vis_data'; + +interface AreaSeriesComponentProps { + index: number; + visData: VisSeries; + groupId: string; +} + +const isShowLines = (lines: VisSeries['lines'], points: VisSeries['points']) => + lines?.show ? true : points?.show ? false : true; + +const getAreaSeriesStyle = ({ color, lines, points }: AreaSeriesComponentProps['visData']) => + ({ + line: { + opacity: isShowLines(lines, points) ? 1 : 0, + stroke: color, + strokeWidth: lines?.lineWidth !== undefined ? Number(lines.lineWidth) : 3, + visible: isShowLines(lines, points), + }, + area: { + fill: color, + opacity: lines?.fill ?? 0, + visible: lines?.show ?? points?.show ?? true, + }, + point: { + fill: points?.fillColor ?? color, + opacity: points?.lineWidth !== undefined ? (points.fill || 1) * 10 : 10, + radius: points?.radius ?? 3, + stroke: color, + strokeWidth: points?.lineWidth ?? 2, + visible: points?.show ?? false, + shape: points?.symbol === 'cross' ? PointShape.X : points?.symbol, + }, + curve: lines?.steps ? CurveType.CURVE_STEP : CurveType.LINEAR, + } as AreaSeriesStyle); + +export const AreaSeriesComponent = ({ index, groupId, visData }: AreaSeriesComponentProps) => ( + +); diff --git a/src/plugins/vis_type_timelion/public/components/series/bar.tsx b/src/plugins/vis_type_timelion/public/components/series/bar.tsx new file mode 100644 index 00000000000000..6a97c8fea96905 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/components/series/bar.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { BarSeries, ScaleType, BarSeriesStyle } from '@elastic/charts'; +import type { VisSeries } from '../../../common/vis_data'; + +interface BarSeriesComponentProps { + index: number; + visData: VisSeries; + groupId: string; +} + +const getBarSeriesStyle = ({ color, bars }: BarSeriesComponentProps['visData']) => { + let opacity = bars.fill ?? 1; + + if (opacity < 0) { + opacity = 0; + } else if (opacity > 1) { + opacity = 1; + } + + return { + rectBorder: { + stroke: color, + strokeWidth: Math.max(1, bars.lineWidth ? Math.ceil(bars.lineWidth / 2) : 1), + visible: true, + }, + rect: { + fill: color, + opacity, + widthPixel: 1, + }, + } as BarSeriesStyle; +}; + +export const BarSeriesComponent = ({ index, groupId, visData }: BarSeriesComponentProps) => ( + +); diff --git a/src/plugins/vis_type_timelion/public/components/series/index.ts b/src/plugins/vis_type_timelion/public/components/series/index.ts new file mode 100644 index 00000000000000..3efe537d0b4672 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/components/series/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { BarSeriesComponent } from './bar'; +export { AreaSeriesComponent } from './area'; diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis.scss b/src/plugins/vis_type_timelion/public/components/timelion_vis.scss index c4d591bc82cad9..663563432d56b5 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_vis.scss +++ b/src/plugins/vis_type_timelion/public/components/timelion_vis.scss @@ -1,60 +1,10 @@ -.timChart { +.timelionChart { height: 100%; width: 100%; display: flex; flex-direction: column; - - // Custom Jquery FLOT / schema selectors - // Cannot change at the moment - - .chart-top-title { - @include euiFontSizeXS; - flex: 0; - text-align: center; - font-weight: $euiFontWeightBold; - } - - .chart-canvas { - min-width: 100%; - flex: 1; - overflow: hidden; - } - - .legendLabel { - white-space: nowrap; - text-overflow: ellipsis; - overflow-x: hidden; - line-height: normal; - } - - .legendColorBox { - vertical-align: middle; - } - - .ngLegendValue { - color: $euiTextColor; - cursor: pointer; - - &:focus, - &:hover { - text-decoration: underline; - } - } - - .ngLegendValueNumber { - margin-left: $euiSizeXS; - margin-right: $euiSizeXS; - font-weight: $euiFontWeightBold; - } - - .flot-tick-label { - font-size: $euiFontSizeXS; - color: $euiColorDarkShade; - } } -.timChart__legendCaption { - color: $euiTextColor; - white-space: nowrap; - font-weight: $euiFontWeightBold; +.timelionChart__topTitle { + text-align: center; } diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx index 09e3b78c922cd9..4690f4fe11e458 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_type_timelion/public/components/timelion_vis_component.tsx @@ -6,422 +6,228 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import $ from 'jquery'; -import moment from 'moment-timezone'; -import { debounce, compact, get, each, cloneDeep, last, map } from 'lodash'; -import { useResizeObserver } from '@elastic/eui'; +import React, { useEffect, useCallback, useMemo, useRef } from 'react'; +import { compact, last, map } from 'lodash'; +import { + Chart, + Settings, + Position, + Axis, + TooltipType, + PointerEvent, + LegendPositionConfig, + LayoutDirection, +} from '@elastic/charts'; +import { EuiTitle } from '@elastic/eui'; -import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; import { useKibana } from '../../../kibana_react/public'; -import { DEFAULT_TIME_FORMAT } from '../../common/lib'; + +import { AreaSeriesComponent, BarSeriesComponent } from './series'; import { - buildSeriesData, - buildOptions, - SERIES_ID_ATTR, - colors, - Axis, - ACTIVE_CURSOR, - eventBus, + extractAllYAxis, + withStaticPadding, + createTickFormat, + validateLegendPositionValue, + MAIN_GROUP_ID, } from '../helpers/panel_utils'; -import { Series, Sheet } from '../helpers/timelion_request_handler'; -import { tickFormatters } from '../helpers/tick_formatters'; -import { generateTicksProvider } from '../helpers/tick_generator'; -import { TimelionVisDependencies } from '../plugin'; +import { colors } from '../helpers/chart_constants'; +import { activeCursor$ } from '../helpers/active_cursor'; -import './timelion_vis.scss'; +import type { Sheet } from '../helpers/timelion_request_handler'; +import type { IInterpreterRenderHandlers } from '../../../expressions'; +import type { TimelionVisDependencies } from '../plugin'; +import type { RangeFilterParams } from '../../../data/public'; +import type { Series } from '../helpers/timelion_request_handler'; -interface CrosshairPlot extends jquery.flot.plot { - setCrosshair: (pos: Position) => void; - clearCrosshair: () => void; -} +import './timelion_vis.scss'; interface TimelionVisComponentProps { - fireEvent: IInterpreterRenderHandlers['event']; interval: string; seriesList: Sheet; + onBrushEvent: (rangeFilterParams: RangeFilterParams) => void; renderComplete: IInterpreterRenderHandlers['done']; } -interface Position { - x: number; - x1: number; - y: number; - y1: number; - pageX: number; - pageY: number; -} - -interface Range { - to: number; - from: number; -} - -interface Ranges { - xaxis: Range; - yaxis: Range; -} - -const DEBOUNCE_DELAY = 50; -// ensure legend is the same height with or without a caption so legend items do not move around -const emptyCaption = '
'; - -function TimelionVisComponent({ +const DefaultYAxis = () => ( + +); + +const renderYAxis = (series: Series[]) => { + const yAxisOptions = extractAllYAxis(series); + + const yAxis = yAxisOptions.map((option, index) => ( + + )); + + return yAxis.length ? yAxis : ; +}; + +const TimelionVisComponent = ({ interval, seriesList, renderComplete, - fireEvent, -}: TimelionVisComponentProps) { + onBrushEvent, +}: TimelionVisComponentProps) => { const kibana = useKibana(); - const [chart, setChart] = useState(() => cloneDeep(seriesList.list)); - const [canvasElem, setCanvasElem] = useState(); - const [chartElem, setChartElem] = useState(null); - - const [originalColorMap, setOriginalColorMap] = useState(() => new Map()); + const chartRef = useRef(null); + const chart = seriesList.list; - const [highlightedSeries, setHighlightedSeries] = useState(null); - const [focusedSeries, setFocusedSeries] = useState(); - const [plot, setPlot] = useState(); - - // Used to toggle the series, and for displaying values on hover - const [legendValueNumbers, setLegendValueNumbers] = useState>(); - const [legendCaption, setLegendCaption] = useState>(); + useEffect(() => { + const subscription = activeCursor$.subscribe((cursor: PointerEvent) => { + chartRef.current?.dispatchExternalPointerEvent(cursor); + }); - const canvasRef = useCallback((node: HTMLDivElement | null) => { - if (node !== null) { - setCanvasElem(node); - } + return () => { + subscription.unsubscribe(); + }; }, []); - const elementRef = useCallback((node: HTMLDivElement | null) => { - if (node !== null) { - setChartElem(node); - } + const handleCursorUpdate = useCallback((cursor: PointerEvent) => { + activeCursor$.next(cursor); }, []); - useEffect( - () => () => { - if (chartElem) { - $(chartElem).off('plotselected').off('plothover').off('mouseleave'); - } - }, - [chartElem] - ); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const highlightSeries = useCallback( - debounce(({ currentTarget }: JQuery.TriggeredEvent) => { - const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); - if (highlightedSeries === id) { + const brushEndListener = useCallback( + ({ x }) => { + if (!x) { return; } - setHighlightedSeries(id); - setChart((chartState) => - chartState.map((series: Series, seriesIndex: number) => { - series.color = - seriesIndex === id - ? originalColorMap.get(series) // color it like it was - : 'rgba(128,128,128,0.1)'; // mark as grey - - return series; - }) - ); - }, DEBOUNCE_DELAY), - [originalColorMap, highlightedSeries] - ); - - const focusSeries = useCallback( - (event: JQuery.TriggeredEvent) => { - const id = Number(event.currentTarget.getAttribute(SERIES_ID_ATTR)); - setFocusedSeries(id); - highlightSeries(event); + onBrushEvent({ + gte: x[0], + lte: x[1], + }); }, - [highlightSeries] + [onBrushEvent] ); - const toggleSeries = useCallback(({ currentTarget }: JQuery.TriggeredEvent) => { - const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); - - setChart((chartState) => - chartState.map((series: Series, seriesIndex: number) => { - if (seriesIndex === id) { - series._hide = !series._hide; - } - return series; - }) - ); - }, []); - - const updateCaption = useCallback( - (plotData: any) => { - if (canvasElem && get(plotData, '[0]._global.legend.showTime', true)) { - const caption = $(''); - caption.html(emptyCaption); - setLegendCaption(caption); - - const canvasNode = $(canvasElem); - canvasNode.find('div.legend table').append(caption); - setLegendValueNumbers(canvasNode.find('.ngLegendValueNumber')); - - const legend = $(canvasElem).find('.ngLegendValue'); - if (legend) { - legend.click(toggleSeries); - legend.focus(focusSeries); - legend.mouseover(highlightSeries); - } - - // legend has been re-created. Apply focus on legend element when previously set - if (focusedSeries || focusedSeries === 0) { - canvasNode.find('div.legend table .legendLabel>span').get(focusedSeries).focus(); - } + const onRenderChange = useCallback( + (isRendered: boolean) => { + if (isRendered) { + renderComplete(); } }, - [focusedSeries, canvasElem, toggleSeries, focusSeries, highlightSeries] + [renderComplete] ); - const updatePlot = useCallback( - (chartValue: Series[], grid?: boolean) => { - if (canvasElem && canvasElem.clientWidth > 0 && canvasElem.clientHeight > 0) { - const options = buildOptions( - interval, - kibana.services.timefilter, - kibana.services.uiSettings, - chartElem?.clientWidth, - grid - ); - const updatedSeries = buildSeriesData(chartValue, options); - - if (options.yaxes) { - options.yaxes.forEach((yaxis: Axis) => { - if (yaxis && yaxis.units) { - const formatters = tickFormatters(); - yaxis.tickFormatter = formatters[yaxis.units.type as keyof typeof formatters]; - const byteModes = ['bytes', 'bytes/s']; - if (byteModes.includes(yaxis.units.type)) { - yaxis.tickGenerator = generateTicksProvider(); - } - } - }); - } - - const newPlot = $.plot($(canvasElem), updatedSeries, options); - setPlot(newPlot); - renderComplete(); + const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [ + seriesList.list, + ]); - updateCaption(newPlot.getData()); - } - }, - [canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption] + const tickFormat = useMemo( + () => createTickFormat(interval, kibana.services.timefilter, kibana.services.uiSettings), + [interval, kibana.services.timefilter, kibana.services.uiSettings] ); - const dimensions = useResizeObserver(chartElem); + const legend = useMemo(() => { + const legendPosition: LegendPositionConfig = { + floating: true, + floatingColumns: 1, + vAlign: Position.Top, + hAlign: Position.Left, + direction: LayoutDirection.Vertical, + }; + let showLegend = true; - useEffect(() => { - updatePlot(chart, seriesList.render && seriesList.render.grid); - }, [chart, updatePlot, seriesList.render, dimensions]); + chart.forEach((series) => { + if (series._global?.legend) { + const { show = true, position, noColumns = legendPosition.floatingColumns } = + series._global?.legend ?? {}; - useEffect(() => { - const colorsSet: Array<[Series, string]> = []; - const newChart = seriesList.list.map((series: Series, seriesIndex: number) => { - const newSeries = { ...series }; - if (!newSeries.color) { - const colorIndex = seriesIndex % colors.length; - newSeries.color = colors[colorIndex]; - } - colorsSet.push([newSeries, newSeries.color]); - return newSeries; - }); - setChart(newChart); - setOriginalColorMap(new Map(colorsSet)); - }, [seriesList.list]); - - const unhighlightSeries = useCallback(() => { - if (highlightedSeries === null) { - return; - } - - setHighlightedSeries(null); - setFocusedSeries(null); - - setChart((chartState) => - chartState.map((series: Series) => { - series.color = originalColorMap.get(series); // reset the colors - return series; - }) - ); - }, [originalColorMap, highlightedSeries]); - - // Shamelessly borrowed from the flotCrosshairs example - const setLegendNumbers = useCallback( - (pos: Position) => { - unhighlightSeries(); - - const axes = plot!.getAxes(); - if (pos.x < axes.xaxis.min! || pos.x > axes.xaxis.max!) { - return; - } + if (validateLegendPositionValue(position)) { + const [vAlign, hAlign] = position.split(''); - const dataset = plot!.getData(); - if (legendCaption) { - legendCaption.text( - moment(pos.x).format(get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT)) - ); - } - for (let i = 0; i < dataset.length; ++i) { - const series = dataset[i]; - const useNearestPoint = series.lines!.show && !series.lines!.steps; - const precision = get(series, '_meta.precision', 2); - - // We're setting this flag on top on the series object belonging to the flot library, so we're simply casting here. - if ((series as { _hide?: boolean })._hide) { - continue; + legendPosition.vAlign = vAlign === 'n' ? Position.Top : Position.Bottom; + legendPosition.hAlign = hAlign === 'e' ? Position.Right : Position.Left; } - const currentPoint = series.data.find((point: [number, number], index: number) => { - if (index + 1 === series.data.length) { - return true; - } - if (useNearestPoint) { - return pos.x - point[0] < series.data[index + 1][0] - pos.x; - } else { - return pos.x < series.data[index + 1][0]; - } - }); - - const y = currentPoint[1]; - - if (legendValueNumbers) { - if (y == null) { - legendValueNumbers.eq(i).empty(); - } else { - let label = y.toFixed(precision); - const formatter = ((series.yaxis as unknown) as Axis).tickFormatter; - if (formatter) { - label = formatter(Number(label), (series.yaxis as unknown) as Axis); - } - legendValueNumbers.eq(i).text(`(${label})`); - } + if (!show) { + showLegend = false; } - } - }, - [plot, legendValueNumbers, unhighlightSeries, legendCaption] - ); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const debouncedSetLegendNumbers = useCallback( - debounce(setLegendNumbers, DEBOUNCE_DELAY, { - maxWait: DEBOUNCE_DELAY, - leading: true, - trailing: false, - }), - [setLegendNumbers] - ); - - const clearLegendNumbers = useCallback(() => { - if (legendCaption) { - legendCaption.html(emptyCaption); - } - each(legendValueNumbers!, (num: Node) => { - $(num).empty(); - }); - }, [legendCaption, legendValueNumbers]); - - const plotHover = useCallback( - (pos: Position) => { - (plot as CrosshairPlot).setCrosshair(pos); - debouncedSetLegendNumbers(pos); - }, - [plot, debouncedSetLegendNumbers] - ); - - const plotHoverHandler = useCallback( - (event: JQuery.TriggeredEvent, pos: Position) => { - if (!plot) { - return; - } - plotHover(pos); - eventBus.trigger(ACTIVE_CURSOR, [event, pos]); - }, - [plot, plotHover] - ); - useEffect(() => { - const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => { - if (!plot) { - return; + if (noColumns !== undefined) { + legendPosition.floatingColumns = noColumns; + } } - plotHover(pos); - }; - - eventBus.on(ACTIVE_CURSOR, updateCursor); - - return () => { - eventBus.off(ACTIVE_CURSOR, updateCursor); - }; - }, [plot, plotHover]); - - const mouseLeaveHandler = useCallback(() => { - if (!plot) { - return; - } - (plot as CrosshairPlot).clearCrosshair(); - clearLegendNumbers(); - }, [plot, clearLegendNumbers]); - - const plotSelectedHandler = useCallback( - (event: JQuery.TriggeredEvent, ranges: Ranges) => { - fireEvent({ - name: 'applyFilter', - data: { - timeFieldName: '*', - filters: [ - { - range: { - '*': { - gte: ranges.xaxis.from, - lte: ranges.xaxis.to, - }, - }, - }, - ], - }, - }); - }, - [fireEvent] - ); - - useEffect(() => { - if (chartElem) { - $(chartElem).off('plotselected').on('plotselected', plotSelectedHandler); - } - }, [chartElem, plotSelectedHandler]); - - useEffect(() => { - if (chartElem) { - $(chartElem).off('mouseleave').on('mouseleave', mouseLeaveHandler); - } - }, [chartElem, mouseLeaveHandler]); + }); - useEffect(() => { - if (chartElem) { - $(chartElem).off('plothover').on('plothover', plotHoverHandler); - } - }, [chartElem, plotHoverHandler]); - - const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [ - seriesList.list, - ]); + return { legendPosition, showLegend }; + }, [chart]); return ( -
-
{title}
-
+
+ {title && ( + +

{title}

+
+ )} + + tickFormat(value), + type: TooltipType.VerticalCursor, + }} + externalPointerEvents={{ tooltip: { visible: false } }} + /> + + + + {renderYAxis(chart)} + + {chart.map((data, index) => { + const visData = { ...data }; + const SeriesComponent = data.bars ? BarSeriesComponent : AreaSeriesComponent; + + if (!visData.color) { + visData.color = colors[index % colors.length]; + } + return ( + + ); + })} +
); -} +}; // default export required for React.Lazy // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts b/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts new file mode 100644 index 00000000000000..7f7f62fd6a9dab --- /dev/null +++ b/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Subject } from 'rxjs'; +import { PointerEvent } from '@elastic/charts'; + +export const activeCursor$ = new Subject(); diff --git a/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts b/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts new file mode 100644 index 00000000000000..b530ec98bd8a17 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const colors = [ + '#01A4A4', + '#C66', + '#D0D102', + '#616161', + '#00A1CB', + '#32742C', + '#F18D05', + '#113F8C', + '#61AE24', + '#D70060', +]; diff --git a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts index 8ef527a181e8ca..1ee834b7d30ed5 100644 --- a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts +++ b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts @@ -6,18 +6,18 @@ * Side Public License, v 1. */ -import { cloneDeep, defaults, mergeWith, compact } from 'lodash'; -import $ from 'jquery'; -import moment, { Moment } from 'moment-timezone'; - -import { TimefilterContract } from 'src/plugins/data/public'; -import { IUiSettingsClient } from 'kibana/public'; +import moment from 'moment-timezone'; +import { Position, AxisSpec } from '@elastic/charts'; +import type { TimefilterContract } from 'src/plugins/data/public'; +import type { IUiSettingsClient } from 'kibana/public'; import { calculateInterval } from '../../common/lib'; import { xaxisFormatterProvider } from './xaxis_formatter'; -import { Series } from './timelion_request_handler'; +import { tickFormatters } from './tick_formatters'; + +import type { Series } from './timelion_request_handler'; -export interface Axis { +export interface IAxis { delta?: number; max?: number; min?: number; @@ -30,87 +30,26 @@ export interface Axis { tickLength: number; timezone: string; tickDecimals?: number; - tickFormatter: ((val: number) => string) | ((val: number, axis: Axis) => string); - tickGenerator?(axis: Axis): number[]; - units?: { type: string }; -} - -interface TimeRangeBounds { - min: Moment | undefined; - max: Moment | undefined; + tickFormatter: (val: number) => string; + tickGenerator?(axis: IAxis): number[]; + units?: { type: string; prefix: string; suffix: string }; + domain?: { + min?: number; + max?: number; + }; + position?: Position; + axisLabel?: string; } -export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION'; -export const eventBus = $({}); - -const colors = [ - '#01A4A4', - '#C66', - '#D0D102', - '#616161', - '#00A1CB', - '#32742C', - '#F18D05', - '#113F8C', - '#61AE24', - '#D70060', -]; - -const SERIES_ID_ATTR = 'data-series-id'; - -function buildSeriesData(chart: Series[], options: jquery.flot.plotOptions) { - const seriesData = chart.map((series: Series, seriesIndex: number) => { - const newSeries: Series = cloneDeep( - defaults(series, { - shadowSize: 0, - lines: { - lineWidth: 3, - }, - }) - ); - - newSeries._id = seriesIndex; - - if (series.color) { - const span = document.createElement('span'); - span.style.color = series.color; - newSeries.color = span.style.color; - } - - if (series._hide) { - newSeries.data = []; - newSeries.stack = false; - newSeries.label = `(hidden) ${series.label}`; - } - - if (series._global) { - mergeWith(options, series._global, (objVal, srcVal) => { - // This is kind of gross, it means that you can't replace a global value with a null - // best you can do is an empty string. Deal with it. - if (objVal == null) { - return srcVal; - } - if (srcVal == null) { - return objVal; - } - }); - } - - return newSeries; - }); +export const validateLegendPositionValue = (position: string) => /^(n|s)(e|w)$/s.test(position); - return compact(seriesData); -} - -function buildOptions( +export const createTickFormat = ( intervalValue: string, timefilter: TimefilterContract, - uiSettings: IUiSettingsClient, - clientWidth = 0, - showGrid?: boolean -) { + uiSettings: IUiSettingsClient +) => { // Get the X-axis tick format - const time: TimeRangeBounds = timefilter.getBounds(); + const time = timefilter.getBounds(); const interval = calculateInterval( (time.min && time.min.valueOf()) || 0, (time.max && time.max.valueOf()) || 0, @@ -120,61 +59,75 @@ function buildOptions( ); const format = xaxisFormatterProvider(uiSettings)(interval); - const tickLetterWidth = 7; - const tickPadding = 45; - - const options = { - xaxis: { - mode: 'time', - tickLength: 5, - timezone: 'browser', - // Calculate how many ticks can fit on the axis - ticks: Math.floor(clientWidth / (format.length * tickLetterWidth + tickPadding)), - // Use moment to format ticks so we get timezone correction - tickFormatter: (val: number) => moment(val).format(format), - }, - selection: { - mode: 'x', - color: '#ccc', - }, - crosshair: { - mode: 'x', - color: '#C66', - lineWidth: 2, - }, - colors, - grid: { - show: showGrid, - borderWidth: 0, - borderColor: null, - margin: 10, - hoverable: true, - autoHighlight: false, - }, - legend: { - backgroundColor: 'rgb(255,255,255,0)', - position: 'nw', - labelBoxBorderColor: 'rgb(255,255,255,0)', - labelFormatter(label: string, series: { _id: number }) { - const wrapperSpan = document.createElement('span'); - const labelSpan = document.createElement('span'); - const numberSpan = document.createElement('span'); - - wrapperSpan.setAttribute('class', 'ngLegendValue'); - wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`); - - labelSpan.appendChild(document.createTextNode(label)); - numberSpan.setAttribute('class', 'ngLegendValueNumber'); - - wrapperSpan.appendChild(labelSpan); - wrapperSpan.appendChild(numberSpan); - - return wrapperSpan.outerHTML; - }, - }, - } as jquery.flot.plotOptions & { yaxes?: Axis[] }; - - return options; -} + return (val: number) => moment(val).format(format); +}; + +/** While we support 2 versions of the timeline, we need this adapter. **/ +export const MAIN_GROUP_ID = 1; + +export const withStaticPadding = (domain: AxisSpec['domain']): AxisSpec['domain'] => + (({ + ...domain, + padding: 50, + paddingUnit: 'pixel', + } as unknown) as AxisSpec['domain']); + +const adaptYaxisParams = (yaxis: IAxis) => { + const y = { ...yaxis }; + + if (y.units) { + const formatters = tickFormatters(y); + y.tickFormatter = formatters[y.units.type as keyof typeof formatters]; + } else if (yaxis.tickDecimals) { + y.tickFormatter = (val: number) => val.toFixed(yaxis.tickDecimals); + } + + return { + title: y.axisLabel, + position: y.position, + tickFormat: y.tickFormatter, + domain: withStaticPadding({ + fit: y.min === undefined && y.max === undefined, + min: y.min, + max: y.max, + }), + }; +}; + +const extractYAxisForSeries = (series: Series) => { + const yaxis = (series._global?.yaxes ?? []).reduce( + (acc: IAxis, item: IAxis) => ({ + ...acc, + ...item, + }), + {} + ); + + if (Object.keys(yaxis).length) { + return adaptYaxisParams(yaxis); + } +}; + +export const extractAllYAxis = (series: Series[]) => { + return series.reduce((acc, data, index) => { + const yaxis = extractYAxisForSeries(data); + const groupId = `${data.yaxis ? data.yaxis : MAIN_GROUP_ID}`; + + if (acc.every((axis) => axis.groupId !== groupId)) { + acc.push({ + groupId, + domain: withStaticPadding({ + fit: false, + }), + id: (yaxis?.position || Position.Left) + index, + position: Position.Left, + ...yaxis, + }); + } else if (yaxis) { + const axisOptionIndex = acc.findIndex((axis) => axis.groupId === groupId); + acc[axisOptionIndex] = { ...acc[axisOptionIndex], ...yaxis }; + } -export { buildSeriesData, buildOptions, SERIES_ID_ATTR, colors }; + return acc; + }, [] as Array>); +}; diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts index 03b7c217069577..9980644c0f9419 100644 --- a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts +++ b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts @@ -7,25 +7,26 @@ */ import { tickFormatters } from './tick_formatters'; +import type { IAxis } from './panel_utils'; -describe('Tick Formatters', function () { +describe('Tick Formatters', () => { let formatters: any; beforeEach(function () { - formatters = tickFormatters(); + formatters = tickFormatters({} as IAxis); }); - describe('Bits mode', function () { + describe('Bits mode', () => { let bitFormatter: any; beforeEach(function () { bitFormatter = formatters.bits; }); - it('is a function', function () { + it('is a function', () => { expect(bitFormatter).toEqual(expect.any(Function)); }); - it('formats with b/kb/mb/gb', function () { + it('formats with b/kb/mb/gb', () => { expect(bitFormatter(7)).toEqual('7b'); expect(bitFormatter(4 * 1000)).toEqual('4kb'); expect(bitFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb'); @@ -40,24 +41,24 @@ describe('Tick Formatters', function () { }); }); - describe('Bits/s mode', function () { + describe('Bits/s mode', () => { let bitsFormatter: any; beforeEach(function () { bitsFormatter = formatters['bits/s']; }); - it('is a function', function () { + it('is a function', () => { expect(bitsFormatter).toEqual(expect.any(Function)); }); - it('formats with b/kb/mb/gb', function () { + it('formats with b/kb/mb/gb', () => { expect(bitsFormatter(7)).toEqual('7b/s'); expect(bitsFormatter(4 * 1000)).toEqual('4kb/s'); expect(bitsFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb/s'); expect(bitsFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb/s'); }); - it('formats negative values with b/kb/mb/gb', function () { + it('formats negative values with b/kb/mb/gb', () => { expect(bitsFormatter(-7)).toEqual('-7b/s'); expect(bitsFormatter(-4 * 1000)).toEqual('-4kb/s'); expect(bitsFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb/s'); @@ -65,24 +66,24 @@ describe('Tick Formatters', function () { }); }); - describe('Bytes mode', function () { + describe('Bytes mode', () => { let byteFormatter: any; beforeEach(function () { byteFormatter = formatters.bytes; }); - it('is a function', function () { + it('is a function', () => { expect(byteFormatter).toEqual(expect.any(Function)); }); - it('formats with B/KB/MB/GB', function () { + it('formats with B/KB/MB/GB', () => { expect(byteFormatter(10)).toEqual('10B'); expect(byteFormatter(10 * 1024)).toEqual('10KB'); expect(byteFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB'); expect(byteFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB'); }); - it('formats negative values with B/KB/MB/GB', function () { + it('formats negative values with B/KB/MB/GB', () => { expect(byteFormatter(-10)).toEqual('-10B'); expect(byteFormatter(-10 * 1024)).toEqual('-10KB'); expect(byteFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB'); @@ -90,24 +91,24 @@ describe('Tick Formatters', function () { }); }); - describe('Bytes/s mode', function () { + describe('Bytes/s mode', () => { let bytesFormatter: any; beforeEach(function () { bytesFormatter = formatters['bytes/s']; }); - it('is a function', function () { + it('is a function', () => { expect(bytesFormatter).toEqual(expect.any(Function)); }); - it('formats with B/KB/MB/GB', function () { + it('formats with B/KB/MB/GB', () => { expect(bytesFormatter(10)).toEqual('10B/s'); expect(bytesFormatter(10 * 1024)).toEqual('10KB/s'); expect(bytesFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB/s'); expect(bytesFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB/s'); }); - it('formats negative values with B/KB/MB/GB', function () { + it('formats negative values with B/KB/MB/GB', () => { expect(bytesFormatter(-10)).toEqual('-10B/s'); expect(bytesFormatter(-10 * 1024)).toEqual('-10KB/s'); expect(bytesFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB/s'); @@ -115,108 +116,105 @@ describe('Tick Formatters', function () { }); }); - describe('Currency mode', function () { + describe('Currency mode', () => { let currencyFormatter: any; beforeEach(function () { currencyFormatter = formatters.currency; }); - it('is a function', function () { + it('is a function', () => { expect(currencyFormatter).toEqual(expect.any(Function)); }); - it('formats with $ by default', function () { + it('formats with $ by default', () => { const axis = { - options: { - units: {}, - }, + units: {}, }; - expect(currencyFormatter(10.2, axis)).toEqual('$10.20'); + formatters = tickFormatters(axis as IAxis); + currencyFormatter = formatters.currency; + expect(currencyFormatter(10.2)).toEqual('$10.20'); }); - it('accepts currency in ISO 4217', function () { + it('accepts currency in ISO 4217', () => { const axis = { - options: { - units: { - prefix: 'CNY', - }, + units: { + prefix: 'CNY', }, }; - - expect(currencyFormatter(10.2, axis)).toEqual('CN¥10.20'); + formatters = tickFormatters(axis as IAxis); + currencyFormatter = formatters.currency; + expect(currencyFormatter(10.2)).toEqual('CN¥10.20'); }); }); - describe('Percent mode', function () { + describe('Percent mode', () => { let percentFormatter: any; beforeEach(function () { percentFormatter = formatters.percent; }); - it('is a function', function () { + it('is a function', () => { expect(percentFormatter).toEqual(expect.any(Function)); }); - it('formats with %', function () { + it('formats with %', () => { const axis = { - options: { - units: {}, - }, + units: {}, }; - expect(percentFormatter(0.1234, axis)).toEqual('12%'); + formatters = tickFormatters(axis as IAxis); + percentFormatter = formatters.percent; + expect(percentFormatter(0.1234)).toEqual('12%'); }); - it('formats with % with decimal precision', function () { + it('formats with % with decimal precision', () => { const tickDecimals = 3; const tickDecimalShift = 2; const axis = { tickDecimals: tickDecimals + tickDecimalShift, - options: { - units: { - tickDecimalsShift: tickDecimalShift, - }, + units: { + tickDecimalsShift: tickDecimalShift, }, - }; - expect(percentFormatter(0.12345, axis)).toEqual('12.345%'); + } as unknown; + formatters = tickFormatters(axis as IAxis); + percentFormatter = formatters.percent; + expect(percentFormatter(0.12345)).toEqual('12.345%'); }); }); - describe('Custom mode', function () { + describe('Custom mode', () => { let customFormatter: any; beforeEach(function () { customFormatter = formatters.custom; }); - it('is a function', function () { + it('is a function', () => { expect(customFormatter).toEqual(expect.any(Function)); }); - it('accepts prefix and suffix', function () { + it('accepts prefix and suffix', () => { const axis = { - options: { - units: { - prefix: 'prefix', - suffix: 'suffix', - }, + units: { + prefix: 'prefix', + suffix: 'suffix', }, tickDecimals: 1, }; - - expect(customFormatter(10.2, axis)).toEqual('prefix10.2suffix'); + formatters = tickFormatters(axis as IAxis); + customFormatter = formatters.custom; + expect(customFormatter(10.2)).toEqual('prefix10.2suffix'); }); - it('correctly renders small values', function () { + it('correctly renders small values', () => { const axis = { - options: { - units: { - prefix: 'prefix', - suffix: 'suffix', - }, + units: { + prefix: 'prefix', + suffix: 'suffix', }, tickDecimals: 3, }; - - expect(customFormatter(0.00499999999999999, axis)).toEqual('prefix0.005suffix'); + formatters = tickFormatters(axis as IAxis); + customFormatter = formatters.custom; + expect(customFormatter(0.00499999999999999)).toEqual('prefix0.005suffix'); }); }); }); diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts index 56fa17e74f2755..eb37e76e1f95d2 100644 --- a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts +++ b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts @@ -8,9 +8,9 @@ import { get } from 'lodash'; -import { Axis } from './panel_utils'; +import { IAxis } from './panel_utils'; -function baseTickFormatter(value: number, axis: Axis) { +function baseTickFormatter(value: number, axis: IAxis) { const factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1; const formatted = '' + Math.round(value * factor) / factor; @@ -45,21 +45,20 @@ function unitFormatter(divisor: number, units: string[]) { }; } -export function tickFormatters() { +export function tickFormatters(axis: IAxis) { return { bits: unitFormatter(1000, ['b', 'kb', 'mb', 'gb', 'tb', 'pb']), 'bits/s': unitFormatter(1000, ['b/s', 'kb/s', 'mb/s', 'gb/s', 'tb/s', 'pb/s']), bytes: unitFormatter(1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']), 'bytes/s': unitFormatter(1024, ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s']), - currency(val: number, axis: Axis) { + currency(val: number) { return val.toLocaleString('en', { style: 'currency', - currency: (axis && axis.options && axis.options.units.prefix) || 'USD', + currency: (axis && axis.units && axis.units.prefix) || 'USD', }); }, - percent(val: number, axis: Axis) { - let precision = - get(axis, 'tickDecimals', 0) - get(axis, 'options.units.tickDecimalsShift', 0); + percent(val: number) { + let precision = get(axis, 'tickDecimals', 0) - get(axis, 'units.tickDecimalsShift', 0); // toFixed only accepts values between 0 and 20 if (precision < 0) { precision = 0; @@ -69,10 +68,10 @@ export function tickFormatters() { return (val * 100).toFixed(precision) + '%'; }, - custom(val: number, axis: Axis) { + custom(val: number) { const formattedVal = baseTickFormatter(val, axis); - const prefix = axis && axis.options && axis.options.units.prefix; - const suffix = axis && axis.options && axis.options.units.suffix; + const prefix = axis && axis.units && axis.units.prefix; + const suffix = axis && axis.units && axis.units.suffix; return prefix + formattedVal + suffix; }, }; diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts b/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts index af559d5aaac2b1..6ffdda0bafdb61 100644 --- a/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts +++ b/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { Axis } from './panel_utils'; +import { IAxis } from './panel_utils'; export function generateTicksProvider() { function floorInBase(n: number, base: number) { return base * Math.floor(n / base); } - function generateTicks(axis: Axis) { + function generateTicks(axis: IAxis) { const returnTicks = []; let tickSize = 2; let delta = axis.delta || 0; @@ -46,5 +46,5 @@ export function generateTicksProvider() { return returnTicks; } - return (axis: Axis) => generateTicks(axis); + return (axis: IAxis) => generateTicks(axis); } diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index 7e8f28bd32b2fd..fe5e9183976fa4 100644 --- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -12,6 +12,7 @@ import { TimelionVisDependencies } from '../plugin'; import { getTimezone } from './get_timezone'; import { TimelionVisParams } from '../timelion_vis_fn'; import { getDataSearch } from '../helpers/plugin_services'; +import { VisSeries } from '../../common/vis_data'; interface Stats { cacheCount: number; @@ -21,17 +22,13 @@ interface Stats { sheetTime: number; } -export interface Series { - _global?: boolean; +export interface Series extends VisSeries { + _global?: Record; _hide?: boolean; _id?: number; _title?: string; - color?: string; - data: Array>; fit: string; - label: string; split: string; - stack?: boolean; type: string; } diff --git a/src/plugins/vis_type_timelion/public/index.ts b/src/plugins/vis_type_timelion/public/index.ts index fa257907a176dc..1ab572b497212a 100644 --- a/src/plugins/vis_type_timelion/public/index.ts +++ b/src/plugins/vis_type_timelion/public/index.ts @@ -9,16 +9,26 @@ import { PluginInitializerContext } from 'kibana/public'; import { TimelionVisPlugin as Plugin } from './plugin'; +import { tickFormatters } from './legacy/tick_formatters'; +import { getTimezone } from './helpers/get_timezone'; +import { xaxisFormatterProvider } from './helpers/xaxis_formatter'; +import { generateTicksProvider } from './helpers/tick_generator'; +import { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib'; +import { parseTimelionExpressionAsync } from '../common/parser_async'; + export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } -export { getTimezone } from './helpers/get_timezone'; -export { tickFormatters } from './helpers/tick_formatters'; -export { xaxisFormatterProvider } from './helpers/xaxis_formatter'; -export { generateTicksProvider } from './helpers/tick_generator'; - -export { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib'; -export { parseTimelionExpressionAsync } from '../common/parser_async'; +// This export should be removed on removing Timeline APP +export const _LEGACY_ = { + DEFAULT_TIME_FORMAT, + calculateInterval, + parseTimelionExpressionAsync, + tickFormatters, + getTimezone, + xaxisFormatterProvider, + generateTicksProvider, +}; export { VisTypeTimelionPluginStart, VisTypeTimelionPluginSetup } from './plugin'; diff --git a/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts b/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts new file mode 100644 index 00000000000000..5dd8431a5a2ab8 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloneDeep, defaults, mergeWith, compact } from 'lodash'; +import $ from 'jquery'; +import moment, { Moment } from 'moment-timezone'; + +import { TimefilterContract } from 'src/plugins/data/public'; +import { IUiSettingsClient } from 'kibana/public'; + +import { calculateInterval } from '../../common/lib'; +import { xaxisFormatterProvider } from '../helpers/xaxis_formatter'; +import { Series } from '../helpers/timelion_request_handler'; +import { colors } from '../helpers/chart_constants'; + +export interface LegacyAxis { + delta?: number; + max?: number; + min?: number; + mode: string; + options?: { + units: { prefix: string; suffix: string }; + }; + tickSize?: number; + ticks: number; + tickLength: number; + timezone: string; + tickDecimals?: number; + tickFormatter: ((val: number) => string) | ((val: number, axis: LegacyAxis) => string); + tickGenerator?(axis: LegacyAxis): number[]; + units?: { type: string }; +} + +interface TimeRangeBounds { + min: Moment | undefined; + max: Moment | undefined; +} + +export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION'; +export const eventBus = $({}); + +const SERIES_ID_ATTR = 'data-series-id'; + +function buildSeriesData(chart: Series[], options: jquery.flot.plotOptions) { + const seriesData = chart.map((series: Series, seriesIndex: number) => { + const newSeries: Series = cloneDeep( + defaults(series, { + shadowSize: 0, + lines: { + lineWidth: 3, + }, + }) + ); + + newSeries._id = seriesIndex; + + if (series.color) { + const span = document.createElement('span'); + span.style.color = series.color; + newSeries.color = span.style.color; + } + + if (series._hide) { + newSeries.data = []; + newSeries.stack = false; + newSeries.label = `(hidden) ${series.label}`; + } + + if (series._global) { + mergeWith(options, series._global, (objVal, srcVal) => { + // This is kind of gross, it means that you can't replace a global value with a null + // best you can do is an empty string. Deal with it. + if (objVal == null) { + return srcVal; + } + if (srcVal == null) { + return objVal; + } + }); + } + + return newSeries; + }); + + return compact(seriesData); +} + +function buildOptions( + intervalValue: string, + timefilter: TimefilterContract, + uiSettings: IUiSettingsClient, + clientWidth = 0, + showGrid?: boolean +) { + // Get the X-axis tick format + const time: TimeRangeBounds = timefilter.getBounds(); + const interval = calculateInterval( + (time.min && time.min.valueOf()) || 0, + (time.max && time.max.valueOf()) || 0, + uiSettings.get('timelion:target_buckets') || 200, + intervalValue, + uiSettings.get('timelion:min_interval') || '1ms' + ); + const format = xaxisFormatterProvider(uiSettings)(interval); + + const tickLetterWidth = 7; + const tickPadding = 45; + + const options = { + xaxis: { + mode: 'time', + tickLength: 5, + timezone: 'browser', + // Calculate how many ticks can fit on the axis + ticks: Math.floor(clientWidth / (format.length * tickLetterWidth + tickPadding)), + // Use moment to format ticks so we get timezone correction + tickFormatter: (val: number) => moment(val).format(format), + }, + selection: { + mode: 'x', + color: '#ccc', + }, + crosshair: { + mode: 'x', + color: '#C66', + lineWidth: 2, + }, + colors, + grid: { + show: showGrid, + borderWidth: 0, + borderColor: null, + margin: 10, + hoverable: true, + autoHighlight: false, + }, + legend: { + backgroundColor: 'rgb(255,255,255,0)', + position: 'nw', + labelBoxBorderColor: 'rgb(255,255,255,0)', + labelFormatter(label: string, series: { _id: number }) { + const wrapperSpan = document.createElement('span'); + const labelSpan = document.createElement('span'); + const numberSpan = document.createElement('span'); + + wrapperSpan.setAttribute('class', 'ngLegendValue'); + wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`); + + labelSpan.appendChild(document.createTextNode(label)); + numberSpan.setAttribute('class', 'ngLegendValueNumber'); + + wrapperSpan.appendChild(labelSpan); + wrapperSpan.appendChild(numberSpan); + + return wrapperSpan.outerHTML; + }, + }, + } as jquery.flot.plotOptions & { yaxes?: LegacyAxis[] }; + + return options; +} + +export { buildSeriesData, buildOptions, SERIES_ID_ATTR }; diff --git a/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts new file mode 100644 index 00000000000000..03b7c217069577 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { tickFormatters } from './tick_formatters'; + +describe('Tick Formatters', function () { + let formatters: any; + + beforeEach(function () { + formatters = tickFormatters(); + }); + + describe('Bits mode', function () { + let bitFormatter: any; + beforeEach(function () { + bitFormatter = formatters.bits; + }); + + it('is a function', function () { + expect(bitFormatter).toEqual(expect.any(Function)); + }); + + it('formats with b/kb/mb/gb', function () { + expect(bitFormatter(7)).toEqual('7b'); + expect(bitFormatter(4 * 1000)).toEqual('4kb'); + expect(bitFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb'); + expect(bitFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb'); + }); + + it('formats negative values with b/kb/mb/gb', () => { + expect(bitFormatter(-7)).toEqual('-7b'); + expect(bitFormatter(-4 * 1000)).toEqual('-4kb'); + expect(bitFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb'); + expect(bitFormatter(-3 * 1000 * 1000 * 1000)).toEqual('-3gb'); + }); + }); + + describe('Bits/s mode', function () { + let bitsFormatter: any; + beforeEach(function () { + bitsFormatter = formatters['bits/s']; + }); + + it('is a function', function () { + expect(bitsFormatter).toEqual(expect.any(Function)); + }); + + it('formats with b/kb/mb/gb', function () { + expect(bitsFormatter(7)).toEqual('7b/s'); + expect(bitsFormatter(4 * 1000)).toEqual('4kb/s'); + expect(bitsFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb/s'); + expect(bitsFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb/s'); + }); + + it('formats negative values with b/kb/mb/gb', function () { + expect(bitsFormatter(-7)).toEqual('-7b/s'); + expect(bitsFormatter(-4 * 1000)).toEqual('-4kb/s'); + expect(bitsFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb/s'); + expect(bitsFormatter(-3 * 1000 * 1000 * 1000)).toEqual('-3gb/s'); + }); + }); + + describe('Bytes mode', function () { + let byteFormatter: any; + beforeEach(function () { + byteFormatter = formatters.bytes; + }); + + it('is a function', function () { + expect(byteFormatter).toEqual(expect.any(Function)); + }); + + it('formats with B/KB/MB/GB', function () { + expect(byteFormatter(10)).toEqual('10B'); + expect(byteFormatter(10 * 1024)).toEqual('10KB'); + expect(byteFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB'); + expect(byteFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB'); + }); + + it('formats negative values with B/KB/MB/GB', function () { + expect(byteFormatter(-10)).toEqual('-10B'); + expect(byteFormatter(-10 * 1024)).toEqual('-10KB'); + expect(byteFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB'); + expect(byteFormatter(-3 * 1024 * 1024 * 1024)).toEqual('-3GB'); + }); + }); + + describe('Bytes/s mode', function () { + let bytesFormatter: any; + beforeEach(function () { + bytesFormatter = formatters['bytes/s']; + }); + + it('is a function', function () { + expect(bytesFormatter).toEqual(expect.any(Function)); + }); + + it('formats with B/KB/MB/GB', function () { + expect(bytesFormatter(10)).toEqual('10B/s'); + expect(bytesFormatter(10 * 1024)).toEqual('10KB/s'); + expect(bytesFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB/s'); + expect(bytesFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB/s'); + }); + + it('formats negative values with B/KB/MB/GB', function () { + expect(bytesFormatter(-10)).toEqual('-10B/s'); + expect(bytesFormatter(-10 * 1024)).toEqual('-10KB/s'); + expect(bytesFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB/s'); + expect(bytesFormatter(-3 * 1024 * 1024 * 1024)).toEqual('-3GB/s'); + }); + }); + + describe('Currency mode', function () { + let currencyFormatter: any; + beforeEach(function () { + currencyFormatter = formatters.currency; + }); + + it('is a function', function () { + expect(currencyFormatter).toEqual(expect.any(Function)); + }); + + it('formats with $ by default', function () { + const axis = { + options: { + units: {}, + }, + }; + expect(currencyFormatter(10.2, axis)).toEqual('$10.20'); + }); + + it('accepts currency in ISO 4217', function () { + const axis = { + options: { + units: { + prefix: 'CNY', + }, + }, + }; + + expect(currencyFormatter(10.2, axis)).toEqual('CN¥10.20'); + }); + }); + + describe('Percent mode', function () { + let percentFormatter: any; + beforeEach(function () { + percentFormatter = formatters.percent; + }); + + it('is a function', function () { + expect(percentFormatter).toEqual(expect.any(Function)); + }); + + it('formats with %', function () { + const axis = { + options: { + units: {}, + }, + }; + expect(percentFormatter(0.1234, axis)).toEqual('12%'); + }); + + it('formats with % with decimal precision', function () { + const tickDecimals = 3; + const tickDecimalShift = 2; + const axis = { + tickDecimals: tickDecimals + tickDecimalShift, + options: { + units: { + tickDecimalsShift: tickDecimalShift, + }, + }, + }; + expect(percentFormatter(0.12345, axis)).toEqual('12.345%'); + }); + }); + + describe('Custom mode', function () { + let customFormatter: any; + beforeEach(function () { + customFormatter = formatters.custom; + }); + + it('is a function', function () { + expect(customFormatter).toEqual(expect.any(Function)); + }); + + it('accepts prefix and suffix', function () { + const axis = { + options: { + units: { + prefix: 'prefix', + suffix: 'suffix', + }, + }, + tickDecimals: 1, + }; + + expect(customFormatter(10.2, axis)).toEqual('prefix10.2suffix'); + }); + + it('correctly renders small values', function () { + const axis = { + options: { + units: { + prefix: 'prefix', + suffix: 'suffix', + }, + }, + tickDecimals: 3, + }; + + expect(customFormatter(0.00499999999999999, axis)).toEqual('prefix0.005suffix'); + }); + }); +}); diff --git a/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts new file mode 100644 index 00000000000000..950226968287ba --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { get } from 'lodash'; + +import type { LegacyAxis } from './panel_utils'; + +function baseTickFormatter(value: number, axis: LegacyAxis) { + const factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1; + const formatted = '' + Math.round(value * factor) / factor; + + // If tickDecimals was specified, ensure that we have exactly that + // much precision; otherwise default to the value's own precision. + + if (axis.tickDecimals != null) { + const decimal = formatted.indexOf('.'); + const precision = decimal === -1 ? 0 : formatted.length - decimal - 1; + if (precision < axis.tickDecimals) { + return ( + (precision ? formatted : formatted + '.') + + ('' + factor).substr(1, axis.tickDecimals - precision) + ); + } + } + + return formatted; +} + +function unitFormatter(divisor: number, units: string[]) { + return (val: number) => { + let index = 0; + const isNegative = val < 0; + val = Math.abs(val); + while (val >= divisor && index < units.length) { + val /= divisor; + index++; + } + const value = (Math.round(val * 100) / 100) * (isNegative ? -1 : 1); + return `${value}${units[index]}`; + }; +} + +export function tickFormatters() { + return { + bits: unitFormatter(1000, ['b', 'kb', 'mb', 'gb', 'tb', 'pb']), + 'bits/s': unitFormatter(1000, ['b/s', 'kb/s', 'mb/s', 'gb/s', 'tb/s', 'pb/s']), + bytes: unitFormatter(1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']), + 'bytes/s': unitFormatter(1024, ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s']), + currency(val: number, axis: LegacyAxis) { + return val.toLocaleString('en', { + style: 'currency', + currency: (axis && axis.options && axis.options.units.prefix) || 'USD', + }); + }, + percent(val: number, axis: LegacyAxis) { + let precision = + get(axis, 'tickDecimals', 0) - get(axis, 'options.units.tickDecimalsShift', 0); + // toFixed only accepts values between 0 and 20 + if (precision < 0) { + precision = 0; + } else if (precision > 20) { + precision = 20; + } + + return (val * 100).toFixed(precision) + '%'; + }, + custom(val: number, axis: LegacyAxis) { + const formattedVal = baseTickFormatter(val, axis); + const prefix = axis && axis.options && axis.options.units.prefix; + const suffix = axis && axis.options && axis.options.units.suffix; + return prefix + formattedVal + suffix; + }, + }; +} diff --git a/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss b/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss new file mode 100644 index 00000000000000..c4d591bc82cad9 --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss @@ -0,0 +1,60 @@ +.timChart { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + + // Custom Jquery FLOT / schema selectors + // Cannot change at the moment + + .chart-top-title { + @include euiFontSizeXS; + flex: 0; + text-align: center; + font-weight: $euiFontWeightBold; + } + + .chart-canvas { + min-width: 100%; + flex: 1; + overflow: hidden; + } + + .legendLabel { + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; + line-height: normal; + } + + .legendColorBox { + vertical-align: middle; + } + + .ngLegendValue { + color: $euiTextColor; + cursor: pointer; + + &:focus, + &:hover { + text-decoration: underline; + } + } + + .ngLegendValueNumber { + margin-left: $euiSizeXS; + margin-right: $euiSizeXS; + font-weight: $euiFontWeightBold; + } + + .flot-tick-label { + font-size: $euiFontSizeXS; + color: $euiColorDarkShade; + } +} + +.timChart__legendCaption { + color: $euiTextColor; + white-space: nowrap; + font-weight: $euiFontWeightBold; +} diff --git a/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx new file mode 100644 index 00000000000000..ddac86fa73beea --- /dev/null +++ b/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx @@ -0,0 +1,418 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import $ from 'jquery'; +import moment from 'moment-timezone'; +import { debounce, compact, get, each, cloneDeep, last, map } from 'lodash'; +import { useResizeObserver } from '@elastic/eui'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { useKibana } from '../../../kibana_react/public'; +import { DEFAULT_TIME_FORMAT } from '../../common/lib'; + +import { + buildSeriesData, + buildOptions, + SERIES_ID_ATTR, + LegacyAxis, + ACTIVE_CURSOR, + eventBus, +} from './panel_utils'; + +import { Series, Sheet } from '../helpers/timelion_request_handler'; +import { colors } from '../helpers/chart_constants'; +import { tickFormatters } from './tick_formatters'; +import { generateTicksProvider } from '../helpers/tick_generator'; + +import type { TimelionVisDependencies } from '../plugin'; +import type { RangeFilterParams } from '../../../data/common'; + +import './timelion_vis.scss'; + +interface CrosshairPlot extends jquery.flot.plot { + setCrosshair: (pos: Position) => void; + clearCrosshair: () => void; +} + +interface TimelionVisComponentProps { + onBrushEvent: (rangeFilterParams: RangeFilterParams) => void; + interval: string; + seriesList: Sheet; + renderComplete: IInterpreterRenderHandlers['done']; +} + +interface Position { + x: number; + x1: number; + y: number; + y1: number; + pageX: number; + pageY: number; +} + +interface Range { + to: number; + from: number; +} + +interface Ranges { + xaxis: Range; + yaxis: Range; +} + +const DEBOUNCE_DELAY = 50; +// ensure legend is the same height with or without a caption so legend items do not move around +const emptyCaption = '
'; + +function TimelionVisComponent({ + interval, + seriesList, + renderComplete, + onBrushEvent, +}: TimelionVisComponentProps) { + const kibana = useKibana(); + const [chart, setChart] = useState(() => cloneDeep(seriesList.list)); + const [canvasElem, setCanvasElem] = useState(); + const [chartElem, setChartElem] = useState(null); + + const [originalColorMap, setOriginalColorMap] = useState(() => new Map()); + + const [highlightedSeries, setHighlightedSeries] = useState(null); + const [focusedSeries, setFocusedSeries] = useState(); + const [plot, setPlot] = useState(); + + // Used to toggle the series, and for displaying values on hover + const [legendValueNumbers, setLegendValueNumbers] = useState>(); + const [legendCaption, setLegendCaption] = useState>(); + + const canvasRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setCanvasElem(node); + } + }, []); + + const elementRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setChartElem(node); + } + }, []); + + useEffect( + () => () => { + if (chartElem) { + $(chartElem).off('plotselected').off('plothover').off('mouseleave'); + } + }, + [chartElem] + ); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + const highlightSeries = useCallback( + debounce(({ currentTarget }: JQuery.TriggeredEvent) => { + const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); + if (highlightedSeries === id) { + return; + } + + setHighlightedSeries(id); + setChart((chartState) => + chartState.map((series: Series, seriesIndex: number) => { + series.color = + seriesIndex === id + ? originalColorMap.get(series) // color it like it was + : 'rgba(128,128,128,0.1)'; // mark as grey + + return series; + }) + ); + }, DEBOUNCE_DELAY), + [originalColorMap, highlightedSeries] + ); + + const focusSeries = useCallback( + (event: JQuery.TriggeredEvent) => { + const id = Number(event.currentTarget.getAttribute(SERIES_ID_ATTR)); + setFocusedSeries(id); + highlightSeries(event); + }, + [highlightSeries] + ); + + const toggleSeries = useCallback(({ currentTarget }: JQuery.TriggeredEvent) => { + const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR)); + + setChart((chartState) => + chartState.map((series: Series, seriesIndex: number) => { + if (seriesIndex === id) { + series._hide = !series._hide; + } + return series; + }) + ); + }, []); + + const updateCaption = useCallback( + (plotData: any) => { + if (canvasElem && get(plotData, '[0]._global.legend.showTime', true)) { + const caption = $(''); + caption.html(emptyCaption); + setLegendCaption(caption); + + const canvasNode = $(canvasElem); + canvasNode.find('div.legend table').append(caption); + setLegendValueNumbers(canvasNode.find('.ngLegendValueNumber')); + + const legend = $(canvasElem).find('.ngLegendValue'); + if (legend) { + legend.click(toggleSeries); + legend.focus(focusSeries); + legend.mouseover(highlightSeries); + } + + // legend has been re-created. Apply focus on legend element when previously set + if (focusedSeries || focusedSeries === 0) { + canvasNode.find('div.legend table .legendLabel>span').get(focusedSeries).focus(); + } + } + }, + [focusedSeries, canvasElem, toggleSeries, focusSeries, highlightSeries] + ); + + const updatePlot = useCallback( + (chartValue: Series[], grid?: boolean) => { + if (canvasElem && canvasElem.clientWidth > 0 && canvasElem.clientHeight > 0) { + const options = buildOptions( + interval, + kibana.services.timefilter, + kibana.services.uiSettings, + chartElem?.clientWidth, + grid + ); + const updatedSeries = buildSeriesData(chartValue, options); + + if (options.yaxes) { + options.yaxes.forEach((yaxis: LegacyAxis) => { + if (yaxis && yaxis.units) { + const formatters = tickFormatters(); + yaxis.tickFormatter = formatters[yaxis.units.type as keyof typeof formatters]; + const byteModes = ['bytes', 'bytes/s']; + if (byteModes.includes(yaxis.units.type)) { + yaxis.tickGenerator = generateTicksProvider(); + } + } + }); + } + + const newPlot = $.plot($(canvasElem), updatedSeries, options); + setPlot(newPlot); + renderComplete(); + + updateCaption(newPlot.getData()); + } + }, + [canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption] + ); + + const dimensions = useResizeObserver(chartElem); + + useEffect(() => { + updatePlot(chart, seriesList.render && seriesList.render.grid); + }, [chart, updatePlot, seriesList.render, dimensions]); + + useEffect(() => { + const colorsSet: Array<[Series, string]> = []; + const newChart = seriesList.list.map((series: Series, seriesIndex: number) => { + const newSeries = { ...series }; + if (!newSeries.color) { + const colorIndex = seriesIndex % colors.length; + newSeries.color = colors[colorIndex]; + } + colorsSet.push([newSeries, newSeries.color]); + return newSeries; + }); + setChart(newChart); + setOriginalColorMap(new Map(colorsSet)); + }, [seriesList.list]); + + const unhighlightSeries = useCallback(() => { + if (highlightedSeries === null) { + return; + } + + setHighlightedSeries(null); + setFocusedSeries(null); + + setChart((chartState) => + chartState.map((series: Series) => { + series.color = originalColorMap.get(series); // reset the colors + return series; + }) + ); + }, [originalColorMap, highlightedSeries]); + + // Shamelessly borrowed from the flotCrosshairs example + const setLegendNumbers = useCallback( + (pos: Position) => { + unhighlightSeries(); + + const axes = plot!.getAxes(); + if (pos.x < axes.xaxis.min! || pos.x > axes.xaxis.max!) { + return; + } + + const dataset = plot!.getData(); + if (legendCaption) { + legendCaption.text( + moment(pos.x).format(get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT)) + ); + } + for (let i = 0; i < dataset.length; ++i) { + const series = dataset[i]; + const useNearestPoint = series.lines!.show && !series.lines!.steps; + const precision = get(series, '_meta.precision', 2); + + // We're setting this flag on top on the series object belonging to the flot library, so we're simply casting here. + if ((series as { _hide?: boolean })._hide) { + continue; + } + + const currentPoint = series.data.find((point: [number, number], index: number) => { + if (index + 1 === series.data.length) { + return true; + } + if (useNearestPoint) { + return pos.x - point[0] < series.data[index + 1][0] - pos.x; + } else { + return pos.x < series.data[index + 1][0]; + } + }); + + const y = currentPoint[1]; + + if (legendValueNumbers) { + if (y == null) { + legendValueNumbers.eq(i).empty(); + } else { + let label = y.toFixed(precision); + const formatter = ((series.yaxis as unknown) as LegacyAxis).tickFormatter; + if (formatter) { + label = formatter(Number(label), (series.yaxis as unknown) as LegacyAxis); + } + legendValueNumbers.eq(i).text(`(${label})`); + } + } + } + }, + [plot, legendValueNumbers, unhighlightSeries, legendCaption] + ); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + const debouncedSetLegendNumbers = useCallback( + debounce(setLegendNumbers, DEBOUNCE_DELAY, { + maxWait: DEBOUNCE_DELAY, + leading: true, + trailing: false, + }), + [setLegendNumbers] + ); + + const clearLegendNumbers = useCallback(() => { + if (legendCaption) { + legendCaption.html(emptyCaption); + } + each(legendValueNumbers!, (num: Node) => { + $(num).empty(); + }); + }, [legendCaption, legendValueNumbers]); + + const plotHover = useCallback( + (pos: Position) => { + (plot as CrosshairPlot).setCrosshair(pos); + debouncedSetLegendNumbers(pos); + }, + [plot, debouncedSetLegendNumbers] + ); + + const plotHoverHandler = useCallback( + (event: JQuery.TriggeredEvent, pos: Position) => { + if (!plot) { + return; + } + plotHover(pos); + eventBus.trigger(ACTIVE_CURSOR, [event, pos]); + }, + [plot, plotHover] + ); + + useEffect(() => { + const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => { + if (!plot) { + return; + } + plotHover(pos); + }; + + eventBus.on(ACTIVE_CURSOR, updateCursor); + + return () => { + eventBus.off(ACTIVE_CURSOR, updateCursor); + }; + }, [plot, plotHover]); + + const mouseLeaveHandler = useCallback(() => { + if (!plot) { + return; + } + (plot as CrosshairPlot).clearCrosshair(); + clearLegendNumbers(); + }, [plot, clearLegendNumbers]); + + const plotSelectedHandler = useCallback( + (event: JQuery.TriggeredEvent, ranges: Ranges) => { + onBrushEvent({ + gte: ranges.xaxis.from, + lte: ranges.xaxis.to, + }); + }, + [onBrushEvent] + ); + + useEffect(() => { + if (chartElem) { + $(chartElem).off('plotselected').on('plotselected', plotSelectedHandler); + } + }, [chartElem, plotSelectedHandler]); + + useEffect(() => { + if (chartElem) { + $(chartElem).off('mouseleave').on('mouseleave', mouseLeaveHandler); + } + }, [chartElem, mouseLeaveHandler]); + + useEffect(() => { + if (chartElem) { + $(chartElem).off('plothover').on('plothover', plotHoverHandler); + } + }, [chartElem, plotHoverHandler]); + + const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [ + seriesList.list, + ]); + + return ( +
+
{title}
+
+
+ ); +} + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { TimelionVisComponent as default }; diff --git a/src/plugins/vis_type_timelion/public/plugin.ts b/src/plugins/vis_type_timelion/public/plugin.ts index 3e9c4cf77687e9..93712ae4507feb 100644 --- a/src/plugins/vis_type_timelion/public/plugin.ts +++ b/src/plugins/vis_type_timelion/public/plugin.ts @@ -22,6 +22,7 @@ import { } from 'src/plugins/data/public'; import { VisualizationsSetup } from '../../visualizations/public'; +import { ChartsPluginSetup } from '../../charts/public'; import { getTimelionVisualizationConfig } from './timelion_vis_fn'; import { getTimelionVisDefinition } from './timelion_vis_type'; @@ -36,6 +37,7 @@ export interface TimelionVisDependencies extends Partial { uiSettings: IUiSettingsClient; http: HttpSetup; timefilter: TimefilterContract; + chartTheme: ChartsPluginSetup['theme']; } /** @internal */ @@ -43,6 +45,7 @@ export interface TimelionVisSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + charts: ChartsPluginSetup; } /** @internal */ @@ -72,13 +75,14 @@ export class TimelionVisPlugin constructor(public initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, - { expressions, visualizations, data }: TimelionVisSetupDependencies + { uiSettings, http }: CoreSetup, + { expressions, visualizations, data, charts }: TimelionVisSetupDependencies ) { const dependencies: TimelionVisDependencies = { - uiSettings: core.uiSettings, - http: core.http, + http, + uiSettings, timefilter: data.query.timefilter.timefilter, + chartTheme: charts.theme, }; expressions.registerFunction(() => getTimelionVisualizationConfig(dependencies)); diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx index 6ef5d29ea8a915..b14055a4d6b634 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx @@ -14,8 +14,11 @@ import { KibanaContextProvider } from '../../kibana_react/public'; import { VisualizationContainer } from '../../visualizations/public'; import { TimelionVisDependencies } from './plugin'; import { TimelionRenderValue } from './timelion_vis_fn'; -// @ts-ignore +import { UI_SETTINGS } from '../common/constants'; +import { RangeFilterParams } from '../../data/public'; + const TimelionVisComponent = lazy(() => import('./components/timelion_vis_component')); +const TimelionVisLegacyComponent = lazy(() => import('./legacy/timelion_vis_component')); export const getTimelionVisRenderer: ( deps: TimelionVisDependencies @@ -31,14 +34,34 @@ export const getTimelionVisRenderer: ( const [seriesList] = visData.sheet; const showNoResult = !seriesList || !seriesList.list.length; + const VisComponent = deps.uiSettings.get(UI_SETTINGS.LEGACY_CHARTS_LIBRARY, false) + ? TimelionVisLegacyComponent + : TimelionVisComponent; + + const onBrushEvent = (rangeFilterParams: RangeFilterParams) => { + handlers.event({ + name: 'applyFilter', + data: { + timeFieldName: '*', + filters: [ + { + range: { + '*': rangeFilterParams, + }, + }, + ], + }, + }); + }; + render( - , diff --git a/src/plugins/vis_type_timelion/server/plugin.ts b/src/plugins/vis_type_timelion/server/plugin.ts index c1800a09ba35c9..fc23569b351e66 100644 --- a/src/plugins/vis_type_timelion/server/plugin.ts +++ b/src/plugins/vis_type_timelion/server/plugin.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import { TypeOf, schema } from '@kbn/config-schema'; +import { TypeOf } from '@kbn/config-schema'; import { RecursiveReadonly } from '@kbn/utility-types'; import { deepFreeze } from '@kbn/std'; @@ -19,10 +19,7 @@ import { functionsRoute } from './routes/functions'; import { validateEsRoute } from './routes/validate_es'; import { runRoute } from './routes/run'; import { ConfigManager } from './lib/config_manager'; - -const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { - defaultMessage: 'experimental', -}); +import { getUiSettings } from './ui_settings'; /** * Describes public Timelion plugin contract returned at the `setup` stage. @@ -78,97 +75,7 @@ export class TimelionPlugin runRoute(router, deps); validateEsRoute(router); - core.uiSettings.register({ - 'timelion:es.timefield': { - name: i18n.translate('timelion.uiSettings.timeFieldLabel', { - defaultMessage: 'Time field', - }), - value: '@timestamp', - description: i18n.translate('timelion.uiSettings.timeFieldDescription', { - defaultMessage: 'Default field containing a timestamp when using {esParam}', - values: { esParam: '.es()' }, - }), - category: ['timelion'], - schema: schema.string(), - }, - 'timelion:es.default_index': { - name: i18n.translate('timelion.uiSettings.defaultIndexLabel', { - defaultMessage: 'Default index', - }), - value: '_all', - description: i18n.translate('timelion.uiSettings.defaultIndexDescription', { - defaultMessage: 'Default elasticsearch index to search with {esParam}', - values: { esParam: '.es()' }, - }), - category: ['timelion'], - schema: schema.string(), - }, - 'timelion:target_buckets': { - name: i18n.translate('timelion.uiSettings.targetBucketsLabel', { - defaultMessage: 'Target buckets', - }), - value: 200, - description: i18n.translate('timelion.uiSettings.targetBucketsDescription', { - defaultMessage: 'The number of buckets to shoot for when using auto intervals', - }), - category: ['timelion'], - schema: schema.number(), - }, - 'timelion:max_buckets': { - name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', { - defaultMessage: 'Maximum buckets', - }), - value: 2000, - description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', { - defaultMessage: 'The maximum number of buckets a single datasource can return', - }), - category: ['timelion'], - schema: schema.number(), - }, - 'timelion:min_interval': { - name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', { - defaultMessage: 'Minimum interval', - }), - value: '1ms', - description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', { - defaultMessage: 'The smallest interval that will be calculated when using "auto"', - description: - '"auto" is a technical value in that context, that should not be translated.', - }), - category: ['timelion'], - schema: schema.string(), - }, - 'timelion:graphite.url': { - name: i18n.translate('timelion.uiSettings.graphiteURLLabel', { - defaultMessage: 'Graphite URL', - description: - 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', - }), - value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null, - description: i18n.translate('timelion.uiSettings.graphiteURLDescription', { - defaultMessage: - '{experimentalLabel} The URL of your graphite host', - values: { experimentalLabel: `[${experimentalLabel}]` }, - }), - type: 'select', - options: config.graphiteUrls || [], - category: ['timelion'], - schema: schema.nullable(schema.string()), - }, - 'timelion:quandl.key': { - name: i18n.translate('timelion.uiSettings.quandlKeyLabel', { - defaultMessage: 'Quandl key', - }), - value: 'someKeyHere', - description: i18n.translate('timelion.uiSettings.quandlKeyDescription', { - defaultMessage: '{experimentalLabel} Your API key from www.quandl.com', - values: { experimentalLabel: `[${experimentalLabel}]` }, - }), - sensitive: true, - category: ['timelion'], - schema: schema.string(), - }, - }); + core.uiSettings.register(getUiSettings(config)); return deepFreeze({ uiEnabled: config.ui.enabled }); } diff --git a/src/plugins/vis_type_timelion/server/series_functions/label.js b/src/plugins/vis_type_timelion/server/series_functions/label.js index d31320d3ad6e90..30a5c626251d1b 100644 --- a/src/plugins/vis_type_timelion/server/series_functions/label.js +++ b/src/plugins/vis_type_timelion/server/series_functions/label.js @@ -44,7 +44,7 @@ export default new Chainable('label', { // that it doesn't prevent Kibana from starting up and we only have an issue using Timelion labels const RE2 = require('re2'); eachSeries.label = eachSeries.label.replace(new RE2(config.regex), config.label); - } else { + } else if (config.label) { eachSeries.label = config.label; } diff --git a/src/plugins/vis_type_timelion/server/ui_settings.ts b/src/plugins/vis_type_timelion/server/ui_settings.ts new file mode 100644 index 00000000000000..1d8dc997a3f6aa --- /dev/null +++ b/src/plugins/vis_type_timelion/server/ui_settings.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import type { UiSettingsParams } from 'kibana/server'; + +import { UI_SETTINGS } from '../common/constants'; +import { configSchema } from '../config'; + +const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { + defaultMessage: 'experimental', +}); + +export function getUiSettings( + config: TypeOf +): Record> { + return { + [UI_SETTINGS.LEGACY_CHARTS_LIBRARY]: { + name: i18n.translate('timelion.uiSettings.legacyChartsLibraryLabel', { + defaultMessage: 'Timelion legacy charts library', + }), + description: i18n.translate('timelion.uiSettings.legacyChartsLibraryDescription', { + defaultMessage: 'Enables the legacy charts library for timelion charts in Visualize', + }), + deprecation: { + message: i18n.translate('timelion.uiSettings.legacyChartsLibraryDeprication', { + defaultMessage: 'This setting is deprecated and will not be supported as of 8.0.', + }), + docLinksKey: 'timelionSettings', + }, + value: false, + category: ['timelion'], + schema: schema.boolean(), + }, + [UI_SETTINGS.ES_TIMEFIELD]: { + name: i18n.translate('timelion.uiSettings.timeFieldLabel', { + defaultMessage: 'Time field', + }), + value: '@timestamp', + description: i18n.translate('timelion.uiSettings.timeFieldDescription', { + defaultMessage: 'Default field containing a timestamp when using {esParam}', + values: { esParam: '.es()' }, + }), + category: ['timelion'], + schema: schema.string(), + }, + [UI_SETTINGS.DEFAULT_INDEX]: { + name: i18n.translate('timelion.uiSettings.defaultIndexLabel', { + defaultMessage: 'Default index', + }), + value: '_all', + description: i18n.translate('timelion.uiSettings.defaultIndexDescription', { + defaultMessage: 'Default elasticsearch index to search with {esParam}', + values: { esParam: '.es()' }, + }), + category: ['timelion'], + schema: schema.string(), + }, + [UI_SETTINGS.TARGET_BUCKETS]: { + name: i18n.translate('timelion.uiSettings.targetBucketsLabel', { + defaultMessage: 'Target buckets', + }), + value: 200, + description: i18n.translate('timelion.uiSettings.targetBucketsDescription', { + defaultMessage: 'The number of buckets to shoot for when using auto intervals', + }), + category: ['timelion'], + schema: schema.number(), + }, + [UI_SETTINGS.MAX_BUCKETS]: { + name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', { + defaultMessage: 'Maximum buckets', + }), + value: 2000, + description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', { + defaultMessage: 'The maximum number of buckets a single datasource can return', + }), + category: ['timelion'], + schema: schema.number(), + }, + [UI_SETTINGS.MIN_INTERVAL]: { + name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', { + defaultMessage: 'Minimum interval', + }), + value: '1ms', + description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', { + defaultMessage: 'The smallest interval that will be calculated when using "auto"', + description: '"auto" is a technical value in that context, that should not be translated.', + }), + category: ['timelion'], + schema: schema.string(), + }, + [UI_SETTINGS.GRAPHITE_URL]: { + name: i18n.translate('timelion.uiSettings.graphiteURLLabel', { + defaultMessage: 'Graphite URL', + description: + 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', + }), + value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null, + description: i18n.translate('timelion.uiSettings.graphiteURLDescription', { + defaultMessage: + '{experimentalLabel} The URL of your graphite host', + values: { experimentalLabel: `[${experimentalLabel}]` }, + }), + type: 'select', + options: config.graphiteUrls || [], + category: ['timelion'], + schema: schema.nullable(schema.string()), + }, + [UI_SETTINGS.QUANDL_KEY]: { + name: i18n.translate('timelion.uiSettings.quandlKeyLabel', { + defaultMessage: 'Quandl key', + }), + value: 'someKeyHere', + description: i18n.translate('timelion.uiSettings.quandlKeyDescription', { + defaultMessage: '{experimentalLabel} Your API key from www.quandl.com', + values: { experimentalLabel: `[${experimentalLabel}]` }, + }), + sensitive: true, + category: ['timelion'], + schema: schema.string(), + }, + }; +}