Replace flot with elastic-chart in Timelion (#81565)

* First draft migrate timelion to elastic-charts

* Some refactoring. Added brush event.

* Added title. Some refactoring

* Fixed some type problems. Added logic for yaxes function

* Fixed some types, added missing functionality for yaxes

* Fixed some types, added missing functionality for stack property

* Fixed unit test

* Removed unneeded code

* Some refactoring

* Some refactoring

* Fixed some remarks.

* Fixed some styles

* Added themes. Removed unneeded styles in BarSeries

* removed unneeded code.

* Fixed some comments

* Fixed vertical cursor across Timelion visualizations of a dashboad

* Fix some problems with styles

* Use RxJS instead of jQuery

* Remove unneeded code

* Fixed some problems

* Fixed unit test

* Fix CI

* Fix eslint

* Fix some gaps

* Fix legend columns

* Some fixes

* add 2 versions of Timeline app

* fix CI

* cleanup code

* fix CI

* fix legend position

* fix some cases

* fix some cases

* remove extra casting

* cleanup code

* fix issue with static

* fix header formatter

* fix points

* fix ts error

* Fix yaxis behavior

* Fix some case with yaxis

* Add deprecation message and update asciidoc

* Fix title

* some text improvements

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Alexey Antonov <alexwizp@gmail.com>
This commit is contained in:
Uladzislau Lasitsa 2021-08-02 11:53:03 +03:00 committed by GitHub
parent 33bc6f1d37
commit 8088565ee9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1719 additions and 772 deletions

View file

@ -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

View file

@ -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`,

View file

@ -80,6 +80,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
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.' },

View file

@ -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;

View file

@ -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": {

View file

@ -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(*)';

View file

@ -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,

View file

@ -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';

View file

@ -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 = '<div class="chart-top-title"></div><div class="chart-canvas"></div>';
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,

View file

@ -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',
};

View file

@ -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<Array<number | null>>;
stack: boolean;
}

View file

@ -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",

View file

@ -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) => (
<AreaSeries
id={index + visData.label}
groupId={groupId}
name={visData.label}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={0}
yAccessors={[1]}
data={visData.data}
sortIndex={index}
color={visData.color}
stackAccessors={visData.stack ? [0] : undefined}
areaSeriesStyle={getAreaSeriesStyle(visData)}
/>
);

View file

@ -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) => (
<BarSeries
id={index + visData.label}
groupId={groupId}
name={visData.label}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={0}
yAccessors={[1]}
data={visData.data}
sortIndex={index}
enableHistogramMode={false}
color={visData.color}
stackAccessors={visData.stack ? [0] : undefined}
barSeriesStyle={getBarSeriesStyle(visData)}
/>
);

View file

@ -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';

View file

@ -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;
}

View file

@ -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 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';
import './timelion_vis.scss';
interface CrosshairPlot extends jquery.flot.plot {
setCrosshair: (pos: Position) => void;
clearCrosshair: () => void;
}
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;
}
const DefaultYAxis = () => (
<Axis
id="left"
domain={withStaticPadding({
fit: false,
})}
position={Position.Left}
groupId={`${MAIN_GROUP_ID}`}
/>
);
interface Range {
to: number;
from: number;
}
const renderYAxis = (series: Series[]) => {
const yAxisOptions = extractAllYAxis(series);
interface Ranges {
xaxis: Range;
yaxis: Range;
}
const yAxis = yAxisOptions.map((option, index) => (
<Axis
groupId={option.groupId}
key={index}
id={option.id!}
title={option.title}
position={option.position}
tickFormat={option.tickFormat}
gridLine={{
visible: !index,
}}
domain={option.domain}
/>
));
const DEBOUNCE_DELAY = 50;
// ensure legend is the same height with or without a caption so legend items do not move around
const emptyCaption = '<br>';
return yAxis.length ? yAxis : <DefaultYAxis />;
};
function TimelionVisComponent({
const TimelionVisComponent = ({
interval,
seriesList,
renderComplete,
fireEvent,
}: TimelionVisComponentProps) {
onBrushEvent,
}: TimelionVisComponentProps) => {
const kibana = useKibana<TimelionVisDependencies>();
const [chart, setChart] = useState(() => cloneDeep(seriesList.list));
const [canvasElem, setCanvasElem] = useState<HTMLDivElement>();
const [chartElem, setChartElem] = useState<HTMLDivElement | null>(null);
const [originalColorMap, setOriginalColorMap] = useState(() => new Map<Series, string>());
const [highlightedSeries, setHighlightedSeries] = useState<number | null>(null);
const [focusedSeries, setFocusedSeries] = useState<number | null>();
const [plot, setPlot] = useState<jquery.flot.plot>();
// Used to toggle the series, and for displaying values on hover
const [legendValueNumbers, setLegendValueNumbers] = useState<JQuery<HTMLElement>>();
const [legendCaption, setLegendCaption] = useState<JQuery<HTMLElement>>();
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 class="timChart__legendCaption"></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: 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();
updateCaption(newPlot.getData());
}
},
[canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption]
);
const dimensions = useResizeObserver(chartElem);
const chartRef = useRef<Chart>(null);
const chart = seriesList.list;
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;
const subscription = activeCursor$.subscribe((cursor: PointerEvent) => {
chartRef.current?.dispatchExternalPointerEvent(cursor);
});
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 Axis).tickFormatter;
if (formatter) {
label = formatter(Number(label), (series.yaxis as unknown) as Axis);
}
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);
subscription.unsubscribe();
};
}, [plot, plotHover]);
}, []);
const mouseLeaveHandler = useCallback(() => {
if (!plot) {
return;
}
(plot as CrosshairPlot).clearCrosshair();
clearLegendNumbers();
}, [plot, clearLegendNumbers]);
const handleCursorUpdate = useCallback((cursor: PointerEvent) => {
activeCursor$.next(cursor);
}, []);
const plotSelectedHandler = useCallback(
(event: JQuery.TriggeredEvent, ranges: Ranges) => {
fireEvent({
name: 'applyFilter',
data: {
timeFieldName: '*',
filters: [
{
range: {
'*': {
gte: ranges.xaxis.from,
lte: ranges.xaxis.to,
},
},
},
],
},
const brushEndListener = useCallback(
({ x }) => {
if (!x) {
return;
}
onBrushEvent({
gte: x[0],
lte: x[1],
});
},
[fireEvent]
[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 onRenderChange = useCallback(
(isRendered: boolean) => {
if (isRendered) {
renderComplete();
}
},
[renderComplete]
);
const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [
seriesList.list,
]);
const tickFormat = useMemo(
() => createTickFormat(interval, kibana.services.timefilter, kibana.services.uiSettings),
[interval, kibana.services.timefilter, kibana.services.uiSettings]
);
const legend = useMemo(() => {
const legendPosition: LegendPositionConfig = {
floating: true,
floatingColumns: 1,
vAlign: Position.Top,
hAlign: Position.Left,
direction: LayoutDirection.Vertical,
};
let showLegend = true;
chart.forEach((series) => {
if (series._global?.legend) {
const { show = true, position, noColumns = legendPosition.floatingColumns } =
series._global?.legend ?? {};
if (validateLegendPositionValue(position)) {
const [vAlign, hAlign] = position.split('');
legendPosition.vAlign = vAlign === 'n' ? Position.Top : Position.Bottom;
legendPosition.hAlign = hAlign === 'e' ? Position.Right : Position.Left;
}
if (!show) {
showLegend = false;
}
if (noColumns !== undefined) {
legendPosition.floatingColumns = noColumns;
}
}
});
return { legendPosition, showLegend };
}, [chart]);
return (
<div ref={elementRef} className="timChart">
<div className="chart-top-title">{title}</div>
<div ref={canvasRef} className="chart-canvas" />
<div className="timelionChart">
{title && (
<EuiTitle className="timelionChart__topTitle" size="xxxs">
<h4>{title}</h4>
</EuiTitle>
)}
<Chart ref={chartRef} renderer="canvas" size={{ width: '100%' }}>
<Settings
onBrushEnd={brushEndListener}
showLegend={legend.showLegend}
showLegendExtra={true}
legendPosition={legend.legendPosition}
onRenderChange={onRenderChange}
onPointerUpdate={handleCursorUpdate}
theme={kibana.services.chartTheme.useChartsTheme()}
baseTheme={kibana.services.chartTheme.useChartsBaseTheme()}
tooltip={{
snap: true,
headerFormatter: ({ value }) => tickFormat(value),
type: TooltipType.VerticalCursor,
}}
externalPointerEvents={{ tooltip: { visible: false } }}
/>
<Axis
id="bottom"
position={Position.Bottom}
showOverlappingTicks
tickFormat={tickFormat}
gridLine={{ 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 (
<SeriesComponent
key={`${index}-${visData.label}`}
visData={visData}
index={chart.length - index}
groupId={`${visData.yaxis ? visData.yaxis : MAIN_GROUP_ID}`}
/>
);
})}
</Chart>
</div>
);
}
};
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export

View file

@ -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<PointerEvent>();

View file

@ -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',
];

View file

@ -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';
export interface Axis {
import type { Series } from './timelion_request_handler';
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 };
tickFormatter: (val: number) => string;
tickGenerator?(axis: IAxis): number[];
units?: { type: string; prefix: string; suffix: string };
domain?: {
min?: number;
max?: number;
};
position?: Position;
axisLabel?: string;
}
interface TimeRangeBounds {
min: Moment | undefined;
max: Moment | undefined;
}
export const validateLegendPositionValue = (position: string) => /^(n|s)(e|w)$/s.test(position);
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;
});
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;
return (val: number) => moment(val).format(format);
};
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');
/** While we support 2 versions of the timeline, we need this adapter. **/
export const MAIN_GROUP_ID = 1;
wrapperSpan.setAttribute('class', 'ngLegendValue');
wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`);
export const withStaticPadding = (domain: AxisSpec['domain']): AxisSpec['domain'] =>
(({
...domain,
padding: 50,
paddingUnit: 'pixel',
} as unknown) as AxisSpec['domain']);
labelSpan.appendChild(document.createTextNode(label));
numberSpan.setAttribute('class', 'ngLegendValueNumber');
const adaptYaxisParams = (yaxis: IAxis) => {
const y = { ...yaxis };
wrapperSpan.appendChild(labelSpan);
wrapperSpan.appendChild(numberSpan);
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 wrapperSpan.outerHTML;
},
},
} as jquery.flot.plotOptions & { yaxes?: Axis[] };
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,
}),
};
};
return options;
}
const extractYAxisForSeries = (series: Series) => {
const yaxis = (series._global?.yaxes ?? []).reduce(
(acc: IAxis, item: IAxis) => ({
...acc,
...item,
}),
{}
);
export { buildSeriesData, buildOptions, SERIES_ID_ATTR, colors };
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 };
}
return acc;
}, [] as Array<Partial<AxisSpec>>);
};

View file

@ -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');
});
});
});

View file

@ -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;
},
};

View file

@ -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);
}

View file

@ -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<any, any>;
_hide?: boolean;
_id?: number;
_title?: string;
color?: string;
data: Array<Record<number, number>>;
fit: string;
label: string;
split: string;
stack?: boolean;
type: string;
}

View file

@ -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';

View file

@ -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 };

View file

@ -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');
});
});
});

View file

@ -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;
},
};
}

View file

@ -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;
}

View file

@ -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 = '<br>';
function TimelionVisComponent({
interval,
seriesList,
renderComplete,
onBrushEvent,
}: TimelionVisComponentProps) {
const kibana = useKibana<TimelionVisDependencies>();
const [chart, setChart] = useState(() => cloneDeep(seriesList.list));
const [canvasElem, setCanvasElem] = useState<HTMLDivElement>();
const [chartElem, setChartElem] = useState<HTMLDivElement | null>(null);
const [originalColorMap, setOriginalColorMap] = useState(() => new Map<Series, string>());
const [highlightedSeries, setHighlightedSeries] = useState<number | null>(null);
const [focusedSeries, setFocusedSeries] = useState<number | null>();
const [plot, setPlot] = useState<jquery.flot.plot>();
// Used to toggle the series, and for displaying values on hover
const [legendValueNumbers, setLegendValueNumbers] = useState<JQuery<HTMLElement>>();
const [legendCaption, setLegendCaption] = useState<JQuery<HTMLElement>>();
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 class="timChart__legendCaption"></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 (
<div ref={elementRef} className="timChart">
<div className="chart-top-title">{title}</div>
<div ref={canvasRef} className="chart-canvas" />
</div>
);
}
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
export { TimelionVisComponent as default };

View file

@ -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<CoreStart> {
uiSettings: IUiSettingsClient;
http: HttpSetup;
timefilter: TimefilterContract;
chartTheme: ChartsPluginSetup['theme'];
}
/** @internal */
@ -43,6 +45,7 @@ export interface TimelionVisSetupDependencies {
expressions: ReturnType<ExpressionsPlugin['setup']>;
visualizations: VisualizationsSetup;
data: DataPublicPluginSetup;
charts: ChartsPluginSetup;
}
/** @internal */
@ -72,13 +75,14 @@ export class TimelionVisPlugin
constructor(public initializerContext: PluginInitializerContext<ConfigSchema>) {}
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));

View file

@ -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(
<VisualizationContainer handlers={handlers} showNoResult={showNoResult}>
<KibanaContextProvider services={{ ...deps }}>
<TimelionVisComponent
<VisComponent
interval={visParams.interval}
seriesList={seriesList}
renderComplete={handlers.done}
fireEvent={handlers.event}
onBrushEvent={onBrushEvent}
/>
</KibanaContextProvider>
</VisualizationContainer>,

View file

@ -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 <a href="https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite" target="_blank" rel="noopener">URL</a> of your graphite host',
values: { experimentalLabel: `<em>[${experimentalLabel}]</em>` },
}),
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: `<em>[${experimentalLabel}]</em>` },
}),
sensitive: true,
category: ['timelion'],
schema: schema.string(),
},
});
core.uiSettings.register(getUiSettings(config));
return deepFreeze({ uiEnabled: config.ui.enabled });
}

View file

@ -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;
}

View file

@ -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<typeof configSchema>
): Record<string, UiSettingsParams<unknown>> {
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 <a href="https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite" target="_blank" rel="noopener">URL</a> of your graphite host',
values: { experimentalLabel: `<em>[${experimentalLabel}]</em>` },
}),
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: `<em>[${experimentalLabel}]</em>` },
}),
sensitive: true,
category: ['timelion'],
schema: schema.string(),
},
};
}