[Lens] Synchronize cursor position for X-axis across all Lens visualizations in a dashboard (#106845) (#107691)

* [Lens] Synchronize cursor position for X-axis across all Lens visualizations in a dashboard

Closes: #77530

* add mocks for active_cursor service

* fix jest tests

* fix jest tests

* apply PR comments

* fix cursor style

* update heatmap, jest

* add tests

* fix wrong import

* replace cursor for timelion

* update tsvb_dashboard baseline

* fix CI

* update baseline

* Update active_cursor_utils.ts

* add debounce

* remove cursor from heatmap and pie

* add tests for debounce

* return theme order back

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alexey Antonov 2021-08-05 00:41:46 +03:00 committed by GitHub
parent 98a66ed56c
commit 602e26b1b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 657 additions and 93 deletions

View file

@ -15,6 +15,8 @@ export { ChartsPluginSetup, ChartsPluginStart } from './plugin';
export * from './static';
export * from './services/palettes/types';
export { lightenColor } from './services/palettes/lighten_color';
export { useActiveCursor } from './services/active_cursor';
export {
PaletteOutput,
CustomPaletteArguments,

View file

@ -8,6 +8,7 @@
import { ChartsPlugin } from './plugin';
import { themeServiceMock } from './services/theme/mock';
import { activeCursorMock } from './services/active_cursor/mock';
import { colorsServiceMock } from './services/legacy_colors/mock';
import { getPaletteRegistry, paletteServiceMock } from './services/palettes/mock';
@ -23,6 +24,7 @@ const createSetupContract = (): Setup => ({
const createStartContract = (): Start => ({
legacyColors: colorsServiceMock,
theme: themeServiceMock,
activeCursor: activeCursorMock,
palettes: paletteServiceMock.setup({} as any),
});

View file

@ -12,6 +12,7 @@ import { palette, systemPalette } from '../common';
import { ThemeService, LegacyColorsService } from './services';
import { PaletteService } from './services/palettes/service';
import { ActiveCursor } from './services/active_cursor';
export type Theme = Omit<ThemeService, 'init'>;
export type Color = Omit<LegacyColorsService, 'init'>;
@ -28,13 +29,16 @@ export interface ChartsPluginSetup {
}
/** @public */
export type ChartsPluginStart = ChartsPluginSetup;
export type ChartsPluginStart = ChartsPluginSetup & {
activeCursor: ActiveCursor;
};
/** @public */
export class ChartsPlugin implements Plugin<ChartsPluginSetup, ChartsPluginStart> {
private readonly themeService = new ThemeService();
private readonly legacyColorsService = new LegacyColorsService();
private readonly paletteService = new PaletteService();
private readonly activeCursor = new ActiveCursor();
private palettes: undefined | ReturnType<PaletteService['setup']>;
@ -45,6 +49,8 @@ export class ChartsPlugin implements Plugin<ChartsPluginSetup, ChartsPluginStart
this.legacyColorsService.init(core.uiSettings);
this.palettes = this.paletteService.setup(this.legacyColorsService);
this.activeCursor.setup();
return {
legacyColors: this.legacyColorsService,
theme: this.themeService,
@ -57,6 +63,7 @@ export class ChartsPlugin implements Plugin<ChartsPluginSetup, ChartsPluginStart
legacyColors: this.legacyColorsService,
theme: this.themeService,
palettes: this.palettes!,
activeCursor: this.activeCursor,
};
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 { ActiveCursor } from './active_cursor';
describe('ActiveCursor', () => {
let activeCursor: ActiveCursor;
beforeEach(() => {
activeCursor = new ActiveCursor();
});
test('should initialize activeCursor$ stream on setup hook', () => {
expect(activeCursor.activeCursor$).toBeUndefined();
activeCursor.setup();
expect(activeCursor.activeCursor$).toMatchInlineSnapshot(`
Subject {
"_isScalar": false,
"closed": false,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
}
`);
});
});

View file

@ -7,6 +7,12 @@
*/
import { Subject } from 'rxjs';
import { PointerEvent } from '@elastic/charts';
import type { ActiveCursorPayload } from './types';
export const activeCursor$ = new Subject<PointerEvent>();
export class ActiveCursor {
public activeCursor$?: Subject<ActiveCursorPayload>;
setup() {
this.activeCursor$ = new Subject();
}
}

View file

@ -0,0 +1,141 @@
/*
* 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 { parseSyncOptions } from './active_cursor_utils';
import type { Datatable } from '../../../../expressions/public';
describe('active_cursor_utils', () => {
describe('parseSyncOptions', () => {
describe('dateHistogramSyncOption', () => {
test('should return isDateHistogram true in case if that mode is active', () => {
expect(parseSyncOptions({ isDateHistogram: true })).toMatchInlineSnapshot(`
Object {
"isDateHistogram": true,
}
`);
});
test('should return isDateHistogram false for other cases', () => {
expect(parseSyncOptions({ datatables: [] as Datatable[] })).toMatchInlineSnapshot(`
Object {
"accessors": Array [],
"isDateHistogram": false,
}
`);
});
});
describe('datatablesSyncOption', () => {
test('should extract accessors', () => {
expect(
parseSyncOptions({
datatables: ([
{
columns: [
{
meta: {
index: 'foo_index',
field: 'foo_field',
},
},
],
},
] as unknown) as Datatable[],
}).accessors
).toMatchInlineSnapshot(`
Array [
"foo_index:foo_field",
]
`);
});
test('should return isDateHistogram true in case all datatables is time based', () => {
expect(
parseSyncOptions({
datatables: ([
{
columns: [
{
meta: {
index: 'foo_index',
field: 'foo_field',
sourceParams: {
appliedTimeRange: {},
},
},
},
],
},
{
columns: [
{
meta: {
index: 'foo_index1',
field: 'foo_field1',
sourceParams: {
appliedTimeRange: {},
},
},
},
],
},
] as unknown) as Datatable[],
})
).toMatchInlineSnapshot(`
Object {
"accessors": Array [
"foo_index:foo_field",
"foo_index1:foo_field1",
],
"isDateHistogram": true,
}
`);
});
test('should return isDateHistogram false in case of not all datatables is time based', () => {
expect(
parseSyncOptions({
datatables: ([
{
columns: [
{
meta: {
index: 'foo_index',
field: 'foo_field',
sourceParams: {
appliedTimeRange: {},
},
},
},
],
},
{
columns: [
{
meta: {
index: 'foo_index1',
field: 'foo_field1',
},
},
],
},
] as unknown) as Datatable[],
})
).toMatchInlineSnapshot(`
Object {
"accessors": Array [
"foo_index:foo_field",
"foo_index1:foo_field1",
],
"isDateHistogram": false,
}
`);
});
});
});
});

View file

@ -0,0 +1,49 @@
/*
* 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 { uniq } from 'lodash';
import type { Datatable } from '../../../../expressions/public';
import type { ActiveCursorSyncOption, DateHistogramSyncOption } from './types';
import type { ActiveCursorPayload } from './types';
function isDateHistogramSyncOption(
syncOption?: ActiveCursorSyncOption
): syncOption is DateHistogramSyncOption {
return Boolean(syncOption && 'isDateHistogram' in syncOption);
}
const parseDatatable = (dataTables: Datatable[]) => {
const isDateHistogram =
Boolean(dataTables.length) &&
dataTables.every((dataTable) =>
dataTable.columns.some((c) => Boolean(c.meta.sourceParams?.appliedTimeRange))
);
const accessors = uniq(
dataTables
.map((dataTable) => {
const column = dataTable.columns.find((c) => c.meta.index && c.meta.field);
if (column?.meta.index) {
return `${column.meta.index}:${column.meta.field}`;
}
})
.filter(Boolean) as string[]
);
return { isDateHistogram, accessors };
};
/** @internal **/
export const parseSyncOptions = (
syncOptions: ActiveCursorSyncOption
): Partial<ActiveCursorPayload> =>
isDateHistogramSyncOption(syncOptions)
? {
isDateHistogram: syncOptions.isDateHistogram,
}
: parseDatatable(syncOptions.datatables);

View file

@ -6,7 +6,5 @@
* Side Public License, v 1.
*/
import { Subject } from 'rxjs';
import { PointerEvent } from '@elastic/charts';
export const activeCursor$ = new Subject<PointerEvent>();
export { ActiveCursor } from './active_cursor';
export { useActiveCursor } from './use_active_cursor';

View file

@ -0,0 +1,19 @@
/*
* 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 type { ActiveCursor } from './active_cursor';
export const activeCursorMock: ActiveCursor = {
activeCursor$: {
subscribe: jest.fn(),
pipe: jest.fn(() => ({
subscribe: jest.fn(),
})),
},
setup: jest.fn(),
} as any;

View file

@ -0,0 +1,35 @@
/*
* 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 type { PointerEvent } from '@elastic/charts';
import type { Datatable } from '../../../../expressions/public';
/** @public **/
export type ActiveCursorSyncOption = DateHistogramSyncOption | DatatablesSyncOption;
/** @internal **/
export interface ActiveCursorPayload {
cursor: PointerEvent;
isDateHistogram?: boolean;
accessors?: string[];
}
/** @internal **/
interface BaseSyncOptions {
debounce?: number;
}
/** @internal **/
export interface DateHistogramSyncOption extends BaseSyncOptions {
isDateHistogram: boolean;
}
/** @internal **/
export interface DatatablesSyncOption extends BaseSyncOptions {
datatables: Datatable[];
}

View file

@ -0,0 +1,183 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import type { RefObject } from 'react';
import { ActiveCursor } from './active_cursor';
import { useActiveCursor } from './use_active_cursor';
import type { ActiveCursorSyncOption, ActiveCursorPayload } from './types';
import type { Chart, PointerEvent } from '@elastic/charts';
import type { Datatable } from '../../../../expressions/public';
describe('useActiveCursor', () => {
let cursor: ActiveCursorPayload['cursor'];
let dispatchExternalPointerEvent: jest.Mock;
const act = (
syncOption: ActiveCursorSyncOption,
events: Array<Partial<ActiveCursorPayload>>,
eventsTimeout = 1
) =>
new Promise(async (resolve) => {
const activeCursor = new ActiveCursor();
let allEventsExecuted = false;
activeCursor.setup();
dispatchExternalPointerEvent.mockImplementation((pointerEvent) => {
if (allEventsExecuted) {
resolve(pointerEvent);
}
});
renderHook(() =>
useActiveCursor(
activeCursor,
{
current: {
dispatchExternalPointerEvent: dispatchExternalPointerEvent as (
pointerEvent: PointerEvent
) => void,
},
} as RefObject<Chart>,
{ ...syncOption, debounce: syncOption.debounce ?? 1 }
)
);
for (const e of events) {
await new Promise((eventResolve) =>
setTimeout(() => {
if (e === events[events.length - 1]) {
allEventsExecuted = true;
}
activeCursor.activeCursor$!.next({ cursor, ...e });
eventResolve(null);
}, eventsTimeout)
);
}
});
beforeEach(() => {
cursor = {} as ActiveCursorPayload['cursor'];
dispatchExternalPointerEvent = jest.fn();
});
test('should debounce events', async () => {
await act(
{
debounce: 5,
datatables: [
{
columns: [
{
meta: {
index: 'foo_index',
field: 'foo_field',
},
},
],
},
] as Datatable[],
},
[
{ accessors: ['foo_index:foo_field'] },
{ accessors: ['foo_index:foo_field'] },
{ accessors: ['foo_index:foo_field'] },
{ accessors: ['foo_index:foo_field'] },
]
);
expect(dispatchExternalPointerEvent).toHaveBeenCalledTimes(1);
});
test('should trigger cursor pointer update (chart type: time, event type: time)', async () => {
await act({ isDateHistogram: true }, [{ isDateHistogram: true }]);
expect(dispatchExternalPointerEvent).toHaveBeenCalledTimes(1);
});
test('should trigger cursor pointer update (chart type: datatable - time based, event type: time)', async () => {
await act(
{
datatables: ([
{
columns: [
{
meta: {
index: 'foo_index',
field: 'foo_field',
sourceParams: {
appliedTimeRange: {},
},
},
},
],
},
] as unknown) as Datatable[],
},
[{ isDateHistogram: true }, { accessors: ['foo_index:foo_field'] }]
);
expect(dispatchExternalPointerEvent).toHaveBeenCalledTimes(2);
});
test('should not trigger cursor pointer update (chart type: datatable, event type: time)', async () => {
await act(
{
datatables: [
{
columns: [
{
meta: {
index: 'foo_index',
field: 'foo_field',
},
},
],
},
] as Datatable[],
},
[{ isDateHistogram: true }, { accessors: ['foo_index:foo_field'] }]
);
expect(dispatchExternalPointerEvent).toHaveBeenCalledTimes(1);
});
test('should works with multi datatables (intersection)', async () => {
await act(
{
datatables: [
{
columns: [
{
meta: {
index: 'ia',
field: 'fa',
},
},
],
},
{
columns: [
{
meta: {
index: 'ib',
field: 'fb',
},
},
],
},
] as Datatable[],
},
[{ accessors: ['foo_index:foo_field', 'ib:fb'] }]
);
expect(dispatchExternalPointerEvent).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 { intersection } from 'lodash';
import { animationFrameScheduler } from 'rxjs';
import { useCallback, useEffect, RefObject } from 'react';
import { filter, debounceTime } from 'rxjs/operators';
import type { Chart } from '@elastic/charts';
import { parseSyncOptions } from './active_cursor_utils';
import type { ActiveCursor } from './active_cursor';
import type { ActiveCursorSyncOption } from './types';
const DEFAULT_DEBOUNCE_TIME = 40;
export const useActiveCursor = (
activeCursor: ActiveCursor,
chartRef: RefObject<Chart>,
syncOptions: ActiveCursorSyncOption
) => {
const { accessors, isDateHistogram } = parseSyncOptions(syncOptions);
const handleCursorUpdate = useCallback(
(cursor) => {
activeCursor.activeCursor$?.next({
cursor,
isDateHistogram,
accessors,
});
},
[activeCursor.activeCursor$, accessors, isDateHistogram]
);
useEffect(() => {
const cursorSubscription = activeCursor.activeCursor$
?.pipe(
debounceTime(syncOptions.debounce ?? DEFAULT_DEBOUNCE_TIME, animationFrameScheduler),
filter((payload) => {
if (payload.isDateHistogram && isDateHistogram) {
return true;
}
return intersection(accessors, payload.accessors).length > 0;
})
)
.subscribe(({ cursor }) => {
chartRef?.current?.dispatchExternalPointerEvent(cursor);
});
return () => {
cursorSubscription?.unsubscribe();
};
}, [activeCursor.activeCursor$, syncOptions.debounce, chartRef, accessors, isDateHistogram]);
return handleCursorUpdate;
};

View file

@ -8,3 +8,4 @@
export { LegacyColorsService } from './legacy_colors';
export { ThemeService } from './theme';
export { ActiveCursor, useActiveCursor } from './active_cursor';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { compact, last, map } from 'lodash';
import {
Chart,
@ -14,13 +14,13 @@ import {
Position,
Axis,
TooltipType,
PointerEvent,
LegendPositionConfig,
LayoutDirection,
} from '@elastic/charts';
import { EuiTitle } from '@elastic/eui';
import { useKibana } from '../../../kibana_react/public';
import { useActiveCursor } from '../../../charts/public';
import { AreaSeriesComponent, BarSeriesComponent } from './series';
@ -33,7 +33,7 @@ import {
} from '../helpers/panel_utils';
import { colors } from '../helpers/chart_constants';
import { activeCursor$ } from '../helpers/active_cursor';
import { getCharts } from '../helpers/plugin_services';
import type { Sheet } from '../helpers/timelion_request_handler';
import type { IInterpreterRenderHandlers } from '../../../expressions';
@ -100,20 +100,14 @@ const TimelionVisComponent = ({
const kibana = useKibana<TimelionVisDependencies>();
const chartRef = useRef<Chart>(null);
const chart = seriesList.list;
const chartsService = getCharts();
useEffect(() => {
const subscription = activeCursor$.subscribe((cursor: PointerEvent) => {
chartRef.current?.dispatchExternalPointerEvent(cursor);
});
const chartTheme = chartsService.theme.useChartsTheme();
const chartBaseTheme = chartsService.theme.useChartsBaseTheme();
return () => {
subscription.unsubscribe();
};
}, []);
const handleCursorUpdate = useCallback((cursor: PointerEvent) => {
activeCursor$.next(cursor);
}, []);
const handleCursorUpdate = useActiveCursor(chartsService.activeCursor, chartRef, {
isDateHistogram: true,
});
const brushEndListener = useCallback(
({ x }) => {
@ -198,8 +192,8 @@ const TimelionVisComponent = ({
legendPosition={legend.legendPosition}
onRenderChange={onRenderChange}
onPointerUpdate={handleCursorUpdate}
theme={kibana.services.chartTheme.useChartsTheme()}
baseTheme={kibana.services.chartTheme.useChartsBaseTheme()}
theme={chartTheme}
baseTheme={chartBaseTheme}
tooltip={{
snap: true,
headerFormatter: ({ value }) => tickFormat(value),

View file

@ -7,6 +7,7 @@
*/
import type { IndexPatternsContract, ISearchStart } from 'src/plugins/data/public';
import type { ChartsPluginStart } from 'src/plugins/charts/public';
import { createGetterSetter } from '../../../kibana_utils/public';
export const [getIndexPatterns, setIndexPatterns] = createGetterSetter<IndexPatternsContract>(
@ -14,3 +15,5 @@ export const [getIndexPatterns, setIndexPatterns] = createGetterSetter<IndexPatt
);
export const [getDataSearch, setDataSearch] = createGetterSetter<ISearchStart>('Search');
export const [getCharts, setCharts] = createGetterSetter<ChartsPluginStart>('Charts');

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import {
import type {
CoreSetup,
CoreStart,
Plugin,
@ -14,30 +14,29 @@ import {
IUiSettingsClient,
HttpSetup,
} from 'kibana/public';
import { Plugin as ExpressionsPlugin } from 'src/plugins/expressions/public';
import {
import type { Plugin as ExpressionsPlugin } from 'src/plugins/expressions/public';
import type {
DataPublicPluginSetup,
DataPublicPluginStart,
TimefilterContract,
} from 'src/plugins/data/public';
import { VisualizationsSetup } from '../../visualizations/public';
import { ChartsPluginSetup } from '../../charts/public';
import type { VisualizationsSetup } from 'src/plugins/visualizations/public';
import type { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public';
import { getTimelionVisualizationConfig } from './timelion_vis_fn';
import { getTimelionVisDefinition } from './timelion_vis_type';
import { setIndexPatterns, setDataSearch } from './helpers/plugin_services';
import { ConfigSchema } from '../config';
import { setIndexPatterns, setDataSearch, setCharts } from './helpers/plugin_services';
import { getArgValueSuggestions } from './helpers/arg_value_suggestions';
import { getTimelionVisRenderer } from './timelion_vis_renderer';
import type { ConfigSchema } from '../config';
/** @internal */
export interface TimelionVisDependencies extends Partial<CoreStart> {
uiSettings: IUiSettingsClient;
http: HttpSetup;
timefilter: TimefilterContract;
chartTheme: ChartsPluginSetup['theme'];
}
/** @internal */
@ -51,6 +50,7 @@ export interface TimelionVisSetupDependencies {
/** @internal */
export interface TimelionVisStartDependencies {
data: DataPublicPluginStart;
charts: ChartsPluginStart;
}
/** @public */
@ -82,7 +82,6 @@ export class TimelionVisPlugin
http,
uiSettings,
timefilter: data.query.timefilter.timefilter,
chartTheme: charts.theme,
};
expressions.registerFunction(() => getTimelionVisualizationConfig(dependencies));
@ -94,9 +93,10 @@ export class TimelionVisPlugin
};
}
public start(core: CoreStart, plugins: TimelionVisStartDependencies) {
setIndexPatterns(plugins.data.indexPatterns);
setDataSearch(plugins.data.search);
public start(core: CoreStart, { data, charts }: TimelionVisStartDependencies) {
setIndexPatterns(data.indexPatterns);
setDataSearch(data.search);
setCharts(charts);
return {
getArgValueSuggestions,

View file

@ -28,7 +28,7 @@ import {
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import { SeriesConfigQueryBarWithIgnoreGlobalFilter } from '../../series_config_query_bar_with_ignore_global_filter';
import { PalettePicker } from '../../palette_picker';
import { getChartsSetup } from '../../../../services';
import { getCharts } from '../../../../services';
import { isPercentDisabled } from '../../lib/stacked';
import { STACKED_OPTIONS } from '../../../visualizations/constants/chart';
@ -120,7 +120,7 @@ export const TimeseriesConfig = injectI18n(function (props) {
const selectedChartTypeOption = chartTypeOptions.find((option) => {
return model.chart_type === option.value;
});
const { palettes } = getChartsSetup();
const { palettes } = getCharts();
const [palettesRegistry, setPalettesRegistry] = useState(null);
useEffect(() => {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useEffect, useRef, useCallback } from 'react';
import React, { useRef, useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { labelDateFormatter } from '../../../components/lib/label_date_formatter';
@ -23,8 +23,7 @@ import {
} from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import { getTimezone } from '../../../lib/get_timezone';
import { activeCursor$ } from '../../lib/active_cursor';
import { getUISettings, getChartsSetup } from '../../../../services';
import { getUISettings, getCharts } from '../../../../services';
import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants';
import { AreaSeriesDecorator } from './decorators/area_decorator';
import { BarSeriesDecorator } from './decorators/bar_decorator';
@ -33,7 +32,7 @@ import { getBaseTheme, getChartClasses } from './utils/theme';
import { TOOLTIP_MODES } from '../../../../../common/enums';
import { getValueOrEmpty } from '../../../../../common/empty_label';
import { getSplitByTermsColor } from '../../../lib/get_split_by_terms_color';
import { renderEndzoneTooltip } from '../../../../../../charts/public';
import { renderEndzoneTooltip, useActiveCursor } from '../../../../../../charts/public';
import { getAxisLabelString } from '../../../components/lib/get_axis_label_string';
import { calculateDomainForSeries } from './utils/series_domain_calculation';
@ -48,10 +47,6 @@ const generateAnnotationData = (values, formatter) =>
const decorateFormatter = (formatter) => ({ value }) => formatter(value);
const handleCursorUpdate = (cursor) => {
activeCursor$.next(cursor);
};
export const TimeSeries = ({
backgroundColor,
showGrid,
@ -69,22 +64,17 @@ export const TimeSeries = ({
interval,
isLastBucketDropped,
}) => {
// If the color isn't configured by the user, use the color mapping service
// to assign a color from the Kibana palette. Colors will be shared across the
// session, including dashboards.
const { theme: themeService, activeCursor: activeCursorService } = getCharts();
const chartRef = useRef();
// const [palettesRegistry, setPalettesRegistry] = useState(null);
const chartTheme = themeService.useChartsTheme();
useEffect(() => {
const updateCursor = (cursor) => {
if (chartRef.current) {
chartRef.current.dispatchExternalPointerEvent(cursor);
}
};
const subscription = activeCursor$.subscribe(updateCursor);
return () => {
subscription.unsubscribe();
};
}, []);
const handleCursorUpdate = useActiveCursor(activeCursorService, chartRef, {
isDateHistogram: true,
});
let tooltipFormatter = decorateFormatter(xAxisFormatter);
if (!isLastBucketDropped) {
@ -104,11 +94,6 @@ export const TimeSeries = ({
// apply legend style change if bgColor is configured
const classes = classNames(getChartClasses(backgroundColor));
// If the color isn't configured by the user, use the color mapping service
// to assign a color from the Kibana palette. Colors will be shared across the
// session, including dashboards.
const { theme: themeService } = getChartsSetup();
const baseTheme = getBaseTheme(themeService.useChartsBaseTheme(), backgroundColor);
const onBrushEndListener = ({ x }) => {
@ -152,6 +137,7 @@ export const TimeSeries = ({
animateData={false}
onPointerUpdate={handleCursorUpdate}
theme={[
chartTheme,
hasBarChart
? {}
: {

View file

@ -20,23 +20,23 @@ import {
setFieldFormats,
setCoreStart,
setDataStart,
setChartsSetup,
setCharts,
} from './services';
import { DataPublicPluginStart } from '../../data/public';
import { ChartsPluginSetup } from '../../charts/public';
import { ChartsPluginStart } from '../../charts/public';
import { getTimeseriesVisRenderer } from './timeseries_vis_renderer';
/** @internal */
export interface MetricsPluginSetupDependencies {
expressions: ReturnType<ExpressionsPublicPlugin['setup']>;
visualizations: VisualizationsSetup;
charts: ChartsPluginSetup;
visualize: VisualizePluginSetup;
}
/** @internal */
export interface MetricsPluginStartDependencies {
data: DataPublicPluginStart;
charts: ChartsPluginStart;
}
/** @internal */
@ -49,7 +49,7 @@ export class MetricsPlugin implements Plugin<void, void> {
public setup(
core: CoreSetup,
{ expressions, visualizations, charts, visualize }: MetricsPluginSetupDependencies
{ expressions, visualizations, visualize }: MetricsPluginSetupDependencies
) {
visualize.visEditorsRegistry.register(TSVB_EDITOR_NAME, EditorController);
expressions.registerFunction(createMetricsFn);
@ -59,11 +59,11 @@ export class MetricsPlugin implements Plugin<void, void> {
})
);
setUISettings(core.uiSettings);
setChartsSetup(charts);
visualizations.createBaseVisualization(metricsVisDefinition);
}
public start(core: CoreStart, { data }: MetricsPluginStartDependencies) {
public start(core: CoreStart, { data, charts }: MetricsPluginStartDependencies) {
setCharts(charts);
setI18n(core.i18n);
setFieldFormats(data.fieldFormats);
setDataStart(data);

View file

@ -8,7 +8,7 @@
import { I18nStart, IUiSettingsClient, CoreStart } from 'src/core/public';
import { createGetterSetter } from '../../kibana_utils/public';
import { ChartsPluginSetup } from '../../charts/public';
import { ChartsPluginStart } from '../../charts/public';
import { DataPublicPluginStart } from '../../data/public';
export const [getUISettings, setUISettings] = createGetterSetter<IUiSettingsClient>('UISettings');
@ -23,6 +23,4 @@ export const [getDataStart, setDataStart] = createGetterSetter<DataPublicPluginS
export const [getI18n, setI18n] = createGetterSetter<I18nStart>('I18n');
export const [getChartsSetup, setChartsSetup] = createGetterSetter<ChartsPluginSetup>(
'ChartsPluginSetup'
);
export const [getCharts, setCharts] = createGetterSetter<ChartsPluginStart>('ChartsPluginStart');

View file

@ -17,7 +17,7 @@ import { VisualizationContainer, PersistedState } from '../../visualizations/pub
import type { TimeseriesVisData } from '../common/types';
import { isVisTableData } from '../common/vis_data_utils';
import { getChartsSetup } from './services';
import { getCharts } from './services';
import type { TimeseriesVisParams } from './types';
import type { ExpressionRenderDefinition } from '../../expressions/common';
@ -49,7 +49,7 @@ export const getTimeseriesVisRenderer: (deps: {
handlers.onDestroy(() => {
unmountComponentAtNode(domNode);
});
const { palettes } = getChartsSetup();
const { palettes } = getCharts();
const showNoResult = !checkIfDataExists(config.visData, config.visParams);
const palettesService = await palettes.getPalettes();

View file

@ -11,6 +11,7 @@ import React, { FC } from 'react';
import {
Direction,
Settings,
SettingsSpecProps,
DomainRange,
Position,
PartialTheme,
@ -49,6 +50,7 @@ type XYSettingsProps = Pick<
| 'xAxis'
| 'orderBucketsBySum'
> & {
onPointerUpdate: SettingsSpecProps['onPointerUpdate'];
xDomain?: DomainRange;
adjustedXDomain?: DomainRange;
showLegend: boolean;
@ -85,6 +87,7 @@ export const XYSettings: FC<XYSettingsProps> = ({
adjustedXDomain,
showLegend,
onElementClick,
onPointerUpdate,
onBrushEnd,
onRenderChange,
legendAction,
@ -107,6 +110,9 @@ export const XYSettings: FC<XYSettingsProps> = ({
barSeriesStyle: {
...valueLabelsStyling,
},
crosshair: {
...theme.crosshair,
},
axes: {
axisTitle: {
padding: {
@ -152,6 +158,7 @@ export const XYSettings: FC<XYSettingsProps> = ({
return (
<Settings
debugState={window._echDebugStateFlag ?? false}
onPointerUpdate={onPointerUpdate}
xDomain={adjustedXDomain}
rotation={rotation}
theme={[themeOverrides, theme]}

View file

@ -9,7 +9,7 @@
import { CoreSetup, CoreStart, Plugin } from '../../../core/public';
import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public';
import { VisualizationsSetup, VisualizationsStart } from '../../visualizations/public';
import { ChartsPluginSetup } from '../../charts/public';
import { ChartsPluginSetup, ChartsPluginStart } from '../../charts/public';
import { DataPublicPluginStart } from '../../data/public';
import { UsageCollectionSetup } from '../../usage_collection/public';
import {
@ -20,6 +20,7 @@ import {
setDocLinks,
setPalettesService,
setTrackUiMetric,
setActiveCursor,
} from './services';
import { visTypesDefinitions } from './vis_types';
@ -46,6 +47,7 @@ export interface VisTypeXyPluginStartDependencies {
expressions: ReturnType<ExpressionsPublicPlugin['start']>;
visualizations: VisualizationsStart;
data: DataPublicPluginStart;
charts: ChartsPluginStart;
}
type VisTypeXyCoreSetup = CoreSetup<VisTypeXyPluginStartDependencies, VisTypeXyPluginStart>;
@ -86,11 +88,11 @@ export class VisTypeXyPlugin
return {};
}
public start(core: CoreStart, { data }: VisTypeXyPluginStartDependencies) {
public start(core: CoreStart, { data, charts }: VisTypeXyPluginStartDependencies) {
setFormatService(data.fieldFormats);
setDataActions(data.actions);
setDocLinks(core.docLinks);
setActiveCursor(charts.activeCursor);
return {};
}
}

View file

@ -10,7 +10,7 @@ import { UiCounterMetricType } from '@kbn/analytics';
import { CoreSetup, DocLinksStart } from '../../../core/public';
import { createGetterSetter } from '../../kibana_utils/public';
import { DataPublicPluginStart } from '../../data/public';
import { ChartsPluginSetup } from '../../charts/public';
import { ChartsPluginSetup, ChartsPluginStart } from '../../charts/public';
export const [getUISettings, setUISettings] = createGetterSetter<CoreSetup['uiSettings']>(
'xy core.uiSettings'
@ -28,6 +28,10 @@ export const [getThemeService, setThemeService] = createGetterSetter<ChartsPlugi
'xy charts.theme'
);
export const [getActiveCursor, setActiveCursor] = createGetterSetter<
ChartsPluginStart['activeCursor']
>('xy charts.activeCursor');
export const [getPalettesService, setPalettesService] = createGetterSetter<
ChartsPluginSetup['palettes']
>('xy charts.palette');

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Chart,
@ -32,7 +32,7 @@ import {
} from '../../charts/public';
import { Datatable, IInterpreterRenderHandlers } from '../../expressions/public';
import type { PersistedState } from '../../visualizations/public';
import { useActiveCursor } from '../../charts/public';
import { VisParams } from './types';
import {
getAdjustedDomain,
@ -47,7 +47,7 @@ import {
} from './utils';
import { XYAxis, XYEndzones, XYCurrentTime, XYSettings, XYThresholdLine } from './components';
import { getConfig } from './config';
import { getThemeService, getDataActions, getPalettesService } from './services';
import { getThemeService, getDataActions, getPalettesService, getActiveCursor } from './services';
import { ChartType } from '../common';
import './_chart.scss';
@ -77,6 +77,11 @@ const VisComponent = (props: VisComponentProps) => {
return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean;
});
const [palettesRegistry, setPalettesRegistry] = useState<PaletteRegistry | null>(null);
const chartRef = useRef<Chart>(null);
const handleCursorUpdate = useActiveCursor(getActiveCursor(), chartRef, {
datatables: [props.visData],
});
const onRenderChange = useCallback<RenderChangeListener>(
(isRendered) => {
@ -333,7 +338,7 @@ const VisComponent = (props: VisComponentProps) => {
showLegend={showLegend}
legendPosition={legendPosition}
/>
<Chart size="100%">
<Chart size="100%" ref={chartRef}>
<ChartSplitter
splitColumnAccessor={splitChartColumnAccessor}
splitRowAccessor={splitChartRowAccessor}
@ -341,6 +346,7 @@ const VisComponent = (props: VisComponentProps) => {
<XYSettings
{...config}
showLegend={showLegend}
onPointerUpdate={handleCursorUpdate}
legendPosition={legendPosition}
xDomain={xDomain}
adjustedXDomain={adjustedXDomain}

View file

@ -172,7 +172,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
// click on specific coordinates
await browser
.getActions()
.move({ x: 100, y: 110, origin: el._webElement })
.move({ x: 105, y: 110, origin: el._webElement })
.click()
.perform();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View file

@ -17,6 +17,7 @@ exports[`xy_expression XYChart component it renders area 1`] = `
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={0}
showLegend={false}
showLegendExtra={false}
@ -234,6 +235,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = `
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={0}
showLegend={false}
showLegendExtra={false}
@ -465,6 +467,7 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = `
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={90}
showLegend={false}
showLegendExtra={false}
@ -696,6 +699,7 @@ exports[`xy_expression XYChart component it renders line 1`] = `
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={0}
showLegend={false}
showLegendExtra={false}
@ -913,6 +917,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = `
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={0}
showLegend={false}
showLegendExtra={false}
@ -1138,6 +1143,7 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = `
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={0}
showLegend={false}
showLegendExtra={false}
@ -1377,6 +1383,7 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] =
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
onPointerUpdate={[Function]}
rotation={90}
showLegend={false}
showLegendExtra={false}

View file

@ -49,7 +49,12 @@ import { XyEndzones } from './x_domain';
const onClickValue = jest.fn();
const onSelectRange = jest.fn();
const chartsThemeService = chartPluginMock.createSetupContract().theme;
const chartSetupContract = chartPluginMock.createSetupContract();
const chartStartContract = chartPluginMock.createStartContract();
const chartsThemeService = chartSetupContract.theme;
const chartsActiveCursorService = chartStartContract.activeCursor;
const paletteService = chartPluginMock.createPaletteRegistry();
const mockPaletteOutput: PaletteOutput = {
@ -473,6 +478,7 @@ describe('xy_expression', () => {
timeZone: 'UTC',
renderMode: 'display',
chartsThemeService,
chartsActiveCursorService,
paletteService,
minInterval: 50,
onClickValue,

View file

@ -7,7 +7,7 @@
import './expression.scss';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import {
Chart,
@ -47,8 +47,10 @@ import { isHorizontalChart, getSeriesColor } from './state_helpers';
import { search } from '../../../../../src/plugins/data/public';
import {
ChartsPluginSetup,
ChartsPluginStart,
PaletteRegistry,
SeriesLayer,
useActiveCursor,
} from '../../../../../src/plugins/charts/public';
import { EmptyPlaceholder } from '../shared_components';
import { getFitOptions } from './fitting_functions';
@ -85,6 +87,7 @@ export {
export type XYChartRenderProps = XYChartProps & {
chartsThemeService: ChartsPluginSetup['theme'];
chartsActiveCursorService: ChartsPluginStart['activeCursor'];
paletteService: PaletteRegistry;
formatFactory: FormatFactory;
timeZone: string;
@ -121,7 +124,8 @@ export function calculateMinInterval({ args: { layers }, data }: XYChartProps) {
export const getXyChartRenderer = (dependencies: {
formatFactory: Promise<FormatFactory>;
chartsThemeService: ChartsPluginSetup['theme'];
chartsThemeService: ChartsPluginStart['theme'];
chartsActiveCursorService: ChartsPluginStart['activeCursor'];
paletteService: PaletteRegistry;
timeZone: string;
}): ExpressionRenderDefinition<XYChartProps> => ({
@ -150,6 +154,7 @@ export const getXyChartRenderer = (dependencies: {
<XYChartReportable
{...config}
formatFactory={formatFactory}
chartsActiveCursorService={dependencies.chartsActiveCursorService}
chartsThemeService={dependencies.chartsThemeService}
paletteService={dependencies.paletteService}
timeZone={dependencies.timeZone}
@ -222,6 +227,7 @@ export function XYChart({
formatFactory,
timeZone,
chartsThemeService,
chartsActiveCursorService,
paletteService,
minInterval,
onClickValue,
@ -240,11 +246,16 @@ export function XYChart({
yRightExtent,
valuesInLegend,
} = args;
const chartRef = useRef<Chart>(null);
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
const darkMode = chartsThemeService.useDarkMode();
const filteredLayers = getFilteredLayers(layers, data);
const handleCursorUpdate = useActiveCursor(chartsActiveCursorService, chartRef, {
datatables: Object.values(data.tables),
});
if (filteredLayers.length === 0) {
const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar';
return <EmptyPlaceholder icon={icon} />;
@ -486,8 +497,9 @@ export function XYChart({
} as LegendPositionConfig;
return (
<Chart>
<Chart ref={chartRef}>
<Settings
onPointerUpdate={handleCursorUpdate}
debugState={window._echDebugStateFlag ?? false}
showLegend={
legend.isVisible && !legend.showSingleSeries

View file

@ -25,7 +25,7 @@ export class XyVisualization {
setup(
core: CoreSetup<LensPluginStartDependencies, void>,
{ expressions, formatFactory, editorFrame, charts }: XyVisualizationPluginSetupPlugins
{ expressions, formatFactory, editorFrame }: XyVisualizationPluginSetupPlugins
) {
editorFrame.registerVisualization(async () => {
const {
@ -41,7 +41,7 @@ export class XyVisualization {
getXyChartRenderer,
getXyVisualization,
} = await import('../async_services');
const [, { data }] = await core.getStartServices();
const [, { data, charts }] = await core.getStartServices();
const palettes = await charts.palettes.getPalettes();
expressions.registerFunction(() => legendConfig);
expressions.registerFunction(() => yAxisConfig);
@ -57,6 +57,7 @@ export class XyVisualization {
getXyChartRenderer({
formatFactory,
chartsThemeService: charts.theme,
chartsActiveCursorService: charts.activeCursor,
paletteService: palettes,
timeZone: getTimeZone(core.uiSettings),
})