Skip to content

Commit

Permalink
feat: Plotly express downsampling (#453)
Browse files Browse the repository at this point in the history
Fixes #41 

Here's a test that you can see the re-downsample is triggered when
zooming in. Also that it still ticks properly and handles multiple
series (the Python side creates 2 tables in this case)

```py
import deephaven.plot.express as dx

import datetime
from deephaven import time_table
t = time_table("PT1S", datetime.datetime.now() - datetime.timedelta(seconds=400000)).update(["Y=Math.sin(ii/1000)", "Z=Math.cos(ii/1000)"])

plot = dx.line(t, x="Timestamp", y=["Y", "Z"])
```

Plot by example

```py
import deephaven.plot.express as dx
import datetime
from deephaven import time_table

t = time_table("PT1S", datetime.datetime.now() - datetime.timedelta(seconds=400000)).update(["EvenOdd=ii % 2 == 0 ? `Even` : `Odd`", "Y= ii % 2 == 0 ? Math.sin(ii/1000) : Math.cos(ii/1000)"])

plot_by = dx.line(t, x="Timestamp", y=["Y"], by=["EvenOdd"])
```

This code shows layering which will use the same table. This only works
if you have a shared X column because of how downsampling works. The
alternative is to create copies of the table and downsample each copy
separately, but I figured we can probably leave that alone unless
somebody requests it since it seems like a pretty rare case.

```py
import deephaven.plot.express as dx
import datetime
from deephaven import time_table

t = time_table("PT1S", datetime.datetime.now() - datetime.timedelta(seconds=400000)).update(["Y=Math.sin(ii/1000)", "Z=Math.cos(ii/1000)"])

fig1 = dx.line(t, x="Timestamp", y="Y",
    color_discrete_sequence=["lemonchiffon"]
)

fig2 = dx.line(t, x="Timestamp", y="Z",
    color_discrete_sequence=["salmon"]
)

layered = dx.layer(fig1, fig2)
```
  • Loading branch information
mattrunyon authored May 15, 2024
1 parent b87a5c6 commit 0101436
Show file tree
Hide file tree
Showing 12 changed files with 6,068 additions and 6,578 deletions.
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
265 changes: 265 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,265 @@
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);
});

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

0 comments on commit 0101436

Please sign in to comment.