(x.length);
+ for (let i = 0; i < x.length; i++) {
+ const j = indices[i]!;
+ sortedX[i] = x[j];
+ sortedY[i] = y[j]!;
+ }
+ return { x: sortedX, y: sortedY };
+}
+
/**
* Downsamples x/y arrays to at most maxPoints by taking every Nth point.
* Always includes the first and last points to preserve the range.
@@ -1410,15 +1505,23 @@ const plotlyChartScripts = `
}
if (!arValue) {
- wrapperDiv.style.position = '';
- wrapperDiv.style.left = '';
- wrapperDiv.style.top = '';
+ // Use absolute positioning so the wrapper is taken out of #chart's
+ // flow. If the wrapper participates in flow, even sub-pixel content-
+ // size changes from Plotly's internal layout can shift #chart's own
+ // clientWidth/Height, which retriggers our ResizeObserver and causes
+ // a second redundant resize. Aspect-ratio mode already works this way.
+ wrapperDiv.style.position = 'absolute';
+ wrapperDiv.style.left = '0';
+ wrapperDiv.style.top = '0';
wrapperDiv.style.width = availW + 'px';
wrapperDiv.style.height = availH + 'px';
wrapperDiv.style.visibility = 'visible';
lastAppliedW = chartDiv.clientWidth;
lastAppliedH = chartDiv.clientHeight;
- Plotly.newPlot(plotDiv, plotDiv.data, Object.assign({}, plotDiv.layout, buildLayoutOverrides(availW, availH)), plotDiv._context);
+ // Use relayout (not newPlot) so we don't re-upload trace data to the GPU
+ // for every resize. The width/height/font overrides are layout-only and
+ // relayout handles them efficiently.
+ Plotly.relayout(plotDiv, buildLayoutOverrides(availW, availH));
return;
}
var parts = arValue.split('/').map(Number);
@@ -1444,7 +1547,8 @@ const plotlyChartScripts = `
wrapperDiv.style.visibility = 'visible';
lastAppliedW = chartDiv.clientWidth;
lastAppliedH = chartDiv.clientHeight;
- Plotly.newPlot(plotDiv, plotDiv.data, Object.assign({}, plotDiv.layout, buildLayoutOverrides(w, h)), plotDiv._context);
+ // Use relayout (not newPlot) so we don't re-upload trace data to the GPU.
+ Plotly.relayout(plotDiv, buildLayoutOverrides(w, h));
}
// Expose for host code (tab switching, edit panel toggle, etc.)
@@ -1512,18 +1616,23 @@ const plotlyChartScripts = `
wrapperDiv.style.height = targetH + 'px';
wrapperDiv.style.margin = '';
} else {
- wrapperDiv.style.position = '';
- wrapperDiv.style.left = '';
- wrapperDiv.style.top = '';
+ // Fill mode: keep the wrapper absolutely positioned (matching
+ // resizeChartCore) so its layout cannot perturb #chart's
+ // clientWidth/Height and retrigger the ResizeObserver.
+ wrapperDiv.style.position = 'absolute';
+ wrapperDiv.style.left = '0';
+ wrapperDiv.style.top = '0';
wrapperDiv.style.width = availW + 'px';
wrapperDiv.style.height = availH + 'px';
}
wrapperDiv.style.visibility = 'visible';
}
- // Apply font/size overrides to the already-rendered plot
+ // Apply font/size overrides to the already-rendered plot via relayout.
+ // newPlot would force Plotly to re-upload all trace data and rebuild the
+ // GL canvas; relayout updates layout-only properties in place.
var overrides = Object.assign({ width: targetW, height: targetH }, applyFontOverrides(plotDiv.layout || {}, targetW, targetH, preset));
- Plotly.newPlot(plotDiv, plotDiv.data, Object.assign({}, plotDiv.layout, overrides), plotDiv._context);
+ Plotly.relayout(plotDiv, overrides);
lastAppliedW = availW;
lastAppliedH = availH;
@@ -1589,6 +1698,67 @@ const plotlyChartScripts = `
}
});
+ // Structured single-chart payload from the host. Avoids the multi-MB innerHTML
+ // parse + script-clone shuffle by going straight to JSON.parse + Plotly.newPlot.
+ // After this returns, plotDiv.data / plotDiv.layout are owned by Plotly, so
+ // resize/copy paths continue to work unchanged.
+ //
+ // postMessage events can in principle originate from any script in the page,
+ // so treat the payload as untrusted: validate divId against a strict pattern
+ // and build the placeholder element with DOM APIs (no innerHTML concatenation).
+ var SAFE_DIV_ID_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
+ window.addEventListener('message', function(event) {
+ var msg = event.data;
+ if (!msg || msg.command !== 'setChartContent' || typeof msg.divId !== 'string') return;
+ if (!SAFE_DIV_ID_RE.test(msg.divId)) {
+ console.error('setChartContent: rejecting unsafe divId');
+ return;
+ }
+ var chartDiv = document.getElementById('chart');
+ if (!chartDiv) return;
+ // Replace contents safely: drop any existing children, then create the
+ // placeholder via createElement so the divId is never parsed as HTML.
+ while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild);
+ var plotPlaceholder = document.createElement('div');
+ plotPlaceholder.id = msg.divId;
+ chartDiv.appendChild(plotPlaceholder);
+ if (typeof Plotly === 'undefined') {
+ console.error('Plotly is not loaded yet; cannot render chart.');
+ return;
+ }
+ try {
+ var data = JSON.parse(msg.dataJson);
+ var layout = JSON.parse(msg.layoutJson);
+ var config = JSON.parse(msg.configJson);
+ Plotly.newPlot(plotPlaceholder, data, layout, config);
+ } catch (e) {
+ console.error('Plotly error:', e);
+ plotPlaceholder.innerText = 'Chart error: ' + (e && e.message ? e.message : e);
+ return;
+ }
+ if (window._chartUpdated) window._chartUpdated();
+ });
+
+ // Tell the host our setChartContent listener is attached so it can replay
+ // the latest payload. This makes structured-content delivery reliable
+ // across initial page load and any subsequent webview.html rebuild,
+ // independent of how the host buffers messages during page startup.
+ if (window._vscodeApi) {
+ try { window._vscodeApi.postMessage({ command: 'chartViewReady' }); } catch (e) {}
+ } else {
+ // _vscodeApi may be set up by another script later; retry briefly.
+ var readyAttempts = 0;
+ var readyTimer = setInterval(function() {
+ readyAttempts++;
+ if (window._vscodeApi) {
+ clearInterval(readyTimer);
+ try { window._vscodeApi.postMessage({ command: 'chartViewReady' }); } catch (e) {}
+ } else if (readyAttempts > 20) {
+ clearInterval(readyTimer);
+ }
+ }, 50);
+ }
+
window.addEventListener('message', async function(event) {
var message = event.data;
if (!(message && message.command === 'copyChart')) return;
@@ -1652,17 +1822,53 @@ const plotlyChartScripts = `
})();
`;
+/**
+ * Result returned by the chart-rendering pipeline.
+ *
+ * - `html`: a pre-baked HTML body to drop into the chart region. Used for the
+ * raw-Plotly path (data already comes from the user's query as JSON) and for
+ * the multi-chart panel grid (multiple charts concatenated into one HTML
+ * string with positioning/styling).
+ * - `plot`: structured data for a single chart. The page handler builds the
+ * chart div and calls `Plotly.newPlot` directly with `JSON.parse`d inputs,
+ * avoiding `innerHTML` of a multi-MB HTML blob and the script-reinjection
+ * dance that comes with it.
+ */
+type ChartRenderResult =
+ | { type: 'html'; html: string }
+ | { type: 'plot'; divId: string; dataJson: string; layoutJson: string; configJson: string };
+
/** View for Plotly charts rendered inside a webview. */
class PlotlyChartView implements IChartView {
onCopyResult: ((pngDataUrl: string, svgDataUrl?: string) => void) | undefined;
onCopyError: ((error: string) => void) | undefined;
private readonly subscription: { dispose(): void };
+ /**
+ * Last structured chart payload sent via `setChartContent`. Cached so we
+ * can replay it whenever the page reports it's ready.
+ *
+ * Why: structured-content rendering does not embed anything into the
+ * adapter's `contentHtml`, so the chart only appears via the message we
+ * post. On initial render (and after any `webview.html` rebuild) the host
+ * sends the message before the page's listener is attached, and depending
+ * on how the host buffers messages during page load, that first message
+ * could be lost. The page sends `chartViewReady` once its handler is up,
+ * and we reliably re-send the latest payload here.
+ */
+ private lastPayload: { divId: string; dataJson: string; layoutJson: string; configJson: string } | undefined;
constructor(
private readonly webview: IWebView,
- private readonly render: (data: ResultTable, options: ChartOptions, darkMode: boolean) => string | undefined
+ private readonly render: (data: ResultTable, options: ChartOptions, darkMode: boolean) => ChartRenderResult | undefined
) {
this.subscription = webview.handle((message) => {
+ if (message.command === 'chartViewReady' && this.lastPayload) {
+ // Page just attached its handler (initial load or rebuild) —
+ // resend the latest payload so the chart renders even if the
+ // immediate post during renderChart was missed.
+ this.webview.invoke('setChartContent', { ...this.lastPayload });
+ return;
+ }
if (message.command === 'copyChartResult') {
const pngDataUrl = message.pngDataUrl as string;
const svgDataUrl = message.svgDataUrl as string | undefined;
@@ -1681,16 +1887,32 @@ class PlotlyChartView implements IChartView {
}
renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean): void {
- const bodyHtml = this.render(data, options, darkMode);
- if (bodyHtml) {
- this.webview.setContent(bodyHtml);
- } else {
+ const result = this.render(data, options, darkMode);
+ if (!result) {
const typeName = escapeHtml(options.type || 'Unknown');
this.webview.setContent(
`` +
`❌ Chart type "${typeName}" is not currently supported.
`
);
+ this.lastPayload = undefined;
+ return;
+ }
+ if (result.type === 'html') {
+ this.webview.setContent(result.html);
+ this.lastPayload = undefined;
+ return;
}
+ // Structured single-chart payload. Cache it so we can replay on
+ // `chartViewReady` after a page (re)load, then send immediately for
+ // the common case where the page is already up.
+ const payload = {
+ divId: result.divId,
+ dataJson: result.dataJson,
+ layoutJson: result.layoutJson,
+ configJson: result.configJson,
+ };
+ this.lastPayload = payload;
+ this.webview.invoke('setChartContent', { ...payload });
}
dispose(): void {
@@ -1777,28 +1999,31 @@ export class PlotlyChartProvider implements IChartProvider {
return new PlotlyChartView(webview, (data, options, darkMode) => this.renderChartToHtmlDiv(data, options, darkMode));
}
- private renderChartToHtmlDiv(data: ResultTable, options: ChartOptions, darkMode = false): string | undefined {
+ private renderChartToHtmlDiv(data: ResultTable, options: ChartOptions, darkMode = false): ChartRenderResult | undefined {
// Allow the chart's mode option to override the editor theme
if (options.mode === ChartMode.Light) darkMode = false;
else if (options.mode === ChartMode.Dark) darkMode = true;
if (options.type === ChartType.Plotly) {
- return this.renderRawPlotlyChart(data, darkMode);
+ const html = this.renderRawPlotlyChart(data, darkMode);
+ return html ? { type: 'html', html } : undefined;
}
- // Multiple independent charts (CSS-scaled)
+ // Multiple independent charts (CSS-scaled). Concatenated into a single HTML
+ // string so the grid layout/scaling sits next to each chart.
if (options.yLayout === ChartYLayout.SeparateCharts && (this.is2dChartType(options.type) || options.type === ChartType.Pie)) {
const xColumn = this.get2dXColumn(data, options);
if (xColumn) {
const yColumns = this.get2dYColumns(data, options, xColumn);
if (yColumns.length > 1) {
- return this.renderMultiChartPanels(data, options, darkMode, yColumns);
+ const html = this.renderMultiChartPanels(data, options, darkMode, yColumns);
+ return html ? { type: 'html', html } : undefined;
}
}
}
const builder = this.buildChart(data, options, darkMode);
- return builder?.toHtmlDiv();
+ return builder?.toContent();
}
/** Returns true for chart types that use 2D x/y column rendering and support y-split. */
@@ -1908,13 +2133,16 @@ export class PlotlyChartProvider implements IChartProvider {
if (!root || !root.data) return undefined;
const dataJson = JSON.stringify(root.data);
- let layoutJson = root.layout ? JSON.stringify(root.layout) : '{}';
- if (darkMode) {
- layoutJson = applyDarkModeToLayout(layoutJson);
- } else {
- layoutJson = applyLightModeToLayout(layoutJson);
- }
+ // Apply theme to the in-memory layout object before the single stringify,
+ // so we don't pay an extra parse/stringify round-trip.
+ const layoutObj = (root.layout && typeof root.layout === 'object')
+ ? root.layout as Record
+ : {};
+ const themedLayout = darkMode
+ ? applyDarkModeToLayout(layoutObj)
+ : applyLightModeToLayout(layoutObj);
+ const layoutJson = JSON.stringify(themedLayout);
const configJson = root.config
? JSON.stringify(root.config)
@@ -2769,11 +2997,10 @@ export class PlotlyChartProvider implements IChartProvider {
yValues = binned.y;
}
if (xValues.length > 0) {
- // Sort by x-value so lines don't zigzag
- const indices = xValues.map((_, i) => i);
- indices.sort((a, b) => (xValues[a]! < xValues[b]! ? -1 : xValues[a]! > xValues[b]! ? 1 : 0));
- let sortedX: unknown[] = indices.map(i => xValues[i]!);
- let sortedY: number[] = indices.map(i => yValues[i]!);
+ // Sort by x-value so lines don't zigzag (no-op when already sorted).
+ const sorted = sortPointsByX(xValues, yValues);
+ let sortedX: unknown[] = sorted.x;
+ let sortedY: number[] = sorted.y;
if (options.accumulate) {
sortedY = accumulateValues(sortedY);
}
@@ -2800,10 +3027,8 @@ export class PlotlyChartProvider implements IChartProvider {
if (shouldBin) {
result = binAndAggregate(result.x, result.y, binSize, aggregation, xIsDateTime);
}
- // Sort by x-value so lines don't zigzag
- const indices = result.x.map((_, i) => i);
- indices.sort((a, b) => (result!.x[a]! < result!.x[b]! ? -1 : result!.x[a]! > result!.x[b]! ? 1 : 0));
- result = { x: indices.map(i => result!.x[i]!), y: indices.map(i => result!.y[i]!) };
+ // Sort by x-value so lines don't zigzag (no-op when already sorted).
+ result = sortPointsByX(result.x, result.y);
if (options.accumulate) {
result = { x: result.x, y: accumulateValues(result.y) };
}
@@ -2994,9 +3219,12 @@ function applyCommonOptions(builder: PlotlyChartBuilder, options: ChartOptions):
// ─── Dark Mode Layout Helper ────────────────────────────────────────────────
-function applyDarkModeToLayout(layoutJson: string): string {
- const layout = (JSON.parse(layoutJson) as Record) ?? {};
-
+/**
+ * Mutates `layout` in place with dark-mode background, font, and axis colors,
+ * and returns it. Operates on the in-memory object so the caller can avoid a
+ * parse/stringify round-trip.
+ */
+function applyDarkModeToLayout(layout: Record): Record {
layout.paper_bgcolor = '#1e1e1e';
layout.plot_bgcolor = '#1e1e1e';
@@ -3021,7 +3249,7 @@ function applyDarkModeToLayout(layoutJson: string): string {
applyDarkModeToAxis(scene, 'zaxis');
}
- return JSON.stringify(layout);
+ return layout;
}
function applyDarkModeToAxis(parent: Record, axisKey: string): void {
@@ -3035,9 +3263,12 @@ function applyDarkModeToAxis(parent: Record, axisKey: string):
// ─── Light Mode Layout Helper ───────────────────────────────────────────────
-function applyLightModeToLayout(layoutJson: string): string {
- const layout = (JSON.parse(layoutJson) as Record) ?? {};
-
+/**
+ * Mutates `layout` in place with light-mode background, font, and axis colors,
+ * and returns it. Operates on the in-memory object so the caller can avoid a
+ * parse/stringify round-trip.
+ */
+function applyLightModeToLayout(layout: Record): Record {
layout.paper_bgcolor = '#ffffff';
layout.plot_bgcolor = '#ffffff';
@@ -3062,7 +3293,7 @@ function applyLightModeToLayout(layoutJson: string): string {
applyLightModeToAxis(scene, 'zaxis');
}
- return JSON.stringify(layout);
+ return layout;
}
function applyLightModeToAxis(parent: Record, axisKey: string): void {
diff --git a/src/Client/features/resultsViewer.ts b/src/Client/features/resultsViewer.ts
index 0a72426..e3f4ffd 100644
--- a/src/Client/features/resultsViewer.ts
+++ b/src/Client/features/resultsViewer.ts
@@ -1805,7 +1805,10 @@ class DocumentViewProvider implements vscode.CustomTextEditorProvider {
}
.view-content { display: none; height: 100%; overflow: hidden; }
.view-content.active { display: flex; flex-direction: column; }
- #chart.has-aspect-ratio.active {
+ #chart.active {
+ /* Always provide a containing block so the absolutely-positioned
+ * wrapper inside can't escape and so layout changes inside the
+ * wrapper don't bubble up to #chart and trigger ResizeObserver loops. */
position: relative;
}
#chart.has-aspect-ratio > :first-child {
@@ -1879,11 +1882,13 @@ class DocumentViewProvider implements vscode.CustomTextEditorProvider {
editPanel.classList.add('visible');
}
}
- // Trigger chart resize when switching to chart
+ // Trigger chart resize when switching to chart. Use rAF (one frame,
+ // ~16ms) instead of a fixed 50ms timeout so the resize happens as
+ // soon as the browser has applied the new layout.
if (viewId === 'chart') {
- setTimeout(function() {
+ requestAnimationFrame(function() {
if (window._chartResize) window._chartResize();
- }, 50);
+ });
}
}
@@ -1898,10 +1903,12 @@ class DocumentViewProvider implements vscode.CustomTextEditorProvider {
window._vscodeApi.postMessage({ command: 'editPanelToggled', visible: editPanelUserVisible });
}
- // Resize chart when panel toggles
- setTimeout(function() {
+ // Resize chart when panel toggles. Use rAF (one frame, ~16ms)
+ // instead of a fixed 50ms timeout so the resize happens as soon as
+ // the browser has applied the panel's display change.
+ requestAnimationFrame(function() {
if (window._chartResize) window._chartResize();
- }, 50);
+ });
}
// Listen for messages from the extension
diff --git a/src/Client/tests/unit/plotlyChartProvider.test.ts b/src/Client/tests/unit/plotlyChartProvider.test.ts
index b8c7d95..73f7563 100644
--- a/src/Client/tests/unit/plotlyChartProvider.test.ts
+++ b/src/Client/tests/unit/plotlyChartProvider.test.ts
@@ -37,6 +37,24 @@ function createMockWebView(): IWebView & {
// ─── Test Data Helpers ──────────────────────────────────────────────────────
+/**
+ * Reverses the encoding done by `escapeForJsStringLiteral` in plotlyChartProvider.ts
+ * so tests can pull the JSON payload out of the rendered `JSON.parse('...')` literal.
+ * Order matters: undo the backslash escape first, then the apostrophe, then ``,
+ * then the line/paragraph separator escapes (which never produce a sequence the
+ * earlier rules can re-match).
+ *
+ * Keep this in lockstep with `escapeForJsStringLiteral` in the production source.
+ */
+function decodeJsStringLiteral(encoded: string): string {
+ return encoded
+ .replace(/\\\\/g, '\\')
+ .replace(/\\'/g, "'")
+ .replace(/<\\\//g, '')
+ .replace(/\\u2028/g, '\u2028')
+ .replace(/\\u2029/g, '\u2029');
+}
+
function makeTable(columns: { name: string; type: string }[], rows: unknown[][]): ResultTable {
return { name: 'TestTable', columns, rows };
}
@@ -186,12 +204,20 @@ describe('CompositeChartProvider', () => {
});
describe('renderChart', () => {
- it('calls webview.setContent() with chart HTML', () => {
+ it('posts chart content via webview.invoke() with parsed JSON payloads', () => {
view.renderChart(make2dTable(), { type: 'Column' }, false);
- expect(webview.setContent).toHaveBeenCalledOnce();
- const html = webview.setContent.mock.calls[0]![0] as string;
- expect(html).toContain('plotly-chart');
- expect(html).toContain('Plotly.newPlot');
+ expect(webview.invoke).toHaveBeenCalledOnce();
+ const [command, args] = webview.invoke.mock.calls[0]!;
+ expect(command).toBe('setChartContent');
+ const payload = args as { divId: string; dataJson: string; layoutJson: string; configJson: string };
+ expect(payload.divId).toBe('plotly-chart');
+ // dataJson must round-trip via JSON.parse to a non-empty trace array.
+ const traces = JSON.parse(payload.dataJson) as unknown[];
+ expect(Array.isArray(traces)).toBe(true);
+ expect(traces.length).toBeGreaterThan(0);
+ // layoutJson and configJson must also be valid JSON.
+ expect(() => JSON.parse(payload.layoutJson)).not.toThrow();
+ expect(() => JSON.parse(payload.configJson)).not.toThrow();
});
it('renders empty traces for table with no rows', () => {
@@ -200,11 +226,11 @@ describe('CompositeChartProvider', () => {
[],
);
view.renderChart(emptyTable, { type: 'Column' }, false);
- expect(webview.setContent).toHaveBeenCalledOnce();
- const html = webview.setContent.mock.calls[0]![0] as string;
- const traces = html.match(/var data = (\[[\s\S]*?\]);\s*var layout/);
- expect(traces).toBeTruthy();
- const parsed = JSON.parse(traces![1]!) as { x: unknown[]; y: unknown[] }[];
+ expect(webview.invoke).toHaveBeenCalledOnce();
+ const [command, args] = webview.invoke.mock.calls[0]!;
+ expect(command).toBe('setChartContent');
+ const payload = args as { dataJson: string };
+ const parsed = JSON.parse(payload.dataJson) as { x: unknown[]; y: unknown[] }[];
expect(parsed[0]!.x).toEqual([]);
expect(parsed[0]!.y).toEqual([]);
});
@@ -215,6 +241,31 @@ describe('CompositeChartProvider', () => {
const html = webview.setContent.mock.calls[0]![0] as string;
expect(html).toContain('not currently supported');
});
+
+ it('replays last setChartContent payload on chartViewReady', () => {
+ // Initial render: payload is sent immediately and cached.
+ view.renderChart(make2dTable(), { type: 'Column' }, false);
+ expect(webview.invoke).toHaveBeenCalledOnce();
+ const initial = webview.invoke.mock.calls[0]!;
+ expect(initial[0]).toBe('setChartContent');
+
+ // Page reports its message handler is attached. The view should
+ // resend the cached payload so initial render survives a missed
+ // first message during webview startup or after webview.html
+ // rebuild.
+ webview.simulateMessage({ command: 'chartViewReady' });
+ expect(webview.invoke).toHaveBeenCalledTimes(2);
+ expect(webview.invoke.mock.calls[1]![0]).toBe('setChartContent');
+ expect(webview.invoke.mock.calls[1]![1]).toEqual(initial[1]);
+ });
+
+ it('does not replay setChartContent on chartViewReady when last result was HTML or unsupported', () => {
+ // Unsupported chart type: nothing to replay, the cached payload
+ // (if any) from a prior structured render must be cleared.
+ view.renderChart(make2dTable(), { type: 'UnknownChart' }, false);
+ webview.simulateMessage({ command: 'chartViewReady' });
+ expect(webview.invoke).not.toHaveBeenCalled();
+ });
});
});
@@ -229,25 +280,48 @@ describe('CompositeChartProvider', () => {
view = provider.createView(webview);
});
- /** Helper to render and return the HTML sent to setContent. */
+ /**
+ * Renders the chart and returns an HTML-shaped string suitable for the
+ * `parseTraces` / `parseLayout` regex helpers. For the normal single-chart
+ * path (which now goes through `webview.invoke('setChartContent', ...)`),
+ * we synthesize HTML in the same shape `createChartDiv` produces so the
+ * existing regex helpers continue to work unchanged. For the raw-Plotly
+ * and multi-chart panel paths (which still go through `setContent`),
+ * we return the HTML directly.
+ *
+ * The `escape` function below MUST mirror `escapeForJsStringLiteral` in
+ * the production source. Keep them in lockstep.
+ */
function renderAndGetHtml(table: ResultTable, options: ChartOptions, darkMode = false): string | undefined {
view.renderChart(table, options, darkMode);
+ // Structured invoke: synthesize HTML using the same JSON.parse('...') format.
+ const invokeCall = webview.invoke.mock.calls.find((c: unknown[]) => c[0] === 'setChartContent');
+ if (invokeCall) {
+ const p = invokeCall[1] as { divId: string; dataJson: string; layoutJson: string; configJson: string };
+ const escape = (j: string) => j
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, "\\'")
+ .replace(/<\//g, '<\\/')
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029');
+ return `\n`;
+ }
if (webview.setContent.mock.calls.length === 0) return undefined;
return webview.setContent.mock.calls[0]![0] as string;
}
/** Helper to parse the Plotly data array from the rendered HTML. */
function parseTraces(html: string): unknown[] {
- const dataMatch = html.match(/var data = (\[[\s\S]*?\]);\s*var layout/);
+ const dataMatch = html.match(/var data = JSON\.parse\('([\s\S]*?)'\);\s*var layout/);
expect(dataMatch).toBeTruthy();
- return JSON.parse(dataMatch![1]!) as unknown[];
+ return JSON.parse(decodeJsStringLiteral(dataMatch![1]!)) as unknown[];
}
/** Helper to parse the Plotly layout from the rendered HTML. */
function parseLayout(html: string): Record {
- const layoutMatch = html.match(/var layout = (\{[\s\S]*?\});\s*var config/);
+ const layoutMatch = html.match(/var layout = JSON\.parse\('([\s\S]*?)'\);\s*var config/);
expect(layoutMatch).toBeTruthy();
- return JSON.parse(layoutMatch![1]!) as Record;
+ return JSON.parse(decodeJsStringLiteral(layoutMatch![1]!)) as Record;
}
describe('columnchart', () => {
@@ -445,6 +519,53 @@ describe('CompositeChartProvider', () => {
});
});
+ describe('scattergl threshold', () => {
+ // Generates a 2-column (datetime, real) table with `n` rows.
+ function makeLargeTimeSeries(n: number): ResultTable {
+ const rows: unknown[][] = new Array(n);
+ const base = new Date('2025-01-01T00:00:00Z').getTime();
+ for (let i = 0; i < n; i++) {
+ rows[i] = [new Date(base + i * 60_000).toISOString(), i];
+ }
+ return makeTable(
+ [{ name: 'Time', type: 'datetime' }, { name: 'Value', type: 'real' }],
+ rows,
+ );
+ }
+
+ it('keeps SVG scatter for line charts at or below threshold', () => {
+ const html = renderAndGetHtml(makeLargeTimeSeries(1000), { type: 'Line' });
+ const trace = parseTraces(html!)[0] as Record;
+ expect(trace.type).toBe('scatter');
+ });
+
+ it('switches line charts above threshold to scattergl', () => {
+ const html = renderAndGetHtml(makeLargeTimeSeries(1001), { type: 'Line' });
+ const trace = parseTraces(html!)[0] as Record;
+ expect(trace.type).toBe('scattergl');
+ });
+
+ it('switches scatter charts above threshold to scattergl', () => {
+ const html = renderAndGetHtml(makeLargeTimeSeries(2000), { type: 'Scatter' });
+ const trace = parseTraces(html!)[0] as Record;
+ expect(trace.type).toBe('scattergl');
+ });
+
+ it('switches non-stacked area charts above threshold to scattergl', () => {
+ const html = renderAndGetHtml(makeLargeTimeSeries(2000), { type: 'Area' });
+ const trace = parseTraces(html!)[0] as Record;
+ expect(trace.type).toBe('scattergl');
+ expect(trace.fill).toBe('tozeroy');
+ });
+
+ it('keeps stacked area charts on SVG scatter regardless of size (stackgroup is unsupported in scattergl)', () => {
+ const html = renderAndGetHtml(makeLargeTimeSeries(5000), { type: 'AreaStacked' });
+ const trace = parseTraces(html!)[0] as Record;
+ expect(trace.type).toBe('scatter');
+ expect(trace.stackgroup).toBe('1');
+ });
+ });
+
describe('piechart', () => {
it('renders a pie trace with labels and values', () => {
const html = renderAndGetHtml(makePieTable(), { type: 'Pie' });
@@ -975,6 +1096,31 @@ describe('CompositeChartProvider', () => {
const html = renderAndGetHtml(table, { type: 'Plotly' });
expect(html).toContain('not currently supported');
});
+
+ it('escapes U+2028 / U+2029 line separators in embedded JSON', () => {
+ // U+2028 (line separator) and U+2029 (paragraph separator) were
+ // illegal inside JS string literals before ES2019 and remain a
+ // syntax-error risk on older runtimes. Verify they are escaped
+ // out of the embedded `JSON.parse('...')` literal.
+ const ls = '\u2028';
+ const ps = '\u2029';
+ const plotlyJson = JSON.stringify({
+ data: [{ type: 'bar', x: ['ok' + ls + 'next'], y: [1] }],
+ layout: { title: 'pre' + ps + 'post' },
+ });
+ const table = makeTable(
+ [{ name: 'plotly_json', type: 'string' }],
+ [[plotlyJson]],
+ );
+ const html = renderAndGetHtml(table, { type: 'Plotly' });
+ expect(html).toBeDefined();
+ // The literal characters must not appear in the produced script;
+ // they must be escape sequences (\u2028 / \u2029) instead.
+ expect(html).not.toContain(ls);
+ expect(html).not.toContain(ps);
+ expect(html).toContain('\\u2028');
+ expect(html).toContain('\\u2029');
+ });
});
// ─── Series columns ────────────────────────────────────────────
@@ -1155,6 +1301,58 @@ describe('CompositeChartProvider', () => {
expect(west.x).toEqual(['2024-01-01', '2024-01-02']);
expect(west.y).toEqual([20, 25]);
});
+
+ it('preserves order when data is already sorted by x (monotonic fast path)', () => {
+ // Already-sorted input exercises the no-allocation fast path.
+ // Output must still match input order exactly.
+ const table = makeTable(
+ [
+ { name: 'timestamp', type: 'datetime' },
+ { name: 'region', type: 'string' },
+ { name: 'count', type: 'int' },
+ ],
+ [
+ ['2024-01-01', 'East', 10],
+ ['2024-01-02', 'East', 15],
+ ['2024-01-03', 'East', 20],
+ ['2024-01-04', 'East', 25],
+ ],
+ );
+ const html = renderAndGetHtml(table, {
+ type: 'Line',
+ xColumn: 'timestamp',
+ yColumns: ['count'],
+ seriesColumns: ['region'],
+ });
+ const east = parseTraces(html!)[0] as Record;
+ expect(east.x).toEqual(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04']);
+ expect(east.y).toEqual([10, 15, 20, 25]);
+ });
+
+ it('preserves equal-x runs as stable in already-sorted input', () => {
+ // Non-decreasing (with equal x's) is the fast-path predicate; verify ties
+ // are kept in input order.
+ const table = makeTable(
+ [
+ { name: 'timestamp', type: 'datetime' },
+ { name: 'count', type: 'int' },
+ ],
+ [
+ ['2024-01-01', 1],
+ ['2024-01-01', 2],
+ ['2024-01-02', 3],
+ ['2024-01-02', 4],
+ ],
+ );
+ const html = renderAndGetHtml(table, {
+ type: 'Line',
+ xColumn: 'timestamp',
+ yColumns: ['count'],
+ });
+ const trace = parseTraces(html!)[0] as Record;
+ expect(trace.x).toEqual(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02']);
+ expect(trace.y).toEqual([1, 2, 3, 4]);
+ });
});
// ─── Auto-inference ─────────────────────────────────────────────