From 1a62b95224f9feba78389da41ad096a8d1bb22ee Mon Sep 17 00:00:00 2001 From: Matt Warren Date: Mon, 1 Jun 2026 13:56:41 -0700 Subject: [PATCH 1/4] graph charts with edges and nodes tables --- src/Client/features/chartEditorProvider.ts | 93 ++- src/Client/features/chartProvider.ts | 25 +- src/Client/features/compositeChartProvider.ts | 48 +- src/Client/features/graphChartProvider.ts | 702 ++++++++++++++++++ src/Client/features/resultsViewer.ts | 77 +- src/Client/features/server.ts | 22 + src/Server/Charting/ChartOptions.cs | 36 + src/Server/Connections/ConnectionManager.cs | 27 +- 8 files changed, 989 insertions(+), 41 deletions(-) create mode 100644 src/Client/features/graphChartProvider.ts diff --git a/src/Client/features/chartEditorProvider.ts b/src/Client/features/chartEditorProvider.ts index ab1a691..77a9d68 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 { @@ -584,6 +595,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 +654,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 +718,40 @@ 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 ?? ''; + const nodesTableOptions = ['', ...siblingNames].map(n => + `` + ).join(''); + // Resolve which sibling table to source node-column choices from. If + // user chose one explicitly, use it; else prefer one literally named + // "nodes" (case-insensitive); else if there's exactly one sibling, use it. + const resolveNodesTable = (): ChartEditorTableInfo | undefined => { + if (currentNodesTable) { + const m = siblingTables.find(t => t.name === currentNodesTable); + if (m) return m; + } + const named = siblingTables.find(t => (t.name ?? '').toLowerCase() === 'nodes'); + if (named) return named; + return siblingTables.length === 1 ? siblingTables[0] : undefined; + }; + 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 ?? ''); + const colOptionsList = columnNames.map(c => `` ).join(''); @@ -980,6 +1035,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..816682f --- /dev/null +++ b/src/Client/features/graphChartProvider.ts @@ -0,0 +1,702 @@ +// 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'; + +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 } } = {}; + private currentChartName: string | undefined; + private currentChartTableName: 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) => 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 }; + const state: ResultChartView = { + graph: { positions: this.cachedPositions }, + }; + if (this.currentChartName) state.name = this.currentChartName; + if (this.currentChartTableName) state.tableName = this.currentChartTableName; + for (const l of this.stateListeners) l(state); + } + }); + } + + 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 positions from disk; in-session edits already merge here. + const saved = viewState?.graph?.positions; + if (saved) { + this.cachedPositions = { ...this.cachedPositions, ...saved }; + } + const token = ++this.renderToken; + const bodyHtml = this.render(data, options, darkMode, ctx, this.cachedPositions, token); + 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) => this.renderGraphHtml(data, options, darkMode, ctx, positions, token)); + } + + private renderGraphHtml(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx: ChartRenderContext | undefined, positions: { [id: string]: { x: number; y: number } }, token: number): 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. + const nodeIdsSorted = [...nodeMap.keys()].sort(); + const seedItems = nodeIdsSorted.concat(edges.map(e => e.data.source + '\u0001' + e.data.target)); + const layoutSeed = 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; + const edgeKindStyles: { kind: string; color: string }[] = []; + let eki = 0; + for (const k of edgeKinds) { + edgeKindStyles.push({ kind: k, color: colors[eki % colors.length]! }); + eki++; + } + 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 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; + } + // Prefer a sibling literally named "nodes" (case-insensitive). + const named = others.find(t => t.name?.toLowerCase() === 'nodes'); + if (named) return named; + // Otherwise auto-pick only when there's exactly one candidate; ambiguous + // multi-table cases stay edges-only until an explicit option is added. + return others.length === 1 ? others[0] : undefined; +} + +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..adfe787 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,21 @@ 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?: { + positions?: { [nodeId: string]: { x: number; y: number } }; + }; +} + /** Serializable representation of a data table. */ export interface ResultTable { name: string; @@ -642,6 +658,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/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, From b932cac3423179d004245032150786c1362cb81e Mon Sep 17 00:00:00 2001 From: Matt Warren Date: Mon, 1 Jun 2026 14:52:16 -0700 Subject: [PATCH 2/4] Allow for regenerating layout --- src/Client/features/graphChartProvider.ts | 151 ++++++++++++++++++---- src/Client/features/server.ts | 9 ++ 2 files changed, 134 insertions(+), 26 deletions(-) diff --git a/src/Client/features/graphChartProvider.ts b/src/Client/features/graphChartProvider.ts index 816682f..7e7f46b 100644 --- a/src/Client/features/graphChartProvider.ts +++ b/src/Client/features/graphChartProvider.ts @@ -26,6 +26,7 @@ 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'; @@ -38,6 +39,17 @@ class GraphChartView implements IChartView { 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; /** @@ -51,7 +63,7 @@ class GraphChartView implements IChartView { 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) => string | undefined + 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) { @@ -64,16 +76,51 @@ class GraphChartView implements IChartView { // during a drag) and we want to retain positions for unmoved // nodes too. this.cachedPositions = { ...this.cachedPositions, ...positions }; - const state: ResultChartView = { - graph: { positions: this.cachedPositions }, - }; - if (this.currentChartName) state.name = this.currentChartName; - if (this.currentChartTableName) state.tableName = this.currentChartTableName; - for (const l of this.stateListeners) l(state); + 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 emitState(): void { + 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; + 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 } @@ -81,13 +128,20 @@ class GraphChartView implements IChartView { renderChart(data: ResultTable, options: ChartOptions, darkMode: boolean, ctx?: ChartRenderContext, viewState?: ResultChartView): void { this.currentChartName = viewState?.name; this.currentChartTableName = viewState?.tableName; - // Adopt any saved positions from disk; in-session edits already merge here. + // 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; } + 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); + const bodyHtml = this.render(data, options, darkMode, ctx, this.cachedPositions, token, this.cachedSeed); if (bodyHtml) { this.webview.setContent(bodyHtml); } else { @@ -136,10 +190,10 @@ export class GraphChartProvider implements IChartProvider { ``, '' ); - return new GraphChartView(webview, (data, options, darkMode, ctx, positions, token) => this.renderGraphHtml(data, options, darkMode, ctx, positions, token)); + 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): string | undefined { + 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; @@ -263,10 +317,11 @@ export class GraphChartProvider implements IChartProvider { // 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. + // cose layout. A seedOverride (from the reroll button / persisted + // state) takes precedence. const nodeIdsSorted = [...nodeMap.keys()].sort(); const seedItems = nodeIdsSorted.concat(edges.map(e => e.data.source + '\u0001' + e.data.target)); - const layoutSeed = hashStringList(seedItems); + const layoutSeed = seedOverride !== undefined ? (seedOverride >>> 0) : hashStringList(seedItems); let cx = 0, cy_ = 0, n_ = 0; if (havePositions) { @@ -292,18 +347,22 @@ export class GraphChartProvider implements IChartProvider { const elements = [...nodes, ...edges]; const colors = ChartColorways.Default; - const edgeKindStyles: { kind: string; color: string }[] = []; - let eki = 0; - for (const k of edgeKinds) { - edgeKindStyles.push({ kind: k, color: colors[eki % colors.length]! }); - eki++; - } + // 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; @@ -390,9 +449,38 @@ export class GraphChartProvider implements IChartProvider { .gc-legend-item { display: flex; align-items: center; gap: 6px; line-height: 1.6; } .gc-legend-swatch { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 10px; border: 1px solid ${theme.nodeBorder}; } .gc-legend-swatch.edge { width: 14px; height: 2px; border-radius: 0; border: 0; flex: 0 0 14px; } +.gc-toolbar { + position: absolute; + top: ${title ? '34px' : '6px'}; + left: 8px; + z-index: 4; + display: flex; + gap: 4px; +} +.gc-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: ${theme.background}; + color: ${theme.foreground}; + border: 1px solid ${theme.nodeBorder}; + border-radius: 3px; + cursor: pointer; + opacity: 0.85; +} +.gc-btn:hover { opacity: 1; } +.gc-btn svg { width: 14px; height: 14px; fill: currentColor; }
${title ? `
${title}
` : ''} +
+ +
${legendHtml}
Loading graph…
@@ -487,7 +575,8 @@ export class GraphChartProvider implements IChartProvider { // across re-renders (and saved into the .kqr file). Uses the // page-level _vscodeApi handle established by the host harness. var renderToken = ${token}; - function postPositions() { + var layoutSeed = (${layoutSeed} >>> 0); + function postPositions(manual) { var api = window._vscodeApi; var positions = {}; cy.nodes().forEach(function(n) { @@ -495,16 +584,26 @@ export class GraphChartProvider implements IChartProvider { positions[n.id()] = { x: p.x, y: p.y }; }); if (api) { - try { api.postMessage({ command: 'graphChartPositions', positions: positions, token: renderToken }); } catch (e) {} + try { api.postMessage({ command: 'graphChartPositions', positions: positions, token: renderToken, seed: layoutSeed, manual: !!manual }); } catch (e) {} } else { // Harness may not have initialised yet on the very first // render; retry shortly so we never lose the initial cose // positions. - setTimeout(postPositions, 50); + setTimeout(function() { postPositions(manual); }, 50); } } - // Capture after each user drag. - cy.on('dragfree', 'node', postPositions); + // Capture after each user drag (marks the layout as manually adjusted). + cy.on('dragfree', 'node', function() { postPositions(true); }); + // Reroll button: ask the host to re-run the layout with a new seed. + var rerollBtn = document.getElementById('gc-reroll'); + if (rerollBtn) { + rerollBtn.addEventListener('click', function() { + var api = window._vscodeApi; + if (api) { + try { api.postMessage({ command: 'graphChartReroll', token: renderToken }); } catch (e) {} + } + }); + } // Build the layout. When we have saved positions we use 'preset' and // feed the coordinates EXPLICITLY via a positions callback (relying on // element.position alone can fall back to a grid in some cytoscape @@ -531,9 +630,9 @@ export class GraphChartProvider implements IChartProvider { // deterministic PRNG seeded from a hash of the data, run the // layout, then restore Math.random. layout = cy.layout({ name: 'cose', animate: false, fit: true, padding: 20, randomize: true }); - layout.one('layoutstop', postPositions); + layout.one('layoutstop', function() { postPositions(false); }); var _origRandom = Math.random; - var _seed = (${layoutSeed} >>> 0) || 1; + var _seed = layoutSeed || 1; Math.random = function() { // mulberry32 _seed |= 0; _seed = (_seed + 0x6D2B79F5) | 0; diff --git a/src/Client/features/server.ts b/src/Client/features/server.ts index adfe787..46d0e27 100644 --- a/src/Client/features/server.ts +++ b/src/Client/features/server.ts @@ -600,7 +600,16 @@ export interface ResultChartView { 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; }; } From eef888d4fc942e33eaa1f14f3af7001c7561716f Mon Sep 17 00:00:00 2001 From: Matt Warren Date: Mon, 1 Jun 2026 14:55:50 -0700 Subject: [PATCH 3/4] Add reference to cytoscape in thirdpartynotices.txt --- ThirdPartyNotices.txt | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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 From fb314a903681e523336b6c7c9d3fa00ea3f800d5 Mon Sep 17 00:00:00 2001 From: Matt Warren Date: Mon, 1 Jun 2026 15:34:37 -0700 Subject: [PATCH 4/4] Changes from PR feedback --- src/Client/features/chartEditorProvider.ts | 69 ++- src/Client/features/graphChartProvider.ts | 59 ++- .../tests/unit/graphChartProvider.test.ts | 409 ++++++++++++++++++ 3 files changed, 514 insertions(+), 23 deletions(-) create mode 100644 src/Client/tests/unit/graphChartProvider.test.ts diff --git a/src/Client/features/chartEditorProvider.ts b/src/Client/features/chartEditorProvider.ts index 77a9d68..0e97832 100644 --- a/src/Client/features/chartEditorProvider.ts +++ b/src/Client/features/chartEditorProvider.ts @@ -505,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; @@ -728,20 +766,23 @@ class ChartEditorView implements IChartEditorView { const siblingTables = tables.filter(t => !primaryTableName || t.name !== primaryTableName); const siblingNames = siblingTables.map(t => t.name).filter(n => !!n); const currentNodesTable = opts.nodesTable ?? ''; - const nodesTableOptions = ['', ...siblingNames].map(n => - `` - ).join(''); + // 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 - // user chose one explicitly, use it; else prefer one literally named - // "nodes" (case-insensitive); else if there's exactly one sibling, use it. + // 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; } - const named = siblingTables.find(t => (t.name ?? '').toLowerCase() === 'nodes'); - if (named) return named; - return siblingTables.length === 1 ? siblingTables[0] : undefined; + return siblingTables.find(t => (t.name ?? '').toLowerCase() === 'nodes'); }; const nodesTable = resolveNodesTable(); const nodeColumnNames = nodesTable?.columns ?? []; @@ -751,6 +792,16 @@ class ChartEditorView implements IChartEditorView { 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 => `` @@ -1043,7 +1094,7 @@ class ChartEditorView implements IChartEditorView {