diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index e90f1eb..b3ae4b0 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -7,6 +7,7 @@ whether by implication, estoppel or otherwise. 1. Plotly.js (https://github.com/plotly/plotly.js) 2. Simple-DataTables (https://github.com/fiduswriter/simple-datatables) +3. Cytoscape.js (https://github.com/cytoscape/cytoscape.js) %% Plotly.js NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -909,3 +910,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= END OF Simple-DataTables NOTICES AND INFORMATION + +%% Cytoscape.js NOTICES AND INFORMATION BEGIN HERE +========================================= +MIT License + +Copyright (c) 2016-2024, The Cytoscape Consortium. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +========================================= +END OF Cytoscape.js NOTICES AND INFORMATION diff --git a/src/Client/features/chartEditorProvider.ts b/src/Client/features/chartEditorProvider.ts index ab1a691..0e97832 100644 --- a/src/Client/features/chartEditorProvider.ts +++ b/src/Client/features/chartEditorProvider.ts @@ -82,13 +82,20 @@ const defaultableChartOptionKeys: Array = [ // ─── Interfaces ───────────────────────────────────────────────────────────── +/** Lightweight description of a result table for the chart editor. */ +export interface ChartEditorTableInfo { + name: string; + columns: string[]; +} + /** * View for the chart-options edit panel in a webview. * Created by `IChartEditorProvider.createView()`. */ export interface IChartEditorView { - /** Populate (or re-populate) the edit panel with the given options and column names. */ - setOptions(options: ChartOptions | undefined, columnNames: string[], defaults?: Partial): void; + /** Populate (or re-populate) the edit panel with the given options and column names. + * `tables` lists all sibling result tables (used by graph chart options). */ + setOptions(options: ChartOptions | undefined, columnNames: string[], defaults?: Partial, tables?: ChartEditorTableInfo[], primaryTableName?: string): void; /** Fires when the user changes any chart option in the edit panel. */ onOptionsChanged: ((options: ChartOptions) => void) | undefined; /** Release handlers and resources. */ @@ -121,19 +128,23 @@ class ChartEditorView implements IChartEditorView { ? this.captureCurrentDefaults(this.currentOptions, this.currentDefaults) : this.restoreMatchingDefaults(this.currentOptions, this.currentDefaults); this.currentOptions = updatedOptions; - this.webview.setContent(this.buildFormHtml(updatedOptions, this.lastColumnNames, this.currentDefaults)); + this.webview.setContent(this.buildFormHtml(updatedOptions, this.lastColumnNames, this.currentDefaults, this.lastTables, this.lastPrimaryTableName)); this.onOptionsChanged?.(updatedOptions); } }); } private lastColumnNames: string[] = []; + private lastTables: ChartEditorTableInfo[] = []; + private lastPrimaryTableName: string | undefined; - setOptions(options: ChartOptions | undefined, columnNames: string[], defaults?: Partial): void { + setOptions(options: ChartOptions | undefined, columnNames: string[], defaults?: Partial, tables?: ChartEditorTableInfo[], primaryTableName?: string): void { this.currentOptions = options ?? { type: 'Column' }; this.lastColumnNames = columnNames; + this.lastTables = tables ?? []; + this.lastPrimaryTableName = primaryTableName; this.currentDefaults = defaults ?? {}; - this.webview.setContent(this.buildFormHtml(this.currentOptions, columnNames, this.currentDefaults)); + this.webview.setContent(this.buildFormHtml(this.currentOptions, columnNames, this.currentDefaults, this.lastTables, this.lastPrimaryTableName)); } dispose(): void { @@ -494,6 +505,44 @@ class ChartEditorView implements IChartEditorView { } } + // Repopulate the node Id/Label/Kind column dropdowns from the columns + // of the newly selected Nodes Table. Done entirely client-side using + // the table→columns map embedded on the select, so the form is not + // rebuilt (which would reset section collapse state and focus). + function _editorOnNodesTableChanged() { + var sel = document.getElementById('opt-nodesTable'); + if (sel) { + var map = {}; + try { map = JSON.parse(sel.getAttribute('data-node-columns') || '{}'); } catch (e) {} + var cols = map[sel.value] || []; + ['opt-nodeIdColumn', 'opt-nodeLabelColumn', 'opt-nodeKindColumn'].forEach(function(id) { + var colSel = document.getElementById(id); + if (!colSel) return; + var prev = colSel.value; + var html = ''; + var keep = false; + for (var i = 0; i < cols.length; i++) { + var c = String(cols[i]); + var selAttr = (c === prev) ? ' selected' : ''; + if (c === prev) keep = true; + html += ''; + } + colSel.innerHTML = html; + // If the previously selected column no longer exists, fall + // back to (auto). + colSel.value = keep ? prev : ''; + }); + } + _editorOnChartOptionChanged(); + } + + function _editorEscapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); + } + function _editorEscapeAttr(s) { + return _editorEscapeHtml(s).replace(/"/g, '"'); + } + document.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('.header-actions')) { return; @@ -584,6 +633,16 @@ class ChartEditorView implements IChartEditorView { if (xTickAngle && xTickAngle.value !== '') opts.xTickAngle = Number(xTickAngle.value); var yTickAngle = document.getElementById('opt-yTickAngle'); if (yTickAngle && yTickAngle.value !== '') opts.yTickAngle = Number(yTickAngle.value); + var nodesTable = document.getElementById('opt-nodesTable'); + if (nodesTable && nodesTable.value) opts.nodesTable = nodesTable.value; + var nodeIdColumn = document.getElementById('opt-nodeIdColumn'); + if (nodeIdColumn && nodeIdColumn.value) opts.nodeIdColumn = nodeIdColumn.value; + var nodeLabelColumn = document.getElementById('opt-nodeLabelColumn'); + if (nodeLabelColumn && nodeLabelColumn.value) opts.nodeLabelColumn = nodeLabelColumn.value; + var nodeKindColumn = document.getElementById('opt-nodeKindColumn'); + if (nodeKindColumn && nodeKindColumn.value) opts.nodeKindColumn = nodeKindColumn.value; + var edgeKindColumn = document.getElementById('opt-edgeKindColumn'); + if (edgeKindColumn && edgeKindColumn.value) opts.edgeKindColumn = edgeKindColumn.value; return opts; } @@ -633,7 +692,7 @@ class ChartEditorView implements IChartEditorView { <\/script>`; } - private buildFormHtml(chartOptions: ChartOptions, columnNames: string[], defaults: Partial): string { + private buildFormHtml(chartOptions: ChartOptions, columnNames: string[], defaults: Partial, tables: ChartEditorTableInfo[] = [], primaryTableName?: string): string { const opts = chartOptions; const formatDefaultLabel = (value: string) => `Default (${value})`; const formatAngleLabel = (value: number | undefined) => value == null ? 'Auto' : `${value}°`; @@ -697,6 +756,53 @@ class ChartEditorView implements IChartEditorView { `` ).join(''); + const currentEdgeKindColumn = opts.edgeKindColumn ?? ''; + const edgeKindColumnOptions = ['', ...columnNames].map(c => + `` + ).join(''); + + // Sibling tables (everything except the primary/edges table) — used to + // populate graph node table/column dropdowns. + const siblingTables = tables.filter(t => !primaryTableName || t.name !== primaryTableName); + const siblingNames = siblingTables.map(t => t.name).filter(n => !!n); + const currentNodesTable = opts.nodesTable ?? ''; + // Options: (auto) = '' → auto-sense a sibling named "nodes"; + // then each sibling table by name. + const nodesTableOptions = [ + ``, + ...siblingNames.map(n => + `` + ), + ].join(''); + // Resolve which sibling table to source node-column choices from. If + // the user chose one explicitly, use it; otherwise auto-sense only a + // sibling literally named "nodes" (case-insensitive). + const resolveNodesTable = (): ChartEditorTableInfo | undefined => { + if (currentNodesTable) { + const m = siblingTables.find(t => t.name === currentNodesTable); + if (m) return m; + } + return siblingTables.find(t => (t.name ?? '').toLowerCase() === 'nodes'); + }; + const nodesTable = resolveNodesTable(); + const nodeColumnNames = nodesTable?.columns ?? []; + const buildNodeColOptions = (current: string) => ['', ...nodeColumnNames].map(c => + `` + ).join(''); + const nodeIdColumnOptions = buildNodeColOptions(opts.nodeIdColumn ?? ''); + const nodeLabelColumnOptions = buildNodeColOptions(opts.nodeLabelColumn ?? ''); + const nodeKindColumnOptions = buildNodeColOptions(opts.nodeKindColumn ?? ''); + // Map of nodes-table select value → that table's column names, so the + // page can repopulate the node-column dropdowns client-side when the + // user changes the Nodes Table (no server round-trip / form rebuild, + // which would reset section collapse state and focus). The empty-string + // key ('(auto)') maps to whichever sibling table resolution picks. + const nodeColumnsByTable: { [tableValue: string]: string[] } = { '': nodeColumnNames }; + for (const t of siblingTables) { + if (t.name) { nodeColumnsByTable[t.name] = t.columns ?? []; } + } + const nodeColumnsByTableJson = escapeHtml(JSON.stringify(nodeColumnsByTable)); + const colOptionsList = columnNames.map(c => `` ).join(''); @@ -980,6 +1086,32 @@ class ChartEditorView implements IChartEditorView { + + + + `; } } diff --git a/src/Client/features/chartProvider.ts b/src/Client/features/chartProvider.ts index a0dd635..5dce0c5 100644 --- a/src/Client/features/chartProvider.ts +++ b/src/Client/features/chartProvider.ts @@ -5,7 +5,7 @@ * Chart provider interfaces and constants. */ -import type { ChartOptions, ResultColumn, ResultTable } from './server'; +import type { ChartOptions, ResultColumn, ResultTable, ResultChartView } from './server'; import type { IWebView } from './webview'; // Re-export so consumers can import from chartProvider @@ -13,6 +13,18 @@ export type { IWebView } from './webview'; // ─── Interfaces ──────────────────────────────────────────────────────── +/** + * Optional cross-table context for chart rendering. Most chart types only + * need the primary `data` table, but a few (currently the graph chart) can + * draw on sibling tables in the same result — for example, an edges table + * paired with a nodes table. Providers should treat `ctx` as advisory and + * still produce a sensible chart from `data` alone when it's absent. + */ +export interface ChartRenderContext { + /** All tables in the originating ResultData, in their original order. */ + tables: ResultTable[]; +} + /** * View for interacting with a chart rendered inside a webview. * Created by IChartProvider.createView(). @@ -20,14 +32,21 @@ export type { IWebView } from './webview'; * The host sets `onCopyResult` / `onCopyError` to receive copy outcomes. */ export interface IChartView { - /** Render the chart with the given data/options and push to the webview. */ - renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean): void; + /** Render the chart with the given data/options and push to the webview. + * `viewState` carries any saved presentation state for this chart (e.g. graph node positions). */ + renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx?: ChartRenderContext, viewState?: ResultChartView): void; /** Trigger the chart copy flow (extension → webview → extension). */ copyChart(): void; /** Called when the webview produces a chart image for copying. */ onCopyResult: ((pngDataUrl: string, svgDataUrl?: string) => void) | undefined; /** Called when the webview chart copy fails. */ onCopyError: ((error: string) => void) | undefined; + /** + * Subscribe to view-state changes (e.g. user dragged a graph node). + * Optional; chart views without persistent UI state may omit this. + * Listeners receive the new state to be merged into ResultData.chartViews. + */ + onDidChangeViewState?(listener: (state: ResultChartView) => void): { dispose(): void }; /** Release handlers and resources. */ dispose(): void; } diff --git a/src/Client/features/compositeChartProvider.ts b/src/Client/features/compositeChartProvider.ts index 8c122f7..787fcb2 100644 --- a/src/Client/features/compositeChartProvider.ts +++ b/src/Client/features/compositeChartProvider.ts @@ -6,11 +6,12 @@ * provider based on the chart type. */ -import type { ChartOptions, ResultTable } from './server'; +import type { ChartOptions, ResultTable, ResultChartView } from './server'; import { ChartType } from './chartProvider'; -import type { IChartView, IWebView, IChartProvider } from './chartProvider'; +import type { IChartView, IWebView, IChartProvider, ChartRenderContext } from './chartProvider'; import { PlotlyChartProvider } from './plotlyChartProvider'; import { TimePivotChartProvider } from './timePivotChartProvider'; +import { GraphChartProvider } from './graphChartProvider'; /** * A chart view that delegates to one of two underlying views @@ -25,39 +26,58 @@ class CompositeChartView implements IChartView { constructor( private readonly plotlyView: IChartView, private readonly timePivotView: IChartView, + private readonly graphView: IChartView, ) { this.activeView = plotlyView; - // Wire up copy callbacks from both views + // Wire up copy callbacks from all views plotlyView.onCopyResult = (png, svg) => this.onCopyResult?.(png, svg); plotlyView.onCopyError = (err) => this.onCopyError?.(err); timePivotView.onCopyResult = (png, svg) => this.onCopyResult?.(png, svg); timePivotView.onCopyError = (err) => this.onCopyError?.(err); + graphView.onCopyResult = (png, svg) => this.onCopyResult?.(png, svg); + graphView.onCopyError = (err) => this.onCopyError?.(err); } - renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean): void { + renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx?: ChartRenderContext, viewState?: ResultChartView): void { if (options.type === ChartType.TimePivot) { this.activeView = this.timePivotView; - // The plotly view caches its last structured payload and replays - // it on `chartViewReady` after a page rebuild. When we delegate - // to TimePivot, we must invalidate that cache so the rebuilt - // page does not draw the previous plotly chart over the new - // TimePivot HTML. - const plotly = this.plotlyView as IChartView & { clearReplayState?: () => void }; - plotly.clearReplayState?.(); + } else if (options.type === ChartType.Graph) { + this.activeView = this.graphView; } else { this.activeView = this.plotlyView; } - this.activeView.renderChart(data, options, darkMode); + // The plotly view caches its last structured payload and replays + // it on `chartViewReady` after a page rebuild. When we delegate to + // a non-Plotly view, invalidate that cache so the rebuilt page does + // not draw the previous Plotly chart over the new HTML content. + if (this.activeView !== this.plotlyView) { + const plotly = this.plotlyView as IChartView & { clearReplayState?: () => void }; + plotly.clearReplayState?.(); + } + this.activeView.renderChart(data, options, darkMode, ctx, viewState); } copyChart(): void { this.activeView.copyChart(); } + onDidChangeViewState(listener: (state: ResultChartView) => void): { dispose(): void } { + // Forward subscriptions to all underlying views; only the graph view + // currently emits state, but this lets the host subscribe once and + // receive updates regardless of which delegate is active. + const subs = [ + this.plotlyView.onDidChangeViewState?.(listener), + this.timePivotView.onDidChangeViewState?.(listener), + this.graphView.onDidChangeViewState?.(listener), + ].filter((s): s is { dispose(): void } => !!s); + return { dispose() { subs.forEach(s => s.dispose()); } }; + } + dispose(): void { this.plotlyView.dispose(); this.timePivotView.dispose(); + this.graphView.dispose(); } } @@ -68,10 +88,12 @@ class CompositeChartView implements IChartView { export class CompositeChartProvider implements IChartProvider { private readonly plotlyProvider = new PlotlyChartProvider(); private readonly timePivotProvider = new TimePivotChartProvider(); + private readonly graphProvider = new GraphChartProvider(); createView(webview: IWebView): IChartView { const plotlyView = this.plotlyProvider.createView(webview); const timePivotView = this.timePivotProvider.createView(webview); - return new CompositeChartView(plotlyView, timePivotView); + const graphView = this.graphProvider.createView(webview); + return new CompositeChartView(plotlyView, timePivotView, graphView); } } diff --git a/src/Client/features/graphChartProvider.ts b/src/Client/features/graphChartProvider.ts new file mode 100644 index 0000000..4b3866a --- /dev/null +++ b/src/Client/features/graphChartProvider.ts @@ -0,0 +1,832 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Graph chart provider — renders a node-link graph using Cytoscape.js. + * + * The chart's primary table is interpreted as an edge list: + * - source column: options.xColumn, else the first column + * - target column: options.yColumns[0], else the second column + * - edge kind: options.edgeKindColumn, else options.seriesColumns[0] (optional) + * + * If a sibling result table is present it is used as the nodes table. + * Selection order: options.nodesTable (by name) → a sibling named "nodes" + * (case-insensitive) → the single non-empty other table. + * + * Node columns honor explicit overrides first, then fall back to name-based + * detection (case-insensitive): + * - id: options.nodeIdColumn → `id` / `nodeid` / `node_id` / `name` / `node` / first column + * - label: options.nodeLabelColumn → `label` / `displayname` / `display_name` / `title` / `name` (else id) + * - kind: options.nodeKindColumn → `nodekind` / `node_kind` / `nodetype` / `node_type` / + * `entitytype` / `entity_type` / `category` / `class` / + * `group` / `role` / `kind` / `type` (optional) + * Any remaining columns appear in the node tooltip when non-null. + */ + +import type { ChartOptions, ResultTable, ResultChartView } from './server'; +import { ChartColorways, ChartMode, getColumnRef, getColumnRefByIndex } from './chartProvider'; +import type { IChartView, IWebView, IChartProvider, ColumnRef, ChartRenderContext } from './chartProvider'; +import * as vscode from 'vscode'; + +const CytoscapeJsCdn = 'https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js'; + +// ─── View ─────────────────────────────────────────────────────────────────── + +class GraphChartView implements IChartView { + onCopyResult: ((pngDataUrl: string, svgDataUrl?: string) => void) | undefined; + onCopyError: ((error: string) => void) | undefined; + private readonly subscription: { dispose(): void }; + private readonly stateListeners = new Set<(state: ResultChartView) => void>(); + /** Latest known node positions, keyed by node id. Survives re-renders. */ + private cachedPositions: { [nodeId: string]: { x: number; y: number } } = {}; + /** Current layout seed (reported by the page; bumped on reroll). */ + private cachedSeed: number | undefined; + /** True once the user has manually dragged a node. */ + private cachedManual = false; + /** Last render arguments, so a reroll can re-render with new state. */ + private lastRenderArgs: { + data: ResultTable; + options: ChartOptions; + darkMode: boolean; + ctx: ChartRenderContext | undefined; + } | undefined; + private currentChartName: string | undefined; + private currentChartTableName: string | undefined; + /** + * Signature of the last graph state we emitted to listeners. Used to + * suppress redundant emits — e.g. when reopening an already-saved graph + * the page replays and reports back the exact positions we loaded, which + * must NOT re-dirty the result/history file. The first auto-layout (which + * differs from the empty/loaded signature) still emits so positions are + * persisted and stay fixed across sessions. + */ + private lastEmittedSignature: string | undefined; + /** + * Monotonic render token. Each render embeds the current value into the + * page; position messages echo it back. Messages whose token is not the + * current one are ignored — this prevents a superseded Cytoscape instance + * (whose old timers/layout are still running after an innerHTML swap) from + * clobbering the cache with stale or pre-layout grid positions. + */ + private renderToken = 0; + + constructor( + private readonly webview: IWebView, + private readonly render: (data: ResultTable, options: ChartOptions, darkMode: boolean, ctx: ChartRenderContext | undefined, positions: { [id: string]: { x: number; y: number } }, token: number, seed: number | undefined) => string | undefined + ) { + this.subscription = webview.handle((msg) => { + if (msg && msg.command === 'graphChartPositions' && msg.positions) { + // Ignore positions from a superseded render. + if (typeof msg.token === 'number' && msg.token !== this.renderToken) { + return; + } + const positions = msg.positions as { [id: string]: { x: number; y: number } }; + // Merge — Cytoscape may emit a subset (only nodes that moved + // during a drag) and we want to retain positions for unmoved + // nodes too. + this.cachedPositions = { ...this.cachedPositions, ...positions }; + if (typeof msg.seed === 'number') { this.cachedSeed = msg.seed; } + if (msg.manual === true) { this.cachedManual = true; } + this.emitState(); + return; + } + if (msg && msg.command === 'graphChartReroll') { + if (typeof msg.token === 'number' && msg.token !== this.renderToken) { + return; + } + void this.reroll(); + return; + } + }); + } + + private buildState(): ResultChartView { + const graph: NonNullable = { positions: this.cachedPositions }; + if (this.cachedSeed !== undefined) { graph.seed = this.cachedSeed; } + if (this.cachedManual) { graph.manual = true; } + const state: ResultChartView = { graph }; + if (this.currentChartName) state.name = this.currentChartName; + if (this.currentChartTableName) state.tableName = this.currentChartTableName; + return state; + } + + private emitState(): void { + const state = this.buildState(); + // Only notify (and thus persist) when the state actually changed. + // Replaying a saved layout reports back identical positions; emitting + // those again would needlessly dirty the file on every open. + const signature = JSON.stringify(state); + if (signature === this.lastEmittedSignature) { return; } + this.lastEmittedSignature = signature; + for (const l of this.stateListeners) l(state); + } + + /** Re-runs the layout with a new seed, discarding cached positions. */ + private async reroll(): Promise { + if (!this.lastRenderArgs) { return; } + if (this.cachedManual) { + const choice = await vscode.window.showWarningMessage( + 'Regenerating the layout will discard your manual node placements. Continue?', + { modal: true }, 'Regenerate' + ); + if (choice !== 'Regenerate') { return; } + } + this.cachedSeed = (this.cachedSeed ?? 0) + 1; + this.cachedPositions = {}; + this.cachedManual = false; + const { data, options, darkMode, ctx } = this.lastRenderArgs; + this.doRender(data, options, darkMode, ctx); + // Persist the new seed and cleared positions immediately; the page + // will also report fresh positions once cose settles. + this.emitState(); + } + + copyChart(): void { + // Copy not yet supported for graph chart + } + + renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx?: ChartRenderContext, viewState?: ResultChartView): void { + this.currentChartName = viewState?.name; + this.currentChartTableName = viewState?.tableName; + // Adopt any saved state from disk; in-session edits already merge here. + const saved = viewState?.graph?.positions; + if (saved) { + this.cachedPositions = { ...this.cachedPositions, ...saved }; + } + if (viewState?.graph?.seed !== undefined) { this.cachedSeed = viewState.graph.seed; } + if (viewState?.graph?.manual) { this.cachedManual = true; } + // Prime the emit signature with the loaded state so that when the page + // replays and reports back these exact positions we don't treat it as + // a change and re-dirty the file. (First-ever render has no saved + // positions, so the auto-layout will differ and be persisted.) + if (saved && Object.keys(saved).length > 0) { + this.lastEmittedSignature = JSON.stringify(this.buildState()); + } + this.doRender(data, options, darkMode, ctx); + } + + private doRender(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx: ChartRenderContext | undefined): void { + this.lastRenderArgs = { data, options, darkMode, ctx }; + const token = ++this.renderToken; + const bodyHtml = this.render(data, options, darkMode, ctx, this.cachedPositions, token, this.cachedSeed); + if (bodyHtml) { + this.webview.setContent(bodyHtml); + } else { + this.webview.setContent( + `
` + + `  Graph chart requires at least two columns (source, target).
` + ); + } + } + + onDidChangeViewState(listener: (state: ResultChartView) => void): { dispose(): void } { + this.stateListeners.add(listener); + return { dispose: () => this.stateListeners.delete(listener) }; + } + + dispose(): void { + this.stateListeners.clear(); + this.subscription.dispose(); + } +} + +// ─── Provider ─────────────────────────────────────────────────────────────── + +interface CyNode { + data: { + id: string; + label: string; + kind?: string; + tip?: string; + }; +} +interface CyEdge { + data: { + id: string; + source: string; + target: string; + label?: string; + kind?: string; + }; +} + +export class GraphChartProvider implements IChartProvider { + + createView(webview: IWebView): IChartView { + webview.setup( + ``, + '' + ); + return new GraphChartView(webview, (data, options, darkMode, ctx, positions, token, seed) => this.renderGraphHtml(data, options, darkMode, ctx, positions, token, seed)); + } + + private renderGraphHtml(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx: ChartRenderContext | undefined, positions: { [id: string]: { x: number; y: number } }, token: number, seedOverride: number | undefined): string | undefined { + if (data.columns.length < 2 || data.rows.length === 0) return undefined; + + if (options.mode === ChartMode.Light) darkMode = false; + else if (options.mode === ChartMode.Dark) darkMode = true; + + const theme = darkMode + ? { + background: '#1e1e1e', + foreground: '#cccccc', + edgeColor: '#888888', + nodeBorder: '#3c3c3c', + labelOutline: '#1e1e1e', + defaultNode: '#888888', + } + : { + background: '#ffffff', + foreground: '#333333', + edgeColor: '#888888', + nodeBorder: '#d4d4d4', + labelOutline: '#ffffff', + defaultNode: '#888888', + }; + + const sourceCol: ColumnRef | undefined = + (options.xColumn ? getColumnRef(data, options.xColumn) : undefined) ?? getColumnRefByIndex(data, 0); + const targetCol: ColumnRef | undefined = + (options.yColumns && options.yColumns[0] ? getColumnRef(data, options.yColumns[0]) : undefined) ?? getColumnRefByIndex(data, 1); + if (!sourceCol || !targetCol) return undefined; + + const edgeKindCol: ColumnRef | undefined = + (options.edgeKindColumn ? getColumnRef(data, options.edgeKindColumn) : undefined) + ?? ((options.seriesColumns && options.seriesColumns[0]) ? getColumnRef(data, options.seriesColumns[0]) : undefined); + + const nodesTable = findNodesTable(data, ctx, options.nodesTable); + + // Build nodes from the nodes table (when present) + const nodeMap = new Map(); + const nodeKinds = new Set(); + + if (nodesTable) { + const nodeIdCol = + (options.nodeIdColumn ? getColumnRef(nodesTable, options.nodeIdColumn) : undefined) + ?? pickColumn(nodesTable, ['id', 'nodeid', 'node_id', 'name', 'node']) + ?? getColumnRefByIndex(nodesTable, 0); + let nodeLabelCol = + (options.nodeLabelColumn ? getColumnRef(nodesTable, options.nodeLabelColumn) : undefined) + ?? pickColumn(nodesTable, ['label', 'displayname', 'display_name', 'title']); + if (!nodeLabelCol && !options.nodeLabelColumn) { + const nameCol = pickColumn(nodesTable, ['name']); + if (nameCol && nameCol.index !== nodeIdCol?.index) nodeLabelCol = nameCol; + } + const nodeKindCol = + (options.nodeKindColumn ? getColumnRef(nodesTable, options.nodeKindColumn) : undefined) + ?? pickColumn(nodesTable, ['nodekind', 'node_kind', 'nodetype', 'node_type', 'entitytype', 'entity_type', 'category', 'class', 'group', 'role', 'kind', 'type']); + const nodeAttrCols = nodesTable.columns + .map((_, i) => getColumnRefByIndex(nodesTable, i)) + .filter((c): c is ColumnRef => !!c) + .filter(c => c.index !== nodeIdCol?.index + && c.index !== nodeLabelCol?.index + && c.index !== nodeKindCol?.index); + + if (nodeIdCol) { + for (const row of nodesTable.rows) { + if (!row) continue; + const idVal = row[nodeIdCol.index]; + if (idVal == null) continue; + const id = String(idVal); + const label = nodeLabelCol ? stringOrId(row[nodeLabelCol.index], id) : id; + const kind = nodeKindCol ? optString(row[nodeKindCol.index]) : undefined; + if (kind) nodeKinds.add(kind); + const tip = buildTooltip(label, kind, nodeAttrCols, row); + const node: CyNode = { data: { id, label } }; + if (kind) node.data.kind = kind; + if (tip) node.data.tip = tip; + nodeMap.set(id, node); + } + } + } + + const ensureNode = (id: string): void => { + if (!nodeMap.has(id)) { + nodeMap.set(id, { data: { id, label: id } }); + } + }; + + const edges: CyEdge[] = []; + const edgeKinds = new Set(); + let edgeIdx = 0; + for (const row of data.rows) { + if (!row) continue; + const sVal = row[sourceCol.index]; + const tVal = row[targetCol.index]; + if (sVal == null || tVal == null) continue; + const s = String(sVal); + const t = String(tVal); + ensureNode(s); + ensureNode(t); + const edge: CyEdge = { data: { id: `e${edgeIdx++}`, source: s, target: t } }; + if (edgeKindCol) { + const kVal = row[edgeKindCol.index]; + if (kVal != null) { + const k = String(kVal); + edge.data.kind = k; + edge.data.label = k; + edgeKinds.add(k); + } + } + edges.push(edge); + } + + if (nodeMap.size === 0) return undefined; + + // Position handling: + // - If we have SAVED positions (user-dragged or persisted), pin them + // exactly via a 'preset' layout. Unknown nodes go to the centroid. + // - Otherwise, run cose. To make the layout reproducible for the same + // data, we seed cose's internal randomness (Math.random) with a + // hash of the data on the page side — see the layout run below. + type CyNodeWithPos = CyNode & { position?: { x: number; y: number } }; + const havePositions = Object.keys(positions).length > 0; + + // Deterministic seed derived from the graph's node ids + edges so that + // re-running a query that yields identical data produces an identical + // cose layout. Both the node ids and the edge keys are sorted before + // hashing so the seed is independent of row order — the same logical + // graph produces the same layout regardless of how the rows are + // ordered. A seedOverride (from the reroll button / persisted state) + // takes precedence. + const nodeIdsSorted = [...nodeMap.keys()].sort(); + const edgeKeysSorted = edges.map(e => e.data.source + '\u0001' + e.data.target).sort(); + const seedItems = nodeIdsSorted.concat(edgeKeysSorted); + const layoutSeed = seedOverride !== undefined ? (seedOverride >>> 0) : hashStringList(seedItems); + + let cx = 0, cy_ = 0, n_ = 0; + if (havePositions) { + for (const k of Object.keys(positions)) { + const p = positions[k]; + if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) { + cx += p.x; cy_ += p.y; n_++; + } + } + if (n_ > 0) { cx /= n_; cy_ /= n_; } + } + const nodes: CyNodeWithPos[] = []; + for (const n of nodeMap.values()) { + const p = positions[n.data.id]; + if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) { + nodes.push({ data: n.data, position: { x: p.x, y: p.y } }); + } else if (havePositions) { + nodes.push({ data: n.data, position: { x: cx, y: cy_ } }); + } else { + nodes.push(n); + } + } + const elements = [...nodes, ...edges]; + + const colors = ChartColorways.Default; + // Assign node and edge kinds from different parts of the colorway so a + // node kind and an (unrelated) edge kind don't get the same color and + // imply a relationship. Nodes take the palette from the start; edges + // continue after the node kinds, wrapping around as needed. + const nodeKindStyles: { kind: string; color: string }[] = []; + let nki = 0; + for (const k of nodeKinds) { + nodeKindStyles.push({ kind: k, color: colors[nki % colors.length]! }); + nki++; + } + const edgeKindStyles: { kind: string; color: string }[] = []; + let eki = 0; + for (const k of edgeKinds) { + edgeKindStyles.push({ kind: k, color: colors[(nodeKindStyles.length + eki) % colors.length]! }); + eki++; + } + + const title = options.title ? escapeHtml(options.title) : ''; + const showLegend = nodeKindStyles.length > 0 || edgeKindStyles.length > 0; + const legendHtml = showLegend ? buildLegendHtml(nodeKindStyles, edgeKindStyles) : ''; + + const nodeFontSize = graphFontSize(options.textSize); + + const elementsLit = escapeForJsStringLiteral(JSON.stringify(elements)); + const edgeKindStylesLit = escapeForJsStringLiteral(JSON.stringify(edgeKindStyles)); + const nodeKindStylesLit = escapeForJsStringLiteral(JSON.stringify(nodeKindStyles)); + const positionsLit = escapeForJsStringLiteral(JSON.stringify(positions)); + + return ` + +
+ ${title ? `
${title}
` : ''} +
+ +
+
+ ${legendHtml} +
Loading graph…
+
+
+ +`; + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function findNodesTable(edges: ResultTable, ctx?: ChartRenderContext, explicitName?: string): ResultTable | undefined { + if (!ctx) return undefined; + const others = ctx.tables.filter(t => t !== edges && t.rows.length > 0 && t.columns.length > 0); + if (explicitName) { + const lower = explicitName.toLowerCase(); + const match = others.find(t => t.name?.toLowerCase() === lower); + if (match) return match; + } + // Auto-sense: only adopt a sibling literally named "nodes" (case-insensitive). + // We deliberately do NOT auto-pick "the single other table" — a result set + // may contain unrelated tables, and silently treating one as nodes is + // surprising. Users wanting a specific table can select it explicitly. + return others.find(t => t.name?.toLowerCase() === 'nodes'); +} + +function pickColumn(table: ResultTable, candidates: string[]): ColumnRef | undefined { + const lower = table.columns.map(c => c.name.toLowerCase()); + for (const cand of candidates) { + const idx = lower.indexOf(cand); + if (idx >= 0) return getColumnRefByIndex(table, idx); + } + return undefined; +} + +function optString(v: unknown): string | undefined { + if (v == null) return undefined; + const s = String(v); + return s.length === 0 ? undefined : s; +} + +/** + * Order-independent-ish 32-bit hash of a list of strings (FNV-1a per item, + * combined). Used to derive a deterministic seed for the cose layout so the + * same graph data produces the same layout. + */ +function hashStringList(items: string[]): number { + let h = 0x811c9dc5; + for (const s of items) { + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + // separator between items + h ^= 0x1b; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function stringOrId(v: unknown, fallback: string): string { + return optString(v) ?? fallback; +} + +/** + * Maps a chart text-size preset to a Cytoscape node label font size (px). + * Uses the same scale convention as the Plotly charts: Extra Small 0.5×, + * Small 0.75×, Medium/Auto 1×, Large 1.5×, Extra Large 2×, applied to an + * 11px base. + */ +function graphFontSize(preset?: string): number { + const scale = preset === 'Extra Small' ? 0.5 + : preset === 'Small' ? 0.75 + : preset === 'Large' ? 1.5 + : preset === 'Extra Large' ? 2.0 + : 1.0; + return Math.round(11 * scale); +} + +function buildTooltip(label: string, kind: string | undefined, attrCols: ColumnRef[], row: (unknown | null)[]): string | undefined { + const lines: string[] = [label]; + if (kind) lines.push('(' + kind + ')'); + for (const c of attrCols) { + const v = row[c.index]; + if (v == null) continue; + const s = String(v); + if (s.length === 0) continue; + lines.push(c.column.name + ': ' + s); + } + return lines.length > 0 ? lines.join('\n') : undefined; +} + +function buildLegendHtml( + nodeKindStyles: { kind: string; color: string }[], + edgeKindStyles: { kind: string; color: string }[] +): string { + const parts: string[] = ['
']; + if (nodeKindStyles.length > 0) { + parts.push('
Nodes
'); + for (const n of nodeKindStyles) { + parts.push(`
${escapeHtml(n.kind)}
`); + } + parts.push('
'); + } + if (edgeKindStyles.length > 0) { + parts.push('
Edges
'); + for (const e of edgeKindStyles) { + parts.push(`
${escapeHtml(e.kind)}
`); + } + parts.push('
'); + } + parts.push('
'); + return parts.join(''); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function escapeForJsStringLiteral(json: string): string { + return json + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/<\//g, '<\\/') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} diff --git a/src/Client/features/resultsViewer.ts b/src/Client/features/resultsViewer.ts index 1ae1206..b39d51a 100644 --- a/src/Client/features/resultsViewer.ts +++ b/src/Client/features/resultsViewer.ts @@ -310,6 +310,34 @@ function findTableView(resultData: server.ResultData, tableName: string): server return resultData.tableViews?.find(v => v.name === tableName); } +/** + * Stores per-chart presentation state into `resultData.chartViews`. Looked + * up by matching `name` (when set) or `tableName`. Mutates in place. + */ +function storeChartView(resultData: server.ResultData, state: server.ResultChartView): void { + if (!resultData.chartViews) { + resultData.chartViews = []; + } + const idx = resultData.chartViews.findIndex(v => chartViewKeyMatches(v, state)); + if (idx >= 0) { + resultData.chartViews[idx] = state; + } else { + resultData.chartViews.push(state); + } +} + +/** Find a previously-saved chart view by name/tableName. */ +function findChartView(resultData: server.ResultData, chart: server.ResultChart | undefined): server.ResultChartView | undefined { + if (!chart || !resultData.chartViews) return undefined; + return resultData.chartViews.find(v => chartViewKeyMatches(v, chart)); +} + +function chartViewKeyMatches(a: { name?: string; tableName?: string }, b: { name?: string; tableName?: string }): boolean { + if (a.name && b.name) return a.name === b.name; + if (a.tableName && b.tableName) return a.tableName === b.tableName; + return !a.name && !b.name && !a.tableName && !b.tableName; +} + /** * Writes the current `resultData` JSON back into the backing text document * and saves it. .kqr documents are not user-authored — there is no UI @@ -501,6 +529,15 @@ export class ResultsViewer { this.panelChartView = this.chartProvider.createView(panelAdapter); this.panelWebView = panelAdapter; this.wireChartView(this.panelChartView); + this.panelChartView.onDidChangeViewState?.((state) => { + if (!this.lastPanelResultData) return; + const raw = getPrimaryChart(this.lastPanelResultData); + storeChartView(this.lastPanelResultData, { + ...state, + ...(raw?.name ? { name: raw.name } : {}), + ...(raw?.tableName ? { tableName: raw.tableName } : {}), + }); + }); // Create chart editor view for the bottom panel const panelEditorAdapter = new WebViewAdapter(webviewView.webview, 'setEditPanelContent'); @@ -674,9 +711,9 @@ export class ResultsViewer { if (hasChart && chartOptions) { const table = chartTable; if (table) { - this.panelChartView?.renderChart(table, chartOptions, darkMode); + this.panelChartView?.renderChart(table, chartOptions, darkMode, { tables: resultData.tables }, findChartView(resultData, rawChart)); } - this.panelEditorView?.setOptions(rawChartOptions, columnNames, chartDefaults); + this.panelEditorView?.setOptions(rawChartOptions, columnNames, chartDefaults, resultData.tables.map(t => ({ name: t.name, columns: t.columns.map(c => c.name) })), chartTable?.name); } const html = this.htmlBuilder.BuildMultiTabbedHtml(hasChart, mode, this.panelWebView, this.panelEditorWebView, chartOptions, columnNames, @@ -824,9 +861,9 @@ export class ResultsViewer { if (hasChart && chartOptions) { const table = chartTable; if (table) { - this.singletonChartView?.renderChart(table, chartOptions, darkMode); + this.singletonChartView?.renderChart(table, chartOptions, darkMode, { tables: resultData.tables }, findChartView(resultData, rawChart)); } - this.singletonEditorView?.setOptions(rawChartOptions, columnNames, chartDefaults); + this.singletonEditorView?.setOptions(rawChartOptions, columnNames, chartDefaults, resultData.tables.map(t => ({ name: t.name, columns: t.columns.map(c => c.name) })), chartTable?.name); } } finally { if (chartAdapter) { chartAdapter.suppressMessages = priorChartSuppress; } @@ -994,6 +1031,16 @@ export class ResultsViewer { this.singletonChartView = this.chartProvider.createView(singletonAdapter); this.singletonWebView = singletonAdapter; this.wireChartView(this.singletonChartView); + this.singletonChartView.onDidChangeViewState?.((state) => { + if (!this.singletonResultData) return; + const raw = getPrimaryChart(this.singletonResultData); + storeChartView(this.singletonResultData, { + ...state, + ...(raw?.name ? { name: raw.name } : {}), + ...(raw?.tableName ? { tableName: raw.tableName } : {}), + }); + this.scheduleSingletonWriteBack(); + }); // Create chart editor view for the singleton view const singletonEditorAdapter = new WebViewAdapter(this.singletonView.webview, 'setEditPanelContent'); @@ -1112,7 +1159,7 @@ export class ResultsViewer { const darkMode = isDarkMode(); const table = getPrimaryChartTable(modifiedData, rawChart); if (table && chartOptions) { - this.singletonChartView?.renderChart(table, chartOptions, darkMode); + this.singletonChartView?.renderChart(table, chartOptions, darkMode, { tables: modifiedData.tables }, findChartView(modifiedData, rawChart)); } } @@ -1123,7 +1170,7 @@ export class ResultsViewer { if (!chartOptions) { return; } const table = getPrimaryChartTable(this.lastPanelResultData, rawChart); if (table) { - this.panelChartView?.renderChart(table, chartOptions, isDarkMode()); + this.panelChartView?.renderChart(table, chartOptions, isDarkMode(), { tables: this.lastPanelResultData.tables }, findChartView(this.lastPanelResultData, rawChart)); } } @@ -1583,6 +1630,18 @@ class DocumentViewProvider implements vscode.CustomTextEditorProvider { this.viewer.wireChartView(docChartView); this.viewer.chartViews.set(webviewPanel, docChartView); this.viewer.chartWebViews.set(webviewPanel, docAdapter); + docChartView.onDidChangeViewState?.((state) => { + const docState = this.viewer.viewerStates.get(webviewPanel); + if (!docState?.resultData) return; + const raw = getPrimaryChart(docState.resultData); + storeChartView(docState.resultData, { + ...state, + ...(raw?.name ? { name: raw.name } : {}), + ...(raw?.tableName ? { tableName: raw.tableName } : {}), + }); + // Document-backed views write through their text document on save; + // mutating in place is sufficient for the next save to capture it. + }); // Create chart editor view for this document view const docEditorAdapter = new WebViewAdapter(webviewPanel.webview, 'setEditPanelContent'); @@ -1796,10 +1855,10 @@ class DocumentViewProvider implements vscode.CustomTextEditorProvider { const table = chartTable; if (table) { const controller = this.viewer.chartViews.get(webviewPanel); - controller?.renderChart(table, chartOptions, darkMode); + controller?.renderChart(table, chartOptions, darkMode, { tables: resultData.tables }, findChartView(resultData, rawChart)); } const editorView = this.viewer.editorViews.get(webviewPanel); - editorView?.setOptions(rawChartOptions, columnNames, chartDefaults); + editorView?.setOptions(rawChartOptions, columnNames, chartDefaults, resultData.tables.map(t => ({ name: t.name, columns: t.columns.map(c => c.name) })), chartTable?.name); } const html = this.BuildMultiTabbedHtml(hasChart, 'all', docWebView, docEditorWebView, chartOptions, columnNames, @@ -1817,7 +1876,7 @@ class DocumentViewProvider implements vscode.CustomTextEditorProvider { const table = getPrimaryChartTable(modifiedData, rawChart); if (table) { const controller = this.viewer.chartViews.get(webviewPanel); - controller?.renderChart(table, chartOptions, darkMode); + controller?.renderChart(table, chartOptions, darkMode, { tables: modifiedData.tables }, findChartView(modifiedData, rawChart)); } } diff --git a/src/Client/features/server.ts b/src/Client/features/server.ts index 85d5c2d..46d0e27 100644 --- a/src/Client/features/server.ts +++ b/src/Client/features/server.ts @@ -561,6 +561,7 @@ export interface ResultData { tables: ResultTable[]; charts?: ResultChart[]; tableViews?: ResultTableView[]; + chartViews?: ResultChartView[]; } /** @@ -588,6 +589,30 @@ export interface ResultChart { options: ChartOptions; } +/** + * Per-chart presentation state. Looked up by matching `name` (if set) or + * `tableName`. Optional/absent fields mean "use default behavior". + */ +export interface ResultChartView { + /** Matches `ResultChart.name`, if the chart has one. */ + name?: string; + /** Matches `ResultChart.tableName`, if the chart is bound to a specific table. */ + tableName?: string; + /** Graph chart-specific state: cached node positions keyed by node id. */ + graph?: { + /** + * Layout seed used to make the cose layout reproducible. Absent means + * "derive from a hash of the data" (the default first-render seed). + * Changed by the user via the reroll button. + */ + seed?: number; + /** Full snapshot of node positions, keyed by node id. */ + positions?: { [nodeId: string]: { x: number; y: number } }; + /** True once the user has manually dragged any node. */ + manual?: boolean; + }; +} + /** Serializable representation of a data table. */ export interface ResultTable { name: string; @@ -642,6 +667,12 @@ export interface ChartOptions { aggregation?: string; maxSeries?: number; maxPointsPerSeries?: number; + // Graph chart options + nodesTable?: string; + nodeIdColumn?: string; + nodeLabelColumn?: string; + nodeKindColumn?: string; + edgeKindColumn?: string; } /** Position in a document. */ diff --git a/src/Client/tests/unit/graphChartProvider.test.ts b/src/Client/tests/unit/graphChartProvider.test.ts new file mode 100644 index 0000000..a1a917b --- /dev/null +++ b/src/Client/tests/unit/graphChartProvider.test.ts @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as vscode from 'vscode'; +import { GraphChartProvider } from '../../features/graphChartProvider'; +import type { IWebView } from '../../features/webview'; +import type { ResultTable, ChartOptions, ResultChartView } from '../../features/server'; +import type { ChartRenderContext } from '../../features/chartProvider'; + +// ─── Mock IWebView ────────────────────────────────────────────────────────── + +interface MockWebView { + webview: IWebView & { + setup: ReturnType; + setContent: ReturnType; + invoke: ReturnType; + }; + /** Simulate a message coming back from the page to the host. */ + send(message: Record): void; + /** The HTML of the most recent setContent call (or undefined). */ + lastHtml(): string | undefined; +} + +function createMockWebView(): MockWebView { + let handler: ((message: Record) => void) | undefined; + const setContent = vi.fn(); + const webview = { + setup: vi.fn(), + setContent, + invoke: vi.fn(), + handle: vi.fn((h: (message: Record) => void) => { + handler = h; + return { dispose: () => { } }; + }), + } as MockWebView['webview']; + return { + webview, + send: (message) => handler?.(message), + lastHtml: () => { + const calls = setContent.mock.calls; + return calls.length ? (calls[calls.length - 1]?.[0] as string) : undefined; + }, + }; +} + +// ─── Test Data Helpers ────────────────────────────────────────────────────── + +function makeTable(name: string, columns: { name: string; type: string }[], rows: unknown[][]): ResultTable { + return { name, columns, rows }; +} + +function edgesTable(rows: unknown[][], cols?: { name: string; type: string }[]): ResultTable { + return makeTable('Edges', cols ?? [{ name: 'Source', type: 'string' }, { name: 'Target', type: 'string' }], rows); +} + +function defaultOptions(): ChartOptions { + return { type: 'Graph' }; +} + +/** Extract the deterministic layout seed embedded in the page script. */ +function extractSeed(html: string): number | undefined { + const m = html.match(/var layoutSeed = \((\d+) >>> 0\)/); + return m ? Number(m[1]) : undefined; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('GraphChartProvider', () => { + let provider: GraphChartProvider; + + beforeEach(() => { + provider = new GraphChartProvider(); + }); + + describe('createView', () => { + it('returns a view with renderChart and dispose', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + expect(view).toBeDefined(); + expect(typeof view.renderChart).toBe('function'); + expect(typeof view.dispose).toBe('function'); + + view.dispose(); + }); + + it('calls webview.setup with the Cytoscape script dependency', () => { + const m = createMockWebView(); + provider.createView(m.webview); + + expect(m.webview.setup).toHaveBeenCalledTimes(1); + const head = m.webview.setup.mock.calls[0]?.[0] as string; + expect(head).toContain('cytoscape'); + }); + }); + + describe('renderChart — validation', () => { + it('renders an error fallback when the table has fewer than 2 columns', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const table = makeTable('Edges', [{ name: 'Source', type: 'string' }], [['A']]); + view.renderChart(table, defaultOptions(), false); + + expect(m.webview.setContent).toHaveBeenCalledTimes(1); + expect(m.lastHtml()).toContain('at least two columns'); + view.dispose(); + }); + + it('renders an error fallback when the table has no rows', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + view.renderChart(edgesTable([]), defaultOptions(), false); + + expect(m.lastHtml()).toContain('at least two columns'); + view.dispose(); + }); + + it('renders an error fallback when every edge row has a null endpoint', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + view.renderChart(edgesTable([[null, null], ['A', null]]), defaultOptions(), false); + + // No nodes were produced → error fallback. + expect(m.lastHtml()).toContain('at least two columns'); + view.dispose(); + }); + }); + + describe('renderChart — edges-only mode', () => { + it('synthesizes nodes from edge endpoints and embeds them in the page', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + view.renderChart(edgesTable([['Alice', 'Bob'], ['Bob', 'Carol']]), defaultOptions(), false); + + const html = m.lastHtml() ?? ''; + expect(html).toContain('Alice'); + expect(html).toContain('Bob'); + expect(html).toContain('Carol'); + expect(html).toContain('gc-cy'); // graph container + view.dispose(); + }); + + it('honors explicit source/target column overrides', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const table = makeTable('Edges', + [{ name: 'From', type: 'string' }, { name: 'Ignored', type: 'string' }, { name: 'To', type: 'string' }], + [['X', 'junk', 'Y']], + ); + const options: ChartOptions = { type: 'Graph', xColumn: 'From', yColumns: ['To'] }; + view.renderChart(table, options, false); + + const html = m.lastHtml() ?? ''; + expect(html).toContain('"source":"X"'); + expect(html).toContain('"target":"Y"'); + view.dispose(); + }); + }); + + describe('renderChart — nodes table auto-detection', () => { + it('uses a sibling table named "nodes" and detects id/label/kind columns by name', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const edges = edgesTable([['n1', 'n2']]); + const nodes = makeTable('Nodes', + [ + { name: 'Id', type: 'string' }, + { name: 'Label', type: 'string' }, + { name: 'Kind', type: 'string' }, + ], + [ + ['n1', 'First Node', 'server'], + ['n2', 'Second Node', 'client'], + ], + ); + const ctx: ChartRenderContext = { tables: [edges, nodes] }; + view.renderChart(edges, defaultOptions(), false, ctx); + + const html = m.lastHtml() ?? ''; + expect(html).toContain('First Node'); + expect(html).toContain('Second Node'); + // Kinds drive the legend and per-kind styling. + expect(html).toContain('server'); + expect(html).toContain('client'); + view.dispose(); + }); + + it('stays edges-only when multiple ambiguous sibling tables exist', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const edges = edgesTable([['a', 'b']]); + const t1 = makeTable('Extra1', [{ name: 'x', type: 'string' }], [['p']]); + const t2 = makeTable('Extra2', [{ name: 'y', type: 'string' }], [['q']]); + const ctx: ChartRenderContext = { tables: [edges, t1, t2] }; + view.renderChart(edges, defaultOptions(), false, ctx); + + const html = m.lastHtml() ?? ''; + // Nodes synthesized from edges only; the ambiguous tables aren't used. + expect(html).toContain('"id":"a"'); + expect(html).toContain('"id":"b"'); + expect(html).not.toContain('"p"'); + view.dispose(); + }); + + it('does NOT auto-pick a single unrelated sibling table (only one named "nodes")', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const edges = edgesTable([['a', 'b']]); + // A lone sibling NOT named "nodes" must not be adopted. + const unrelated = makeTable('Lookup', + [{ name: 'id', type: 'string' }, { name: 'label', type: 'string' }], + [['a', 'Should Not Appear']], + ); + const ctx: ChartRenderContext = { tables: [edges, unrelated] }; + view.renderChart(edges, defaultOptions(), false, ctx); + + const html = m.lastHtml() ?? ''; + expect(html).not.toContain('Should Not Appear'); + expect(html).toContain('"id":"a"'); + view.dispose(); + }); + }); + + describe('deterministic seeding', () => { + it('produces the same seed for identical data across renders', () => { + const m1 = createMockWebView(); + const v1 = provider.createView(m1.webview); + const m2 = createMockWebView(); + const v2 = provider.createView(m2.webview); + + v1.renderChart(edgesTable([['A', 'B'], ['B', 'C']]), defaultOptions(), false); + v2.renderChart(edgesTable([['A', 'B'], ['B', 'C']]), defaultOptions(), false); + + const s1 = extractSeed(m1.lastHtml() ?? ''); + const s2 = extractSeed(m2.lastHtml() ?? ''); + expect(s1).toBeDefined(); + expect(s1).toBe(s2); + v1.dispose(); + v2.dispose(); + }); + + it('produces the same seed regardless of edge row order', () => { + const m1 = createMockWebView(); + const v1 = provider.createView(m1.webview); + const m2 = createMockWebView(); + const v2 = provider.createView(m2.webview); + + v1.renderChart(edgesTable([['A', 'B'], ['B', 'C'], ['C', 'D']]), defaultOptions(), false); + v2.renderChart(edgesTable([['C', 'D'], ['A', 'B'], ['B', 'C']]), defaultOptions(), false); + + expect(extractSeed(m1.lastHtml() ?? '')).toBe(extractSeed(m2.lastHtml() ?? '')); + v1.dispose(); + v2.dispose(); + }); + + it('produces different seeds for different graphs', () => { + const m1 = createMockWebView(); + const v1 = provider.createView(m1.webview); + const m2 = createMockWebView(); + const v2 = provider.createView(m2.webview); + + v1.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false); + v2.renderChart(edgesTable([['X', 'Y']]), defaultOptions(), false); + + expect(extractSeed(m1.lastHtml() ?? '')).not.toBe(extractSeed(m2.lastHtml() ?? '')); + v1.dispose(); + v2.dispose(); + }); + + it('uses the seed from persisted view state when provided', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const viewState: ResultChartView = { graph: { seed: 123456 } }; + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false, undefined, viewState); + + expect(extractSeed(m.lastHtml() ?? '')).toBe(123456); + view.dispose(); + }); + }); + + describe('persisted view state (positions)', () => { + it('adopts saved positions and pins them via a preset layout', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const viewState: ResultChartView = { + graph: { positions: { A: { x: 10, y: 20 }, B: { x: 30, y: 40 } } }, + }; + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false, undefined, viewState); + + const html = m.lastHtml() ?? ''; + expect(html).toContain('preset'); + expect(html).toContain('"A":{"x":10,"y":20}'); + view.dispose(); + }); + + it('emits view state when the page reports new node positions', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + const listener = vi.fn(); + view.onDidChangeViewState?.(listener); + + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false); + m.send({ command: 'graphChartPositions', positions: { A: { x: 1, y: 2 } }, seed: 7, manual: true }); + + expect(listener).toHaveBeenCalledTimes(1); + const state = listener.mock.calls[0]?.[0] as ResultChartView; + expect(state.graph?.positions).toMatchObject({ A: { x: 1, y: 2 } }); + expect(state.graph?.seed).toBe(7); + expect(state.graph?.manual).toBe(true); + view.dispose(); + }); + + it('does not re-emit identical position reports (no needless write-back)', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + const listener = vi.fn(); + view.onDidChangeViewState?.(listener); + + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false); + const msg = { command: 'graphChartPositions', positions: { A: { x: 1, y: 2 } } }; + m.send(msg); + m.send({ ...msg, positions: { A: { x: 1, y: 2 } } }); + + expect(listener).toHaveBeenCalledTimes(1); + view.dispose(); + }); + + it('ignores position reports from a superseded render token', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + const listener = vi.fn(); + view.onDidChangeViewState?.(listener); + + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false); + // renderToken is 1 after the first render; a stale token must be ignored. + m.send({ command: 'graphChartPositions', positions: { A: { x: 9, y: 9 } }, token: 0 }); + + expect(listener).not.toHaveBeenCalled(); + view.dispose(); + }); + }); + + describe('reroll (regenerate layout)', () => { + it('re-renders with an incremented seed when not manually adjusted', () => { + const m = createMockWebView(); + const view = provider.createView(m.webview); + + const viewState: ResultChartView = { graph: { seed: 100 } }; + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false, undefined, viewState); + const before = m.webview.setContent.mock.calls.length; + + m.send({ command: 'graphChartReroll', token: 1 }); + + expect(m.webview.setContent.mock.calls.length).toBe(before + 1); + expect(extractSeed(m.lastHtml() ?? '')).toBe(101); + view.dispose(); + }); + + it('prompts for confirmation and aborts when the layout was manually adjusted', async () => { + const warn = vi.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue(undefined); + const m = createMockWebView(); + const view = provider.createView(m.webview); + + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false); + // Mark as manually adjusted. + m.send({ command: 'graphChartPositions', positions: { A: { x: 1, y: 2 } }, manual: true }); + const before = m.webview.setContent.mock.calls.length; + + m.send({ command: 'graphChartReroll', token: 1 }); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledOnce(); + // User dismissed the dialog → no re-render. + expect(m.webview.setContent.mock.calls.length).toBe(before); + warn.mockRestore(); + view.dispose(); + }); + + it('re-rolls after the user confirms the manual-discard prompt', async () => { + const warn = vi.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue('Regenerate' as never); + const m = createMockWebView(); + const view = provider.createView(m.webview); + + view.renderChart(edgesTable([['A', 'B']]), defaultOptions(), false); + m.send({ command: 'graphChartPositions', positions: { A: { x: 1, y: 2 } }, manual: true }); + const before = m.webview.setContent.mock.calls.length; + + m.send({ command: 'graphChartReroll', token: 1 }); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledOnce(); + expect(m.webview.setContent.mock.calls.length).toBe(before + 1); + warn.mockRestore(); + view.dispose(); + }); + }); +}); diff --git a/src/Server/Charting/ChartOptions.cs b/src/Server/Charting/ChartOptions.cs index 6faf552..caf7002 100644 --- a/src/Server/Charting/ChartOptions.cs +++ b/src/Server/Charting/ChartOptions.cs @@ -242,6 +242,37 @@ public class ChartOptions [DataMember(Name = "markerSize")] public string? MarkerSize { get; init; } + /// + /// Name of the sibling result table to use as the nodes table for graph charts. + /// If null, the provider auto-detects (prefers a table named "nodes", else the single non-empty other table). + /// + [DataMember(Name = "nodesTable")] + public string? NodesTable { get; init; } + + /// + /// Name of the column on the nodes table that contains the node id. If null, auto-detected by name (id, name, ...). + /// + [DataMember(Name = "nodeIdColumn")] + public string? NodeIdColumn { get; init; } + + /// + /// Name of the column on the nodes table to use as the node label. If null, auto-detected by name. + /// + [DataMember(Name = "nodeLabelColumn")] + public string? NodeLabelColumn { get; init; } + + /// + /// Name of the column on the nodes table to use as the node kind (drives per-kind coloring). If null, auto-detected by name. + /// + [DataMember(Name = "nodeKindColumn")] + public string? NodeKindColumn { get; init; } + + /// + /// Name of the column on the edges table to use as the edge kind. If null, falls back to the first entry. + /// + [DataMember(Name = "edgeKindColumn")] + public string? EdgeKindColumn { get; init; } + /// /// Converts a from the Kusto SDK to a . /// @@ -308,6 +339,11 @@ public ChartOptions WithDefaults(ChartOptions defaults) MarkerShape = this.MarkerShape ?? defaults.MarkerShape, CycleMarkerShapes = this.CycleMarkerShapes ?? defaults.CycleMarkerShapes, MarkerSize = this.MarkerSize ?? defaults.MarkerSize, + NodesTable = this.NodesTable ?? defaults.NodesTable, + NodeIdColumn = this.NodeIdColumn ?? defaults.NodeIdColumn, + NodeLabelColumn = this.NodeLabelColumn ?? defaults.NodeLabelColumn, + NodeKindColumn = this.NodeKindColumn ?? defaults.NodeKindColumn, + EdgeKindColumn = this.EdgeKindColumn ?? defaults.EdgeKindColumn, }; } diff --git a/src/Server/Connections/ConnectionManager.cs b/src/Server/Connections/ConnectionManager.cs index 99ba3a2..e5e8722 100644 --- a/src/Server/Connections/ConnectionManager.cs +++ b/src/Server/Connections/ConnectionManager.cs @@ -400,16 +400,23 @@ private async Task ExecuteCoreAsync( using (resultReader) { var dataSet = KustoDataReaderParser.ParseV1(resultReader, null); - var mainResult = dataSet?.GetMainResultsOrNull(); - var tables = dataSet != null - ? dataSet.Tables.Where(t => t.TableKind == WellKnownDataSet.PrimaryResult).Select(t => (DataTable)t.TableData).ToImmutableList() - : null; - var chartOptions = mainResult?.VisualizationOptions != null && mainResult.VisualizationOptions.Visualization != Data.Utils.VisualizationKind.None - ? ChartOptions.FromChartVisualizationOptions(mainResult.VisualizationOptions) - : null; - var charts = chartOptions != null - ? ImmutableList.Create(new ResultChart { Options = chartOptions }) - : null; + var primaryTables = dataSet != null + ? dataSet.Tables.Where(t => t.TableKind == WellKnownDataSet.PrimaryResult).ToImmutableList() + : ImmutableList.Empty; + var tables = primaryTables.Select(t => (DataTable)t.TableData).ToImmutableList(); + + // A `render` may be attached to any statement, not only the main result. + ImmutableList.Builder? chartsBuilder = null; + foreach (var t in primaryTables) + { + var viz = t.VisualizationOptions; + if (viz == null || viz.Visualization == Data.Utils.VisualizationKind.None) continue; + var opts = ChartOptions.FromChartVisualizationOptions(viz); + chartsBuilder ??= ImmutableList.CreateBuilder(); + chartsBuilder.Add(new ResultChart { TableName = t.TableData?.TableName, Options = opts }); + } + var charts = chartsBuilder?.ToImmutable(); + return new ExecuteResult { Tables = tables,