-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Plotly express downsampling (#453)
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
1 parent
b87a5c6
commit 0101436
Showing
12 changed files
with
6,068 additions
and
6,578 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
265 changes: 265 additions & 0 deletions
265
plugins/plotly-express/src/js/src/PlotlyExpressChartModel.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
); | ||
}); | ||
}); |
Oops, something went wrong.