diff --git a/src/Client/features/plotlyChartProvider.ts b/src/Client/features/plotlyChartProvider.ts index b7fbbe7..175b229 100644 --- a/src/Client/features/plotlyChartProvider.ts +++ b/src/Client/features/plotlyChartProvider.ts @@ -34,6 +34,22 @@ const PlotlyScatterModes = { LinesAndMarkers: 'lines+markers', } as const; +/** + * Per-trace point count above which we switch from Plotly's SVG `scatter` trace + * to its WebGL `scattergl` trace. SVG renders one DOM node per marker/segment, + * so it becomes unresponsive in the low thousands of points; `scattergl` draws + * the whole trace into a single canvas and stays interactive into the millions. + * + * `scattergl` does NOT support `stackgroup`/`groupnorm`, so stacked area charts + * always stay on `scatter` regardless of point count. + */ +const WebGlPointThreshold = 1000; + +/** Returns 'scattergl' when the trace exceeds the WebGL threshold and is GL-compatible, else 'scatter'. */ +function pickScatterType(pointCount: number, glCompatible = true): 'scatter' | 'scattergl' { + return glCompatible && pointCount > WebGlPointThreshold ? 'scattergl' : 'scatter'; +} + const PlotlyFillModes = { ToZeroY: 'tozeroy', ToNextY: 'tonexty', @@ -284,7 +300,7 @@ interface BarTrace extends PlotlyTraceBase { } interface ScatterTrace extends PlotlyTraceBase { - type: 'scatter'; + type: 'scatter' | 'scattergl'; x: unknown[]; y: unknown[]; mode: string; @@ -538,6 +554,25 @@ class PlotlyChartBuilder { return createChartDiv(dataJson, layoutJson, configJson, divId); } + /** + * Returns the chart's data/layout/config as JSON strings ready to be sent + * to the webview page via a structured postMessage. The page handler does + * `JSON.parse` + `Plotly.newPlot` directly, skipping the round-trip through + * `` in the data can't break out of the + * enclosing ``; @@ -1130,6 +1196,35 @@ function aggregateValues(values: number[], aggregation: AggregationType): number } } +/** + * Returns x/y sorted ascending by x. Skips all sorting work — and avoids + * allocating two new arrays — when the input is already monotonically + * non-decreasing in x, which is the common case for time-series data + * (Kusto queries that end in `order by t asc`, `range`-generated rows, + * binned aggregates emitted in bin order, etc.). + * + * The comparison uses raw JS `<`/`>`, which match the previous behavior + * for numbers, Date objects, and ISO-8601 datetime strings. + */ +function sortPointsByX(x: unknown[], y: number[]): { x: unknown[]; y: number[] } { + let sorted = true; + for (let i = 1; i < x.length; i++) { + if (x[i - 1]! > x[i]!) { sorted = false; break; } + } + if (sorted) return { x, y }; + const indices = new Array(x.length); + for (let i = 0; i < x.length; i++) indices[i] = i; + indices.sort((a, b) => (x[a]! < x[b]! ? -1 : x[a]! > x[b]! ? 1 : 0)); + const sortedX = new Array(x.length); + const sortedY = new Array(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 ` { }); 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 ─────────────────────────────────────────────