Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Plotly express downsampling #453

Merged
merged 12 commits into from
May 15, 2024
11,727 changes: 5,206 additions & 6,521 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions plugins/plotly-express/src/js/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const baseConfig = require('../../../../jest.config.base.cjs');
const packageJson = require('./package');

module.exports = {
...baseConfig,
displayName: packageJson.name,
};
24 changes: 13 additions & 11 deletions plugins/plotly-express/src/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,32 @@
"update-dh-packages": "node ../../../../tools/update-dh-packages.mjs"
},
"devDependencies": {
"@deephaven/jsapi-types": "0.64.0",
"@deephaven/jsapi-types": "1.0.0-dev0.34.0",
"@types/deep-equal": "^1.0.1",
"@types/plotly.js": "^2.12.18",
"@types/plotly.js-dist-min": "^2.3.1",
"@types/react": "^17.0.2",
"@types/react-plotly.js": "^2.6.0",
"@vitejs/plugin-react-swc": "^3.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.5.4",
"vite": "~4.1.4"
},
"peerDependencies": {
"react": "^17.0.2"
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"dependencies": {
"@deephaven/chart": "0.64.0",
"@deephaven/components": "0.64.0",
"@deephaven/dashboard": "0.64.0",
"@deephaven/dashboard-core-plugins": "0.64.0",
"@deephaven/icons": "0.64.0",
"@deephaven/jsapi-bootstrap": "0.64.0",
"@deephaven/log": "0.64.0",
"@deephaven/plugin": "0.64.0",
"@deephaven/utils": "0.64.0",
"@deephaven/chart": "0.75.0",
"@deephaven/components": "0.75.0",
"@deephaven/dashboard": "0.75.0",
"@deephaven/dashboard-core-plugins": "0.75.0",
"@deephaven/icons": "0.75.0",
"@deephaven/jsapi-bootstrap": "0.75.0",
"@deephaven/log": "0.75.0",
"@deephaven/plugin": "0.75.0",
"@deephaven/utils": "0.75.0",
"deep-equal": "^2.2.1",
"plotly.js": "^2.29.1",
"plotly.js-dist-min": "^2.29.1",
Expand Down
6 changes: 3 additions & 3 deletions plugins/plotly-express/src/js/src/DashboardPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
PanelEvent,
useListener,
} from '@deephaven/dashboard';
import type { VariableDescriptor } from '@deephaven/jsapi-types';
import type { dh } from '@deephaven/jsapi-types';
import PlotlyExpressChartPanel from './PlotlyExpressChartPanel.js';
import type { PlotlyChartWidget } from './PlotlyExpressChartUtils.js';

Expand All @@ -27,7 +27,7 @@ export function DashboardPlugin(
fetch: () => Promise<PlotlyChartWidget>;
metadata?: Record<string, unknown>;
panelId?: string;
widget: VariableDescriptor;
widget: dh.ide.VariableDescriptor;
}) => {
const { type, name } = widget;
if (type !== 'deephaven.plot.express.DeephavenFigure') {
Expand All @@ -47,7 +47,7 @@ export function DashboardPlugin(
},
fetch,
},
title: name,
title: name ?? undefined,
id: panelId,
};

Expand Down
4 changes: 2 additions & 2 deletions plugins/plotly-express/src/js/src/PlotlyExpressChart.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, { useEffect, useRef, useState } from 'react';
import Plotly from 'plotly.js-dist-min';
import { Chart } from '@deephaven/chart';
import type { Widget } from '@deephaven/jsapi-types';
import type { dh } from '@deephaven/jsapi-types';
import { type WidgetComponentProps } from '@deephaven/plugin';
import { useApi } from '@deephaven/jsapi-bootstrap';
import PlotlyExpressChartModel from './PlotlyExpressChartModel.js';
import { useHandleSceneTicks } from './useHandleSceneTicks.js';

export function PlotlyExpressChart(
props: WidgetComponentProps<Widget>
props: WidgetComponentProps<dh.Widget>
): JSX.Element | null {
const dh = useApi();
const { fetch } = props;
Expand Down
267 changes: 267 additions & 0 deletions plugins/plotly-express/src/js/src/PlotlyExpressChartModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import type { Layout } from 'plotly.js';
import { dh as DhType } from '@deephaven/jsapi-types';
import { TestUtils } from '@deephaven/utils';
import { ChartModel } from '@deephaven/chart';
import { PlotlyExpressChartModel } from './PlotlyExpressChartModel';
import { PlotlyChartWidgetData } from './PlotlyExpressChartUtils';

const SMALL_TABLE = TestUtils.createMockProxy<DhType.Table>({
columns: [{ name: 'x' }, { name: 'y' }] as DhType.Column[],
size: 500,
subscribe: () => TestUtils.createMockProxy(),
});

const LARGE_TABLE = TestUtils.createMockProxy<DhType.Table>({
columns: [{ name: 'x' }, { name: 'y' }] as DhType.Column[],
size: 50_000,
subscribe: () => TestUtils.createMockProxy(),
});

const REALLY_LARGE_TABLE = TestUtils.createMockProxy<DhType.Table>({
columns: [{ name: 'x' }, { name: 'y' }] as DhType.Column[],
size: 5_000_000,
subscribe: () => TestUtils.createMockProxy(),
});

function createMockWidget(tables: DhType.Table[], plotType = 'scatter') {
const layoutAxes: Partial<Layout> = {};
tables.forEach((_, i) => {
if (i === 0) {
layoutAxes.xaxis = {};
layoutAxes.yaxis = {};
} else {
layoutAxes[`xaxis${i + 1}` as 'xaxis'] = {};
layoutAxes[`yaxis${i + 1}` as 'yaxis'] = {};
}
});

const widgetData = {
type: 'test',
figure: {
deephaven: {
mappings: tables.map((_, i) => ({
table: i,
data_columns: {
x: [`/plotly/data/${i}/x`],
y: [`/plotly/data/${i}/y`],
},
})),
is_user_set_color: false,
is_user_set_template: false,
},
plotly: {
data: tables.map((_, i) => ({
type: plotType as 'scatter',
mode: 'lines',
xaxis: i === 0 ? 'x' : `x${i + 1}`,
yaxis: i === 0 ? 'y' : `y${i + 1}`,
})),
layout: {
title: 'layout',
...layoutAxes,
},
},
},
revision: 0,
new_references: tables.map((_, i) => i),
removed_references: [],
} satisfies PlotlyChartWidgetData;

return {
getDataAsString: () => JSON.stringify(widgetData),
exportedObjects: tables.map(t => ({
fetch: () => Promise.resolve(t),
reexport: jest.fn(),
close: jest.fn(),
})),
addEventListener: jest.fn(),
} satisfies Partial<DhType.Widget> as unknown as DhType.Widget;
}

type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;

const mockDownsample = jest.fn(t => t);

const mockDh = {
calendar: {
DayOfWeek: {
values: () => [],
},
},
plot: {
Downsample: {
runChartDownsample: mockDownsample,
},
ChartData: (() =>
TestUtils.createMockProxy()) as unknown as typeof DhType.plot.ChartData,
},
Table: {
EVENT_UPDATED: 'updated',
},
Widget: {
EVENT_MESSAGE: 'message',
},
} satisfies DeepPartial<typeof DhType> as unknown as typeof DhType;

beforeEach(() => {
jest.resetAllMocks();
});

describe('PlotlyExpressChartModel', () => {
it('should create a new instance of PlotlyExpressChartModel', () => {
const mockWidget = createMockWidget([]);

const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

expect(chartModel.isSubscribed).toBe(false);
expect(chartModel.layout).toEqual(
JSON.parse(mockWidget.getDataAsString()).figure.plotly.layout
);
});

it('should subscribe', async () => {
const mockWidget = createMockWidget([]);
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

await chartModel.subscribe(jest.fn());
expect(chartModel.isSubscribed).toBe(true);
});

it('should not downsample line charts when the table is small', async () => {
const mockWidget = createMockWidget([SMALL_TABLE]);
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

const mockSubscribe = jest.fn();
await chartModel.subscribe(mockSubscribe);
await new Promise(process.nextTick); // Subscribe and addTable are async
expect(mockDownsample).toHaveBeenCalledTimes(0);
expect(mockSubscribe).toHaveBeenCalledTimes(0);
// expect(chartModel.fireDownsampleStart).toHaveBeenCalledTimes(0);
// expect(chartModel.fireDownsampleFinish).toHaveBeenCalledTimes(0);
mattrunyon marked this conversation as resolved.
Show resolved Hide resolved
});

it('should downsample line charts when the table is big', async () => {
const mockWidget = createMockWidget([LARGE_TABLE]);
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

const mockSubscribe = jest.fn();
await chartModel.subscribe(mockSubscribe);
await new Promise(process.nextTick); // Subscribe and addTable are async
expect(mockDownsample).toHaveBeenCalledTimes(1);
expect(mockSubscribe).toHaveBeenCalledTimes(2);
expect(mockSubscribe).toHaveBeenNthCalledWith(
1,
new CustomEvent(ChartModel.EVENT_DOWNSAMPLESTARTED)
);
expect(mockSubscribe).toHaveBeenLastCalledWith(
new CustomEvent(ChartModel.EVENT_DOWNSAMPLEFINISHED)
);
});

it('should downsample only the required tables', async () => {
const mockWidget = createMockWidget([
SMALL_TABLE,
LARGE_TABLE,
REALLY_LARGE_TABLE,
]);
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

const mockSubscribe = jest.fn();
await chartModel.subscribe(mockSubscribe);
await new Promise(process.nextTick); // Subscribe and addTable are async
expect(mockDownsample).toHaveBeenCalledTimes(2);
expect(mockSubscribe).toHaveBeenCalledTimes(4);
expect(mockSubscribe).toHaveBeenNthCalledWith(
1,
new CustomEvent(ChartModel.EVENT_DOWNSAMPLESTARTED)
);
expect(mockSubscribe).toHaveBeenNthCalledWith(
2,
new CustomEvent(ChartModel.EVENT_DOWNSAMPLESTARTED)
);
expect(mockSubscribe).toHaveBeenNthCalledWith(
3,
new CustomEvent(ChartModel.EVENT_DOWNSAMPLEFINISHED)
);
expect(mockSubscribe).toHaveBeenLastCalledWith(
new CustomEvent(ChartModel.EVENT_DOWNSAMPLEFINISHED)
);
});

it('should fail to downsample for non-line plots', async () => {
const mockWidget = createMockWidget([LARGE_TABLE], 'scatterpolar');
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

const mockSubscribe = jest.fn();
await chartModel.subscribe(mockSubscribe);
await new Promise(process.nextTick); // Subscribe and addTable are async
expect(mockDownsample).toHaveBeenCalledTimes(0);
expect(mockSubscribe).toHaveBeenCalledTimes(1);
expect(mockSubscribe).toHaveBeenCalledWith(
new CustomEvent(ChartModel.EVENT_DOWNSAMPLEFAILED)
);
});

it('should fetch non-line plots under the max threshold with downsampling disabled', async () => {
const mockWidget = createMockWidget([LARGE_TABLE], 'scatterpolar');
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

const mockSubscribe = jest.fn();
chartModel.isDownsamplingDisabled = true;
await chartModel.subscribe(mockSubscribe);
await new Promise(process.nextTick); // Subscribe and addTable are async
expect(mockDownsample).toHaveBeenCalledTimes(0);
expect(mockSubscribe).toHaveBeenCalledTimes(0);
});

it('should not fetch non-line plots over the max threshold with downsampling disabled', async () => {
const mockWidget = createMockWidget([REALLY_LARGE_TABLE], 'scatterpolar');
const chartModel = new PlotlyExpressChartModel(
mockDh,
mockWidget,
jest.fn()
);

const mockSubscribe = jest.fn();
chartModel.isDownsamplingDisabled = true;
await chartModel.subscribe(mockSubscribe);
await new Promise(process.nextTick); // Subscribe and addTable are async
expect(mockDownsample).toHaveBeenCalledTimes(0);
expect(mockSubscribe).toHaveBeenCalledTimes(1);
expect(mockSubscribe).toHaveBeenCalledWith(
new CustomEvent(ChartModel.EVENT_DOWNSAMPLEFAILED)
);
});
});
Loading
Loading