From 32b5170bc54d07c63476d169c608e8ba6560f1b0 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Thu, 26 Feb 2026 11:45:22 +0000 Subject: [PATCH 01/33] add orbital entanglement diagram and svg save --- package-lock.json | 11 + package.json | 1 + samples/notebooks/orbital_entanglement.ipynb | 232 ++++++ source/npm/qsharp/ux/index.ts | 4 + source/npm/qsharp/ux/orbitalEntanglement.tsx | 667 ++++++++++++++++++ source/widgets/js/index.tsx | 64 ++ source/widgets/src/qsharp_widgets/__init__.py | 232 ++++++ 7 files changed, 1211 insertions(+) create mode 100644 samples/notebooks/orbital_entanglement.ipynb create mode 100644 source/npm/qsharp/ux/orbitalEntanglement.tsx diff --git a/package-lock.json b/package-lock.json index d852b2be49..c99df4d094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "monaco-editor": "^0.44.0", "openai": "^4.83.0", "preact": "^10.20.0", + "preact-render-to-string": "^6.6.6", "prettier": "^3.3.3", "punycode": "^2.3.1", "typescript": "^5.5.4", @@ -5366,6 +5367,16 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/preact-render-to-string": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.6.tgz", + "integrity": "sha512-EfqZJytnjJldV+YaaqhthU2oXsEf5e+6rDv957p+zxAvNfFLQOPfvBOTncscQ+akzu6Wrl7s3Pa0LjUQmWJsGQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": ">=10 || >= 11.0.0-0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 54bd2645d2..8ba2dd075a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "monaco-editor": "^0.44.0", "openai": "^4.83.0", "preact": "^10.20.0", + "preact-render-to-string": "^6.6.6", "prettier": "^3.3.3", "punycode": "^2.3.1", "typescript": "^5.5.4", diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb new file mode 100644 index 0000000000..0813b34098 --- /dev/null +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -0,0 +1,232 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "4ce65e98", + "metadata": {}, + "source": [ + "# Orbital Entanglement Chord Diagram — 250 Synthetic Orbitals\n", + "\n", + "Demonstrates the `OrbitalEntanglement` JS widget on a large system with\n", + "**250 orbitals**. 50 orbitals form a strongly-coupled core with high\n", + "single-orbital entropies; the remaining 200 have a long tail of weak\n", + "entropy and negligible mutual information.\n", + "\n", + "No quantum chemistry calculation is needed — we build a mock dataset\n", + "with NumPy and pass raw arrays directly to the widget." + ] + }, + { + "cell_type": "markdown", + "id": "d2428548", + "metadata": {}, + "source": [ + "## 1 — Build synthetic data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c033bd86", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "rng = np.random.default_rng(42)\n", + "\n", + "N = 250 # total orbitals\n", + "N_CORE = 50 # strongly-coupled subset to highlight\n", + "\n", + "# ── Single-orbital entropies ─────────────────────────────────────────\n", + "# Three regimes interleaved across the orbital indices:\n", + "# • ~50 \"core\" orbitals with high entropy (near ln 4)\n", + "# • ~60 \"medium\" orbitals with moderate entropy\n", + "# • ~140 \"spectator\" orbitals with a long decaying tail\n", + "s1 = np.zeros(N)\n", + "\n", + "all_indices = rng.permutation(N)\n", + "core_idx = np.sort(all_indices[:N_CORE])\n", + "medium_idx = np.sort(all_indices[N_CORE : N_CORE + 60])\n", + "tail_idx = np.sort(all_indices[N_CORE + 60 :])\n", + "\n", + "s1[core_idx] = rng.beta(2.5, 1.2, len(core_idx)) * np.log(4.0)\n", + "s1[medium_idx] = rng.beta(1.8, 3.0, len(medium_idx)) * np.log(4.0) * 0.5\n", + "s1[tail_idx] = np.sort(rng.exponential(0.03, len(tail_idx)))[::-1]\n", + "\n", + "# ── Mutual information matrix ──────────────────────────────────────\n", + "mi = np.zeros((N, N))\n", + "\n", + "# 1) Five intra-core clusters of ~10 orbitals each with strong MI\n", + "cluster_size = len(core_idx) // 5\n", + "for k in range(5):\n", + " cl = core_idx[k * cluster_size : (k + 1) * cluster_size]\n", + " for ii, i in enumerate(cl):\n", + " for j in cl[ii + 1 :]:\n", + " val = rng.beta(3, 1.5) * np.log(16.0) * 0.55\n", + " mi[i, j] = mi[j, i] = val\n", + "\n", + "# 2) Sparse inter-cluster core links\n", + "for ii, i in enumerate(core_idx):\n", + " for j in core_idx[ii + 1 :]:\n", + " if mi[i, j] == 0 and rng.random() < 0.15:\n", + " val = rng.exponential(0.08)\n", + " mi[i, j] = mi[j, i] = val\n", + "\n", + "# 3) Medium orbitals: moderate MI to a few core and medium neighbours\n", + "for i in medium_idx:\n", + " n_core_links = rng.integers(1, 4)\n", + " targets = rng.choice(core_idx, size=n_core_links, replace=False)\n", + " for j in targets:\n", + " val = rng.exponential(0.12)\n", + " mi[i, j] = mi[j, i] = val\n", + " n_med_links = rng.integers(0, 3)\n", + " others = rng.choice(\n", + " medium_idx[medium_idx != i],\n", + " size=min(n_med_links, len(medium_idx) - 1),\n", + " replace=False,\n", + " )\n", + " for j in others:\n", + " if mi[i, j] == 0:\n", + " val = rng.exponential(0.06)\n", + " mi[i, j] = mi[j, i] = val\n", + "\n", + "# 4) Tail orbitals: very sparse, weak links to core or medium\n", + "for i in tail_idx:\n", + " if rng.random() < 0.12:\n", + " pool = np.concatenate([core_idx, medium_idx])\n", + " j = rng.choice(pool)\n", + " val = rng.exponential(0.02)\n", + " mi[i, j] = mi[j, i] = val\n", + "\n", + "np.fill_diagonal(mi, 0.0)\n", + "\n", + "print(f\"{N} orbitals, {N_CORE} selected (highlighted)\")\n", + "print(f\"Core indices (first 10): {core_idx[:10].tolist()} ...\")\n", + "print(f\"s1 range: {s1.min():.4f} – {s1.max():.4f}\")\n", + "print(f\"MI range: {mi[mi > 0].min():.4f} – {mi.max():.4f}\")\n", + "print(f\"Non-zero MI pairs: {(mi > 0).sum() // 2}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b35327c0", + "metadata": {}, + "source": [ + "## 2 — Display the interactive widget\n", + "\n", + "The `OrbitalEntanglement` widget renders as an SVG chord diagram\n", + "directly in the notebook output. Arc length encodes single-orbital\n", + "entropy; chord thickness encodes mutual information. The 50 core\n", + "orbitals are highlighted with a dark outline." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7469867d", + "metadata": {}, + "outputs": [], + "source": [ + "from qsharp_widgets import OrbitalEntanglement\n", + "\n", + "widget = OrbitalEntanglement(\n", + " s1_entropies=s1.tolist(),\n", + " mutual_information=mi.tolist(),\n", + " labels=[str(i) for i in range(N)],\n", + " selected_indices=core_idx.tolist(),\n", + " title=f\"Synthetic Orbital Entanglement — {N} orbitals ({N_CORE} core)\",\n", + " gap_deg=0.6,\n", + " arc_width=0.05,\n", + " mi_threshold=0.01,\n", + " width=800,\n", + " height=880,\n", + ")\n", + "widget" + ] + }, + { + "cell_type": "markdown", + "id": "93a1c191", + "metadata": {}, + "source": [ + "## 3 — Export as SVG (light & dark mode)\n", + "\n", + "`export_svg()` renders the diagram server-side via Node.js — the same\n", + "Preact component used by the interactive widget — so fonts and colours\n", + "are deterministic regardless of viewer.\n", + "\n", + "Use `dark_mode=True` for light text on a dark background, or\n", + "`dark_mode=False` (default) for dark text on a transparent background." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f10a304c", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "# Light mode (default)\n", + "light_path = Path(\"orbital_entanglement_light.svg\")\n", + "saved = widget.export_svg(path=light_path, dark_mode=False)\n", + "print(f\"Light SVG → {saved} ({light_path.stat().st_size / 1024:.1f} KB)\")\n", + "\n", + "# Dark mode\n", + "dark_path = Path(\"orbital_entanglement_dark.svg\")\n", + "saved = widget.export_svg(path=dark_path, dark_mode=True)\n", + "print(f\"Dark SVG → {saved} ({dark_path.stat().st_size / 1024:.1f} KB)\")" + ] + }, + { + "cell_type": "markdown", + "id": "4d2f8950", + "metadata": {}, + "source": [ + "## 4 — Display the exported SVGs inline\n", + "\n", + "Render both the light and dark variants to verify fonts, text\n", + "colour, and background are baked into the SVG correctly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "842d7931", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import SVG, display, HTML\n", + "\n", + "display(HTML(\"

Light mode

\"))\n", + "display(SVG(filename=str(light_path)))\n", + "\n", + "display(HTML(\"

Dark mode

\"))\n", + "display(SVG(filename=str(dark_path)))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index bec89d0339..f6f82a46ce 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -24,6 +24,10 @@ export { Circuit, CircuitPanel } from "./circuit.js"; export { setRenderer, Markdown } from "./renderers.js"; export { Atoms, type ZoneLayout, type TraceData } from "./atoms/index.js"; export { MoleculeViewer } from "./chem/index.js"; +export { + OrbitalEntanglement, + type OrbitalEntanglementProps, +} from "./orbitalEntanglement.js"; export { ensureTheme, detectThemeChange, diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx new file mode 100644 index 0000000000..8442b288be --- /dev/null +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -0,0 +1,667 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Orbital entanglement chord diagram. + * + * Renders single-orbital entropies and mutual information as an SVG chord + * diagram. Arc length is proportional to single-orbital entropy; chord + * thickness is proportional to pairwise mutual information. + * + * The diagram is rendered entirely as native SVG so that the markup can be + * serialised to a standalone `.svg` file from the Python widget. + */ + +import { useState, useRef, useEffect } from "preact/hooks"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface OrbitalEntanglementProps { + /** Single-orbital entropies, length N. */ + s1Entropies: number[]; + /** Mutual information matrix, N×N (row-major flat array or nested). */ + mutualInformation: number[][]; + /** Orbital labels (length N). Falls back to "0", "1", … */ + labels?: string[]; + /** Indices of orbitals to highlight with an outline. */ + selectedIndices?: number[]; + + // --- visual knobs (all optional with sensible defaults) --- + gapDeg?: number; + radius?: number; + arcWidth?: number; + lineScale?: number | null; + miThreshold?: number; + s1Vmax?: number | null; + miVmax?: number | null; + title?: string | null; + width?: number; + height?: number; + selectionColor?: string; + selectionLinewidth?: number; + /** + * When `true` renders light text on a dark background; when `false` + * renders dark text on a transparent background. Leave `undefined` + * (the default) to inherit from the host page via `currentColor`. + */ + darkMode?: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function deg2xy(deg: number, r: number): [number, number] { + const rad = (deg * Math.PI) / 180; + return [r * Math.cos(rad), r * Math.sin(rad)]; +} + +/** Linear interpolation between two RGB‑A colours given as [r,g,b,a]. */ +type RGBA = [number, number, number, number]; + +function lerpColor(a: RGBA, b: RGBA, t: number): RGBA { + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + a[3] + (b[3] - a[3]) * t, + ]; +} + +/** Parse "#rrggbb" to RGBA. */ +function hexToRGBA(hex: string): RGBA { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + return [r, g, b, 1]; +} + +function rgbaToCSS(c: RGBA): string { + return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; +} + +/** Evaluate a 3‑stop linear colour-map at position t ∈ [0,1]. */ +function colormapEval(stops: [string, string, string], t: number): string { + const clamped = Math.max(0, Math.min(1, t)); + const colors = stops.map(hexToRGBA) as [RGBA, RGBA, RGBA]; + if (clamped <= 0.5) { + return rgbaToCSS(lerpColor(colors[0], colors[1], clamped * 2)); + } + return rgbaToCSS(lerpColor(colors[1], colors[2], (clamped - 0.5) * 2)); +} + +const ARC_CMAP: [string, string, string] = ["#d8d8d8", "#c82020", "#1a1a1a"]; +const CHORD_CMAP: [string, string, string] = ["#d8d8d8", "#2060b0", "#1a1a1a"]; + +/** + * Detect whether the host background is dark or light by sampling the + * computed background-color of the nearest ancestor with one. + * Returns a high-contrast colour for selection outlines. + */ +function detectSelectionColor(el: Element | null): string { + if (!el || typeof getComputedStyle === "undefined") return "#FFD700"; + let node: Element | null = el; + while (node) { + const bg = getComputedStyle(node).backgroundColor; + if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") { + const m = bg.match(/\d+/g); + if (m) { + const [r, g, b] = m.map(Number); + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + // Use vivid colours that pop against the arc colourmap + return lum > 0.5 ? "#FF8C00" : "#FFD700"; + } + } + node = node.parentElement; + } + return "#FFD700"; +} + +/** Build an SVG arc‑path for a filled annular segment. */ +function arcPath( + startDeg: number, + endDeg: number, + innerR: number, + outerR: number, +): string { + // Discretise to a polygon — simpler and avoids arc‑sweep flag headaches + // with very small or very large arcs. + const N = 80; + const pts: string[] = []; + for (let i = 0; i <= N; i++) { + const theta = ((startDeg + ((endDeg - startDeg) * i) / N) * Math.PI) / 180; + pts.push(`${outerR * Math.cos(theta)},${outerR * Math.sin(theta)}`); + } + for (let i = N; i >= 0; i--) { + const theta = ((startDeg + ((endDeg - startDeg) * i) / N) * Math.PI) / 180; + pts.push(`${innerR * Math.cos(theta)},${innerR * Math.sin(theta)}`); + } + return ( + `M ${pts[0]} ` + + pts + .slice(1) + .map((p) => `L ${p}`) + .join(" ") + + " Z" + ); +} + +/** Cubic Bézier chord between two angles on the inner rim. */ +function chordPath( + angleA: number, + angleB: number, + radius: number, + arcWidth: number, +): string { + const inner = radius - arcWidth; + const ctrlR = inner * 0.55; + const [x0, y0] = deg2xy(angleA, inner); + const [cx0, cy0] = deg2xy(angleA, ctrlR); + const [cx1, cy1] = deg2xy(angleB, ctrlR); + const [x1, y1] = deg2xy(angleB, inner); + return `M ${x0},${y0} C ${cx0},${cy0} ${cx1},${cy1} ${x1},${y1}`; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function OrbitalEntanglement(props: OrbitalEntanglementProps) { + const { + s1Entropies, + mutualInformation, + labels: labelsProp, + selectedIndices, + gapDeg = 3, + radius = 1, + arcWidth = 0.08, + lineScale: lineScaleProp = null, + miThreshold = 0, + s1Vmax = null, + miVmax = null, + title = "Orbital Entanglement", + width = 600, + height = 660, + selectionColor: selectionColorProp, + selectionLinewidth = 1.2, + darkMode, + } = props; + + // --- theme-resolved colours --- + const FONT_FAMILY = '"Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + const hasExplicitTheme = darkMode !== undefined; + const textColor = hasExplicitTheme + ? darkMode + ? "#e0e0e0" + : "#222222" + : "currentColor"; + const bgColor = hasExplicitTheme + ? darkMode + ? "#1e1e1e" + : "transparent" + : "transparent"; + + const n = s1Entropies.length; + + // --- hover state --- + const [hoveredIdx, setHoveredIdx] = useState(null); + + // --- background-aware selection colour --- + const svgRef = useRef(null); + const [autoSelectionColor, setAutoSelectionColor] = useState("#FFD700"); + useEffect(() => { + if (svgRef.current) { + setAutoSelectionColor(detectSelectionColor(svgRef.current)); + } + }, []); + const selectionColor = selectionColorProp ?? autoSelectionColor; + + // --- labels --- + const labels: string[] = + labelsProp && labelsProp.length === n + ? labelsProp + : Array.from({ length: n }, (_, i) => String(i)); + + // --- colour scales --- + const s1Max = s1Vmax ?? Math.log(4); + const miMax = miVmax ?? Math.log(16); + + const arcColours = s1Entropies.map((v) => colormapEval(ARC_CMAP, v / s1Max)); + + // --- line scale --- + const maxLw = Math.max(12 * (20 / Math.max(n, 1)) ** 0.5, 2); + let lineScale: number; + { + let miPeak = 0; + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) + miPeak = Math.max(miPeak, mutualInformation[i][j]); + if (miPeak <= 0) miPeak = 1; + lineScale = + lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(miPeak); + } + + // --- arc geometry --- + const totals = s1Entropies.slice(); + let grand = totals.reduce((a, b) => a + b, 0); + if (grand === 0) { + totals.fill(1); + grand = n; + } + const gapTotal = gapDeg * n; + const arcDegs = totals.map((t) => ((360 - gapTotal) * t) / grand); + + const starts: number[] = new Array(n); + starts[0] = 0; + for (let i = 1; i < n; i++) { + starts[i] = starts[i - 1] + arcDegs[i - 1] + gapDeg; + } + + const arcMids = starts.map((s, i) => s + arcDegs[i] / 2); + + // --- label tiers (avoid overlapping) --- + const labelFontSize = n <= 20 ? 13.5 : 10.5; + const maxLabelLen = Math.max(...labels.map((l) => l.length)); + const charDeg = (labelFontSize * 0.7 * maxLabelLen) / Math.max(radius, 0.5); + const minSepDeg = charDeg * 0.8; + const baseOffset = 0.07; + const tierStep = 0.09; + const maxTiers = 4; + + const indexOrder = Array.from({ length: n }, (_, i) => i).sort( + (a, b) => arcMids[a] - arcMids[b], + ); + const tier = new Array(n).fill(0); + let prevAngle = -999; + let prevTier = -1; + for (const idx of indexOrder) { + const ang = arcMids[idx]; + if (ang - prevAngle < minSepDeg) { + tier[idx] = (prevTier + 1) % maxTiers; + } else { + tier[idx] = 0; + } + prevAngle = ang; + prevTier = tier[idx]; + } + // wrap‑around + const firstIdx = indexOrder[0]; + const lastIdx = indexOrder[indexOrder.length - 1]; + const wrapGap = arcMids[firstIdx] + 360 - arcMids[lastIdx]; + if (wrapGap < minSepDeg && tier[firstIdx] === tier[lastIdx]) { + tier[firstIdx] = (tier[lastIdx] + 1) % maxTiers; + } + + // --- chord computation --- + const miRowSums = mutualInformation.map((row) => + row.reduce((a, b) => a + b, 0), + ); + + type Conn = { j: number; val: number }; + const nodeConns: Conn[][] = Array.from({ length: n }, () => []); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (i === j) continue; + const val = mutualInformation[i][j]; + if (val <= miThreshold) continue; + nodeConns[i].push({ j, val }); + } + const mid = arcMids[i]; + nodeConns[i].sort( + (a, b) => + ((mid - arcMids[a.j] + 360) % 360) - ((mid - arcMids[b.j] + 360) % 360), + ); + } + + const cursor = starts.slice(); + const allocated = new Map(); + for (let i = 0; i < n; i++) { + for (const { j, val } of nodeConns[i]) { + const span = miRowSums[i] > 0 ? (arcDegs[i] * val) / miRowSums[i] : 0; + allocated.set(`${i},${j}`, cursor[i] + span / 2); + cursor[i] += span; + } + } + + type Chord = { + i: number; + j: number; + val: number; + angleI: number; + angleJ: number; + }; + const chords: Chord[] = []; + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const keyIJ = `${i},${j}`; + const keyJI = `${j},${i}`; + if (!allocated.has(keyIJ)) continue; + chords.push({ + i, + j, + val: mutualInformation[i][j], + angleI: allocated.get(keyIJ)!, + angleJ: allocated.get(keyJI)!, + }); + } + } + // lightest first so darkest draws on top + chords.sort((a, b) => a.val - b.val); + + // --- hover: partition chords --- + const isHovering = hoveredIdx !== null; + const bgChords: Chord[] = []; + const fgChords: Chord[] = []; + const connectedSet = new Set(); + if (isHovering) { + for (const ch of chords) { + if (ch.i === hoveredIdx || ch.j === hoveredIdx) { + fgChords.push(ch); + connectedSet.add(ch.i); + connectedSet.add(ch.j); + } else { + bgChords.push(ch); + } + } + } + + // --- selected set --- + const selectedSet = new Set((selectedIndices ?? []).map(String)); + + // --- viewBox --- + const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; + const lim = radius + maxOffset; + // Map [-lim, lim] to [0, width/height] — compact legend area + const titleH = 50; // px reserved for title at top + const legendH = 180; // total height reserved for two colour bars + ticks + const diagramH = height - legendH - titleH; + const vbPad = lim * 0.04; + const vbSize = (lim + vbPad) * 2; + const scale = Math.min(width, diagramH) / vbSize; + + // Colour-bar dimensions (drawn inside the SVG, close to diagram) + const cbGap = 40; // px between diagram bottom and first bar + const cbY = titleH + diagramH + cbGap; + const cbW = width * 0.6; + const cbX = (width - cbW) / 2; + const cbH = 10; + const cbSpacing = 68; // vertical distance between the two bars (label + bar + ticks) + const numCbStops = 64; + const numTicks = 5; // tick count on each colour bar + + return ( + + {/* Title */} + {title && ( + + {title} + + )} + + {/* Diagram group — centred and scaled to fit */} + + {/* Chord lines — when hovering, split into dimmed background + bright foreground */} + {(isHovering ? bgChords : chords).map((ch, ci) => { + const c = colormapEval(CHORD_CMAP, ch.val / miMax); + const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); + const lw = lwPx / scale; + return ( + + ); + })} + {/* Highlighted chords for hovered orbital (drawn on top) */} + {fgChords.map((ch, ci) => { + const c = colormapEval(CHORD_CMAP, ch.val / miMax); + const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); + const lw = lwPx / scale; + return ( + + ); + })} + + {/* Arcs */} + {Array.from({ length: n }, (_, i) => ( + setHoveredIdx(i)} + onMouseLeave={() => setHoveredIdx(null)} + style={{ cursor: "pointer" }} + /> + ))} + + {/* Selection outlines */} + {Array.from({ length: n }, (_, i) => + selectedSet.has(labels[i]) ? ( + + ) : null, + )} + + {/* Labels & tick lines */} + {Array.from({ length: n }, (_, i) => { + const mid = arcMids[i]; + const t = tier[i]; + const offset = baseOffset + t * tierStep; + const [lx, ly] = deg2xy(mid, radius + offset); + const angle = mid % 360; + const ha = angle > 90 && angle < 270 ? "end" : "start"; + const rot = angle > 90 && angle < 270 ? angle - 180 : angle; + + const tickLine = + t > 0 + ? (() => { + const [rx, ry] = deg2xy(mid, radius + 0.01); + return ( + + ); + })() + : null; + + // Font size in SVG user units — we're in a scaled group so + // approximate by dividing the pt size by the scale factor. + const fsPx = labelFontSize / scale; + + // When hovering, replace the plain label with value info + const isThisHovered = hoveredIdx === i; + const isConnected = connectedSet.has(i); + let labelText = labels[i]; + let labelOpacity = 1; + if (isHovering) { + if (isThisHovered) { + labelText = `${labels[i]} S\u2081=${s1Entropies[i].toFixed(3)}`; + } else if (isConnected && hoveredIdx !== null) { + labelText = `${labels[i]} MI=${mutualInformation[hoveredIdx][i].toFixed(3)}`; + } else { + labelOpacity = 0.15; + } + } + + return ( + + {tickLine} + + {labelText} + + + ); + })} + + + {/* ---- Colour-bar legends ---- */} + {/* Arc (entropy) colour bar */} + + + Single-orbital entropy + + {Array.from({ length: numCbStops }, (_, k) => { + const t = k / (numCbStops - 1); + return ( + + ); + })} + {/* Ticks */} + {Array.from({ length: numTicks }, (_, k) => { + const frac = k / (numTicks - 1); + const xPos = cbX + cbW * frac; + const val = s1Max * frac; + return ( + + + + {val.toFixed(2)} + + + ); + })} + + + {/* Chord (MI) colour bar */} + + + Mutual information + + {Array.from({ length: numCbStops }, (_, k) => { + const t = k / (numCbStops - 1); + return ( + + ); + })} + {/* Ticks */} + {Array.from({ length: numTicks }, (_, k) => { + const frac = k / (numTicks - 1); + const xPos = cbX + cbW * frac; + const val = miMax * frac; + return ( + + + + {val.toFixed(2)} + + + ); + })} + + + ); +} diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index 6041bd5020..e6c4411b32 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -16,6 +16,7 @@ import { type ZoneLayout, type TraceData, MoleculeViewer, + OrbitalEntanglement, } from "qsharp-lang/ux"; import markdownIt from "markdown-it"; import "./widgets.css"; @@ -86,6 +87,9 @@ function render({ model, el }: RenderArgs) { case "MoleculeViewer": renderMoleculeViewer({ model, el }); break; + case "OrbitalEntanglement": + renderOrbitalEntanglement({ model, el }); + break; default: throw new Error(`Unknown component type ${componentType}`); } @@ -301,6 +305,66 @@ function renderAtoms({ model, el }: RenderArgs) { model.on("change:trace_data", onChange); } +function renderOrbitalEntanglement({ model, el }: RenderArgs) { + const onChange = () => { + const s1Entropies = model.get("s1_entropies") as number[]; + const mutualInformation = model.get( + "mutual_information", + ) as number[][]; + const labels = model.get("labels") as string[]; + const selectedIndices = model.get( + "selected_indices", + ) as number[] | null; + const options = (model.get("options") || {}) as Record; + + prender( + , + el, + ); + }; + + onChange(); + model.on("change:s1_entropies", onChange); + model.on("change:mutual_information", onChange); + model.on("change:labels", onChange); + model.on("change:selected_indices", onChange); + model.on("change:options", onChange); + + // Handle SVG export requests from Python + model.on("msg:custom", (msg: { type: string }) => { + if (msg.type === "export_svg") { + const svgEl = el.querySelector("svg"); + if (svgEl) { + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgEl); + model.send({ + type: "svg_data", + svg: svgString, + }); + } + } + }); +} + function renderMoleculeViewer({ model, el }: RenderArgs) { const onChange = () => { const moleculeData = model.get("molecule_data") as string; diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 6680273098..71a9702a84 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -224,6 +224,238 @@ def __init__(self, machine_layout, trace_data): super().__init__(machine_layout=machine_layout, trace_data=trace_data) +class OrbitalEntanglement(anywidget.AnyWidget): + _esm = pathlib.Path(__file__).parent / "static" / "index.js" + _css = pathlib.Path(__file__).parent / "static" / "index.css" + + comp = traitlets.Unicode("OrbitalEntanglement").tag(sync=True) + s1_entropies = traitlets.List().tag(sync=True) + mutual_information = traitlets.List().tag(sync=True) + labels = traitlets.List().tag(sync=True) + selected_indices = traitlets.List(allow_none=True, default_value=None).tag( + sync=True + ) + options = traitlets.Dict().tag(sync=True) + + _svg_data = None + _svg_event = None + + def __init__( + self, + wavefunction=None, + *, + s1_entropies=None, + mutual_information=None, + labels=None, + selected_indices=None, + **options, + ): + """ + Displays an orbital entanglement chord diagram. + + Can be constructed either from a ``Wavefunction`` object or from raw + entropy / mutual-information arrays. + + Parameters + ---------- + wavefunction : optional + A ``Wavefunction`` with single-orbital entropies and mutual + information. When provided, *s1_entropies* and + *mutual_information* are extracted automatically. + s1_entropies : list[float], optional + Single-orbital entropies (length *N*). Required when + *wavefunction* is not given. + mutual_information : list[list[float]], optional + N×N mutual-information matrix. Required when *wavefunction* + is not given. + labels : list[str], optional + Orbital labels. Defaults to ``["0", "1", …]``. + selected_indices : list[int], optional + Orbital indices to highlight. + **options + Forwarded to the JS component as visual knobs + (``gap_deg``, ``radius``, ``arc_width``, ``line_scale``, + ``mi_threshold``, ``s1_vmax``, ``mi_vmax``, ``title``, + ``width``, ``height``, ``selection_color``, + ``selection_linewidth``). + """ + if wavefunction is not None: + import numpy as np + + s1_entropies = np.asarray( + wavefunction.get_single_orbital_entropies() + ).tolist() + mutual_information = np.asarray( + wavefunction.get_mutual_information() + ).tolist() + n = len(s1_entropies) + if labels is None: + try: + orbitals = wavefunction.get_orbitals() + if orbitals.has_active_space(): + active_indices = orbitals.get_active_space_indices()[0] + labels = [str(idx) for idx in active_indices] + else: + labels = [str(i) for i in range(n)] + except (AttributeError, TypeError, IndexError): + labels = [str(i) for i in range(n)] + elif s1_entropies is None or mutual_information is None: + raise ValueError( + "Either 'wavefunction' or both 's1_entropies' and " + "'mutual_information' must be provided." + ) + + if labels is None: + labels = [str(i) for i in range(len(s1_entropies))] + + # Store data for Python-side SVG rendering + self._init_s1 = list(s1_entropies) + self._init_mi = [list(row) for row in mutual_information] + self._init_labels = list(labels) + self._init_selected = list(selected_indices) if selected_indices else [] + self._init_options = dict(options) + + super().__init__( + s1_entropies=s1_entropies, + mutual_information=mutual_information, + labels=labels, + selected_indices=selected_indices, + options=options, + ) + self.on_msg(self._handle_msg) + + def _handle_msg(self, widget, content, buffers): + if content.get("type") == "svg_data": + self._svg_data = content["svg"] + if self._svg_event is not None: + self._svg_event.set() + + def export_svg(self, path=None, timeout=5, dark_mode=False): + """Export the diagram as an SVG string or file. + + If the widget is displayed in a notebook, the front-end is asked to + serialise its live SVG. If that fails (or the widget was never + displayed), the same Preact component is rendered server-side via + Node.js — the output is identical to the interactive widget. + + Parameters + ---------- + path : str or Path, optional + When given the SVG is written to this file and the path is + returned. Otherwise the SVG markup string is returned. + timeout : float + Seconds to wait for the front-end round-trip before falling + back to server-side rendering. + dark_mode : bool + When ``True`` the exported SVG uses light text on a dark + background; when ``False`` (default) dark text on a + transparent background. + + Returns + ------- + str + SVG markup (when *path* is ``None``) or the file path. + """ + svg = None + + # Try the front-end round-trip first (only works when displayed) + try: + import threading + + self._svg_data = None + self._svg_event = threading.Event() + self.send({"type": "export_svg"}) + if self._svg_event.wait(timeout=timeout): + svg = self._svg_data + self._svg_event = None + except Exception: + pass + + if not isinstance(svg, str): + # Fall back to server-side rendering via Node.js + svg = _render_svg_node( + s1_entropies=self._init_s1, + mutual_information=self._init_mi, + labels=self._init_labels, + selected_indices=self._init_selected, + dark_mode=dark_mode, + **self._init_options, + ) + + if path is not None: + from pathlib import Path as _P + + _P(path).write_text(svg, encoding="utf-8") + return str(path) + return svg + + +# --------------------------------------------------------------------------- +# Server-side SVG rendering via Node.js (same Preact component as the widget) +# --------------------------------------------------------------------------- + +# Path to the Node SSR helper script bundled alongside the widget JS. +_RENDER_SVG_SCRIPT = pathlib.Path(__file__).parent / "static" / "render_svg.mjs" + + +def _snake_to_camel(name: str) -> str: + """Convert ``snake_case`` to ``camelCase``.""" + parts = name.split("_") + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + +def _render_svg_node( + s1_entropies, + mutual_information, + labels, + selected_indices=None, + dark_mode=False, + **options, +): + """Render the OrbitalEntanglement component server-side via Node.js. + + This calls the same compiled Preact component used by the interactive + widget, ensuring pixel-identical SVG output. + """ + import json + import shutil + import subprocess + + node = shutil.which("node") + if node is None: + raise RuntimeError( + "Node.js is required for server-side SVG rendering but " + "'node' was not found on the PATH." + ) + + # Build the props object with camelCase keys matching the TS interface + props: dict = { + "s1Entropies": s1_entropies, + "mutualInformation": mutual_information, + "labels": labels, + } + if selected_indices: + props["selectedIndices"] = selected_indices + props["darkMode"] = bool(dark_mode) + for key, val in options.items(): + props[_snake_to_camel(key)] = val + + result = subprocess.run( + [node, str(_RENDER_SVG_SCRIPT)], + input=json.dumps(props), + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + raise RuntimeError( + f"Node SSR render failed (exit {result.returncode}):\n" f"{result.stderr}" + ) + + return result.stdout + + class MoleculeViewer(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" _css = pathlib.Path(__file__).parent / "static" / "index.css" From a6353a72ccf950651434f7e45ef1fac4ac434661 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Thu, 26 Feb 2026 14:50:16 +0000 Subject: [PATCH 02/33] add svg export for histogram and circuit --- source/npm/qsharp/ux/histogram.tsx | 8 +- source/npm/qsharp/ux/orbitalEntanglement.tsx | 2 +- source/widgets/js/index.tsx | 12 +- source/widgets/js/render_svg.mjs | 221 ++++++++++++++++++ source/widgets/package.json | 2 +- source/widgets/src/qsharp_widgets/__init__.py | 141 ++++++++--- 6 files changed, 340 insertions(+), 46 deletions(-) create mode 100644 source/widgets/js/render_svg.mjs diff --git a/source/npm/qsharp/ux/histogram.tsx b/source/npm/qsharp/ux/histogram.tsx index d7504232bd..1eeb5912ab 100644 --- a/source/npm/qsharp/ux/histogram.tsx +++ b/source/npm/qsharp/ux/histogram.tsx @@ -439,13 +439,17 @@ export function Histogram(props: { menuClicked(item.category, row)} > {option} diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 8442b288be..1f216c6200 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -374,7 +374,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; // Map [-lim, lim] to [0, width/height] — compact legend area - const titleH = 50; // px reserved for title at top + const titleH = 50; // px reserved for title at top const legendH = 180; // total height reserved for two colour bars + ticks const diagramH = height - legendH - titleH; const vbPad = lim * 0.04; diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index e6c4411b32..0fdd460fa7 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -308,13 +308,9 @@ function renderAtoms({ model, el }: RenderArgs) { function renderOrbitalEntanglement({ model, el }: RenderArgs) { const onChange = () => { const s1Entropies = model.get("s1_entropies") as number[]; - const mutualInformation = model.get( - "mutual_information", - ) as number[][]; + const mutualInformation = model.get("mutual_information") as number[][]; const labels = model.get("labels") as string[]; - const selectedIndices = model.get( - "selected_indices", - ) as number[] | null; + const selectedIndices = model.get("selected_indices") as number[] | null; const options = (model.get("options") || {}) as Record; prender( @@ -334,9 +330,7 @@ function renderOrbitalEntanglement({ model, el }: RenderArgs) { width={options.width as number | undefined} height={options.height as number | undefined} selectionColor={options.selection_color as string | undefined} - selectionLinewidth={ - options.selection_linewidth as number | undefined - } + selectionLinewidth={options.selection_linewidth as number | undefined} />, el, ); diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs new file mode 100644 index 0000000000..a71cc07bb9 --- /dev/null +++ b/source/widgets/js/render_svg.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Server-side renderer for Q# visualisation components. +// Reads JSON from stdin, writes SVG/HTML to stdout. +// +// Input format: +// { "component": "OrbitalEntanglement" | "Histogram" | "Circuit", +// "props": { ... } } +// +// This file is bundled by esbuild into a self-contained script so that it +// works wherever Node.js is available — no sibling module imports needed. + +import { readFileSync } from "node:fs"; +import { h } from "preact"; +import renderToString from "preact-render-to-string"; +import { OrbitalEntanglement } from "../../npm/qsharp/ux/orbitalEntanglement.tsx"; +import { Histogram } from "../../npm/qsharp/ux/histogram.tsx"; +import { draw as drawCircuit } from "../../npm/qsharp/ux/circuit-vis/index.ts"; +import { toCircuitGroup } from "../../npm/qsharp/src/data-structures/legacyCircuitUpdate.ts"; + +// ---------- Embedded CSS for standalone SVGs ---------- +// The interactive widgets inherit CSS from the page's theme variables. +// For standalone SVG export we resolve those variables to concrete values. + +const HISTOGRAM_CSS_LIGHT = ` + .bar { fill: #8ab8ff; } + .bar-label { font-size: 3pt; fill: #000; text-anchor: end; pointer-events: none; } + .bar-label-ket { font-family: Consolas, "Menlo", monospace; font-variant-ligatures: none; } + .histo-label { font-size: 3.5pt; fill: #222; } + .hover-text { font-size: 3.5pt; fill: #222; text-anchor: middle; } +`; + +const HISTOGRAM_CSS_DARK = ` + .bar { fill: #4aa3ff; } + .bar-label { font-size: 3pt; fill: #fff; text-anchor: end; pointer-events: none; } + .bar-label-ket { font-family: Consolas, "Menlo", monospace; font-variant-ligatures: none; } + .histo-label { font-size: 3.5pt; fill: #eee; } + .hover-text { font-size: 3.5pt; fill: #eee; text-anchor: middle; } +`; + +const CIRCUIT_CSS_LIGHT = ` + line, circle, rect { stroke: #202020; stroke-width: 1; } + text { fill: #202020; dominant-baseline: middle; text-anchor: middle; + user-select: none; pointer-events: none; } + .qs-maintext { font-family: "KaTeX_Main", sans-serif; font-style: normal; } + .qs-mathtext { font-family: "KaTeX_Math", serif; } + .gate .qs-group-label { fill: #202020; text-anchor: start; } + .gate-unitary { fill: #333333; } + .gate text { fill: #ffffff; } + .control-line, .control-dot { fill: #202020; } + .oplus > line, .oplus > circle { fill: #ececf0; stroke: #202020; stroke-width: 2; } + .gate-measure { fill: #007acc; } + .qs-line-measure, .arc-measure { stroke: #ffffff; fill: none; stroke-width: 1; } + .gate-ket { fill: #007acc; } + text.ket-text { fill: #ffffff; stroke: none; } + rect.gate-swap { fill: transparent; stroke: transparent; } + .register-classical { stroke-width: 0.5; } + .qubit-wire { stroke: #202020; } + .qs-qubit-label { fill: #202020; } + .gate-collapse circle, .gate-expand circle { fill: white; stroke-width: 2px; stroke: black; } + .gate-collapse path, .gate-expand path { stroke-width: 4px; stroke: black; } + .classical-container { stroke-dasharray: 8, 8; fill-opacity: 0; } + .classically-controlled-btn circle { fill: #ececf0; stroke-width: 1; } + .classically-controlled-btn text { dominant-baseline: middle; text-anchor: middle; + stroke: none; font-family: "KaTeX_Main", sans-serif; fill: #202020; } +`; + +const CIRCUIT_CSS_DARK = ` + line, circle, rect { stroke: #d4d4d4; stroke-width: 1; } + text { fill: #d4d4d4; dominant-baseline: middle; text-anchor: middle; + user-select: none; pointer-events: none; } + .qs-maintext { font-family: "KaTeX_Main", sans-serif; font-style: normal; } + .qs-mathtext { font-family: "KaTeX_Math", serif; } + .gate .qs-group-label { fill: #d4d4d4; text-anchor: start; } + .gate-unitary { fill: #3c3c3c; } + .gate text { fill: #ffffff; } + .control-line, .control-dot { fill: #d4d4d4; } + .oplus > line, .oplus > circle { fill: #1e1e1e; stroke: #d4d4d4; stroke-width: 2; } + .gate-measure { fill: #0e639c; } + .qs-line-measure, .arc-measure { stroke: #ffffff; fill: none; stroke-width: 1; } + .gate-ket { fill: #0e639c; } + text.ket-text { fill: #ffffff; stroke: none; } + rect.gate-swap { fill: transparent; stroke: transparent; } + .register-classical { stroke-width: 0.5; } + .qubit-wire { stroke: #d4d4d4; } + .qs-qubit-label { fill: #d4d4d4; } + .gate-collapse circle, .gate-expand circle { fill: #1e1e1e; stroke-width: 2px; stroke: #d4d4d4; } + .gate-collapse path, .gate-expand path { stroke-width: 4px; stroke: #d4d4d4; } + .classical-container { stroke-dasharray: 8, 8; fill-opacity: 0; } + .classically-controlled-btn circle { fill: #1e1e1e; stroke-width: 1; } + .classically-controlled-btn text { dominant-baseline: middle; text-anchor: middle; + stroke: none; font-family: "KaTeX_Main", sans-serif; fill: #d4d4d4; } +`; + +/** Inject a `; + // Insert right after the opening tag + return svgString.replace(/>/, `>${styleBlock}`); +} + +const input = readFileSync(0, "utf-8"); // stdin +const { component, props } = JSON.parse(input); + +let output = ""; + +switch (component) { + // ---- OrbitalEntanglement (pure Preact SVG) ---- + case "OrbitalEntanglement": { + const vnode = h(OrbitalEntanglement, props); + output = renderToString(vnode); + break; + } + + // ---- Histogram (pure Preact SVG) ---- + case "Histogram": { + // The TS component expects `data` as a Map, but JSON gives us an object. + const dark = !!props.darkMode; + const histProps = { + ...props, + data: new Map(Object.entries(props.data)), + onFilter: () => {}, + }; + delete histProps.darkMode; + const vnode = h(Histogram, histProps); + let html = renderToString(vnode); + + // Strip any

...

shots header that precedes the SVG + html = html.replace(/^]*>[\s\S]*?<\/h4>/, ""); + + // Remove interactive elements that shouldn't appear in static export: + // - settings icon: ... + // - info icon: ... + // - dropdown menu: ... + // - help-info pane: ... (last one) + html = html.replace(/]*>[\s\S]*?<\/g>/g, ""); + html = html.replace(/]*>[\s\S]*?<\/g>/g, ""); + // The help-info is a wrapping nested elements – + // match from the last to the closing + html = html.replace(/]*>[\s\S]*?<\/g>\s*(?=<\/svg>)/, ""); + + // Add xmlns for standalone SVG and set a reasonable default render size + html = html.replace( + /]*>)/, + `$1` + ); + + output = html; + break; + } + + // ---- Circuit (imperative DOM via qviz) ---- + case "Circuit": { + // qviz.draw() needs a real DOM. Use jsdom. + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM(""); + const win = dom.window; + + // Patch globals so qviz helpers can call + // document.createElementNS, document.createElement, getComputedStyle, etc. + globalThis.document = win.document; + globalThis.window = win; + globalThis.getComputedStyle = win.getComputedStyle; + globalThis.DOMPoint = win.DOMPoint; + globalThis.performance = win.performance; + globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); + + const container = win.document.createElement("div"); + const circuitData = typeof props.circuit === "string" + ? JSON.parse(props.circuit) + : props.circuit; + + // Normalise any legacy/raw format into a proper CircuitGroup + const result = toCircuitGroup(circuitData); + if (!result.ok) { + process.stderr.write(`Circuit conversion error: ${result.error}\n`); + process.exit(1); + } + + drawCircuit(result.circuitGroup, container); + + // The rendered SVG is the first child with class "qviz" + const svg = container.querySelector("svg.qviz"); + if (svg) { + // Ensure the xmlns is present for standalone SVG files + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + // Inject embedded CSS so the SVG renders correctly standalone + const dark = !!props.darkMode; + const css = dark ? CIRCUIT_CSS_DARK : CIRCUIT_CSS_LIGHT; + const styleEl = dom.window.document.createElementNS( + "http://www.w3.org/2000/svg", "style" + ); + styleEl.textContent = css; + const defs = dom.window.document.createElementNS( + "http://www.w3.org/2000/svg", "defs" + ); + defs.appendChild(styleEl); + svg.insertBefore(defs, svg.firstChild); + output = svg.outerHTML; + } else { + // Fallback: return the full container HTML + output = container.innerHTML; + } + break; + } + + default: + process.stderr.write(`Unknown component: ${component}\n`); + process.exit(1); +} + +process.stdout.write(output); diff --git a/source/widgets/package.json b/source/widgets/package.json index 5926f1dced..0085a1e81d 100644 --- a/source/widgets/package.json +++ b/source/widgets/package.json @@ -1,7 +1,7 @@ { "scripts": { "dev": "npm run build -- --sourcemap=inline --watch", - "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static" + "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static && npx esbuild js/render_svg.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_svg.mjs --loader:.css=empty --external:jsdom" }, "devDependencies": {} } diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 71a9702a84..8bba191371 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -199,6 +199,46 @@ def run(self, entry_expr, shots): # Update the UI one last time to make sure we show the final results self._update_ui() + def export_svg(self, path=None, dark_mode=False): + """Export the histogram as an SVG string or file. + + The same Preact component used by the interactive widget is + rendered server-side via Node.js. + + Parameters + ---------- + path : str or Path, optional + When given the SVG is written to this file and the path is + returned. Otherwise the SVG markup string is returned. + dark_mode : bool + When ``True`` the exported SVG uses light text on a dark + background; when ``False`` (default) dark text on a + transparent background. + + Returns + ------- + str + SVG markup (when *path* is ``None``) or the file path. + """ + props = { + "data": dict(self.buckets), + "shotCount": self.shot_count, + "filter": "", + "shotsHeader": self.shot_header, + "labels": self.labels, + "items": self.items, + "sort": self.sort, + "darkMode": bool(dark_mode), + } + svg = _render_component_node("Histogram", props) + + if path is not None: + from pathlib import Path as _P + + _P(path).write_text(svg, encoding="utf-8") + return str(path) + return svg + class Circuit(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -211,6 +251,43 @@ def __init__(self, circuit): super().__init__(circuit_json=circuit.json()) self.layout.overflow = "visible scroll" + def export_svg(self, path=None, dark_mode=False): + """Export the circuit diagram as an SVG string or file. + + The same ``qviz`` renderer used by the interactive widget is + executed server-side via Node.js (with ``jsdom`` providing the + DOM). + + Parameters + ---------- + path : str or Path, optional + When given the SVG is written to this file and the path is + returned. Otherwise the SVG markup string is returned. + dark_mode : bool + When ``True`` the exported SVG uses light text on a dark + background; when ``False`` (default) dark text on a + transparent background. + + Returns + ------- + str + SVG markup (when *path* is ``None``) or the file path. + """ + import json + + props = { + "circuit": json.loads(self.circuit_json), + "darkMode": bool(dark_mode), + } + svg = _render_component_node("Circuit", props) + + if path is not None: + from pathlib import Path as _P + + _P(path).write_text(svg, encoding="utf-8") + return str(path) + return svg + class Atoms(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -373,14 +450,17 @@ def export_svg(self, path=None, timeout=5, dark_mode=False): if not isinstance(svg, str): # Fall back to server-side rendering via Node.js - svg = _render_svg_node( - s1_entropies=self._init_s1, - mutual_information=self._init_mi, - labels=self._init_labels, - selected_indices=self._init_selected, - dark_mode=dark_mode, - **self._init_options, - ) + props: dict = { + "s1Entropies": self._init_s1, + "mutualInformation": self._init_mi, + "labels": self._init_labels, + } + if self._init_selected: + props["selectedIndices"] = self._init_selected + props["darkMode"] = bool(dark_mode) + for key, val in self._init_options.items(): + props[_snake_to_camel(key)] = val + svg = _render_component_node("OrbitalEntanglement", props) if path is not None: from pathlib import Path as _P @@ -391,7 +471,7 @@ def export_svg(self, path=None, timeout=5, dark_mode=False): # --------------------------------------------------------------------------- -# Server-side SVG rendering via Node.js (same Preact component as the widget) +# Server-side SVG rendering via Node.js (same components as the widget) # --------------------------------------------------------------------------- # Path to the Node SSR helper script bundled alongside the widget JS. @@ -404,18 +484,22 @@ def _snake_to_camel(name: str) -> str: return parts[0] + "".join(p.capitalize() for p in parts[1:]) -def _render_svg_node( - s1_entropies, - mutual_information, - labels, - selected_indices=None, - dark_mode=False, - **options, -): - """Render the OrbitalEntanglement component server-side via Node.js. +def _render_component_node(component: str, props: dict) -> str: + """Render a component server-side via Node.js. + + Parameters + ---------- + component : str + Component name (``"OrbitalEntanglement"``, ``"Histogram"``, + ``"Circuit"``). + props : dict + Props dict that will be JSON-serialised and passed to the JS + component. - This calls the same compiled Preact component used by the interactive - widget, ensuring pixel-identical SVG output. + Returns + ------- + str + The rendered SVG / HTML markup. """ import json import shutil @@ -428,21 +512,11 @@ def _render_svg_node( "'node' was not found on the PATH." ) - # Build the props object with camelCase keys matching the TS interface - props: dict = { - "s1Entropies": s1_entropies, - "mutualInformation": mutual_information, - "labels": labels, - } - if selected_indices: - props["selectedIndices"] = selected_indices - props["darkMode"] = bool(dark_mode) - for key, val in options.items(): - props[_snake_to_camel(key)] = val + payload = json.dumps({"component": component, "props": props}) result = subprocess.run( [node, str(_RENDER_SVG_SCRIPT)], - input=json.dumps(props), + input=payload, capture_output=True, text=True, timeout=30, @@ -450,7 +524,8 @@ def _render_svg_node( if result.returncode != 0: raise RuntimeError( - f"Node SSR render failed (exit {result.returncode}):\n" f"{result.stderr}" + f"Node SSR render failed (exit {result.returncode}):\n" + f"{result.stderr}" ) return result.stdout From ed9db514317147ef60541afbdcce4ff6d5e0c92d Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Thu, 26 Feb 2026 14:52:39 +0000 Subject: [PATCH 03/33] linting --- source/widgets/js/render_svg.mjs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs index a71cc07bb9..1abc3de6f6 100644 --- a/source/widgets/js/render_svg.mjs +++ b/source/widgets/js/render_svg.mjs @@ -94,13 +94,6 @@ const CIRCUIT_CSS_DARK = ` stroke: none; font-family: "KaTeX_Main", sans-serif; fill: #d4d4d4; } `; -/** Inject a `; - // Insert right after the opening tag - return svgString.replace(/>/, `>${styleBlock}`); -} - const input = readFileSync(0, "utf-8"); // stdin const { component, props } = JSON.parse(input); From b693c7b17469ef2ebbc96d702368e043866b1639 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Thu, 26 Feb 2026 15:09:44 +0000 Subject: [PATCH 04/33] allow grouping of selected orbitals --- samples/notebooks/orbital_entanglement.ipynb | 1 + source/npm/qsharp/ux/orbitalEntanglement.tsx | 36 +++++++++++++++---- source/widgets/js/index.tsx | 1 + source/widgets/js/render_svg.mjs | 22 +++++++----- source/widgets/src/qsharp_widgets/__init__.py | 17 +++++++-- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index 0813b34098..ae391c0da3 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -136,6 +136,7 @@ " labels=[str(i) for i in range(N)],\n", " selected_indices=core_idx.tolist(),\n", " title=f\"Synthetic Orbital Entanglement — {N} orbitals ({N_CORE} core)\",\n", + " group_selected=True,\n", " gap_deg=0.6,\n", " arc_width=0.05,\n", " mi_threshold=0.01,\n", diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 1f216c6200..a188a82328 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -41,6 +41,11 @@ export interface OrbitalEntanglementProps { height?: number; selectionColor?: string; selectionLinewidth?: number; + /** + * When `true`, reorder arcs so that selected orbitals sit adjacent + * on the ring (labels still show the original orbital names). + */ + groupSelected?: boolean; /** * When `true` renders light text on a dark background; when `false` * renders dark text on a transparent background. Leave `undefined` @@ -186,6 +191,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { height = 660, selectionColor: selectionColorProp, selectionLinewidth = 1.2, + groupSelected = false, darkMode, } = props; @@ -253,10 +259,31 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { const gapTotal = gapDeg * n; const arcDegs = totals.map((t) => ((360 - gapTotal) * t) / grand); + // --- selected set --- + const selectedSet = new Set((selectedIndices ?? []).map(String)); + + // --- ring ordering (group selected orbitals together when requested) --- + const order: number[] = Array.from({ length: n }, (_, i) => i); + if (groupSelected && selectedIndices && selectedIndices.length > 0) { + const sel: number[] = []; + const unsel: number[] = []; + for (let i = 0; i < n; i++) { + if (selectedSet.has(labels[i])) { + sel.push(i); + } else { + unsel.push(i); + } + } + order.length = 0; + order.push(...sel, ...unsel); + } + const starts: number[] = new Array(n); - starts[0] = 0; - for (let i = 1; i < n; i++) { - starts[i] = starts[i - 1] + arcDegs[i - 1] + gapDeg; + starts[order[0]] = 0; + for (let p = 1; p < n; p++) { + const prev = order[p - 1]; + const curr = order[p]; + starts[curr] = starts[prev] + arcDegs[prev] + gapDeg; } const arcMids = starts.map((s, i) => s + arcDegs[i] / 2); @@ -367,9 +394,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { } } - // --- selected set --- - const selectedSet = new Set((selectedIndices ?? []).map(String)); - // --- viewBox --- const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index 0fdd460fa7..06000629ab 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -331,6 +331,7 @@ function renderOrbitalEntanglement({ model, el }: RenderArgs) { height={options.height as number | undefined} selectionColor={options.selection_color as string | undefined} selectionLinewidth={options.selection_linewidth as number | undefined} + groupSelected={options.group_selected as boolean | undefined} />, el, ); diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs index 1abc3de6f6..5b859fc6f3 100644 --- a/source/widgets/js/render_svg.mjs +++ b/source/widgets/js/render_svg.mjs @@ -132,19 +132,22 @@ switch (component) { html = html.replace(/]*>[\s\S]*?<\/g>/g, ""); // The help-info is a wrapping nested elements – // match from the last to the closing - html = html.replace(/]*>[\s\S]*?<\/g>\s*(?=<\/svg>)/, ""); + html = html.replace( + /]*>[\s\S]*?<\/g>\s*(?=<\/svg>)/, + "", + ); // Add xmlns for standalone SVG and set a reasonable default render size html = html.replace( /]*>)/, - `$1` + `$1`, ); output = html; @@ -168,9 +171,10 @@ switch (component) { globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); const container = win.document.createElement("div"); - const circuitData = typeof props.circuit === "string" - ? JSON.parse(props.circuit) - : props.circuit; + const circuitData = + typeof props.circuit === "string" + ? JSON.parse(props.circuit) + : props.circuit; // Normalise any legacy/raw format into a proper CircuitGroup const result = toCircuitGroup(circuitData); @@ -190,11 +194,13 @@ switch (component) { const dark = !!props.darkMode; const css = dark ? CIRCUIT_CSS_DARK : CIRCUIT_CSS_LIGHT; const styleEl = dom.window.document.createElementNS( - "http://www.w3.org/2000/svg", "style" + "http://www.w3.org/2000/svg", + "style", ); styleEl.textContent = css; const defs = dom.window.document.createElementNS( - "http://www.w3.org/2000/svg", "defs" + "http://www.w3.org/2000/svg", + "defs", ); defs.appendChild(styleEl); svg.insertBefore(defs, svg.firstChild); diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 8bba191371..e7b724ee16 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -325,6 +325,7 @@ def __init__( mutual_information=None, labels=None, selected_indices=None, + group_selected=False, **options, ): """ @@ -349,6 +350,10 @@ def __init__( Orbital labels. Defaults to ``["0", "1", …]``. selected_indices : list[int], optional Orbital indices to highlight. + group_selected : bool, optional + When ``True``, reorder arcs so that selected orbitals sit + adjacent on the ring (labels still show the original orbital + names). Defaults to ``False``. **options Forwarded to the JS component as visual knobs (``gap_deg``, ``radius``, ``arc_width``, ``line_scale``, @@ -390,14 +395,19 @@ def __init__( self._init_mi = [list(row) for row in mutual_information] self._init_labels = list(labels) self._init_selected = list(selected_indices) if selected_indices else [] + self._init_group_selected = bool(group_selected) self._init_options = dict(options) + # Merge group_selected into the options dict so the JS widget sees it + opts_with_group = dict(options) + opts_with_group["group_selected"] = bool(group_selected) + super().__init__( s1_entropies=s1_entropies, mutual_information=mutual_information, labels=labels, selected_indices=selected_indices, - options=options, + options=opts_with_group, ) self.on_msg(self._handle_msg) @@ -457,6 +467,8 @@ def export_svg(self, path=None, timeout=5, dark_mode=False): } if self._init_selected: props["selectedIndices"] = self._init_selected + if self._init_group_selected: + props["groupSelected"] = True props["darkMode"] = bool(dark_mode) for key, val in self._init_options.items(): props[_snake_to_camel(key)] = val @@ -524,8 +536,7 @@ def _render_component_node(component: str, props: dict) -> str: if result.returncode != 0: raise RuntimeError( - f"Node SSR render failed (exit {result.returncode}):\n" - f"{result.stderr}" + f"Node SSR render failed (exit {result.returncode}):\n" f"{result.stderr}" ) return result.stdout From 869d14cdb200ca73573691fb446d985c98001226 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Fri, 27 Feb 2026 12:57:20 +0000 Subject: [PATCH 05/33] resolve comments --- source/widgets/js/render_svg.mjs | 191 ++---- source/widgets/js/svgDomShim.mjs | 580 ++++++++++++++++++ source/widgets/package.json | 2 +- source/widgets/src/qsharp_widgets/__init__.py | 25 +- 4 files changed, 651 insertions(+), 147 deletions(-) create mode 100644 source/widgets/js/svgDomShim.mjs diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs index 5b859fc6f3..d186ae13b4 100644 --- a/source/widgets/js/render_svg.mjs +++ b/source/widgets/js/render_svg.mjs @@ -19,80 +19,17 @@ import { OrbitalEntanglement } from "../../npm/qsharp/ux/orbitalEntanglement.tsx import { Histogram } from "../../npm/qsharp/ux/histogram.tsx"; import { draw as drawCircuit } from "../../npm/qsharp/ux/circuit-vis/index.ts"; import { toCircuitGroup } from "../../npm/qsharp/src/data-structures/legacyCircuitUpdate.ts"; - -// ---------- Embedded CSS for standalone SVGs ---------- -// The interactive widgets inherit CSS from the page's theme variables. -// For standalone SVG export we resolve those variables to concrete values. - -const HISTOGRAM_CSS_LIGHT = ` - .bar { fill: #8ab8ff; } - .bar-label { font-size: 3pt; fill: #000; text-anchor: end; pointer-events: none; } - .bar-label-ket { font-family: Consolas, "Menlo", monospace; font-variant-ligatures: none; } - .histo-label { font-size: 3.5pt; fill: #222; } - .hover-text { font-size: 3.5pt; fill: #222; text-anchor: middle; } -`; - -const HISTOGRAM_CSS_DARK = ` - .bar { fill: #4aa3ff; } - .bar-label { font-size: 3pt; fill: #fff; text-anchor: end; pointer-events: none; } - .bar-label-ket { font-family: Consolas, "Menlo", monospace; font-variant-ligatures: none; } - .histo-label { font-size: 3.5pt; fill: #eee; } - .hover-text { font-size: 3.5pt; fill: #eee; text-anchor: middle; } -`; - -const CIRCUIT_CSS_LIGHT = ` - line, circle, rect { stroke: #202020; stroke-width: 1; } - text { fill: #202020; dominant-baseline: middle; text-anchor: middle; - user-select: none; pointer-events: none; } - .qs-maintext { font-family: "KaTeX_Main", sans-serif; font-style: normal; } - .qs-mathtext { font-family: "KaTeX_Math", serif; } - .gate .qs-group-label { fill: #202020; text-anchor: start; } - .gate-unitary { fill: #333333; } - .gate text { fill: #ffffff; } - .control-line, .control-dot { fill: #202020; } - .oplus > line, .oplus > circle { fill: #ececf0; stroke: #202020; stroke-width: 2; } - .gate-measure { fill: #007acc; } - .qs-line-measure, .arc-measure { stroke: #ffffff; fill: none; stroke-width: 1; } - .gate-ket { fill: #007acc; } - text.ket-text { fill: #ffffff; stroke: none; } - rect.gate-swap { fill: transparent; stroke: transparent; } - .register-classical { stroke-width: 0.5; } - .qubit-wire { stroke: #202020; } - .qs-qubit-label { fill: #202020; } - .gate-collapse circle, .gate-expand circle { fill: white; stroke-width: 2px; stroke: black; } - .gate-collapse path, .gate-expand path { stroke-width: 4px; stroke: black; } - .classical-container { stroke-dasharray: 8, 8; fill-opacity: 0; } - .classically-controlled-btn circle { fill: #ececf0; stroke-width: 1; } - .classically-controlled-btn text { dominant-baseline: middle; text-anchor: middle; - stroke: none; font-family: "KaTeX_Main", sans-serif; fill: #202020; } -`; - -const CIRCUIT_CSS_DARK = ` - line, circle, rect { stroke: #d4d4d4; stroke-width: 1; } - text { fill: #d4d4d4; dominant-baseline: middle; text-anchor: middle; - user-select: none; pointer-events: none; } - .qs-maintext { font-family: "KaTeX_Main", sans-serif; font-style: normal; } - .qs-mathtext { font-family: "KaTeX_Math", serif; } - .gate .qs-group-label { fill: #d4d4d4; text-anchor: start; } - .gate-unitary { fill: #3c3c3c; } - .gate text { fill: #ffffff; } - .control-line, .control-dot { fill: #d4d4d4; } - .oplus > line, .oplus > circle { fill: #1e1e1e; stroke: #d4d4d4; stroke-width: 2; } - .gate-measure { fill: #0e639c; } - .qs-line-measure, .arc-measure { stroke: #ffffff; fill: none; stroke-width: 1; } - .gate-ket { fill: #0e639c; } - text.ket-text { fill: #ffffff; stroke: none; } - rect.gate-swap { fill: transparent; stroke: transparent; } - .register-classical { stroke-width: 0.5; } - .qubit-wire { stroke: #d4d4d4; } - .qs-qubit-label { fill: #d4d4d4; } - .gate-collapse circle, .gate-expand circle { fill: #1e1e1e; stroke-width: 2px; stroke: #d4d4d4; } - .gate-collapse path, .gate-expand path { stroke-width: 4px; stroke: #d4d4d4; } - .classical-container { stroke-dasharray: 8, 8; fill-opacity: 0; } - .classically-controlled-btn circle { fill: #1e1e1e; stroke-width: 1; } - .classically-controlled-btn text { dominant-baseline: middle; text-anchor: middle; - stroke: none; font-family: "KaTeX_Main", sans-serif; fill: #d4d4d4; } -`; +import { installSvgDomShim } from "./svgDomShim.mjs"; + +// ---------- Canonical CSS for standalone SVGs ---------- +// Import the same CSS files used by the interactive widgets. In a +// standalone SVG the :root selector matches the element, so +// CSS var() fallback chains resolve to their concrete light-mode values +// (e.g. var(--vscode-editor-foreground, var(--jp-widgets-color, #202020)) +// → #202020) because none of the host-specific custom properties exist. +import themeCss from "../../npm/qsharp/ux/qdk-theme.css"; +import uxCss from "../../npm/qsharp/ux/qsharp-ux.css"; +import circuitCss from "../../npm/qsharp/ux/qsharp-circuit.css"; const input = readFileSync(0, "utf-8"); // stdin const { component, props } = JSON.parse(input); @@ -110,13 +47,11 @@ switch (component) { // ---- Histogram (pure Preact SVG) ---- case "Histogram": { // The TS component expects `data` as a Map, but JSON gives us an object. - const dark = !!props.darkMode; const histProps = { ...props, data: new Map(Object.entries(props.data)), onFilter: () => {}, }; - delete histProps.darkMode; const vnode = h(Histogram, histProps); let html = renderToString(vnode); @@ -143,11 +78,10 @@ switch (component) { ']*>)/, - `$1`, + `$1`, ); output = html; @@ -156,58 +90,53 @@ switch (component) { // ---- Circuit (imperative DOM via qviz) ---- case "Circuit": { - // qviz.draw() needs a real DOM. Use jsdom. - const { JSDOM } = await import("jsdom"); - const dom = new JSDOM(""); - const win = dom.window; - - // Patch globals so qviz helpers can call - // document.createElementNS, document.createElement, getComputedStyle, etc. - globalThis.document = win.document; - globalThis.window = win; - globalThis.getComputedStyle = win.getComputedStyle; - globalThis.DOMPoint = win.DOMPoint; - globalThis.performance = win.performance; - globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); - - const container = win.document.createElement("div"); - const circuitData = - typeof props.circuit === "string" - ? JSON.parse(props.circuit) - : props.circuit; - - // Normalise any legacy/raw format into a proper CircuitGroup - const result = toCircuitGroup(circuitData); - if (!result.ok) { - process.stderr.write(`Circuit conversion error: ${result.error}\n`); - process.exit(1); - } - - drawCircuit(result.circuitGroup, container); - - // The rendered SVG is the first child with class "qviz" - const svg = container.querySelector("svg.qviz"); - if (svg) { - // Ensure the xmlns is present for standalone SVG files - svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - // Inject embedded CSS so the SVG renders correctly standalone - const dark = !!props.darkMode; - const css = dark ? CIRCUIT_CSS_DARK : CIRCUIT_CSS_LIGHT; - const styleEl = dom.window.document.createElementNS( - "http://www.w3.org/2000/svg", - "style", - ); - styleEl.textContent = css; - const defs = dom.window.document.createElementNS( - "http://www.w3.org/2000/svg", - "defs", - ); - defs.appendChild(styleEl); - svg.insertBefore(defs, svg.firstChild); - output = svg.outerHTML; - } else { - // Fallback: return the full container HTML - output = container.innerHTML; + // qviz.draw() uses the DOM API internally. Install a minimal SVG DOM + // shim so that it works without jsdom or any external dependency. + const restore = installSvgDomShim(); + + try { + const container = document.createElement("div"); + const circuitData = + typeof props.circuit === "string" + ? JSON.parse(props.circuit) + : props.circuit; + + // Normalise any legacy/raw format into a proper CircuitGroup + const result = toCircuitGroup(circuitData); + if (!result.ok) { + process.stderr.write(`Circuit conversion error: ${result.error}\n`); + process.exit(1); + } + + drawCircuit(result.circuitGroup, container); + + // The rendered SVG is the first child with class "qviz" + const svg = container.querySelector("svg.qviz"); + if (svg) { + // Ensure the xmlns is present for standalone SVG files + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + // Add qs-circuit class so the nested circuit CSS selectors match + const existing = svg.getAttribute("class") || ""; + svg.setAttribute("class", `qs-circuit ${existing}`.trim()); + // Inject canonical CSS so the SVG renders correctly standalone + const styleEl = document.createElementNS( + "http://www.w3.org/2000/svg", + "style", + ); + styleEl.textContent = `${themeCss}\n${uxCss}\n${circuitCss}`; + const defs = document.createElementNS( + "http://www.w3.org/2000/svg", + "defs", + ); + defs.appendChild(styleEl); + svg.insertBefore(defs, svg.firstChild); + output = svg.outerHTML; + } else { + // Fallback: return the full container HTML + output = container.innerHTML; + } + } finally { + restore(); } break; } diff --git a/source/widgets/js/svgDomShim.mjs b/source/widgets/js/svgDomShim.mjs new file mode 100644 index 0000000000..bead5adfcb --- /dev/null +++ b/source/widgets/js/svgDomShim.mjs @@ -0,0 +1,580 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Minimal SVG DOM shim for server-side circuit rendering. + * + * Implements just enough of the DOM API so that the circuit-vis rendering + * path (formatUtils → gateFormatter/inputFormatter/registerFormatter → sqore) + * can build an SVG element tree and serialise it to markup via `outerHTML`. + * + * This eliminates the runtime dependency on jsdom, which is large and not + * typically available in user environments without explicit installation. + */ + +// Characters that need escaping in XML text content / attribute values. +const XML_ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """ }; +const xmlEscape = (s) => s.replace(/[&<>"]/g, (c) => XML_ESCAPE_MAP[c]); + +// -------------------------------------------------------------------------- +// ShimStyleDeclaration – trivial style property bag +// -------------------------------------------------------------------------- + +class ShimStyleDeclaration { + constructor() { + /** @type {Map} */ + this._props = new Map(); + } + + setProperty(name, value) { + this._props.set(name, value); + } + + getPropertyValue(name) { + return this._props.get(name) ?? ""; + } + + // Allow direct property assignment (e.g. el.style.pointerEvents = "none"). + // We convert camelCase to kebab-case for the serialized style attribute. + get pointerEvents() { + return this.getPropertyValue("pointer-events"); + } + set pointerEvents(v) { + this.setProperty("pointer-events", v); + } + + get maxWidth() { + return this.getPropertyValue("max-width"); + } + set maxWidth(v) { + this.setProperty("max-width", v); + } + + toString() { + const parts = []; + for (const [k, v] of this._props) { + parts.push(`${k}: ${v}`); + } + return parts.join("; "); + } +} + +// -------------------------------------------------------------------------- +// ShimClassList – minimal classList implementation +// -------------------------------------------------------------------------- + +class ShimClassList { + /** @param {ShimElement} owner */ + constructor(owner) { + this._owner = owner; + /** @type {Set} */ + this._set = new Set(); + } + + add(...names) { + for (const n of names) this._set.add(n); + this._sync(); + } + + remove(...names) { + for (const n of names) this._set.delete(n); + this._sync(); + } + + contains(name) { + return this._set.has(name); + } + + _sync() { + if (this._set.size > 0) { + this._owner._attributes.set("class", [...this._set].join(" ")); + } else { + this._owner._attributes.delete("class"); + } + } + + /** Re-populate from the class attribute string. */ + _fromAttr(val) { + this._set.clear(); + if (val) { + for (const c of val.split(/\s+/)) { + if (c) this._set.add(c); + } + } + } +} + +// -------------------------------------------------------------------------- +// ShimNode – base class for TextNode and Element +// -------------------------------------------------------------------------- + +class ShimNode { + constructor(nodeType) { + this.nodeType = nodeType; + this.parentNode = null; + this.parentElement = null; + } +} + +// -------------------------------------------------------------------------- +// ShimTextNode +// -------------------------------------------------------------------------- + +class ShimTextNode extends ShimNode { + constructor(text) { + super(3 /* TEXT_NODE */); + this._text = text; + } + + get textContent() { + return this._text; + } + set textContent(v) { + this._text = v; + } + + /** Serialise to XML-safe text. */ + get outerHTML() { + return xmlEscape(this._text); + } +} + +// -------------------------------------------------------------------------- +// ShimElement – the core of the shim +// -------------------------------------------------------------------------- + +class ShimElement extends ShimNode { + constructor(namespaceURI, tagName) { + super(1 /* ELEMENT_NODE */); + this.namespaceURI = namespaceURI; + this.tagName = tagName; + /** @type {Map} */ + this._attributes = new Map(); + /** @type {ShimNode[]} */ + this._children = []; + this.style = new ShimStyleDeclaration(); + this.classList = new ShimClassList(this); + } + + // --- Attribute methods --- + + setAttribute(name, value) { + this._attributes.set(name, String(value)); + if (name === "class") this.classList._fromAttr(String(value)); + } + + getAttribute(name) { + return this._attributes.get(name) ?? null; + } + + removeAttribute(name) { + this._attributes.delete(name); + } + + // --- Child methods --- + + appendChild(child) { + if (child.parentNode) { + child.parentNode.removeChild(child); + } + child.parentNode = this; + child.parentElement = this; + this._children.push(child); + return child; + } + + removeChild(child) { + const idx = this._children.indexOf(child); + if (idx !== -1) { + this._children.splice(idx, 1); + child.parentNode = null; + child.parentElement = null; + } + return child; + } + + replaceChild(newChild, oldChild) { + const idx = this._children.indexOf(oldChild); + if (idx !== -1) { + if (newChild.parentNode) newChild.parentNode.removeChild(newChild); + oldChild.parentNode = null; + oldChild.parentElement = null; + newChild.parentNode = this; + newChild.parentElement = this; + this._children[idx] = newChild; + } + return oldChild; + } + + insertBefore(newChild, refChild) { + if (newChild.parentNode) newChild.parentNode.removeChild(newChild); + const idx = refChild ? this._children.indexOf(refChild) : -1; + newChild.parentNode = this; + newChild.parentElement = this; + if (idx === -1) { + this._children.push(newChild); + } else { + this._children.splice(idx, 0, newChild); + } + return newChild; + } + + get firstChild() { + return this._children[0] ?? null; + } + + get children() { + return this._children.filter((c) => c.nodeType === 1); + } + + get childNodes() { + return this._children; + } + + // --- textContent --- + + get textContent() { + return this._children + .map((c) => c.textContent ?? "") + .join(""); + } + + set textContent(val) { + // Remove all existing children + for (const c of this._children) { + c.parentNode = null; + c.parentElement = null; + } + this._children = []; + if (val) { + this._children.push(new ShimTextNode(val)); + } + } + + // --- innerHTML (set only – needed by gateFormatter + inputFormatter) --- + + set innerHTML(markup) { + // Remove existing children + for (const c of this._children) { + c.parentNode = null; + c.parentElement = null; + } + this._children = []; + // Parse the simple SVG/XML fragments used by the circuit-vis code. + parseFragmentInto(this, markup); + } + + get innerHTML() { + return this._children.map((c) => c.outerHTML).join(""); + } + + // --- querySelector (basic: supports "tagname" and "tagname.class") --- + + querySelector(selector) { + return this._querySelectorOne(selector); + } + + /** @returns {ShimElement | null} */ + _querySelectorOne(selector) { + for (const child of this._children) { + if (child.nodeType !== 1) continue; + if (_matchesSelector(child, selector)) return child; + const found = child._querySelectorOne(selector); + if (found) return found; + } + return null; + } + + querySelectorAll(selector) { + const results = []; + this._querySelectorAll(selector, results); + return results; + } + + _querySelectorAll(selector, results) { + for (const child of this._children) { + if (child.nodeType !== 1) continue; + if (_matchesSelector(child, selector)) results.push(child); + child._querySelectorAll(selector, results); + } + } + + // --- addEventListener (no-op for SSR) --- + addEventListener() {} + removeEventListener() {} + + // --- Serialisation --- + + get outerHTML() { + const attrs = []; + for (const [k, v] of this._attributes) { + attrs.push(` ${k}="${xmlEscape(v)}"`); + } + // Merge style into attributes if non-empty + const styleStr = this.style.toString(); + if (styleStr) { + attrs.push(` style="${xmlEscape(styleStr)}"`); + } + + const attrStr = attrs.join(""); + + if (this._children.length === 0) { + return `<${this.tagName}${attrStr}/>`; + } + + // For `, - ); - - output = html; - break; - } - - // ---- Circuit (imperative DOM via qviz) ---- - case "Circuit": { - // qviz.draw() uses the DOM API internally. Install a minimal SVG DOM - // shim so that it works without jsdom or any external dependency. - const restore = installSvgDomShim(); - - try { - const container = document.createElement("div"); - const circuitData = - typeof props.circuit === "string" - ? JSON.parse(props.circuit) - : props.circuit; - - // Normalise any legacy/raw format into a proper CircuitGroup - const result = toCircuitGroup(circuitData); - if (!result.ok) { - process.stderr.write(`Circuit conversion error: ${result.error}\n`); - process.exit(1); - } - - drawCircuit(result.circuitGroup, container); - - // The rendered SVG is the first child with class "qviz" - const svg = container.querySelector("svg.qviz"); - if (svg) { - // Ensure the xmlns is present for standalone SVG files - svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - // Add qs-circuit class so the nested circuit CSS selectors match - const existing = svg.getAttribute("class") || ""; - svg.setAttribute("class", `qs-circuit ${existing}`.trim()); - // Inject canonical CSS so the SVG renders correctly standalone - const styleEl = document.createElementNS( - "http://www.w3.org/2000/svg", - "style", - ); - styleEl.textContent = `${themeCss}\n${uxCss}\n${circuitCss}`; - const defs = document.createElementNS( - "http://www.w3.org/2000/svg", - "defs", - ); - defs.appendChild(styleEl); - svg.insertBefore(defs, svg.firstChild); - output = svg.outerHTML; - } else { - // Fallback: return the full container HTML - output = container.innerHTML; - } - } finally { - restore(); - } + output = histogramToSvg(histProps); break; } diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 45b172b26f..00d1bb1608 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -120,8 +120,6 @@ class Histogram(anywidget.AnyWidget): labels = traitlets.Unicode("raw").tag(sync=True) items = traitlets.Unicode("all").tag(sync=True) sort = traitlets.Unicode("a-to-z").tag(sync=True) - # Automatically populated by the front-end MutationObserver. - _live_svg = traitlets.Unicode("").tag(sync=True) def _update_ui(self): self.buckets = self._new_buckets.copy() @@ -201,38 +199,48 @@ def run(self, entry_expr, shots): # Update the UI one last time to make sure we show the final results self._update_ui() - def export_svg(self, path=None): - """Export the histogram as an SVG string or file. + def _build_svg_props(self, dark_mode=False): + """Build the props dict for SVG rendering.""" + return { + "data": dict(self.buckets), + "shotCount": self.shot_count, + "filter": "", + "labels": self.labels, + "items": self.items, + "sort": self.sort, + "darkMode": bool(dark_mode), + } - If the widget has been displayed in a notebook, the cached live - SVG is returned — this captures any interactive changes the user - made (sorting, labels, item filters). Otherwise the same Preact - component is rendered server-side via Node.js using the current - traitlet values. + def export_svg(self, path=None, dark_mode=False): + """Render the histogram to a standalone SVG. + + When the widget is displayed in a notebook the SVG is rendered + in-browser by the same ``histogramToSvg`` function used by + the Node.js SSR script. When the widget is not connected the + function falls back to spawning Node.js. + + The traitlets (including interactive state like labels/items/sort + which the front-end syncs back) are read at call time, so exports + always reflect the latest user changes. Parameters ---------- path : str or Path, optional When given the SVG is written to this file and the path is returned. Otherwise the SVG markup string is returned. + dark_mode : bool + When ``True`` the exported SVG uses light text on a dark + background; when ``False`` (default) dark text on a light + background. Returns ------- str SVG markup (when *path* is ``None``) or the file path. """ - svg = self._live_svg if self._live_svg else None - - if not isinstance(svg, str): - props = { - "data": dict(self.buckets), - "shotCount": self.shot_count, - "filter": "", - "shotsHeader": self.shot_header, - "labels": self.labels, - "items": self.items, - "sort": self.sort, - } + svg = self._export_svg_via_widget(dark_mode) + if svg is None: + props = self._build_svg_props(dark_mode) svg = _render_component_node("Histogram", props) if path is not None: @@ -242,6 +250,37 @@ def export_svg(self, path=None): return str(path) return svg + def _export_svg_via_widget(self, dark_mode=False): + """Try to render SVG in-browser via the live widget front-end. + + Sends a custom message asking the JS side to call + ``histogramToSvg()`` and waits for the response. Returns + ``None`` if the widget is not connected. + """ + import threading + + result = [None] + event = threading.Event() + + def _on_msg(_, content, buffers): + if isinstance(content, dict) and content.get("type") == "svg_result": + result[0] = content.get("svg") + event.set() + + try: + self.on_msg(_on_msg) + self.send({"type": "export_svg", "dark_mode": bool(dark_mode)}) + if event.wait(timeout=5): + return result[0] + except Exception: + pass + finally: + try: + self.on_msg(_on_msg, remove=True) + except Exception: + pass + return None + class Circuit(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -249,50 +288,11 @@ class Circuit(anywidget.AnyWidget): comp = traitlets.Unicode("Circuit").tag(sync=True) circuit_json = traitlets.Unicode().tag(sync=True) - # Automatically populated by the front-end MutationObserver whenever - # the circuit SVG re-renders (e.g. after expand/collapse). - _live_svg = traitlets.Unicode("").tag(sync=True) def __init__(self, circuit): super().__init__(circuit_json=circuit.json()) self.layout.overflow = "visible scroll" - def export_svg(self, path=None): - """Export the circuit diagram as an SVG string or file. - - If the widget has been displayed in a notebook, the cached live - SVG is returned — this captures any interactive changes the user - made (expanded/collapsed blocks). Otherwise the same ``qviz`` - renderer is executed server-side via Node.js. - - Parameters - ---------- - path : str or Path, optional - When given the SVG is written to this file and the path is - returned. Otherwise the SVG markup string is returned. - - Returns - ------- - str - SVG markup (when *path* is ``None``) or the file path. - """ - import json - - svg = self._live_svg if self._live_svg else None - - if not isinstance(svg, str): - props = { - "circuit": json.loads(self.circuit_json), - } - svg = _render_component_node("Circuit", props) - - if path is not None: - from pathlib import Path as _P - - _P(path).write_text(svg, encoding="utf-8") - return str(path) - return svg - class Atoms(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -310,9 +310,15 @@ class ChordDiagram(anywidget.AnyWidget): """General-purpose chord diagram widget. Displays per-node scalar values as coloured arcs and pairwise weights - as chords. This is the generic base; ``OrbitalEntanglement`` is a - convenience subclass that maps orbital-specific terminology onto - these general parameters. + as chords. ``OrbitalEntanglement`` is a convenience subclass that + maps orbital-specific terminology onto these general parameters. + + The component renders self-contained SVG with inline styles, so the + same ``export_svg()`` code path (server-side ``renderToString``) + works identically whether or not a live DOM is available. Interactive + state (e.g. the grouping toggle) is synced back to the ``options`` + traitlet by the JS front-end so exports always reflect the latest + user changes. """ _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -326,8 +332,6 @@ class ChordDiagram(anywidget.AnyWidget): sync=True ) options = traitlets.Dict().tag(sync=True) - # Automatically populated by the front-end MutationObserver. - _live_svg = traitlets.Unicode("").tag(sync=True) def __init__( self, @@ -360,6 +364,7 @@ def __init__( ``edge_threshold``, ``node_vmax``, ``edge_vmax``, ``node_colormap``, ``edge_colormap``, ``node_colorbar_label``, ``edge_colorbar_label``, + ``node_hover_prefix``, ``edge_hover_prefix``, ``title``, ``width``, ``height``, ``selection_color``, ``selection_linewidth``). """ @@ -367,27 +372,43 @@ def __init__( if labels is None: labels = [str(i) for i in range(n)] - # Store for Python-side SVG rendering fallback - self._init_node_values = list(node_values) - self._init_pairwise_weights = [list(row) for row in pairwise_weights] - self._init_labels = list(labels) - self._init_selected = list(selected_indices) if selected_indices else [] - self._init_group_selected = bool(group_selected) - self._init_options = dict(options) - - opts_with_group = dict(options) - opts_with_group["group_selected"] = bool(group_selected) + opts = dict(options) + opts["group_selected"] = bool(group_selected) super().__init__( - node_values=node_values, - pairwise_weights=pairwise_weights, - labels=labels, - selected_indices=selected_indices, - options=opts_with_group, + node_values=list(node_values), + pairwise_weights=[list(row) for row in pairwise_weights], + labels=list(labels), + selected_indices=list(selected_indices) if selected_indices else None, + options=opts, ) + def _build_svg_props(self, dark_mode=False): + """Build the camelCase props dict for SVG rendering.""" + props: dict = { + "nodeValues": list(self.node_values), + "pairwiseWeights": [list(row) for row in self.pairwise_weights], + "labels": list(self.labels), + "darkMode": bool(dark_mode), + } + if self.selected_indices: + props["selectedIndices"] = list(self.selected_indices) + for key, val in (self.options or {}).items(): + props[_snake_to_camel(key)] = val + return props + def export_svg(self, path=None, dark_mode=False): - """Export the diagram as an SVG string or file. + """Render the diagram to a standalone SVG. + + When the widget is displayed in a notebook the SVG is rendered + in-browser by the same ``chordDiagramToSvg`` function used by + the Node.js SSR script — one rendering path everywhere. When + the widget is not connected (e.g. a plain Python script) the + function falls back to spawning Node.js. + + The ``options`` traitlet (including interactive state like the + grouping toggle) is read at call time, so exports always + reflect the latest user changes. Parameters ---------- @@ -404,21 +425,9 @@ def export_svg(self, path=None, dark_mode=False): str SVG markup (when *path* is ``None``) or the file path. """ - svg = self._live_svg if self._live_svg else None - - if not isinstance(svg, str): - props: dict = { - "nodeValues": self._init_node_values, - "pairwiseWeights": self._init_pairwise_weights, - "labels": self._init_labels, - } - if self._init_selected: - props["selectedIndices"] = self._init_selected - if self._init_group_selected: - props["groupSelected"] = True - props["darkMode"] = bool(dark_mode) - for key, val in self._init_options.items(): - props[_snake_to_camel(key)] = val + svg = self._export_svg_via_widget(dark_mode) + if svg is None: + props = self._build_svg_props(dark_mode) svg = _render_component_node("ChordDiagram", props) if path is not None: @@ -428,21 +437,48 @@ def export_svg(self, path=None, dark_mode=False): return str(path) return svg + def _export_svg_via_widget(self, dark_mode=False): + """Try to render SVG in-browser via the live widget front-end. -class OrbitalEntanglement(anywidget.AnyWidget): - _esm = pathlib.Path(__file__).parent / "static" / "index.js" - _css = pathlib.Path(__file__).parent / "static" / "index.css" - - comp = traitlets.Unicode("OrbitalEntanglement").tag(sync=True) - s1_entropies = traitlets.List().tag(sync=True) - mutual_information = traitlets.List().tag(sync=True) - labels = traitlets.List().tag(sync=True) - selected_indices = traitlets.List(allow_none=True, default_value=None).tag( - sync=True - ) - options = traitlets.Dict().tag(sync=True) - # Automatically populated by the front-end MutationObserver. - _live_svg = traitlets.Unicode("").tag(sync=True) + Sends a custom message asking the JS side to call + ``chordDiagramToSvg()`` and waits for the response. Returns + ``None`` if the widget is not connected. + """ + import threading + + result = [None] + event = threading.Event() + + def _on_msg(_, content, buffers): + if isinstance(content, dict) and content.get("type") == "svg_result": + result[0] = content.get("svg") + event.set() + + try: + self.on_msg(_on_msg) + self.send({"type": "export_svg", "dark_mode": bool(dark_mode)}) + # Wait up to 5 seconds for the front-end to respond + if event.wait(timeout=5): + return result[0] + except Exception: + pass + finally: + try: + self.on_msg(_on_msg, remove=True) + except Exception: + pass + return None + + +class OrbitalEntanglement(ChordDiagram): + """Orbital entanglement chord diagram. + + Convenience subclass of ``ChordDiagram`` that accepts + orbital-specific terminology (``s1_entropies``, + ``mutual_information``) and supplies sensible defaults for quantum + chemistry visualisation (colorbar labels, scale maxima, hover + prefixes). + """ def __init__( self, @@ -453,13 +489,16 @@ def __init__( labels=None, selected_indices=None, group_selected=False, + mi_threshold=None, + s1_vmax=None, + mi_vmax=None, + title="Orbital Entanglement", **options, ): - """ - Displays an orbital entanglement chord diagram. + """Create an orbital entanglement diagram. - Can be constructed either from a ``Wavefunction`` object or from raw - entropy / mutual-information arrays. + Can be constructed either from a ``Wavefunction`` object or from + raw entropy / mutual-information arrays. Parameters ---------- @@ -479,15 +518,22 @@ def __init__( Orbital indices to highlight. group_selected : bool, optional When ``True``, reorder arcs so that selected orbitals sit - adjacent on the ring (labels still show the original orbital - names). Defaults to ``False``. + adjacent on the ring. Defaults to ``False``. + mi_threshold : float, optional + Minimum mutual information to draw a chord. + s1_vmax : float, optional + Clamp for the single-orbital entropy colour scale. + Defaults to ``ln(4)``. + mi_vmax : float, optional + Clamp for the mutual-information colour scale. + Defaults to ``ln(16)``. + title : str, optional + Diagram title. Defaults to ``"Orbital Entanglement"``. **options - Forwarded to the JS component as visual knobs - (``gap_deg``, ``radius``, ``arc_width``, ``line_scale``, - ``mi_threshold``, ``s1_vmax``, ``mi_vmax``, ``title``, - ``width``, ``height``, ``selection_color``, - ``selection_linewidth``). + Additional visual knobs forwarded to the JS component. """ + import math + if wavefunction is not None: import numpy as np @@ -514,75 +560,27 @@ def __init__( "'mutual_information' must be provided." ) - if labels is None: - labels = [str(i) for i in range(len(s1_entropies))] - - # Store data for Python-side SVG rendering - self._init_s1 = list(s1_entropies) - self._init_mi = [list(row) for row in mutual_information] - self._init_labels = list(labels) - self._init_selected = list(selected_indices) if selected_indices else [] - self._init_group_selected = bool(group_selected) - self._init_options = dict(options) - - # Merge group_selected into the options dict so the JS widget sees it - opts_with_group = dict(options) - opts_with_group["group_selected"] = bool(group_selected) + # Map OE-specific params to generic ChordDiagram options + if mi_threshold is not None: + options.setdefault("edge_threshold", mi_threshold) + options.setdefault("node_vmax", s1_vmax if s1_vmax is not None else math.log(4)) + options.setdefault( + "edge_vmax", mi_vmax if mi_vmax is not None else math.log(16) + ) + options.setdefault("node_colorbar_label", "Single-orbital entropy") + options.setdefault("edge_colorbar_label", "Mutual information") + options.setdefault("node_hover_prefix", "S\u2081=") + options.setdefault("edge_hover_prefix", "MI=") + options.setdefault("title", title) super().__init__( - s1_entropies=s1_entropies, - mutual_information=mutual_information, + node_values=s1_entropies, + pairwise_weights=mutual_information, labels=labels, selected_indices=selected_indices, - options=opts_with_group, + group_selected=group_selected, + **options, ) - - def export_svg(self, path=None, dark_mode=False): - """Export the diagram as an SVG string or file. - - If the widget has been displayed in a notebook, the cached live - SVG is returned — this captures any interactive changes the user - made (grouping toggle). Otherwise the same Preact component is - rendered server-side via Node.js. - - Parameters - ---------- - path : str or Path, optional - When given the SVG is written to this file and the path is - returned. Otherwise the SVG markup string is returned. - dark_mode : bool - When ``True`` the exported SVG uses light text on a dark - background; when ``False`` (default) dark text on a - transparent background. - - Returns - ------- - str - SVG markup (when *path* is ``None``) or the file path. - """ - svg = self._live_svg if self._live_svg else None - - if not isinstance(svg, str): - # Fall back to server-side rendering via Node.js - props: dict = { - "s1Entropies": self._init_s1, - "mutualInformation": self._init_mi, - "labels": self._init_labels, - } - if self._init_selected: - props["selectedIndices"] = self._init_selected - if self._init_group_selected: - props["groupSelected"] = True - props["darkMode"] = bool(dark_mode) - for key, val in self._init_options.items(): - props[_snake_to_camel(key)] = val - svg = _render_component_node("OrbitalEntanglement", props) - - if path is not None: - from pathlib import Path as _P - - _P(path).write_text(svg, encoding="utf-8") - return str(path) return svg @@ -606,7 +604,7 @@ def _render_component_node(component: str, props: dict) -> str: Parameters ---------- component : str - Component name (``"OrbitalEntanglement"``, ``"Histogram"``, + Component name (``"ChordDiagram"``, ``"Histogram"``, ``"Circuit"``). props : dict Props dict that will be JSON-serialised and passed to the JS From 752865c2f1bd05bb7f32372c3f5f71bb8be0670f Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Sat, 28 Feb 2026 14:28:20 +0000 Subject: [PATCH 11/33] fix selection --- source/npm/qsharp/ux/orbitalEntanglement.tsx | 4 ++-- source/widgets/src/qsharp_widgets/__init__.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 63f359b4d7..52861c0cea 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -357,7 +357,7 @@ export function ChordDiagram(props: ChordDiagramProps) { const sel: number[] = []; const unsel: number[] = []; for (let i = 0; i < n; i++) { - if (selectedSet.has(labels[i])) { + if (selectedSet.has(String(i))) { sel.push(i); } else { unsel.push(i); @@ -630,7 +630,7 @@ export function ChordDiagram(props: ChordDiagramProps) { {/* Selection outlines */} {Array.from({ length: n }, (_, i) => - selectedSet.has(labels[i]) ? ( + selectedSet.has(String(i)) ? ( Date: Sat, 28 Feb 2026 14:30:01 +0000 Subject: [PATCH 12/33] remove domshim file --- source/widgets/js/svgDomShim.mjs | 596 ------------------------------- 1 file changed, 596 deletions(-) delete mode 100644 source/widgets/js/svgDomShim.mjs diff --git a/source/widgets/js/svgDomShim.mjs b/source/widgets/js/svgDomShim.mjs deleted file mode 100644 index d13494041e..0000000000 --- a/source/widgets/js/svgDomShim.mjs +++ /dev/null @@ -1,596 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Minimal SVG DOM shim for server-side circuit rendering. - * - * Implements just enough of the DOM API so that the circuit-vis rendering - * path (formatUtils → gateFormatter/inputFormatter/registerFormatter → sqore) - * can build an SVG element tree and serialise it to markup via `outerHTML`. - * - * This eliminates the runtime dependency on jsdom, which is large and not - * typically available in user environments without explicit installation. - */ - -// Characters that need escaping in XML text content / attribute values. -const XML_ESCAPE_MAP = { - "&": "&", - "<": "<", - ">": ">", - '"': """, -}; -const xmlEscape = (s) => s.replace(/[&<>"]/g, (c) => XML_ESCAPE_MAP[c]); - -// -------------------------------------------------------------------------- -// ShimStyleDeclaration – trivial style property bag -// -------------------------------------------------------------------------- - -class ShimStyleDeclaration { - constructor() { - /** @type {Map} */ - this._props = new Map(); - } - - setProperty(name, value) { - this._props.set(name, value); - } - - getPropertyValue(name) { - return this._props.get(name) ?? ""; - } - - // Allow direct property assignment (e.g. el.style.pointerEvents = "none"). - // We convert camelCase to kebab-case for the serialized style attribute. - get pointerEvents() { - return this.getPropertyValue("pointer-events"); - } - set pointerEvents(v) { - this.setProperty("pointer-events", v); - } - - get maxWidth() { - return this.getPropertyValue("max-width"); - } - set maxWidth(v) { - this.setProperty("max-width", v); - } - - toString() { - const parts = []; - for (const [k, v] of this._props) { - parts.push(`${k}: ${v}`); - } - return parts.join("; "); - } -} - -// -------------------------------------------------------------------------- -// ShimClassList – minimal classList implementation -// -------------------------------------------------------------------------- - -class ShimClassList { - /** @param {ShimElement} owner */ - constructor(owner) { - this._owner = owner; - /** @type {Set} */ - this._set = new Set(); - } - - add(...names) { - for (const n of names) this._set.add(n); - this._sync(); - } - - remove(...names) { - for (const n of names) this._set.delete(n); - this._sync(); - } - - contains(name) { - return this._set.has(name); - } - - _sync() { - if (this._set.size > 0) { - this._owner._attributes.set("class", [...this._set].join(" ")); - } else { - this._owner._attributes.delete("class"); - } - } - - /** Re-populate from the class attribute string. */ - _fromAttr(val) { - this._set.clear(); - if (val) { - for (const c of val.split(/\s+/)) { - if (c) this._set.add(c); - } - } - } -} - -// -------------------------------------------------------------------------- -// ShimNode – base class for TextNode and Element -// -------------------------------------------------------------------------- - -class ShimNode { - constructor(nodeType) { - this.nodeType = nodeType; - this.parentNode = null; - this.parentElement = null; - } -} - -// -------------------------------------------------------------------------- -// ShimTextNode -// -------------------------------------------------------------------------- - -class ShimTextNode extends ShimNode { - constructor(text) { - super(3 /* TEXT_NODE */); - this._text = text; - } - - get textContent() { - return this._text; - } - set textContent(v) { - this._text = v; - } - - /** Serialise to XML-safe text. */ - get outerHTML() { - return xmlEscape(this._text); - } -} - -// -------------------------------------------------------------------------- -// ShimElement – the core of the shim -// -------------------------------------------------------------------------- - -class ShimElement extends ShimNode { - constructor(namespaceURI, tagName) { - super(1 /* ELEMENT_NODE */); - this.namespaceURI = namespaceURI; - this.tagName = tagName; - /** @type {Map} */ - this._attributes = new Map(); - /** @type {ShimNode[]} */ - this._children = []; - this.style = new ShimStyleDeclaration(); - this.classList = new ShimClassList(this); - } - - // --- Attribute methods --- - - setAttribute(name, value) { - this._attributes.set(name, String(value)); - if (name === "class") this.classList._fromAttr(String(value)); - } - - getAttribute(name) { - return this._attributes.get(name) ?? null; - } - - removeAttribute(name) { - this._attributes.delete(name); - } - - // --- Child methods --- - - appendChild(child) { - if (child.parentNode) { - child.parentNode.removeChild(child); - } - child.parentNode = this; - child.parentElement = this; - this._children.push(child); - return child; - } - - removeChild(child) { - const idx = this._children.indexOf(child); - if (idx !== -1) { - this._children.splice(idx, 1); - child.parentNode = null; - child.parentElement = null; - } - return child; - } - - replaceChild(newChild, oldChild) { - const idx = this._children.indexOf(oldChild); - if (idx !== -1) { - if (newChild.parentNode) newChild.parentNode.removeChild(newChild); - oldChild.parentNode = null; - oldChild.parentElement = null; - newChild.parentNode = this; - newChild.parentElement = this; - this._children[idx] = newChild; - } - return oldChild; - } - - insertBefore(newChild, refChild) { - if (newChild.parentNode) newChild.parentNode.removeChild(newChild); - const idx = refChild ? this._children.indexOf(refChild) : -1; - newChild.parentNode = this; - newChild.parentElement = this; - if (idx === -1) { - this._children.push(newChild); - } else { - this._children.splice(idx, 0, newChild); - } - return newChild; - } - - get firstChild() { - return this._children[0] ?? null; - } - - get children() { - return this._children.filter((c) => c.nodeType === 1); - } - - get childNodes() { - return this._children; - } - - // --- textContent --- - - get textContent() { - return this._children.map((c) => c.textContent ?? "").join(""); - } - - set textContent(val) { - // Remove all existing children - for (const c of this._children) { - c.parentNode = null; - c.parentElement = null; - } - this._children = []; - if (val) { - this._children.push(new ShimTextNode(val)); - } - } - - // --- innerHTML (set only – needed by gateFormatter + inputFormatter) --- - - set innerHTML(markup) { - // Remove existing children - for (const c of this._children) { - c.parentNode = null; - c.parentElement = null; - } - this._children = []; - // Parse the simple SVG/XML fragments used by the circuit-vis code. - parseFragmentInto(this, markup); - } - - get innerHTML() { - return this._children.map((c) => c.outerHTML).join(""); - } - - // --- querySelector (basic: supports "tagname" and "tagname.class") --- - - querySelector(selector) { - return this._querySelectorOne(selector); - } - - /** @returns {ShimElement | null} */ - _querySelectorOne(selector) { - for (const child of this._children) { - if (child.nodeType !== 1) continue; - if (_matchesSelector(child, selector)) return child; - const found = child._querySelectorOne(selector); - if (found) return found; - } - return null; - } - - querySelectorAll(selector) { - const results = []; - this._querySelectorAll(selector, results); - return results; - } - - _querySelectorAll(selector, results) { - for (const child of this._children) { - if (child.nodeType !== 1) continue; - if (_matchesSelector(child, selector)) results.push(child); - child._querySelectorAll(selector, results); - } - } - - // --- addEventListener (no-op for SSR) --- - addEventListener() {} - removeEventListener() {} - - // --- Serialisation --- - - get outerHTML() { - const attrs = []; - for (const [k, v] of this._attributes) { - attrs.push(` ${k}="${xmlEscape(v)}"`); - } - // Merge style into attributes if non-empty - const styleStr = this.style.toString(); - if (styleStr) { - attrs.push(` style="${xmlEscape(styleStr)}"`); - } - - const attrStr = attrs.join(""); - - if (this._children.length === 0) { - return `<${this.tagName}${attrStr}/>`; - } - - // For `; + for (const rowSvg of rowSvgs) { + svg += rowSvg; + } + svg += ``; + + return svg; +} diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index f854e2de38..7d979e3a36 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -14,17 +14,14 @@ export { type CircuitGroup, type CircuitProps, } from "./data.js"; -export { - Histogram, - histogramToSvg, - type HistogramProps, -} from "./histogram.js"; +export { Histogram, histogramToSvg, type HistogramProps } from "./histogram.js"; export { ReTable } from "./reTable.js"; export { SpaceChart } from "./spaceChart.js"; export { ScatterChart } from "./scatterChart.js"; export { EstimatesOverview } from "./estimatesOverview.js"; export { EstimatesPanel } from "./estimatesPanel.js"; export { Circuit, CircuitPanel } from "./circuit.js"; +export { circuitToSvg, type CircuitToSvgOptions } from "./circuitToSvg.js"; export { setRenderer, Markdown } from "./renderers.js"; export { Atoms, type ZoneLayout, type TraceData } from "./atoms/index.js"; export { MoleculeViewer } from "./chem/index.js"; diff --git a/source/widgets/js/render_png.mjs b/source/widgets/js/render_png.mjs new file mode 100644 index 0000000000..45ac999e93 --- /dev/null +++ b/source/widgets/js/render_png.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Headless PNG renderer for MoleculeViewer using Playwright + 3Dmol. +// Reads JSON from stdin, writes PNG to stdout. +// +// Input format: +// { "molecule_data": "...", // XYZ format string +// "cube_data": "...", // optional: Gaussian cube file string +// "iso_value": 0.02, // optional: isovalue for orbital +// "width": 640, // optional: image width +// "height": 480, // optional: image height +// "style": "Sphere" // optional: Sphere|Stick|Line +// } +// +// Requires: playwright (npm), Chromium browser installed via +// npx playwright install chromium + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// 3Dmol source is copied alongside this script at build time. +const threeDmolSrc = readFileSync(resolve(__dirname, "3Dmol-min.js"), "utf-8"); + +const input = readFileSync(0, "utf-8"); +const props = JSON.parse(input); + +const { + molecule_data: moleculeData, + cube_data: cubeData, + iso_value: isoValue = 0.02, + width = 640, + height = 480, + style = "Sphere", +} = props; + +if (!moleculeData) { + process.stderr.write("Error: molecule_data is required\n"); + process.exit(1); +} + +// Build self-contained HTML +const html = ` + + + +
+ + + +`; + +// Launch Playwright and screenshot +async function render() { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch { + process.stderr.write( + "Error: playwright is not installed.\n" + + "Install it with: npm install playwright\n" + + "Then install a browser: npx playwright install chromium\n", + ); + process.exit(1); + } + + const browser = await chromium.launch({ + args: ["--no-sandbox", "--disable-gpu"], + }); + try { + const page = await browser.newPage({ + viewport: { width, height }, + }); + + await page.setContent(html, { waitUntil: "domcontentloaded" }); + + // Wait for 3Dmol to finish rendering + await page.waitForFunction("window.__renderDone === true", { + timeout: 15000, + }); + + // Give WebGL a moment to flush + await page.waitForTimeout(500); + + const png = await page.screenshot({ + type: "png", + omitBackground: true, + }); + + process.stdout.write(png); + } finally { + await browser.close(); + } +} + +render().catch((err) => { + process.stderr.write(`Render error: ${err.message}\n`); + process.exit(1); +}); diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs index 32e2755944..e16e0d1978 100644 --- a/source/widgets/js/render_svg.mjs +++ b/source/widgets/js/render_svg.mjs @@ -6,7 +6,7 @@ // Reads JSON from stdin, writes SVG/HTML to stdout. // // Input format: -// { "component": "ChordDiagram" | "Histogram", +// { "component": "ChordDiagram" | "Histogram" | "Circuit" | "OrbitalEntanglement", // "props": { ... } } // // This file is bundled by esbuild into a self-contained script so that it @@ -15,6 +15,7 @@ import { readFileSync } from "node:fs"; import { chordDiagramToSvg } from "../../npm/qsharp/ux/orbitalEntanglement.tsx"; import { histogramToSvg } from "../../npm/qsharp/ux/histogram.tsx"; +import { circuitToSvg } from "../../npm/qsharp/ux/circuitToSvg.ts"; const input = readFileSync(0, "utf-8"); // stdin const { component, props } = JSON.parse(input); @@ -28,6 +29,12 @@ switch (component) { break; } + // ---- OrbitalEntanglement (alias for ChordDiagram with orbital defaults) ---- + case "OrbitalEntanglement": { + output = chordDiagramToSvg(props); + break; + } + // ---- Histogram (pure Preact SVG) ---- case "Histogram": { // The TS component expects `data` as a Map, but JSON gives us an object. @@ -39,6 +46,20 @@ switch (component) { break; } + // ---- Circuit (pure string SVG, no DOM needed) ---- + case "Circuit": { + const circuitData = + typeof props.circuit === "string" + ? JSON.parse(props.circuit) + : props.circuit; + output = circuitToSvg(circuitData, { + gatesPerRow: props.gates_per_row ?? 0, + darkMode: props.dark_mode ?? false, + renderDepth: props.render_depth ?? 0, + }); + break; + } + default: process.stderr.write(`Unknown component: ${component}\n`); process.exit(1); diff --git a/source/widgets/package.json b/source/widgets/package.json index 9cbe45766b..ba32e0a576 100644 --- a/source/widgets/package.json +++ b/source/widgets/package.json @@ -1,7 +1,7 @@ { "scripts": { "dev": "npm run build -- --sourcemap=inline --watch", - "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static && npx esbuild js/render_svg.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_svg.mjs --loader:.css=text" + "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static && npx esbuild js/render_svg.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_svg.mjs --loader:.css=text && npx esbuild js/render_png.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_png.mjs --external:playwright && cp ../../node_modules/3dmol/build/3Dmol-min.js src/qsharp_widgets/static/3Dmol-min.js" }, "devDependencies": {} } diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 697746c801..6b2fba3b75 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -293,6 +293,45 @@ def __init__(self, circuit): super().__init__(circuit_json=circuit.json()) self.layout.overflow = "visible scroll" + def export_svg(self, path=None, dark_mode=False, gates_per_row=0, render_depth=0): + """Render the circuit to a standalone SVG. + + Parameters + ---------- + path : str or Path, optional + When given the SVG is written to this file and the path is + returned. Otherwise the SVG markup string is returned. + dark_mode : bool + When ``True`` the exported SVG uses light-on-dark colours. + gates_per_row : int + Maximum gate columns per row before wrapping. ``0`` (default) + means no wrapping. + render_depth : int + How many levels of grouped operations to expand. + ``0`` (default) shows groups as collapsed boxes. + ``1`` expands one level, showing children inline. + Use a large number (e.g. 99) to fully expand. + + Returns + ------- + str + SVG markup (when *path* is ``None``) or the file path. + """ + props = { + "circuit": self.circuit_json, + "dark_mode": bool(dark_mode), + "gates_per_row": int(gates_per_row), + "render_depth": int(render_depth), + } + svg = _render_component_node("Circuit", props) + + if path is not None: + from pathlib import Path as _P + + _P(path).write_text(svg, encoding="utf-8") + return str(path) + return svg + class Atoms(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -590,6 +629,9 @@ def __init__( # Path to the Node SSR helper script bundled alongside the widget JS. _RENDER_SVG_SCRIPT = pathlib.Path(__file__).parent / "static" / "render_svg.mjs" +# Path to the headless PNG renderer (Playwright + 3Dmol). +_RENDER_PNG_SCRIPT = pathlib.Path(__file__).parent / "static" / "render_png.mjs" + def _snake_to_camel(name: str) -> str: """Convert ``snake_case`` to ``camelCase``.""" @@ -662,3 +704,116 @@ def __init__(self, molecule_data, cube_data={}, isoval=0.02): super().__init__( molecule_data=molecule_data, cube_data=cube_data, isoval=isoval ) + + def export_png( + self, + path=None, + width=640, + height=480, + style="Sphere", + cube_label=None, + iso_value=None, + ): + """Render the molecule to a PNG image using headless Chromium. + + Uses Playwright to launch a headless browser with 3Dmol — the + same library as the interactive widget — so the output is + pixel-identical. Requires ``playwright`` (npm) and a Chromium + browser (``npx playwright install chromium``). + + Parameters + ---------- + path : str or Path, optional + When given the PNG is written to this file and the path is + returned. Otherwise the raw PNG bytes are returned. + width : int + Image width in pixels. + height : int + Image height in pixels. + style : str + Visualisation style: ``"Sphere"`` (default), ``"Stick"``, + or ``"Line"``. + cube_label : str, optional + Key into ``cube_data`` dict selecting which orbital to + render. When ``None`` and exactly one cube file is + available it is used automatically. + iso_value : float, optional + Isovalue threshold for orbital rendering. Defaults to the + widget's ``isoval`` traitlet. + + Returns + ------- + bytes or str + PNG bytes (when *path* is ``None``) or the file path. + """ + import json + import shutil + import subprocess + + node = shutil.which("node") + if node is None: + raise RuntimeError( + "Node.js is required for PNG rendering but " + "'node' was not found on the PATH." + ) + + props = { + "molecule_data": self.molecule_data, + "width": int(width), + "height": int(height), + "style": style, + } + + # Resolve cube data + cube_str = None + if cube_label is not None: + cube_str = self.cube_data.get(cube_label) + elif len(self.cube_data) == 1: + cube_str = next(iter(self.cube_data.values())) + + if cube_str is not None: + props["cube_data"] = cube_str + props["iso_value"] = float( + iso_value if iso_value is not None else self.isoval + ) + + payload = json.dumps(props) + + result = subprocess.run( + [node, str(_RENDER_PNG_SCRIPT)], + input=payload, + capture_output=True, + text=False, + timeout=30, + ) + + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="replace") + if "playwright" in stderr.lower() and "install" in stderr.lower(): + raise RuntimeError( + "PNG rendering requires a Chromium browser managed by " + "Playwright. Install it once with:\n\n" + " npx playwright install chromium\n\n" + "Then retry export_png()." + ) + if "playwright is not installed" in stderr.lower(): + raise RuntimeError( + "PNG rendering requires the 'playwright' npm package " + "and a Chromium browser.\n\n" + "Install them with:\n" + " npm install playwright\n" + " npx playwright install chromium\n\n" + "Then retry export_png()." + ) + raise RuntimeError( + f"PNG render failed (exit {result.returncode}):\n{stderr}" + ) + + png_bytes = result.stdout + + if path is not None: + from pathlib import Path as _P + + _P(path).write_bytes(png_bytes) + return str(path) + return png_bytes From 541b0f678fa6781ffc1a8ab127e8c24b53e44b97 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Wed, 25 Mar 2026 12:33:47 +0100 Subject: [PATCH 14/33] save pre-merge --- source/npm/qsharp/ux/histogram.tsx | 2 +- source/npm/qsharp/ux/index.ts | 6 +- source/npm/qsharp/ux/orbitalEntanglement.tsx | 703 ++++-------------- source/widgets/js/index.tsx | 162 ++-- source/widgets/src/qsharp_widgets/__init__.py | 555 ++------------ 5 files changed, 258 insertions(+), 1170 deletions(-) diff --git a/source/npm/qsharp/ux/histogram.tsx b/source/npm/qsharp/ux/histogram.tsx index 067a420be1..2f81f547a7 100644 --- a/source/npm/qsharp/ux/histogram.tsx +++ b/source/npm/qsharp/ux/histogram.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { h } from "preact"; -import renderToString from "preact-render-to-string"; +import { renderToString } from "preact-render-to-string"; // Concrete colour palettes for standalone SVG export (no host CSS vars). const lightPalette = { diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index 7d979e3a36..f6f82a46ce 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -14,22 +14,18 @@ export { type CircuitGroup, type CircuitProps, } from "./data.js"; -export { Histogram, histogramToSvg, type HistogramProps } from "./histogram.js"; +export { Histogram } from "./histogram.js"; export { ReTable } from "./reTable.js"; export { SpaceChart } from "./spaceChart.js"; export { ScatterChart } from "./scatterChart.js"; export { EstimatesOverview } from "./estimatesOverview.js"; export { EstimatesPanel } from "./estimatesPanel.js"; export { Circuit, CircuitPanel } from "./circuit.js"; -export { circuitToSvg, type CircuitToSvgOptions } from "./circuitToSvg.js"; export { setRenderer, Markdown } from "./renderers.js"; export { Atoms, type ZoneLayout, type TraceData } from "./atoms/index.js"; export { MoleculeViewer } from "./chem/index.js"; export { - ChordDiagram, OrbitalEntanglement, - chordDiagramToSvg, - type ChordDiagramProps, type OrbitalEntanglementProps, } from "./orbitalEntanglement.js"; export { diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 52861c0cea..eb5a4438fc 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -2,96 +2,31 @@ // Licensed under the MIT License. /** - * Generic chord diagram. + * Orbital entanglement chord diagram. * - * Renders per-node scalar values and pairwise edge weights as an SVG chord - * diagram. Arc length is proportional to the node value; chord thickness - * is proportional to pairwise weight. + * Renders single-orbital entropies and mutual information as an SVG chord + * diagram. Arc length is proportional to single-orbital entropy; chord + * thickness is proportional to pairwise mutual information. * * The diagram is rendered entirely as native SVG so that the markup can be * serialised to a standalone `.svg` file from the Python widget. - * - * `OrbitalEntanglement` is a thin wrapper that supplies orbital-specific - * defaults (title, legend labels, colormaps, scale maxima). */ -import { useState, useRef, useEffect } from "preact/hooks"; -import { h } from "preact"; -import renderToString from "preact-render-to-string"; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export interface ChordDiagramProps { - /** Per-node scalar values (length N). Drives arc colour. */ - nodeValues: number[]; - /** N×N symmetric weight matrix. Drives chord colour / width. */ - pairwiseWeights: number[][]; - /** Node labels (length N). Falls back to "0", "1", … */ - labels?: string[]; - /** Indices of nodes to highlight with an outline. */ - selectedIndices?: number[]; - - // --- visual knobs (all optional with sensible defaults) --- - gapDeg?: number; - radius?: number; - arcWidth?: number; - lineScale?: number | null; - /** Minimum edge weight to draw a chord. */ - edgeThreshold?: number; - /** Clamp for node colour scale. */ - nodeVmax?: number | null; - /** Clamp for edge colour scale. */ - edgeVmax?: number | null; - title?: string | null; - width?: number; - height?: number; - selectionColor?: string; - selectionLinewidth?: number; - /** 3-stop hex colourmap for arcs. */ - nodeColormap?: [string, string, string]; - /** 3-stop hex colourmap for chords. */ - edgeColormap?: [string, string, string]; - /** Legend label for the node colour bar. */ - nodeColorbarLabel?: string | null; - /** Legend label for the edge colour bar. */ - edgeColorbarLabel?: string | null; - /** Prefix shown before the node value on hover (e.g. "S₁="). */ - nodeHoverPrefix?: string; - /** Prefix shown before the edge value on hover (e.g. "MI="). */ - edgeHoverPrefix?: string; - /** - * When `true`, reorder arcs so that selected nodes sit adjacent - * on the ring (labels still show the original names). - */ - groupSelected?: boolean; - /** - * When `true` renders light text on a dark background; when `false` - * renders dark text on a transparent background. Leave `undefined` - * (the default) to inherit from the host page via `--qdk-*` CSS - * custom properties (which map VS Code / Jupyter theme vars), with - * a final fallback to `currentColor` / `transparent`. - */ - darkMode?: boolean; - /** - * When `true`, interactive-only UI elements (e.g. the grouping toggle) - * are suppressed. Used during server-side SVG export. - */ - static?: boolean; - /** - * Callback fired when the user toggles the grouping control. - * The host can use this to sync the new state back to a data model. - */ - onGroupChange?: (grouped: boolean) => void; -} - -/** Convenience alias keeping the old prop names for backward compat. */ export interface OrbitalEntanglementProps { + /** Single-orbital entropies, length N. */ s1Entropies: number[]; + /** Mutual information matrix, N×N (row-major flat array or nested). */ mutualInformation: number[][]; + /** Orbital labels (length N). Falls back to "0", "1", … */ labels?: string[]; + /** Indices of orbitals to highlight with an outline. */ selectedIndices?: number[]; + + // --- visual knobs (all optional with sensible defaults) --- gapDeg?: number; radius?: number; arcWidth?: number; @@ -104,12 +39,6 @@ export interface OrbitalEntanglementProps { height?: number; selectionColor?: string; selectionLinewidth?: number; - nodeColormap?: [string, string, string]; - edgeColormap?: [string, string, string]; - groupSelected?: boolean; - darkMode?: boolean; - static?: boolean; - onGroupChange?: (grouped: boolean) => void; } // --------------------------------------------------------------------------- @@ -155,40 +84,8 @@ function colormapEval(stops: [string, string, string], t: number): string { return rgbaToCSS(lerpColor(colors[1], colors[2], (clamped - 0.5) * 2)); } -const DEFAULT_NODE_CMAP: [string, string, string] = [ - "#d8d8d8", - "#c82020", - "#1a1a1a", -]; -const DEFAULT_EDGE_CMAP: [string, string, string] = [ - "#d8d8d8", - "#2060b0", - "#1a1a1a", -]; - -/** - * Detect whether the host background is dark or light by sampling the - * computed background-color of the nearest ancestor with one. - * Returns a high-contrast colour for selection outlines. - */ -function detectSelectionColor(el: Element | null): string { - if (!el || typeof getComputedStyle === "undefined") return "#FFD700"; - let node: Element | null = el; - while (node) { - const bg = getComputedStyle(node).backgroundColor; - if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") { - const m = bg.match(/\d+/g); - if (m) { - const [r, g, b] = m.map(Number); - const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - // Use vivid colours that pop against the arc colourmap - return lum > 0.5 ? "#FF8C00" : "#FFD700"; - } - } - node = node.parentElement; - } - return "#FFD700"; -} +const ARC_CMAP: [string, string, string] = ["#d8d8d8", "#c82020", "#1a1a1a"]; +const CHORD_CMAP: [string, string, string] = ["#d8d8d8", "#2060b0", "#1a1a1a"]; /** Build an SVG arc‑path for a filled annular segment. */ function arcPath( @@ -209,14 +106,7 @@ function arcPath( const theta = ((startDeg + ((endDeg - startDeg) * i) / N) * Math.PI) / 180; pts.push(`${innerR * Math.cos(theta)},${innerR * Math.sin(theta)}`); } - return ( - `M ${pts[0]} ` + - pts - .slice(1) - .map((p) => `L ${p}`) - .join(" ") + - " Z" - ); + return `M ${pts[0]} ` + pts.slice(1).map((p) => `L ${p}`).join(" ") + " Z"; } /** Cubic Bézier chord between two angles on the inner rim. */ @@ -239,77 +129,27 @@ function chordPath( // Component // --------------------------------------------------------------------------- -export function ChordDiagram(props: ChordDiagramProps) { +export function OrbitalEntanglement(props: OrbitalEntanglementProps) { const { - nodeValues, - pairwiseWeights, + s1Entropies, + mutualInformation, labels: labelsProp, selectedIndices, gapDeg = 3, radius = 1, arcWidth = 0.08, lineScale: lineScaleProp = null, - edgeThreshold = 0, - nodeVmax = null, - edgeVmax = null, - title = null, + miThreshold = 0, + s1Vmax = null, + miVmax = null, + title = "Orbital Entanglement", width = 600, height = 660, - selectionColor: selectionColorProp, - selectionLinewidth = 1.2, - nodeColormap = DEFAULT_NODE_CMAP, - edgeColormap = DEFAULT_EDGE_CMAP, - nodeColorbarLabel = null, - edgeColorbarLabel = null, - nodeHoverPrefix = "", - edgeHoverPrefix = "", - groupSelected = false, - darkMode, - static: isStatic = false, - onGroupChange, + selectionColor = "#222222", + selectionLinewidth = 2.5, } = props; - // --- theme-resolved colours --- - // When darkMode is undefined the component inherits from the host - // environment via --qdk-* CSS custom properties (set by qdk-theme.css - // which maps VS Code / Jupyter / OS theme vars). The final fallback - // is `currentColor` / `transparent` for plain-browser contexts. - // When darkMode is explicitly true/false, concrete hex values are - // used so exported SVGs are fully self-contained. - const FONT_FAMILY = '"Segoe UI", Roboto, Helvetica, Arial, sans-serif'; - const hasExplicitTheme = darkMode !== undefined; - const textColor = hasExplicitTheme - ? darkMode - ? "#e0e0e0" - : "#222222" - : "var(--qdk-host-foreground, currentColor)"; - const bgColor = hasExplicitTheme - ? darkMode - ? "#1e1e1e" - : "transparent" - : "var(--qdk-host-background, transparent)"; - - const n = nodeValues.length; - - // --- hover state --- - const [hoveredIdx, setHoveredIdx] = useState(null); - - // --- grouping toggle (only relevant when there is a selection) --- - const hasSelection = - selectedIndices !== undefined && selectedIndices.length > 0; - const [isGrouped, setIsGrouped] = useState(groupSelected); - // Sync if the prop changes externally - useEffect(() => setIsGrouped(groupSelected), [groupSelected]); - - // --- background-aware selection colour --- - const svgRef = useRef(null); - const [autoSelectionColor, setAutoSelectionColor] = useState("#FFD700"); - useEffect(() => { - if (svgRef.current) { - setAutoSelectionColor(detectSelectionColor(svgRef.current)); - } - }, []); - const selectionColor = selectionColorProp ?? autoSelectionColor; + const n = s1Entropies.length; // --- labels --- const labels: string[] = @@ -318,28 +158,25 @@ export function ChordDiagram(props: ChordDiagramProps) { : Array.from({ length: n }, (_, i) => String(i)); // --- colour scales --- - const nodeMax = nodeVmax ?? Math.max(...nodeValues, 1); - const edgeMax = - edgeVmax ?? Math.max(...pairwiseWeights.flatMap((row) => row), 1); + const s1Max = s1Vmax ?? Math.log(4); + const miMax = miVmax ?? Math.log(16); - const arcColours = nodeValues.map((v) => - colormapEval(nodeColormap, v / nodeMax), - ); + const arcColours = s1Entropies.map((v) => colormapEval(ARC_CMAP, v / s1Max)); // --- line scale --- const maxLw = Math.max(12 * (20 / Math.max(n, 1)) ** 0.5, 2); let lineScale: number; { - let peak = 0; + let miPeak = 0; for (let i = 0; i < n; i++) - for (let j = 0; j < n; j++) peak = Math.max(peak, pairwiseWeights[i][j]); - if (peak <= 0) peak = 1; + for (let j = 0; j < n; j++) miPeak = Math.max(miPeak, mutualInformation[i][j]); + if (miPeak <= 0) miPeak = 1; lineScale = - lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(peak); + lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(miPeak); } // --- arc geometry --- - const totals = nodeValues.slice(); + const totals = s1Entropies.slice(); let grand = totals.reduce((a, b) => a + b, 0); if (grand === 0) { totals.fill(1); @@ -348,37 +185,16 @@ export function ChordDiagram(props: ChordDiagramProps) { const gapTotal = gapDeg * n; const arcDegs = totals.map((t) => ((360 - gapTotal) * t) / grand); - // --- selected set --- - const selectedSet = new Set((selectedIndices ?? []).map(String)); - - // --- ring ordering (group selected orbitals together when requested) --- - const order: number[] = Array.from({ length: n }, (_, i) => i); - if (isGrouped && selectedIndices && selectedIndices.length > 0) { - const sel: number[] = []; - const unsel: number[] = []; - for (let i = 0; i < n; i++) { - if (selectedSet.has(String(i))) { - sel.push(i); - } else { - unsel.push(i); - } - } - order.length = 0; - order.push(...sel, ...unsel); - } - const starts: number[] = new Array(n); - starts[order[0]] = 0; - for (let p = 1; p < n; p++) { - const prev = order[p - 1]; - const curr = order[p]; - starts[curr] = starts[prev] + arcDegs[prev] + gapDeg; + starts[0] = 0; + for (let i = 1; i < n; i++) { + starts[i] = starts[i - 1] + arcDegs[i - 1] + gapDeg; } const arcMids = starts.map((s, i) => s + arcDegs[i] / 2); // --- label tiers (avoid overlapping) --- - const labelFontSize = n <= 20 ? 13.5 : 10.5; + const labelFontSize = n <= 20 ? 9 : 7; const maxLabelLen = Math.max(...labels.map((l) => l.length)); const charDeg = (labelFontSize * 0.7 * maxLabelLen) / Math.max(radius, 0.5); const minSepDeg = charDeg * 0.8; @@ -411,21 +227,22 @@ export function ChordDiagram(props: ChordDiagramProps) { } // --- chord computation --- - const rowSums = pairwiseWeights.map((row) => row.reduce((a, b) => a + b, 0)); + const miRowSums = mutualInformation.map((row) => + row.reduce((a, b) => a + b, 0), + ); type Conn = { j: number; val: number }; const nodeConns: Conn[][] = Array.from({ length: n }, () => []); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { if (i === j) continue; - const val = pairwiseWeights[i][j]; - if (val <= edgeThreshold) continue; + const val = mutualInformation[i][j]; + if (val <= miThreshold) continue; nodeConns[i].push({ j, val }); } const mid = arcMids[i]; nodeConns[i].sort( - (a, b) => - ((mid - arcMids[a.j] + 360) % 360) - ((mid - arcMids[b.j] + 360) % 360), + (a, b) => ((mid - arcMids[a.j] + 360) % 360) - ((mid - arcMids[b.j] + 360) % 360), ); } @@ -433,19 +250,14 @@ export function ChordDiagram(props: ChordDiagramProps) { const allocated = new Map(); for (let i = 0; i < n; i++) { for (const { j, val } of nodeConns[i]) { - const span = rowSums[i] > 0 ? (arcDegs[i] * val) / rowSums[i] : 0; + const span = + miRowSums[i] > 0 ? (arcDegs[i] * val) / miRowSums[i] : 0; allocated.set(`${i},${j}`, cursor[i] + span / 2); cursor[i] += span; } } - type Chord = { - i: number; - j: number; - val: number; - angleI: number; - angleJ: number; - }; + type Chord = { val: number; angleI: number; angleJ: number }; const chords: Chord[] = []; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { @@ -453,9 +265,7 @@ export function ChordDiagram(props: ChordDiagramProps) { const keyJI = `${j},${i}`; if (!allocated.has(keyIJ)) continue; chords.push({ - i, - j, - val: pairwiseWeights[i][j], + val: mutualInformation[i][j], angleI: allocated.get(keyIJ)!, angleJ: allocated.get(keyJI)!, }); @@ -464,148 +274,65 @@ export function ChordDiagram(props: ChordDiagramProps) { // lightest first so darkest draws on top chords.sort((a, b) => a.val - b.val); - // --- hover: partition chords --- - const isHovering = hoveredIdx !== null; - const bgChords: Chord[] = []; - const fgChords: Chord[] = []; - const connectedSet = new Set(); - if (isHovering) { - for (const ch of chords) { - if (ch.i === hoveredIdx || ch.j === hoveredIdx) { - fgChords.push(ch); - connectedSet.add(ch.i); - connectedSet.add(ch.j); - } else { - bgChords.push(ch); - } - } - } + // --- selected set --- + const selectedSet = new Set( + (selectedIndices ?? []).map(String), + ); // --- viewBox --- - const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; + const maxOffset = + baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; - // Map [-lim, lim] to [0, width/height] — compact legend area - const titleH = 50; // px reserved for title at top - const hasNodeBar = !!nodeColorbarLabel; - const hasEdgeBar = !!edgeColorbarLabel; - const legendH = - hasNodeBar || hasEdgeBar ? (hasNodeBar && hasEdgeBar ? 180 : 100) : 0; - const diagramH = height - legendH - titleH; - const vbPad = lim * 0.04; + // Map [-lim, lim] to [0, width/height] with some padding for colour bars + const diagramH = height - 60; // leave room for legends + const vbPad = lim * 0.05; const vbSize = (lim + vbPad) * 2; - const scale = Math.min(width, diagramH) / vbSize; - // Colour-bar dimensions (drawn inside the SVG, close to diagram) - const cbGap = 40; // px between diagram bottom and first bar - const cbY = titleH + diagramH + cbGap; + // Colour-bar dimensions (drawn inside the SVG) + const cbY = diagramH + 8; const cbW = width * 0.6; const cbX = (width - cbW) / 2; - const cbH = 10; - const cbSpacing = 68; // vertical distance between the two bars (label + bar + ticks) + const cbH = 12; const numCbStops = 64; - const numTicks = 5; // tick count on each colour bar return ( {/* Title */} {title && ( {title} )} - {/* Group-selected toggle (only when there is a selection; hidden in static SVG export) */} - {hasSelection && !isStatic && ( - { - setIsGrouped((v) => { - const next = !v; - onGroupChange?.(next); - return next; - }); - }} - > - - {isGrouped - ? "Ungroup selected items" - : "Group selected items together"} - - - - - {isGrouped ? "Grouped" : "Ungrouped"} - - - )} - {/* Diagram group — centred and scaled to fit */} - {/* Chord lines — when hovering, split into dimmed background + bright foreground */} - {(isHovering ? bgChords : chords).map((ch, ci) => { - const c = colormapEval(edgeColormap, ch.val / edgeMax); - const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); - const lw = lwPx / scale; + {/* Chord lines (lightest → darkest) */} + {chords.map((ch, ci) => { + const c = colormapEval(CHORD_CMAP, ch.val / miMax); + const lw = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); return ( - ); - })} - {/* Highlighted chords for hovered orbital (drawn on top) */} - {fgChords.map((ch, ci) => { - const c = colormapEval(edgeColormap, ch.val / edgeMax); - const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); - const lw = lwPx / scale; - return ( - ); })} @@ -614,35 +341,20 @@ export function ChordDiagram(props: ChordDiagramProps) { {Array.from({ length: n }, (_, i) => ( setHoveredIdx(i)} - onMouseLeave={() => setHoveredIdx(null)} - style={{ cursor: "pointer" }} /> ))} {/* Selection outlines */} {Array.from({ length: n }, (_, i) => - selectedSet.has(String(i)) ? ( + selectedSet.has(labels[i]) ? ( ) : null, )} @@ -668,7 +380,7 @@ export function ChordDiagram(props: ChordDiagramProps) { x2={lx} y2={ly} stroke="#aaaaaa" - stroke-width={0.5 / scale} + stroke-width={0.005} /> ); })() @@ -676,25 +388,10 @@ export function ChordDiagram(props: ChordDiagramProps) { // Font size in SVG user units — we're in a scaled group so // approximate by dividing the pt size by the scale factor. - const fsPx = labelFontSize / scale; - - // When hovering, replace the plain label with value info - const isThisHovered = hoveredIdx === i; - const isConnected = connectedSet.has(i); - let labelText = labels[i]; - let labelOpacity = 1; - if (isHovering) { - if (isThisHovered) { - labelText = `${labels[i]} ${nodeHoverPrefix}${nodeValues[i].toFixed(3)}`; - } else if (isConnected && hoveredIdx !== null) { - labelText = `${labels[i]} ${edgeHoverPrefix}${pairwiseWeights[hoveredIdx][i].toFixed(3)}`; - } else { - labelOpacity = 0.15; - } - } + const fsPx = labelFontSize / (Math.min(width, diagramH) / vbSize / 2); return ( - + {tickLine} - {labelText} + {labels[i]} ); @@ -714,174 +411,86 @@ export function ChordDiagram(props: ChordDiagramProps) { {/* ---- Colour-bar legends ---- */} - {/* Node value colour bar */} - {nodeColorbarLabel && ( - - - {nodeColorbarLabel} - - {Array.from({ length: numCbStops }, (_, k) => { - const t = k / (numCbStops - 1); - return ( - - ); - })} - {/* Ticks */} - {Array.from({ length: numTicks }, (_, k) => { - const frac = k / (numTicks - 1); - const xPos = cbX + cbW * frac; - const val = nodeMax * frac; - return ( - - - - {val.toFixed(2)} - - - ); - })} - - )} + {/* Arc (entropy) colour bar */} + + + Single-orbital entropy + + {Array.from({ length: numCbStops }, (_, k) => { + const t = k / (numCbStops - 1); + return ( + + ); + })} + + 0 + + + {s1Max.toFixed(2)} + + - {/* Edge weight colour bar */} - {edgeColorbarLabel && ( - - - {edgeColorbarLabel} - - {Array.from({ length: numCbStops }, (_, k) => { - const t = k / (numCbStops - 1); - return ( - - ); - })} - {/* Ticks */} - {Array.from({ length: numTicks }, (_, k) => { - const frac = k / (numTicks - 1); - const xPos = cbX + cbW * frac; - const val = edgeMax * frac; - return ( - - - - {val.toFixed(2)} - - - ); - })} - - )} + {/* Chord (MI) colour bar */} + + + Mutual information + + {Array.from({ length: numCbStops }, (_, k) => { + const t = k / (numCbStops - 1); + return ( + + ); + })} + + 0 + + + {miMax.toFixed(2)} + + ); } - -// --------------------------------------------------------------------------- -// Orbital Entanglement — convenience wrapper -// --------------------------------------------------------------------------- - -/** - * Orbital entanglement chord diagram. - * - * Thin wrapper around `ChordDiagram` that accepts `s1Entropies` / - * `mutualInformation` and supplies orbital-specific defaults for the - * title, legend labels, colormaps, and scale maxima. - */ -export function OrbitalEntanglement(props: OrbitalEntanglementProps) { - const { - s1Entropies, - mutualInformation, - miThreshold, - s1Vmax, - miVmax, - title = "Orbital Entanglement", - ...rest - } = props; - - return ( - - ); -} - -// --------------------------------------------------------------------------- -// SVG serialisation — single code-path for all contexts -// --------------------------------------------------------------------------- - -/** - * Render a `ChordDiagram` (or `OrbitalEntanglement`) to a standalone SVG - * string. This is the **only** function that converts props → SVG markup - * and is used identically by: - * - * 1. The Node.js SSR script (`render_svg.mjs`) - * 2. The anywidget front-end (`index.tsx`) for in-browser export - * 3. Any VS Code webview extension that needs an SVG string - * - * Interactive-only UI (e.g. the grouping toggle) is suppressed via - * `static: true`. - */ -export function chordDiagramToSvg(props: ChordDiagramProps): string { - return renderToString(h(ChordDiagram, { ...props, static: true })); -} diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index e806faf9f2..e6c4411b32 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -6,7 +6,6 @@ import { ReTable, SpaceChart, Histogram, - histogramToSvg, CreateSingleEstimateResult, EstimatesOverview, EstimatesPanel, @@ -17,8 +16,7 @@ import { type ZoneLayout, type TraceData, MoleculeViewer, - ChordDiagram, - chordDiagramToSvg, + OrbitalEntanglement, } from "qsharp-lang/ux"; import markdownIt from "markdown-it"; import "./widgets.css"; @@ -64,13 +62,6 @@ function render({ model, el }: RenderArgs) { el.ownerDocument.head.appendChild(forceStyle); } - // Belt-and-suspenders: also set the background inline on the nearest - // ipywidget container (if any) so it wins regardless of CSS load order. - const bgContainer = el.closest(".cell-output-ipywidget-background"); - if (bgContainer instanceof HTMLElement) { - bgContainer.style.backgroundColor = "transparent"; - } - switch (componentType) { case "SpaceChart": renderChart({ model, el }); @@ -96,9 +87,8 @@ function render({ model, el }: RenderArgs) { case "MoleculeViewer": renderMoleculeViewer({ model, el }); break; - case "ChordDiagram": case "OrbitalEntanglement": - renderChordDiagram({ model, el }); + renderOrbitalEntanglement({ model, el }); break; default: throw new Error(`Unknown component type ${componentType}`); @@ -241,38 +231,26 @@ function createOnRowDeleted( }; } -function histogramPropsFromModel(model: AnyModel) { - const buckets = model.get("buckets") as { [key: string]: number }; - const bucketMap = new Map(Object.entries(buckets)); - const shotCount = model.get("shot_count") as number; - const shotHeader = model.get("shot_header") as boolean; - const labels = model.get("labels") as "raw" | "kets" | "none"; - const items = model.get("items") as "all" | "top-10" | "top-25"; - const sort = model.get("sort") as "a-to-z" | "high-to-low" | "low-to-high"; - return { bucketMap, shotCount, shotHeader, labels, items, sort }; -} - function renderHistogram({ model, el }: RenderArgs) { const onChange = () => { - const { bucketMap, shotCount, shotHeader, labels, items, sort } = - histogramPropsFromModel(model); + const buckets = model.get("buckets") as { [key: string]: number }; + const bucketMap = new Map(Object.entries(buckets)); + const shot_count = model.get("shot_count") as number; + const shot_header = model.get("shot_header") as boolean; + const labels = model.get("labels") as "raw" | "kets" | "none"; + const items = model.get("items") as "all" | "top-10" | "top-25"; + const sort = model.get("sort") as "a-to-z" | "high-to-low" | "low-to-high"; prender( undefined} - shotsHeader={shotHeader} + shotsHeader={shot_header} labels={labels} items={items} sort={sort} - onSettingsChange={(settings) => { - model.set("labels", settings.labels); - model.set("items", settings.items); - model.set("sort", settings.sort); - model.save_changes(); - }} >, el, ); @@ -285,24 +263,6 @@ function renderHistogram({ model, el }: RenderArgs) { model.on("change:labels", onChange); model.on("change:items", onChange); model.on("change:sort", onChange); - - // Handle SVG export requests from Python - model.on("msg:custom", (msg: Record) => { - if (msg.type === "export_svg") { - const { bucketMap, shotCount, labels, items, sort } = - histogramPropsFromModel(model); - const svg = histogramToSvg({ - data: bucketMap, - shotCount, - filter: "", - labels, - items, - sort, - darkMode: msg.dark_mode as boolean | undefined, - }); - model.send({ type: "svg_result", svg }); - } - }); } function renderCircuit({ model, el }: RenderArgs) { @@ -345,102 +305,66 @@ function renderAtoms({ model, el }: RenderArgs) { model.on("change:trace_data", onChange); } -function chordPropsFromModel(model: AnyModel) { - const nodeValues = model.get("node_values") as number[]; - const pairwiseWeights = model.get("pairwise_weights") as number[][]; - const labels = model.get("labels") as string[]; - const selectedIndices = model.get("selected_indices") as number[] | null; - const options = (model.get("options") || {}) as Record; - return { nodeValues, pairwiseWeights, labels, selectedIndices, options }; -} - -function renderChordDiagram({ model, el }: RenderArgs) { +function renderOrbitalEntanglement({ model, el }: RenderArgs) { const onChange = () => { - const { nodeValues, pairwiseWeights, labels, selectedIndices, options } = - chordPropsFromModel(model); + const s1Entropies = model.get("s1_entropies") as number[]; + const mutualInformation = model.get( + "mutual_information", + ) as number[][]; + const labels = model.get("labels") as string[]; + const selectedIndices = model.get( + "selected_indices", + ) as number[] | null; + const options = (model.get("options") || {}) as Record; prender( - { - const newOpts = { ...options, group_selected: grouped }; - model.set("options", newOpts); - model.save_changes(); - }} + selectionLinewidth={ + options.selection_linewidth as number | undefined + } />, el, ); }; onChange(); - model.on("change:node_values", onChange); - model.on("change:pairwise_weights", onChange); + model.on("change:s1_entropies", onChange); + model.on("change:mutual_information", onChange); model.on("change:labels", onChange); model.on("change:selected_indices", onChange); model.on("change:options", onChange); - // Handle SVG export requests from Python — same rendering function - // used by the Node SSR script, executed here in-browser so no - // subprocess is needed when the widget is live. - model.on("msg:custom", (msg: Record) => { + // Handle SVG export requests from Python + model.on("msg:custom", (msg: { type: string }) => { if (msg.type === "export_svg") { - const { nodeValues, pairwiseWeights, labels, selectedIndices, options } = - chordPropsFromModel(model); - const svg = chordDiagramToSvg({ - nodeValues, - pairwiseWeights, - labels, - selectedIndices: selectedIndices ?? undefined, - darkMode: msg.dark_mode as boolean | undefined, - ...snakeToCamelOptions(options), - }); - model.send({ type: "svg_result", svg }); + const svgEl = el.querySelector("svg"); + if (svgEl) { + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgEl); + model.send({ + type: "svg_data", + svg: svgString, + }); + } } }); } -/** Convert a snake_case options dict to camelCase props. */ -function snakeToCamelOptions( - options: Record, -): Record { - const result: Record = {}; - for (const [key, val] of Object.entries(options)) { - const camel = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); - result[camel] = val; - } - return result; -} - function renderMoleculeViewer({ model, el }: RenderArgs) { const onChange = () => { const moleculeData = model.get("molecule_data") as string; diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 6b2fba3b75..27b9c1dc2d 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -199,88 +199,6 @@ def run(self, entry_expr, shots): # Update the UI one last time to make sure we show the final results self._update_ui() - def _build_svg_props(self, dark_mode=False): - """Build the props dict for SVG rendering.""" - return { - "data": dict(self.buckets), - "shotCount": self.shot_count, - "filter": "", - "labels": self.labels, - "items": self.items, - "sort": self.sort, - "darkMode": bool(dark_mode), - } - - def export_svg(self, path=None, dark_mode=False): - """Render the histogram to a standalone SVG. - - When the widget is displayed in a notebook the SVG is rendered - in-browser by the same ``histogramToSvg`` function used by - the Node.js SSR script. When the widget is not connected the - function falls back to spawning Node.js. - - The traitlets (including interactive state like labels/items/sort - which the front-end syncs back) are read at call time, so exports - always reflect the latest user changes. - - Parameters - ---------- - path : str or Path, optional - When given the SVG is written to this file and the path is - returned. Otherwise the SVG markup string is returned. - dark_mode : bool - When ``True`` the exported SVG uses light text on a dark - background; when ``False`` (default) dark text on a light - background. - - Returns - ------- - str - SVG markup (when *path* is ``None``) or the file path. - """ - svg = self._export_svg_via_widget(dark_mode) - if svg is None: - props = self._build_svg_props(dark_mode) - svg = _render_component_node("Histogram", props) - - if path is not None: - from pathlib import Path as _P - - _P(path).write_text(svg, encoding="utf-8") - return str(path) - return svg - - def _export_svg_via_widget(self, dark_mode=False): - """Try to render SVG in-browser via the live widget front-end. - - Sends a custom message asking the JS side to call - ``histogramToSvg()`` and waits for the response. Returns - ``None`` if the widget is not connected. - """ - import threading - - result = [None] - event = threading.Event() - - def _on_msg(_, content, buffers): - if isinstance(content, dict) and content.get("type") == "svg_result": - result[0] = content.get("svg") - event.set() - - try: - self.on_msg(_on_msg) - self.send({"type": "export_svg", "dark_mode": bool(dark_mode)}) - if event.wait(timeout=5): - return result[0] - except Exception: - pass - finally: - try: - self.on_msg(_on_msg, remove=True) - except Exception: - pass - return None - class Circuit(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -293,45 +211,6 @@ def __init__(self, circuit): super().__init__(circuit_json=circuit.json()) self.layout.overflow = "visible scroll" - def export_svg(self, path=None, dark_mode=False, gates_per_row=0, render_depth=0): - """Render the circuit to a standalone SVG. - - Parameters - ---------- - path : str or Path, optional - When given the SVG is written to this file and the path is - returned. Otherwise the SVG markup string is returned. - dark_mode : bool - When ``True`` the exported SVG uses light-on-dark colours. - gates_per_row : int - Maximum gate columns per row before wrapping. ``0`` (default) - means no wrapping. - render_depth : int - How many levels of grouped operations to expand. - ``0`` (default) shows groups as collapsed boxes. - ``1`` expands one level, showing children inline. - Use a large number (e.g. 99) to fully expand. - - Returns - ------- - str - SVG markup (when *path* is ``None``) or the file path. - """ - props = { - "circuit": self.circuit_json, - "dark_mode": bool(dark_mode), - "gates_per_row": int(gates_per_row), - "render_depth": int(render_depth), - } - svg = _render_component_node("Circuit", props) - - if path is not None: - from pathlib import Path as _P - - _P(path).write_text(svg, encoding="utf-8") - return str(path) - return svg - class Atoms(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" @@ -345,179 +224,19 @@ def __init__(self, machine_layout, trace_data): super().__init__(machine_layout=machine_layout, trace_data=trace_data) -class ChordDiagram(anywidget.AnyWidget): - """General-purpose chord diagram widget. - - Displays per-node scalar values as coloured arcs and pairwise weights - as chords. ``OrbitalEntanglement`` is a convenience subclass that - maps orbital-specific terminology onto these general parameters. - - The component renders self-contained SVG with inline styles, so the - same ``export_svg()`` code path (server-side ``renderToString``) - works identically whether or not a live DOM is available. Interactive - state (e.g. the grouping toggle) is synced back to the ``options`` - traitlet by the JS front-end so exports always reflect the latest - user changes. - """ - +class OrbitalEntanglement(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" _css = pathlib.Path(__file__).parent / "static" / "index.css" - comp = traitlets.Unicode("ChordDiagram").tag(sync=True) - node_values = traitlets.List().tag(sync=True) - pairwise_weights = traitlets.List().tag(sync=True) + comp = traitlets.Unicode("OrbitalEntanglement").tag(sync=True) + s1_entropies = traitlets.List().tag(sync=True) + mutual_information = traitlets.List().tag(sync=True) labels = traitlets.List().tag(sync=True) - selected_indices = traitlets.List(allow_none=True, default_value=None).tag( - sync=True - ) + selected_indices = traitlets.List(allow_none=True, default_value=None).tag(sync=True) options = traitlets.Dict().tag(sync=True) - def __init__( - self, - node_values, - pairwise_weights, - *, - labels=None, - selected_indices=None, - group_selected=False, - **options, - ): - """Create a chord diagram. - - Parameters - ---------- - node_values : list[float] - Per-node scalar values (length *N*). Drives arc colour. - pairwise_weights : list[list[float]] - N×N symmetric weight matrix. Drives chord colour / width. - labels : list[str], optional - Node labels. Defaults to ``["0", "1", …]``. - selected_indices : list[int], optional - Node indices to highlight. - group_selected : bool, optional - When ``True``, reorder arcs so that selected nodes sit - adjacent on the ring. Defaults to ``False``. - **options - Forwarded to the JS component as visual knobs - (``gap_deg``, ``radius``, ``arc_width``, ``line_scale``, - ``edge_threshold``, ``node_vmax``, ``edge_vmax``, - ``node_colormap``, ``edge_colormap``, - ``node_colorbar_label``, ``edge_colorbar_label``, - ``node_hover_prefix``, ``edge_hover_prefix``, - ``title``, ``width``, ``height``, ``selection_color``, - ``selection_linewidth``). - """ - n = len(node_values) - if labels is None: - labels = [str(i) for i in range(n)] - - opts = dict(options) - opts["group_selected"] = bool(group_selected) - - super().__init__( - node_values=list(node_values), - pairwise_weights=[list(row) for row in pairwise_weights], - labels=list(labels), - selected_indices=list(selected_indices) if selected_indices else None, - options=opts, - ) - - def _build_svg_props(self, dark_mode=False): - """Build the camelCase props dict for SVG rendering.""" - props: dict = { - "nodeValues": list(self.node_values), - "pairwiseWeights": [list(row) for row in self.pairwise_weights], - "labels": list(self.labels), - "darkMode": bool(dark_mode), - } - if self.selected_indices: - props["selectedIndices"] = list(self.selected_indices) - for key, val in (self.options or {}).items(): - props[_snake_to_camel(key)] = val - return props - - def export_svg(self, path=None, dark_mode=False): - """Render the diagram to a standalone SVG. - - When the widget is displayed in a notebook the SVG is rendered - in-browser by the same ``chordDiagramToSvg`` function used by - the Node.js SSR script — one rendering path everywhere. When - the widget is not connected (e.g. a plain Python script) the - function falls back to spawning Node.js. - - The ``options`` traitlet (including interactive state like the - grouping toggle) is read at call time, so exports always - reflect the latest user changes. - - Parameters - ---------- - path : str or Path, optional - When given the SVG is written to this file and the path is - returned. Otherwise the SVG markup string is returned. - dark_mode : bool - When ``True`` the exported SVG uses light text on a dark - background; when ``False`` (default) dark text on a - transparent background. - - Returns - ------- - str - SVG markup (when *path* is ``None``) or the file path. - """ - svg = self._export_svg_via_widget(dark_mode) - if svg is None: - props = self._build_svg_props(dark_mode) - svg = _render_component_node("ChordDiagram", props) - - if path is not None: - from pathlib import Path as _P - - _P(path).write_text(svg, encoding="utf-8") - return str(path) - return svg - - def _export_svg_via_widget(self, dark_mode=False): - """Try to render SVG in-browser via the live widget front-end. - - Sends a custom message asking the JS side to call - ``chordDiagramToSvg()`` and waits for the response. Returns - ``None`` if the widget is not connected. - """ - import threading - - result = [None] - event = threading.Event() - - def _on_msg(_, content, buffers): - if isinstance(content, dict) and content.get("type") == "svg_result": - result[0] = content.get("svg") - event.set() - - try: - self.on_msg(_on_msg) - self.send({"type": "export_svg", "dark_mode": bool(dark_mode)}) - # Wait up to 5 seconds for the front-end to respond - if event.wait(timeout=5): - return result[0] - except Exception: - pass - finally: - try: - self.on_msg(_on_msg, remove=True) - except Exception: - pass - return None - - -class OrbitalEntanglement(ChordDiagram): - """Orbital entanglement chord diagram. - - Convenience subclass of ``ChordDiagram`` that accepts - orbital-specific terminology (``s1_entropies``, - ``mutual_information``) and supplies sensible defaults for quantum - chemistry visualisation (colorbar labels, scale maxima, hover - prefixes). - """ + _svg_data = None + _svg_event = None def __init__( self, @@ -527,17 +246,13 @@ def __init__( mutual_information=None, labels=None, selected_indices=None, - group_selected=False, - mi_threshold=None, - s1_vmax=None, - mi_vmax=None, - title="Orbital Entanglement", **options, ): - """Create an orbital entanglement diagram. + """ + Displays an orbital entanglement chord diagram. - Can be constructed either from a ``Wavefunction`` object or from - raw entropy / mutual-information arrays. + Can be constructed either from a ``Wavefunction`` object or from raw + entropy / mutual-information arrays. Parameters ---------- @@ -555,24 +270,13 @@ def __init__( Orbital labels. Defaults to ``["0", "1", …]``. selected_indices : list[int], optional Orbital indices to highlight. - group_selected : bool, optional - When ``True``, reorder arcs so that selected orbitals sit - adjacent on the ring. Defaults to ``False``. - mi_threshold : float, optional - Minimum mutual information to draw a chord. - s1_vmax : float, optional - Clamp for the single-orbital entropy colour scale. - Defaults to ``ln(4)``. - mi_vmax : float, optional - Clamp for the mutual-information colour scale. - Defaults to ``ln(16)``. - title : str, optional - Diagram title. Defaults to ``"Orbital Entanglement"``. **options - Additional visual knobs forwarded to the JS component. + Forwarded to the JS component as visual knobs + (``gap_deg``, ``radius``, ``arc_width``, ``line_scale``, + ``mi_threshold``, ``s1_vmax``, ``mi_vmax``, ``title``, + ``width``, ``height``, ``selection_color``, + ``selection_linewidth``). """ - import math - if wavefunction is not None: import numpy as np @@ -599,90 +303,58 @@ def __init__( "'mutual_information' must be provided." ) - # Map OE-specific params to generic ChordDiagram options - if mi_threshold is not None: - options.setdefault("edge_threshold", mi_threshold) - options.setdefault("node_vmax", s1_vmax if s1_vmax is not None else math.log(4)) - options.setdefault( - "edge_vmax", mi_vmax if mi_vmax is not None else math.log(16) - ) - options.setdefault("node_colorbar_label", "Single-orbital entropy") - options.setdefault("edge_colorbar_label", "Mutual information") - options.setdefault("node_hover_prefix", "S\u2081=") - options.setdefault("edge_hover_prefix", "MI=") - options.setdefault("title", title) + if labels is None: + labels = [str(i) for i in range(len(s1_entropies))] super().__init__( - node_values=s1_entropies, - pairwise_weights=mutual_information, + s1_entropies=s1_entropies, + mutual_information=mutual_information, labels=labels, selected_indices=selected_indices, - group_selected=group_selected, - **options, + options=options, ) + self.on_msg(self._handle_msg) + def _handle_msg(self, widget, content, buffers): + if content.get("type") == "svg_data": + self._svg_data = content["svg"] + if self._svg_event is not None: + self._svg_event.set() -# --------------------------------------------------------------------------- -# Server-side SVG rendering via Node.js (same components as the widget) -# --------------------------------------------------------------------------- - -# Path to the Node SSR helper script bundled alongside the widget JS. -_RENDER_SVG_SCRIPT = pathlib.Path(__file__).parent / "static" / "render_svg.mjs" - -# Path to the headless PNG renderer (Playwright + 3Dmol). -_RENDER_PNG_SCRIPT = pathlib.Path(__file__).parent / "static" / "render_png.mjs" - - -def _snake_to_camel(name: str) -> str: - """Convert ``snake_case`` to ``camelCase``.""" - parts = name.split("_") - return parts[0] + "".join(p.capitalize() for p in parts[1:]) - - -def _render_component_node(component: str, props: dict) -> str: - """Render a component server-side via Node.js. + def export_svg(self, path=None, timeout=5): + """Export the rendered diagram as an SVG string or file. - Parameters - ---------- - component : str - Component name (``"ChordDiagram"``, ``"Histogram"``, - ``"Circuit"``). - props : dict - Props dict that will be JSON-serialised and passed to the JS - component. - - Returns - ------- - str - The rendered SVG / HTML markup. - """ - import json - import shutil - import subprocess - - node = shutil.which("node") - if node is None: - raise RuntimeError( - "Node.js is required for server-side SVG rendering but " - "'node' was not found on the PATH." - ) - - payload = json.dumps({"component": component, "props": props}) + Parameters + ---------- + path : str or Path, optional + When given the SVG is written to this file and the path is + returned. Otherwise the SVG markup string is returned. + timeout : float + Seconds to wait for the front-end to respond. - result = subprocess.run( - [node, str(_RENDER_SVG_SCRIPT)], - input=payload, - capture_output=True, - text=True, - timeout=30, - ) + Returns + ------- + str + SVG markup (when *path* is ``None``) or the file path. + """ + import threading - if result.returncode != 0: - raise RuntimeError( - f"Node SSR render failed (exit {result.returncode}):\n" f"{result.stderr}" - ) + self._svg_data = None + self._svg_event = threading.Event() + self.send({"type": "export_svg"}) + if not self._svg_event.wait(timeout=timeout): + raise TimeoutError( + "Timed out waiting for the front-end to return the SVG. " + "Make sure the widget is displayed in a notebook cell." + ) + svg = self._svg_data + self._svg_event = None + if path is not None: + from pathlib import Path as _P - return result.stdout + _P(path).write_text(svg, encoding="utf-8") + return str(path) + return svg class MoleculeViewer(anywidget.AnyWidget): @@ -704,116 +376,3 @@ def __init__(self, molecule_data, cube_data={}, isoval=0.02): super().__init__( molecule_data=molecule_data, cube_data=cube_data, isoval=isoval ) - - def export_png( - self, - path=None, - width=640, - height=480, - style="Sphere", - cube_label=None, - iso_value=None, - ): - """Render the molecule to a PNG image using headless Chromium. - - Uses Playwright to launch a headless browser with 3Dmol — the - same library as the interactive widget — so the output is - pixel-identical. Requires ``playwright`` (npm) and a Chromium - browser (``npx playwright install chromium``). - - Parameters - ---------- - path : str or Path, optional - When given the PNG is written to this file and the path is - returned. Otherwise the raw PNG bytes are returned. - width : int - Image width in pixels. - height : int - Image height in pixels. - style : str - Visualisation style: ``"Sphere"`` (default), ``"Stick"``, - or ``"Line"``. - cube_label : str, optional - Key into ``cube_data`` dict selecting which orbital to - render. When ``None`` and exactly one cube file is - available it is used automatically. - iso_value : float, optional - Isovalue threshold for orbital rendering. Defaults to the - widget's ``isoval`` traitlet. - - Returns - ------- - bytes or str - PNG bytes (when *path* is ``None``) or the file path. - """ - import json - import shutil - import subprocess - - node = shutil.which("node") - if node is None: - raise RuntimeError( - "Node.js is required for PNG rendering but " - "'node' was not found on the PATH." - ) - - props = { - "molecule_data": self.molecule_data, - "width": int(width), - "height": int(height), - "style": style, - } - - # Resolve cube data - cube_str = None - if cube_label is not None: - cube_str = self.cube_data.get(cube_label) - elif len(self.cube_data) == 1: - cube_str = next(iter(self.cube_data.values())) - - if cube_str is not None: - props["cube_data"] = cube_str - props["iso_value"] = float( - iso_value if iso_value is not None else self.isoval - ) - - payload = json.dumps(props) - - result = subprocess.run( - [node, str(_RENDER_PNG_SCRIPT)], - input=payload, - capture_output=True, - text=False, - timeout=30, - ) - - if result.returncode != 0: - stderr = result.stderr.decode("utf-8", errors="replace") - if "playwright" in stderr.lower() and "install" in stderr.lower(): - raise RuntimeError( - "PNG rendering requires a Chromium browser managed by " - "Playwright. Install it once with:\n\n" - " npx playwright install chromium\n\n" - "Then retry export_png()." - ) - if "playwright is not installed" in stderr.lower(): - raise RuntimeError( - "PNG rendering requires the 'playwright' npm package " - "and a Chromium browser.\n\n" - "Install them with:\n" - " npm install playwright\n" - " npx playwright install chromium\n\n" - "Then retry export_png()." - ) - raise RuntimeError( - f"PNG render failed (exit {result.returncode}):\n{stderr}" - ) - - png_bytes = result.stdout - - if path is not None: - from pathlib import Path as _P - - _P(path).write_bytes(png_bytes) - return str(path) - return png_bytes From 8cb0e4852f3cbe470866f5763755f6ac90ff6218 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Wed, 25 Mar 2026 12:35:58 +0100 Subject: [PATCH 15/33] formatting --- source/npm/qsharp/ux/orbitalEntanglement.tsx | 46 ++++++++++++-------- source/widgets/js/index.tsx | 12 ++--- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index eb5a4438fc..31286b790c 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -106,7 +106,14 @@ function arcPath( const theta = ((startDeg + ((endDeg - startDeg) * i) / N) * Math.PI) / 180; pts.push(`${innerR * Math.cos(theta)},${innerR * Math.sin(theta)}`); } - return `M ${pts[0]} ` + pts.slice(1).map((p) => `L ${p}`).join(" ") + " Z"; + return ( + `M ${pts[0]} ` + + pts + .slice(1) + .map((p) => `L ${p}`) + .join(" ") + + " Z" + ); } /** Cubic Bézier chord between two angles on the inner rim. */ @@ -169,7 +176,8 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { { let miPeak = 0; for (let i = 0; i < n; i++) - for (let j = 0; j < n; j++) miPeak = Math.max(miPeak, mutualInformation[i][j]); + for (let j = 0; j < n; j++) + miPeak = Math.max(miPeak, mutualInformation[i][j]); if (miPeak <= 0) miPeak = 1; lineScale = lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(miPeak); @@ -242,7 +250,8 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { } const mid = arcMids[i]; nodeConns[i].sort( - (a, b) => ((mid - arcMids[a.j] + 360) % 360) - ((mid - arcMids[b.j] + 360) % 360), + (a, b) => + ((mid - arcMids[a.j] + 360) % 360) - ((mid - arcMids[b.j] + 360) % 360), ); } @@ -250,8 +259,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { const allocated = new Map(); for (let i = 0; i < n; i++) { for (const { j, val } of nodeConns[i]) { - const span = - miRowSums[i] > 0 ? (arcDegs[i] * val) / miRowSums[i] : 0; + const span = miRowSums[i] > 0 ? (arcDegs[i] * val) / miRowSums[i] : 0; allocated.set(`${i},${j}`, cursor[i] + span / 2); cursor[i] += span; } @@ -275,13 +283,10 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { chords.sort((a, b) => a.val - b.val); // --- selected set --- - const selectedSet = new Set( - (selectedIndices ?? []).map(String), - ); + const selectedSet = new Set((selectedIndices ?? []).map(String)); // --- viewBox --- - const maxOffset = - baseOffset + Math.max(0, ...tier) * tierStep + 0.15; + const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; // Map [-lim, lim] to [0, width/height] with some padding for colour bars const diagramH = height - 60; // leave room for legends @@ -341,7 +346,12 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { {Array.from({ length: n }, (_, i) => ( ))} @@ -351,7 +361,12 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { selectedSet.has(labels[i]) ? ( ); })} - + 0 { const s1Entropies = model.get("s1_entropies") as number[]; - const mutualInformation = model.get( - "mutual_information", - ) as number[][]; + const mutualInformation = model.get("mutual_information") as number[][]; const labels = model.get("labels") as string[]; - const selectedIndices = model.get( - "selected_indices", - ) as number[] | null; + const selectedIndices = model.get("selected_indices") as number[] | null; const options = (model.get("options") || {}) as Record; prender( @@ -334,9 +330,7 @@ function renderOrbitalEntanglement({ model, el }: RenderArgs) { width={options.width as number | undefined} height={options.height as number | undefined} selectionColor={options.selection_color as string | undefined} - selectionLinewidth={ - options.selection_linewidth as number | undefined - } + selectionLinewidth={options.selection_linewidth as number | undefined} />, el, ); From 2948f3b5748b6a2c5d7d50941aafc80d483d5cc3 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Wed, 25 Mar 2026 12:43:04 +0100 Subject: [PATCH 16/33] resolve some comments --- source/npm/qsharp/ux/circuitToSvg.ts | 2 +- source/npm/qsharp/ux/histogram.tsx | 4 ++-- source/npm/qsharp/ux/orbitalEntanglement.tsx | 20 ++++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/source/npm/qsharp/ux/circuitToSvg.ts b/source/npm/qsharp/ux/circuitToSvg.ts index e5ea58147a..45d3ee9f2e 100644 --- a/source/npm/qsharp/ux/circuitToSvg.ts +++ b/source/npm/qsharp/ux/circuitToSvg.ts @@ -460,7 +460,7 @@ const CIRCUIT_CSS_DARK = ` export interface CircuitToSvgOptions { /** Maximum number of gate columns per row before wrapping. 0 = no wrap. */ gatesPerRow?: number; - /** Use dark-mode colours. */ + /** Use dark-mode colors. */ darkMode?: boolean; /** How many levels of grouped operations to expand. * 0 (default) = show groups as collapsed single-gate boxes. diff --git a/source/npm/qsharp/ux/histogram.tsx b/source/npm/qsharp/ux/histogram.tsx index 2f81f547a7..5e265aa683 100644 --- a/source/npm/qsharp/ux/histogram.tsx +++ b/source/npm/qsharp/ux/histogram.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "preact/hooks"; import { h } from "preact"; import { renderToString } from "preact-render-to-string"; -// Concrete colour palettes for standalone SVG export (no host CSS vars). +// Concrete color palettes for standalone SVG export (no host CSS vars). const lightPalette = { hostBackground: "#eee", hostForeground: "#222", @@ -355,7 +355,7 @@ export function Histogram(props: HistogramProps) { function onWheel(e: WheelEvent): void { // Ctrl+scroll is the event sent by pinch-to-zoom on a trackpad. Shift+scroll is common for - // panning horizontally. See https://danburzo.ro/dom-gestures/ for the messy details. + // panning horizontally. if (!e.ctrlKey && !e.shiftKey) return; // When using a mouse wheel, the deltaY is the scroll amount, but if the shift key is pressed diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 31286b790c..5e402f505a 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -50,7 +50,7 @@ function deg2xy(deg: number, r: number): [number, number] { return [r * Math.cos(rad), r * Math.sin(rad)]; } -/** Linear interpolation between two RGB‑A colours given as [r,g,b,a]. */ +/** Linear interpolation between two RGB‑A colors given as [r,g,b,a]. */ type RGBA = [number, number, number, number]; function lerpColor(a: RGBA, b: RGBA, t: number): RGBA { @@ -74,7 +74,7 @@ function rgbaToCSS(c: RGBA): string { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; } -/** Evaluate a 3‑stop linear colour-map at position t ∈ [0,1]. */ +/** Evaluate a 3‑stop linear color-map at position t ∈ [0,1]. */ function colormapEval(stops: [string, string, string], t: number): string { const clamped = Math.max(0, Math.min(1, t)); const colors = stops.map(hexToRGBA) as [RGBA, RGBA, RGBA]; @@ -164,11 +164,11 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { ? labelsProp : Array.from({ length: n }, (_, i) => String(i)); - // --- colour scales --- + // --- color scales --- const s1Max = s1Vmax ?? Math.log(4); const miMax = miVmax ?? Math.log(16); - const arcColours = s1Entropies.map((v) => colormapEval(ARC_CMAP, v / s1Max)); + const arccolors = s1Entropies.map((v) => colormapEval(ARC_CMAP, v / s1Max)); // --- line scale --- const maxLw = Math.max(12 * (20 / Math.max(n, 1)) ** 0.5, 2); @@ -288,12 +288,12 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { // --- viewBox --- const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; - // Map [-lim, lim] to [0, width/height] with some padding for colour bars + // Map [-lim, lim] to [0, width/height] with some padding for color bars const diagramH = height - 60; // leave room for legends const vbPad = lim * 0.05; const vbSize = (lim + vbPad) * 2; - // Colour-bar dimensions (drawn inside the SVG) + // color-bar dimensions (drawn inside the SVG) const cbY = diagramH + 8; const cbW = width * 0.6; const cbX = (width - cbW) / 2; @@ -352,7 +352,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { radius - arcWidth, radius, )} - fill={arcColours[i]} + fill={arccolors[i]} /> ))} @@ -425,8 +425,8 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { })}
- {/* ---- Colour-bar legends ---- */} - {/* Arc (entropy) colour bar */} + {/* ---- color-bar legends ---- */} + {/* Arc (entropy) color bar */} - {/* Chord (MI) colour bar */} + {/* Chord (MI) color bar */} Date: Wed, 25 Mar 2026 12:56:20 +0100 Subject: [PATCH 17/33] fix build --- source/npm/qsharp/ux/orbitalEntanglement.tsx | 13 +++++++++++++ source/widgets/js/render_svg.mjs | 13 ++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 5e402f505a..ecde723b8c 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { h } from "preact"; +import { renderToString } from "preact-render-to-string"; + /** * Orbital entanglement chord diagram. * @@ -504,3 +507,13 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { ); } + +// --------------------------------------------------------------------------- +// SVG serialisation +// --------------------------------------------------------------------------- + +export function orbitalEntanglementToSvg( + props: OrbitalEntanglementProps, +): string { + return renderToString(h(OrbitalEntanglement, props)); +} diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs index e16e0d1978..52efa55724 100644 --- a/source/widgets/js/render_svg.mjs +++ b/source/widgets/js/render_svg.mjs @@ -13,7 +13,7 @@ // works wherever Node.js is available — no sibling module imports needed. import { readFileSync } from "node:fs"; -import { chordDiagramToSvg } from "../../npm/qsharp/ux/orbitalEntanglement.tsx"; +import { orbitalEntanglementToSvg } from "../../npm/qsharp/ux/orbitalEntanglement.tsx"; import { histogramToSvg } from "../../npm/qsharp/ux/histogram.tsx"; import { circuitToSvg } from "../../npm/qsharp/ux/circuitToSvg.ts"; @@ -23,15 +23,10 @@ const { component, props } = JSON.parse(input); let output = ""; switch (component) { - // ---- ChordDiagram (generic chord diagram, pure Preact SVG) ---- - case "ChordDiagram": { - output = chordDiagramToSvg(props); - break; - } - - // ---- OrbitalEntanglement (alias for ChordDiagram with orbital defaults) ---- + // ---- OrbitalEntanglement (chord diagram) ---- + case "ChordDiagram": case "OrbitalEntanglement": { - output = chordDiagramToSvg(props); + output = orbitalEntanglementToSvg(props); break; } From 41553175c28b30de912e56fe4f4705368d1f101d Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Mon, 30 Mar 2026 11:22:40 +0200 Subject: [PATCH 18/33] test again --- source/npm/qsharp/ux/index.ts | 1 + source/npm/qsharp/ux/orbitalEntanglement.tsx | 33 +++++--- source/widgets/js/index.tsx | 76 ++++++++++++------- source/widgets/src/qsharp_widgets/__init__.py | 12 ++- 4 files changed, 83 insertions(+), 39 deletions(-) diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index f6f82a46ce..9215869206 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -27,6 +27,7 @@ export { MoleculeViewer } from "./chem/index.js"; export { OrbitalEntanglement, type OrbitalEntanglementProps, + orbitalEntanglementToSvg, } from "./orbitalEntanglement.js"; export { ensureTheme, diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index ecde723b8c..806686507b 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -42,6 +42,7 @@ export interface OrbitalEntanglementProps { height?: number; selectionColor?: string; selectionLinewidth?: number; + darkMode?: boolean; } // --------------------------------------------------------------------------- @@ -157,8 +158,21 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { height = 660, selectionColor = "#222222", selectionLinewidth = 2.5, + darkMode, } = props; + const isStatic = darkMode !== undefined; + const fgColor = isStatic + ? darkMode + ? "#e0e0e0" + : "#222222" + : "currentColor"; + const bgColor = isStatic + ? darkMode + ? "#1e1e1e" + : "transparent" + : "transparent"; + const n = s1Entropies.length; // --- labels --- @@ -309,7 +323,8 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { width={width} height={height} class="qs-orbital-entanglement" - style={{ background: "transparent" }} + style={{ background: bgColor }} + {...(isStatic ? { "xmlns:xlink": "http://www.w3.org/1999/xlink" } : {})} > {/* Title */} {title && ( @@ -319,7 +334,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { text-anchor="middle" font-size="14" font-weight="bold" - fill="currentColor" + fill={fgColor} > {title} @@ -418,7 +433,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { dominant-baseline="central" font-size={fsPx} font-weight="bold" - fill="currentColor" + fill={fgColor} transform={`rotate(${rot},${lx},${ly})`} > {labels[i]} @@ -436,7 +451,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { y={cbY - 2} text-anchor="middle" font-size="9" - fill="currentColor" + fill={fgColor} > Single-orbital entropy
@@ -453,7 +468,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { /> ); })} - + 0 {s1Max.toFixed(2)} @@ -474,7 +489,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { y={cbY + cbH + 22} text-anchor="middle" font-size="9" - fill="currentColor" + fill={fgColor} > Mutual information @@ -491,7 +506,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { /> ); })} - + 0 {miMax.toFixed(2)} diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index 0fdd460fa7..5e0de85b7b 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -17,6 +17,8 @@ import { type TraceData, MoleculeViewer, OrbitalEntanglement, + type OrbitalEntanglementProps, + orbitalEntanglementToSvg, } from "qsharp-lang/ux"; import markdownIt from "markdown-it"; import "./widgets.css"; @@ -306,34 +308,39 @@ function renderAtoms({ model, el }: RenderArgs) { } function renderOrbitalEntanglement({ model, el }: RenderArgs) { - const onChange = () => { + /** Read model state and build the full props object for OrbitalEntanglement. */ + function getWidgetProps( + extra?: Partial, + ): OrbitalEntanglementProps { const s1Entropies = model.get("s1_entropies") as number[]; const mutualInformation = model.get("mutual_information") as number[][]; const labels = model.get("labels") as string[]; const selectedIndices = model.get("selected_indices") as number[] | null; - const options = (model.get("options") || {}) as Record; + const opts = (model.get("options") || {}) as Record; + + return { + s1Entropies, + mutualInformation, + labels, + selectedIndices: selectedIndices ?? undefined, + gapDeg: opts.gap_deg as number | undefined, + radius: opts.radius as number | undefined, + arcWidth: opts.arc_width as number | undefined, + lineScale: opts.line_scale as number | null | undefined, + miThreshold: opts.mi_threshold as number | undefined, + s1Vmax: opts.s1_vmax as number | null | undefined, + miVmax: opts.mi_vmax as number | null | undefined, + title: opts.title as string | null | undefined, + width: opts.width as number | undefined, + height: opts.height as number | undefined, + selectionColor: opts.selection_color as string | undefined, + selectionLinewidth: opts.selection_linewidth as number | undefined, + ...extra, + }; + } - prender( - , - el, - ); + const onChange = () => { + prender(, el); }; onChange(); @@ -344,12 +351,25 @@ function renderOrbitalEntanglement({ model, el }: RenderArgs) { model.on("change:options", onChange); // Handle SVG export requests from Python - model.on("msg:custom", (msg: { type: string }) => { + model.on("msg:custom", (msg: { type: string; dark_mode?: boolean }) => { if (msg.type === "export_svg") { - const svgEl = el.querySelector("svg"); - if (svgEl) { - const serializer = new XMLSerializer(); - const svgString = serializer.serializeToString(svgEl); + let svgString: string | undefined; + + if (msg.dark_mode !== undefined) { + // Re-render with explicit dark_mode for standalone SVG + svgString = orbitalEntanglementToSvg( + getWidgetProps({ darkMode: msg.dark_mode }), + ); + } else { + // Serialize the current DOM SVG + const svgEl = el.querySelector("svg"); + if (svgEl) { + const serializer = new XMLSerializer(); + svgString = serializer.serializeToString(svgEl); + } + } + + if (svgString) { model.send({ type: "svg_data", svg: svgString, diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 27b9c1dc2d..7223d02dee 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -321,7 +321,7 @@ def _handle_msg(self, widget, content, buffers): if self._svg_event is not None: self._svg_event.set() - def export_svg(self, path=None, timeout=5): + def export_svg(self, path=None, timeout=5, dark_mode=None): """Export the rendered diagram as an SVG string or file. Parameters @@ -331,6 +331,11 @@ def export_svg(self, path=None, timeout=5): returned. Otherwise the SVG markup string is returned. timeout : float Seconds to wait for the front-end to respond. + dark_mode : bool, optional + When ``True`` the exported SVG uses light-on-dark colours; + when ``False`` it uses dark-on-light colours. If ``None`` + (the default) the current in-notebook rendering is serialised + as-is (colours follow the host theme via CSS variables). Returns ------- @@ -341,7 +346,10 @@ def export_svg(self, path=None, timeout=5): self._svg_data = None self._svg_event = threading.Event() - self.send({"type": "export_svg"}) + msg: dict[str, object] = {"type": "export_svg"} + if dark_mode is not None: + msg["dark_mode"] = bool(dark_mode) + self.send(msg) if not self._svg_event.wait(timeout=timeout): raise TimeoutError( "Timed out waiting for the front-end to return the SVG. " From 78198ca15cf7ed4242d6257f9a2c2914f0057050 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Mon, 30 Mar 2026 13:44:11 +0200 Subject: [PATCH 19/33] test --- source/widgets/src/qsharp_widgets/__init__.py | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 7223d02dee..c0880655a7 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -321,6 +321,66 @@ def _handle_msg(self, widget, content, buffers): if self._svg_event is not None: self._svg_event.set() + def _build_props(self, dark_mode=None): + """Build the props dict expected by the JS rendering component.""" + props = { + "s1Entropies": list(self.s1_entropies), + "mutualInformation": [list(row) for row in self.mutual_information], + "labels": list(self.labels), + } + if self.selected_indices is not None: + props["selectedIndices"] = list(self.selected_indices) + # Map snake_case options to camelCase props + _key_map = { + "gap_deg": "gapDeg", + "radius": "radius", + "arc_width": "arcWidth", + "line_scale": "lineScale", + "mi_threshold": "miThreshold", + "s1_vmax": "s1Vmax", + "mi_vmax": "miVmax", + "title": "title", + "width": "width", + "height": "height", + "selection_color": "selectionColor", + "selection_linewidth": "selectionLinewidth", + } + for k, v in (self.options or {}).items(): + if k in _key_map: + props[_key_map[k]] = v + if dark_mode is not None: + props["darkMode"] = bool(dark_mode) + return props + + def _render_svg_server_side(self, dark_mode=None): + """Render SVG via the bundled Node.js script (no frontend needed).""" + import json + import shutil + import subprocess + + node = shutil.which("node") + if node is None: + raise RuntimeError( + "Node.js is required for server-side SVG export but " + "'node' was not found on PATH." + ) + script = pathlib.Path(__file__).parent / "static" / "render_svg.mjs" + payload = json.dumps( + {"component": "OrbitalEntanglement", "props": self._build_props(dark_mode)} + ) + result = subprocess.run( + [node, str(script)], + input=payload, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError( + f"Server-side SVG render failed:\n{result.stderr}" + ) + return result.stdout + def export_svg(self, path=None, timeout=5, dark_mode=None): """Export the rendered diagram as an SVG string or file. @@ -351,11 +411,10 @@ def export_svg(self, path=None, timeout=5, dark_mode=None): msg["dark_mode"] = bool(dark_mode) self.send(msg) if not self._svg_event.wait(timeout=timeout): - raise TimeoutError( - "Timed out waiting for the front-end to return the SVG. " - "Make sure the widget is displayed in a notebook cell." - ) - svg = self._svg_data + # No frontend responded — fall back to server-side rendering. + svg = self._render_svg_server_side(dark_mode) + else: + svg = self._svg_data self._svg_event = None if path is not None: from pathlib import Path as _P From 5919c0ef7e79077539608d64ee4cc5c1c0f7dee2 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Mon, 30 Mar 2026 14:32:53 +0200 Subject: [PATCH 20/33] minor fixes --- source/npm/qsharp/ux/orbitalEntanglement.tsx | 4 +-- source/widgets/js/index.tsx | 19 +++++--------- source/widgets/src/qsharp_widgets/__init__.py | 25 ++++++------------- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 806686507b..0ff9541df3 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -320,8 +320,8 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { return ( ; + const camelOpts: Record = {}; + for (const [k, v] of Object.entries(opts)) { + camelOpts[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v; + } return { s1Entropies, mutualInformation, labels, selectedIndices: selectedIndices ?? undefined, - gapDeg: opts.gap_deg as number | undefined, - radius: opts.radius as number | undefined, - arcWidth: opts.arc_width as number | undefined, - lineScale: opts.line_scale as number | null | undefined, - miThreshold: opts.mi_threshold as number | undefined, - s1Vmax: opts.s1_vmax as number | null | undefined, - miVmax: opts.mi_vmax as number | null | undefined, - title: opts.title as string | null | undefined, - width: opts.width as number | undefined, - height: opts.height as number | undefined, - selectionColor: opts.selection_color as string | undefined, - selectionLinewidth: opts.selection_linewidth as number | undefined, + ...camelOpts, ...extra, - }; + } as OrbitalEntanglementProps; } const onChange = () => { diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index c0880655a7..59efda660e 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -9,6 +9,13 @@ import anywidget import traitlets +import re as _re + + +def _snake_to_camel(name: str) -> str: + return _re.sub(r"_([a-z])", lambda m: m.group(1).upper(), name) + + try: __version__ = importlib.metadata.version("qsharp_widgets") except importlib.metadata.PackageNotFoundError: @@ -330,24 +337,8 @@ def _build_props(self, dark_mode=None): } if self.selected_indices is not None: props["selectedIndices"] = list(self.selected_indices) - # Map snake_case options to camelCase props - _key_map = { - "gap_deg": "gapDeg", - "radius": "radius", - "arc_width": "arcWidth", - "line_scale": "lineScale", - "mi_threshold": "miThreshold", - "s1_vmax": "s1Vmax", - "mi_vmax": "miVmax", - "title": "title", - "width": "width", - "height": "height", - "selection_color": "selectionColor", - "selection_linewidth": "selectionLinewidth", - } for k, v in (self.options or {}).items(): - if k in _key_map: - props[_key_map[k]] = v + props[_snake_to_camel(k)] = v if dark_mode is not None: props["darkMode"] = bool(dark_mode) return props From 644cd10b97854e1589ad3bfa3b86e40dc7effb38 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 10:47:51 +0200 Subject: [PATCH 21/33] remove all but the new widget --- source/npm/qsharp/ux/circuitToSvg.ts | 625 ------------------ source/npm/qsharp/ux/histogram.tsx | 163 +---- source/npm/qsharp/ux/index.ts | 1 - source/npm/qsharp/ux/orbitalEntanglement.tsx | 85 +-- source/npm/qsharp/ux/qsharp-ux.css | 53 ++ source/widgets/js/index.tsx | 29 - source/widgets/js/render_svg.mjs | 63 -- source/widgets/package.json | 2 +- source/widgets/src/qsharp_widgets/__init__.py | 96 --- 9 files changed, 86 insertions(+), 1031 deletions(-) delete mode 100644 source/npm/qsharp/ux/circuitToSvg.ts delete mode 100644 source/widgets/js/render_svg.mjs diff --git a/source/npm/qsharp/ux/circuitToSvg.ts b/source/npm/qsharp/ux/circuitToSvg.ts deleted file mode 100644 index 45d3ee9f2e..0000000000 --- a/source/npm/qsharp/ux/circuitToSvg.ts +++ /dev/null @@ -1,625 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Pure-string SVG renderer for quantum circuits. - * - * Produces a standalone `` string from a CircuitGroup / Circuit object - * with no DOM dependency. Designed for static export (reports, papers). - * - * Supports a `gatesPerRow` option that wraps the circuit into multiple rows - * so wide circuits fit within a target page width. - */ - -import { - toCircuitGroup, - type CircuitGroup, - type Circuit, - type Column, - type Operation, - type Qubit, -} from "./circuit-vis/circuit.js"; -import type { Register } from "./circuit-vis/register.js"; - -// ── Layout constants (matching circuit-vis/constants.ts) ──────────────── - -const GATE_HEIGHT = 40; -const MIN_GATE_WIDTH = 40; -const GATE_PAD = 6; -const LABEL_FONT = 14; -const ARGS_FONT = 12; -const START_X = 80; // space for qubit labels -const START_Y = 40; -const WIRE_END_PAD = 20; -const CONTROL_DOT_R = 5; -const OPLUS_R = 18; -const MEAS_W = 40; -const MEAS_H = 40; -const KET_W = 40; -const ROW_GAP = 30; // vertical gap between wrapped rows - -// ── Helpers ───────────────────────────────────────────────────────────── - -function esc(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function attrs(a: Record): string { - return Object.entries(a) - .map(([k, v]) => `${k}="${esc(String(v))}"`) - .join(" "); -} - -/** Approximate text width for label sizing (sans-serif ~0.6em per char). */ -function textWidth(s: string, fontSize: number): number { - return s.length * fontSize * 0.6; -} - -/** Compute the display width of a gate given its label and args. */ -function gateWidth(label: string, displayArgs?: string): number { - let w = textWidth(label, LABEL_FONT) + 20; - if (displayArgs) { - w = Math.max(w, textWidth(displayArgs, ARGS_FONT) + 20); - } - return Math.max(MIN_GATE_WIDTH, Math.ceil(w)); -} - -// ── Math characters ───────────────────────────────────────────────────── - -const MATH = { - psi: "\u03c8", // ψ - rangle: "\u27e9", // ⟩ - dagger: "\u2020", // † -}; - -// ── Qubit label rendering ─────────────────────────────────────────────── - -function qubitLabel(qid: number, y: number): string { - // |ψ₀⟩ style label - const sub = String(qid) - .split("") - .map((c) => String.fromCharCode(0x2080 + Number(c))) - .join(""); - const label = `|${MATH.psi}${sub}${MATH.rangle}`; - return `${esc(label)}`; -} - -// ── Gate SVG fragments ────────────────────────────────────────────────── - -function unitaryBox( - cx: number, - ys: number[], - label: string, - w: number, - displayArgs?: string, -): string { - const topY = Math.min(...ys); - const bottomY = Math.max(...ys); - const h = Math.max(GATE_HEIGHT, bottomY - topY + GATE_HEIGHT); - const bx = cx - w / 2; - const by = (topY + bottomY) / 2 - h / 2; - let s = ``; - s += `${esc(label)}`; - if (displayArgs) { - s += `${esc(displayArgs)}`; - } - return s; -} - -function measureBox(cx: number, y: number): string { - const bx = cx - MEAS_W / 2; - const by = y - MEAS_H / 2; - let s = ``; - // Arc - const arcRx = MEAS_W * 0.3; - const arcRy = MEAS_H * 0.3; - s += ``; - // Arrow - s += ``; - return s; -} - -function ketBox(cx: number, y: number, label: string): string { - const bx = cx - KET_W / 2; - const by = y - GATE_HEIGHT / 2; - let s = ``; - s += `${esc(label)}`; - return s; -} - -function controlDot(cx: number, y: number): string { - return ``; -} - -function controlLine(cx: number, y1: number, y2: number): string { - return ``; -} - -function oplusGate(cx: number, y: number): string { - const r = OPLUS_R; - let s = ``; - s += ``; - s += ``; - s += ``; - s += ``; - return s; -} - -function swapCross(cx: number, y: number): string { - const d = 8; - let s = ``; - s += ``; - return s; -} - -// ── Group expansion ───────────────────────────────────────────────────── - -/** Expand grouped operations to the specified depth. - * At depth 0, groups are left as-is (rendered as a single box). - * At depth 1+, the group's children columns are inlined. */ -function expandGrid(grid: Column[], depth: number): Column[] { - if (depth <= 0) return grid; - - const result: Column[] = []; - for (const col of grid) { - const expandedComponents: Operation[] = []; - let inlinedColumns: Column[] | null = null; - - for (const op of col.components) { - if (op.children && op.children.length > 0) { - // This operation is a group — expand it - const childGrid = expandGrid(op.children, depth - 1); - if (inlinedColumns === null) { - inlinedColumns = childGrid; - } else { - // Merge: pad the shorter one with empty columns - for (let i = 0; i < childGrid.length; i++) { - if (i < inlinedColumns.length) { - inlinedColumns[i] = { - components: [ - ...inlinedColumns[i].components, - ...childGrid[i].components, - ], - }; - } else { - inlinedColumns.push(childGrid[i]); - } - } - } - } else { - expandedComponents.push(op); - } - } - - if (inlinedColumns) { - // If there are also non-group ops in this column, prepend them - // to the first inlined column - if (expandedComponents.length > 0) { - inlinedColumns[0] = { - components: [...expandedComponents, ...inlinedColumns[0].components], - }; - } - result.push(...inlinedColumns); - } else { - result.push(col); - } - } - return result; -} - -// ── Column width computation ──────────────────────────────────────────── - -interface QubitPos { - id: number; - y: number; -} - -function computeQubitPositions(qubits: Qubit[], offsetY: number): QubitPos[] { - let y = offsetY + START_Y + GATE_PAD + GATE_HEIGHT / 2; - return qubits.map((q) => { - const pos = { id: q.id, y }; - // Advance by gate+pad for each qubit row, plus classical results - const numClassical = q.numResults ?? 0; - y += GATE_HEIGHT + GATE_PAD * 2; - y += numClassical * (GATE_HEIGHT / 2 + GATE_PAD); - return pos; - }); -} - -function qubitY(positions: QubitPos[], qubitId: number): number { - const q = positions.find((p) => p.id === qubitId); - return q ? q.y : 0; -} - -function operationWidth(op: Operation): number { - switch (op.kind) { - case "measurement": - return MEAS_W; - case "ket": - return KET_W; - case "unitary": { - if (op.gate === "CNOT" || op.gate === "CX" || op.gate === "X") { - // Check if it's a controlled-X (rendered as CNOT dot+oplus) - if (op.controls && op.controls.length > 0 && op.gate === "X") { - return OPLUS_R * 2; - } - if (op.gate === "CNOT" || op.gate === "CX") { - return OPLUS_R * 2; - } - } - if (op.gate === "SWAP") return MIN_GATE_WIDTH; - const argStr = op.args?.join(", "); - return gateWidth(op.gate + (op.isAdjoint ? MATH.dagger : ""), argStr); - } - default: - return MIN_GATE_WIDTH; - } -} - -function columnWidth(col: Column): number { - let maxW = MIN_GATE_WIDTH; - for (const op of col.components) { - maxW = Math.max(maxW, operationWidth(op)); - } - return maxW; -} - -// ── Bounding box tracking for operations ──────────────────────────────── - -function trackOperationBB( - op: Operation, - cx: number, - positions: QubitPos[], - extendBB: (cx: number, cy: number, hw: number, hh: number) => void, -) { - switch (op.kind) { - case "measurement": { - const qy = qubitY(positions, op.qubits[0].qubit); - extendBB(cx, qy, MEAS_W / 2 + 2, MEAS_H / 2 + 2); - break; - } - case "ket": { - const qy = qubitY(positions, op.targets[0].qubit); - extendBB(cx, qy, KET_W / 2 + 2, GATE_HEIGHT / 2 + 2); - break; - } - case "unitary": { - const targetYs = op.targets.map((t: Register) => - qubitY(positions, t.qubit), - ); - const controlYs = (op.controls ?? []).map((c: Register) => - qubitY(positions, c.qubit), - ); - const allYs = [...targetYs, ...controlYs]; - const minY = Math.min(...allYs); - const maxY = Math.max(...allYs); - - // CNOT / controlled-X - if ( - (op.gate === "CNOT" || - op.gate === "CX" || - (op.gate === "X" && (op.controls?.length ?? 0) > 0)) && - targetYs.length === 1 - ) { - extendBB(cx, targetYs[0], OPLUS_R + 2, OPLUS_R + 2); - for (const cy of controlYs) - extendBB(cx, cy, CONTROL_DOT_R + 2, CONTROL_DOT_R + 2); - break; - } - - // SWAP - if (op.gate === "SWAP" && targetYs.length === 2) { - for (const ty of targetYs) extendBB(cx, ty, 12, 12); - for (const cy of controlYs) - extendBB(cx, cy, CONTROL_DOT_R + 2, CONTROL_DOT_R + 2); - break; - } - - // Regular unitary box - const argStr = op.args?.join(", "); - const w = gateWidth(op.gate + (op.isAdjoint ? MATH.dagger : ""), argStr); - const h = Math.max(GATE_HEIGHT, maxY - minY + GATE_HEIGHT); - extendBB(cx, (minY + maxY) / 2, w / 2 + 2, h / 2 + 2); - for (const cy of controlYs) - extendBB(cx, cy, CONTROL_DOT_R + 2, CONTROL_DOT_R + 2); - break; - } - } -} - -// ── Render one operation ──────────────────────────────────────────────── - -function renderOperation( - op: Operation, - cx: number, - positions: QubitPos[], -): string { - let svg = ""; - - switch (op.kind) { - case "measurement": { - const qy = qubitY(positions, op.qubits[0].qubit); - svg += measureBox(cx, qy); - break; - } - case "ket": { - const qy = qubitY(positions, op.targets[0].qubit); - svg += ketBox(cx, qy, op.gate); - break; - } - case "unitary": { - const label = op.gate + (op.isAdjoint ? MATH.dagger : ""); - const argStr = op.args?.join(", "); - const targetYs = op.targets.map((t: Register) => - qubitY(positions, t.qubit), - ); - const controls = op.controls ?? []; - const controlYs = controls.map((c: Register) => - qubitY(positions, c.qubit), - ); - - const allYs = [...targetYs, ...controlYs]; - const minY = Math.min(...allYs); - const maxY = Math.max(...allYs); - - // Handle special gates - if ( - (op.gate === "CNOT" || op.gate === "CX") && - targetYs.length === 1 && - controlYs.length >= 1 - ) { - // CNOT: control dot(s) + oplus on target - if (minY !== maxY) svg += controlLine(cx, minY, maxY); - for (const cy of controlYs) svg += controlDot(cx, cy); - svg += oplusGate(cx, targetYs[0]); - break; - } - - if (op.gate === "X" && controls.length > 0 && targetYs.length === 1) { - // Controlled-X rendered as CNOT - if (minY !== maxY) svg += controlLine(cx, minY, maxY); - for (const cy of controlYs) svg += controlDot(cx, cy); - svg += oplusGate(cx, targetYs[0]); - break; - } - - if (op.gate === "SWAP" && targetYs.length === 2) { - if (minY !== maxY) svg += controlLine(cx, minY, maxY); - for (const cy of controlYs) svg += controlDot(cx, cy); - svg += swapCross(cx, targetYs[0]); - svg += swapCross(cx, targetYs[1]); - break; - } - - // Controlled unitary: dots + line + box on targets - if (controls.length > 0) { - if (minY !== maxY) svg += controlLine(cx, minY, maxY); - for (const cy of controlYs) svg += controlDot(cx, cy); - } - - const w = gateWidth(label, argStr); - svg += unitaryBox(cx, targetYs, label, w, argStr); - break; - } - } - return svg; -} - -// ── CSS for standalone SVG ────────────────────────────────────────────── - -const CIRCUIT_CSS = ` - .qs-circuit line, .qs-circuit circle, .qs-circuit rect { - stroke: #202020; stroke-width: 1; - } - .qs-circuit text { - fill: #202020; dominant-baseline: middle; text-anchor: middle; - font-family: "KaTeX_Main", sans-serif; user-select: none; - } - .qs-circuit .qs-qubit-label { text-anchor: end; } - .qs-circuit .gate-unitary { fill: #ddd; } - .qs-circuit .gate text { fill: #202020; } - .qs-circuit .gate-measure { fill: #007acc; } - .qs-circuit .arc-measure, .qs-circuit .qs-line-measure { - stroke: #fff; fill: none; stroke-width: 1; - } - .qs-circuit .gate-ket { fill: #007acc; } - .qs-circuit .ket-text { fill: #fff; stroke: none; } - .qs-circuit .control-dot { fill: #202020; stroke: none; } - .qs-circuit .control-line { stroke: #202020; stroke-width: 1; } - .qs-circuit .oplus > circle { fill: #fff; stroke: #202020; stroke-width: 2; } - .qs-circuit .oplus > line { stroke: #202020; stroke-width: 2; } - .qs-circuit rect.gate-swap { fill: transparent; stroke: transparent; } - .qs-circuit .register-classical { stroke-width: 0.5; } -`; - -const CIRCUIT_CSS_DARK = ` - .qs-circuit line, .qs-circuit circle, .qs-circuit rect { - stroke: #d4d4d4; stroke-width: 1; - } - .qs-circuit text { fill: #d4d4d4; } - .qs-circuit .gate-unitary { fill: #333; } - .qs-circuit .gate text { fill: #d4d4d4; } - .qs-circuit .gate-measure { fill: #007acc; } - .qs-circuit .arc-measure, .qs-circuit .qs-line-measure { - stroke: #fff; fill: none; stroke-width: 1; - } - .qs-circuit .gate-ket { fill: #007acc; } - .qs-circuit .ket-text { fill: #fff; stroke: none; } - .qs-circuit .control-dot { fill: #d4d4d4; stroke: none; } - .qs-circuit .control-line { stroke: #d4d4d4; stroke-width: 1; } - .qs-circuit .oplus > circle { fill: #1e1e1e; stroke: #d4d4d4; stroke-width: 2; } - .qs-circuit .oplus > line { stroke: #d4d4d4; stroke-width: 2; } -`; - -// ── Main export ───────────────────────────────────────────────────────── - -export interface CircuitToSvgOptions { - /** Maximum number of gate columns per row before wrapping. 0 = no wrap. */ - gatesPerRow?: number; - /** Use dark-mode colors. */ - darkMode?: boolean; - /** How many levels of grouped operations to expand. - * 0 (default) = show groups as collapsed single-gate boxes. - * 1 = expand one level, showing children inline. - * Infinity = fully expand everything. */ - renderDepth?: number; -} - -/** - * Render a quantum circuit to a standalone SVG string. - * - * Accepts any format that `toCircuitGroup` understands (Circuit, - * CircuitGroup, or legacy schema). Returns a self-contained SVG with - * embedded CSS — no external stylesheet needed. - * - * @param circuit Circuit data (object or JSON string). - * @param options Rendering options. - * @returns SVG markup string. - */ -export function circuitToSvg( - circuit: CircuitGroup | Circuit | unknown, - options: CircuitToSvgOptions = {}, -): string { - const { gatesPerRow = 0, darkMode = false, renderDepth = 0 } = options; - - // Parse if given as string - const data = typeof circuit === "string" ? JSON.parse(circuit) : circuit; - - const result = toCircuitGroup(data); - if (!result.ok) { - throw new Error(`Circuit conversion error: ${result.error}`); - } - - const cg = result.circuitGroup; - if (cg.circuits.length === 0 || !cg.circuits[0]) { - throw new Error("No circuit found in input."); - } - - const circ = cg.circuits[0]; - const qubits = circ.qubits ?? []; - // Expand grouped operations to the requested depth - const grid = expandGrid(circ.componentGrid ?? [], renderDepth); - - if (qubits.length === 0) { - return `Empty circuit`; - } - - // Split columns into rows - const rows: Column[][] = []; - if (gatesPerRow > 0 && grid.length > gatesPerRow) { - for (let i = 0; i < grid.length; i += gatesPerRow) { - rows.push(grid.slice(i, i + gatesPerRow)); - } - } else { - rows.push(grid); - } - - // Compute qubit positions for a single row (relative to row origin) - const basePositions = computeQubitPositions(qubits, 0); - const rowHeight = - basePositions.length > 0 - ? basePositions[basePositions.length - 1].y - - basePositions[0].y + - GATE_HEIGHT + - GATE_PAD * 2 - : GATE_HEIGHT + GATE_PAD * 2; - - let totalWidth = 0; - let totalHeight = 0; - const rowSvgs: string[] = []; - - // Track bounding box of all rendered elements - let bbMinX = Infinity, - bbMinY = Infinity, - bbMaxX = -Infinity, - bbMaxY = -Infinity; - function extendBB(cx: number, cy: number, hw: number, hh: number) { - bbMinX = Math.min(bbMinX, cx - hw); - bbMinY = Math.min(bbMinY, cy - hh); - bbMaxX = Math.max(bbMaxX, cx + hw); - bbMaxY = Math.max(bbMaxY, cy + hh); - } - - for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { - const rowCols = rows[rowIdx]; - const rowOffsetY = rowIdx * (rowHeight + ROW_GAP); - - // Qubit positions for this row - const positions = computeQubitPositions(qubits, rowOffsetY); - - // Compute column x-positions - const colWidths = rowCols.map(columnWidth); - const colXs: number[] = []; - let x = START_X + GATE_PAD; - for (let i = 0; i < rowCols.length; i++) { - x += colWidths[i] / 2 + GATE_PAD; - colXs.push(x); - x += colWidths[i] / 2 + GATE_PAD; - } - const wireEndX = x + WIRE_END_PAD; - - // Qubit labels (only first row if wrapping) - let rowSvg = ""; - if (rowIdx === 0 || gatesPerRow > 0) { - for (const pos of positions) { - rowSvg += qubitLabel(pos.id, pos.y); - // Label text extends left; approximate width - extendBB(START_X - 15, pos.y, 60, LABEL_FONT / 2 + 2); - } - } - - // Horizontal wires - const wireStartX = START_X - 10; - for (const pos of positions) { - rowSvg += ``; - extendBB( - (wireStartX + wireEndX) / 2, - pos.y, - (wireEndX - wireStartX) / 2, - 1, - ); - } - - // Gates - for (let ci = 0; ci < rowCols.length; ci++) { - const col = rowCols[ci]; - const cx = colXs[ci]; - for (const op of col.components) { - rowSvg += `${renderOperation(op, cx, positions)}`; - // Track bounding box for each operation - trackOperationBB(op, cx, positions, extendBB); - } - } - - rowSvgs.push(rowSvg); - totalWidth = Math.max(totalWidth, wireEndX); - totalHeight = rowOffsetY + rowHeight; - } - - // Build final SVG with exact bounding box + small padding - const pad = 5; - if (bbMinX === Infinity) { - bbMinX = 0; - bbMinY = 0; - bbMaxX = totalWidth; - bbMaxY = totalHeight; - } - const vbX = bbMinX - pad; - const vbY = bbMinY - pad; - const vbW = bbMaxX - bbMinX + pad * 2; - const vbH = bbMaxY - bbMinY + pad * 2; - const css = darkMode ? CIRCUIT_CSS_DARK : CIRCUIT_CSS; - - let svg = ``; - svg += ``; - for (const rowSvg of rowSvgs) { - svg += rowSvg; - } - svg += ``; - - return svg; -} diff --git a/source/npm/qsharp/ux/histogram.tsx b/source/npm/qsharp/ux/histogram.tsx index 5e265aa683..9b8b85e679 100644 --- a/source/npm/qsharp/ux/histogram.tsx +++ b/source/npm/qsharp/ux/histogram.tsx @@ -2,86 +2,6 @@ // Licensed under the MIT License. import { useEffect, useRef, useState } from "preact/hooks"; -import { h } from "preact"; -import { renderToString } from "preact-render-to-string"; - -// Concrete color palettes for standalone SVG export (no host CSS vars). -const lightPalette = { - hostBackground: "#eee", - hostForeground: "#222", - textHighContrast: "#000", - widgetOutline: "#ccc", - shapeFill: "#8ab8ff", - shapeFillSelected: "#b5c5f2", - shapeStrokeSelected: "#587ddd", - shapeStrokeHover: "#6b6b6b", - menuFill: "#c4dbeb", - menuFillHover: "#9cf", - menuFillSelected: "#7af", - midGray: "#888", -}; - -const darkPalette = { - hostBackground: "#222", - hostForeground: "#eee", - textHighContrast: "#fff", - widgetOutline: "#444", - shapeFill: "#4aa3ff", - shapeFillSelected: "#ffd54f", - shapeStrokeSelected: "#ffecb3", - shapeStrokeHover: "#c5c5c5", - menuFill: "#444", - menuFillHover: "#468", - menuFillSelected: "#47a", - midGray: "#888", -}; - -/** Build a

Total shots: {props.shotCount}

) : null} - - {embedCss ? ( - - - - ) : null} + {bucketArray.map((entry, idx) => { const label = showKetLabels ? resultToKet(entry[0]) : entry[0]; @@ -481,9 +376,9 @@ export function Histogram(props: HistogramProps) { y={y} width={barFillWidth} height={height} - onMouseOver={isStatic ? undefined : onMouseOverRect} - onMouseOut={isStatic ? undefined : onMouseOutRect} - onClick={isStatic ? undefined : onClickRect} + onMouseOver={onMouseOverRect} + onMouseOut={onMouseOutRect} + onClick={onClickRect} data-raw-label={entry[0]} > {barLabel} @@ -507,14 +402,14 @@ export function Histogram(props: HistogramProps) { {histogramLabel} - {!isStatic && ( + { {hoverLabel} - )} + } {/* The settings icon */} - {!isStatic && ( + { - )} + } {/* The info icon */} - {!isStatic && ( + { - )} + } {/* The menu box */} - {!isStatic && ( + { - )} + } {/* The info box */} - {!isStatic && ( + {
- )} + } ); } - -/** - * Render a standalone Histogram SVG string. - * Uses `renderToString` from preact-render-to-string. - * - * @param props - Props for the Histogram component. - * `darkMode` should be `true` or `false` (not `undefined`) so - * CSS custom properties are resolved to concrete values. - * `static` defaults to `true` when not specified. - */ -export function histogramToSvg( - props: Omit, -): string { - const fullProps: HistogramProps = { - ...props, - static: props.static ?? true, - onFilter: () => {}, - onSettingsChange: undefined, - shotsHeader: false, - }; - let svg = renderToString(h(Histogram, fullProps)); - // renderToString wraps in a fragment — extract the - const svgStart = svg.indexOf(" 0) svg = svg.slice(svgStart); - const svgEnd = svg.lastIndexOf(""); - if (svgEnd >= 0) svg = svg.slice(0, svgEnd + 6); - return svg; -} diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index 9215869206..f6f82a46ce 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -27,7 +27,6 @@ export { MoleculeViewer } from "./chem/index.js"; export { OrbitalEntanglement, type OrbitalEntanglementProps, - orbitalEntanglementToSvg, } from "./orbitalEntanglement.js"; export { ensureTheme, diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/orbitalEntanglement.tsx index 0ff9541df3..11de696e32 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/orbitalEntanglement.tsx @@ -1,18 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { h } from "preact"; -import { renderToString } from "preact-render-to-string"; - /** * Orbital entanglement chord diagram. * * Renders single-orbital entropies and mutual information as an SVG chord * diagram. Arc length is proportional to single-orbital entropy; chord * thickness is proportional to pairwise mutual information. - * - * The diagram is rendered entirely as native SVG so that the markup can be - * serialised to a standalone `.svg` file from the Python widget. */ // --------------------------------------------------------------------------- @@ -42,7 +36,6 @@ export interface OrbitalEntanglementProps { height?: number; selectionColor?: string; selectionLinewidth?: number; - darkMode?: boolean; } // --------------------------------------------------------------------------- @@ -156,23 +149,10 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { title = "Orbital Entanglement", width = 600, height = 660, - selectionColor = "#222222", + selectionColor = "var(--qdk-focus-border)", selectionLinewidth = 2.5, - darkMode, } = props; - const isStatic = darkMode !== undefined; - const fgColor = isStatic - ? darkMode - ? "#e0e0e0" - : "#222222" - : "currentColor"; - const bgColor = isStatic - ? darkMode - ? "#1e1e1e" - : "transparent" - : "transparent"; - const n = s1Entropies.length; // --- labels --- @@ -300,7 +280,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { chords.sort((a, b) => a.val - b.val); // --- selected set --- - const selectedSet = new Set((selectedIndices ?? []).map(String)); + const selectedSet = new Set(selectedIndices ?? []); // --- viewBox --- const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; @@ -321,21 +301,12 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { {/* Title */} {title && ( - + {title} )} @@ -376,7 +347,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { {/* Selection outlines */} {Array.from({ length: n }, (_, i) => - selectedSet.has(labels[i]) ? ( + selectedSet.has(i) ? ( ); })() @@ -427,13 +397,10 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { {tickLine} {labels[i]} @@ -446,13 +413,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { {/* ---- color-bar legends ---- */} {/* Arc (entropy) color bar */} - + Single-orbital entropy {Array.from({ length: numCbStops }, (_, k) => { @@ -468,15 +429,13 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { /> ); })} - + 0 {s1Max.toFixed(2)} @@ -487,9 +446,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { Mutual information @@ -506,15 +463,17 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { /> ); })} - + 0 {miMax.toFixed(2)} @@ -522,13 +481,3 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { ); } - -// --------------------------------------------------------------------------- -// SVG serialisation -// --------------------------------------------------------------------------- - -export function orbitalEntanglementToSvg( - props: OrbitalEntanglementProps, -): string { - return renderToString(h(OrbitalEntanglement, props)); -} diff --git a/source/npm/qsharp/ux/qsharp-ux.css b/source/npm/qsharp/ux/qsharp-ux.css index 8ae3ceca6a..02034b091d 100644 --- a/source/npm/qsharp/ux/qsharp-ux.css +++ b/source/npm/qsharp/ux/qsharp-ux.css @@ -266,6 +266,59 @@ modern-normalize (see https://mattbrictson.com/blog/css-normalize-and-reset for fill: var(--qdk-host-foreground); } +/* Orbital entanglement */ + +.qs-orbital-entanglement { + max-width: 600px; + color: var(--qdk-host-foreground); + background-color: var(--qdk-host-background); + font-family: var(--qdk-font-family); +} + +.qs-orbital-entanglement-title, +.qs-orbital-entanglement-legend-title, +.qs-orbital-entanglement-legend-value, +.qs-orbital-entanglement-label { + fill: currentColor; +} + +.qs-orbital-entanglement-title { + text-anchor: middle; + font-size: 14px; + font-weight: 600; +} + +.qs-orbital-entanglement-legend-title { + text-anchor: middle; + font-size: 9px; +} + +.qs-orbital-entanglement-legend-value { + font-size: 8px; +} + +.qs-orbital-entanglement-legend-value-end { + text-anchor: end; +} + +.qs-orbital-entanglement-label { + dominant-baseline: central; + font-weight: 600; +} + +.qs-orbital-entanglement-label-start { + text-anchor: start; +} + +.qs-orbital-entanglement-label-end { + text-anchor: end; +} + +.qs-orbital-entanglement-label-tick { + stroke: var(--qdk-mid-gray); + stroke-width: 0.005; +} + /* RE details */ .estimate-details { diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index 748edc727d..2c14f61155 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -18,7 +18,6 @@ import { MoleculeViewer, OrbitalEntanglement, type OrbitalEntanglementProps, - orbitalEntanglementToSvg, } from "qsharp-lang/ux"; import markdownIt from "markdown-it"; import "./widgets.css"; @@ -342,34 +341,6 @@ function renderOrbitalEntanglement({ model, el }: RenderArgs) { model.on("change:labels", onChange); model.on("change:selected_indices", onChange); model.on("change:options", onChange); - - // Handle SVG export requests from Python - model.on("msg:custom", (msg: { type: string; dark_mode?: boolean }) => { - if (msg.type === "export_svg") { - let svgString: string | undefined; - - if (msg.dark_mode !== undefined) { - // Re-render with explicit dark_mode for standalone SVG - svgString = orbitalEntanglementToSvg( - getWidgetProps({ darkMode: msg.dark_mode }), - ); - } else { - // Serialize the current DOM SVG - const svgEl = el.querySelector("svg"); - if (svgEl) { - const serializer = new XMLSerializer(); - svgString = serializer.serializeToString(svgEl); - } - } - - if (svgString) { - model.send({ - type: "svg_data", - svg: svgString, - }); - } - } - }); } function renderMoleculeViewer({ model, el }: RenderArgs) { diff --git a/source/widgets/js/render_svg.mjs b/source/widgets/js/render_svg.mjs deleted file mode 100644 index 52efa55724..0000000000 --- a/source/widgets/js/render_svg.mjs +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Server-side renderer for Q# visualisation components. -// Reads JSON from stdin, writes SVG/HTML to stdout. -// -// Input format: -// { "component": "ChordDiagram" | "Histogram" | "Circuit" | "OrbitalEntanglement", -// "props": { ... } } -// -// This file is bundled by esbuild into a self-contained script so that it -// works wherever Node.js is available — no sibling module imports needed. - -import { readFileSync } from "node:fs"; -import { orbitalEntanglementToSvg } from "../../npm/qsharp/ux/orbitalEntanglement.tsx"; -import { histogramToSvg } from "../../npm/qsharp/ux/histogram.tsx"; -import { circuitToSvg } from "../../npm/qsharp/ux/circuitToSvg.ts"; - -const input = readFileSync(0, "utf-8"); // stdin -const { component, props } = JSON.parse(input); - -let output = ""; - -switch (component) { - // ---- OrbitalEntanglement (chord diagram) ---- - case "ChordDiagram": - case "OrbitalEntanglement": { - output = orbitalEntanglementToSvg(props); - break; - } - - // ---- Histogram (pure Preact SVG) ---- - case "Histogram": { - // The TS component expects `data` as a Map, but JSON gives us an object. - const histProps = { - ...props, - data: new Map(Object.entries(props.data)), - }; - output = histogramToSvg(histProps); - break; - } - - // ---- Circuit (pure string SVG, no DOM needed) ---- - case "Circuit": { - const circuitData = - typeof props.circuit === "string" - ? JSON.parse(props.circuit) - : props.circuit; - output = circuitToSvg(circuitData, { - gatesPerRow: props.gates_per_row ?? 0, - darkMode: props.dark_mode ?? false, - renderDepth: props.render_depth ?? 0, - }); - break; - } - - default: - process.stderr.write(`Unknown component: ${component}\n`); - process.exit(1); -} - -process.stdout.write(output); diff --git a/source/widgets/package.json b/source/widgets/package.json index ba32e0a576..99b732b050 100644 --- a/source/widgets/package.json +++ b/source/widgets/package.json @@ -1,7 +1,7 @@ { "scripts": { "dev": "npm run build -- --sourcemap=inline --watch", - "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static && npx esbuild js/render_svg.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_svg.mjs --loader:.css=text && npx esbuild js/render_png.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_png.mjs --external:playwright && cp ../../node_modules/3dmol/build/3Dmol-min.js src/qsharp_widgets/static/3Dmol-min.js" + "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static && npx esbuild js/render_png.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_png.mjs --external:playwright && cp ../../node_modules/3dmol/build/3Dmol-min.js src/qsharp_widgets/static/3Dmol-min.js" }, "devDependencies": {} } diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 59efda660e..63a118363c 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -242,9 +242,6 @@ class OrbitalEntanglement(anywidget.AnyWidget): selected_indices = traitlets.List(allow_none=True, default_value=None).tag(sync=True) options = traitlets.Dict().tag(sync=True) - _svg_data = None - _svg_event = None - def __init__( self, wavefunction=None, @@ -320,99 +317,6 @@ def __init__( selected_indices=selected_indices, options=options, ) - self.on_msg(self._handle_msg) - - def _handle_msg(self, widget, content, buffers): - if content.get("type") == "svg_data": - self._svg_data = content["svg"] - if self._svg_event is not None: - self._svg_event.set() - - def _build_props(self, dark_mode=None): - """Build the props dict expected by the JS rendering component.""" - props = { - "s1Entropies": list(self.s1_entropies), - "mutualInformation": [list(row) for row in self.mutual_information], - "labels": list(self.labels), - } - if self.selected_indices is not None: - props["selectedIndices"] = list(self.selected_indices) - for k, v in (self.options or {}).items(): - props[_snake_to_camel(k)] = v - if dark_mode is not None: - props["darkMode"] = bool(dark_mode) - return props - - def _render_svg_server_side(self, dark_mode=None): - """Render SVG via the bundled Node.js script (no frontend needed).""" - import json - import shutil - import subprocess - - node = shutil.which("node") - if node is None: - raise RuntimeError( - "Node.js is required for server-side SVG export but " - "'node' was not found on PATH." - ) - script = pathlib.Path(__file__).parent / "static" / "render_svg.mjs" - payload = json.dumps( - {"component": "OrbitalEntanglement", "props": self._build_props(dark_mode)} - ) - result = subprocess.run( - [node, str(script)], - input=payload, - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode != 0: - raise RuntimeError( - f"Server-side SVG render failed:\n{result.stderr}" - ) - return result.stdout - - def export_svg(self, path=None, timeout=5, dark_mode=None): - """Export the rendered diagram as an SVG string or file. - - Parameters - ---------- - path : str or Path, optional - When given the SVG is written to this file and the path is - returned. Otherwise the SVG markup string is returned. - timeout : float - Seconds to wait for the front-end to respond. - dark_mode : bool, optional - When ``True`` the exported SVG uses light-on-dark colours; - when ``False`` it uses dark-on-light colours. If ``None`` - (the default) the current in-notebook rendering is serialised - as-is (colours follow the host theme via CSS variables). - - Returns - ------- - str - SVG markup (when *path* is ``None``) or the file path. - """ - import threading - - self._svg_data = None - self._svg_event = threading.Event() - msg: dict[str, object] = {"type": "export_svg"} - if dark_mode is not None: - msg["dark_mode"] = bool(dark_mode) - self.send(msg) - if not self._svg_event.wait(timeout=timeout): - # No frontend responded — fall back to server-side rendering. - svg = self._render_svg_server_side(dark_mode) - else: - svg = self._svg_data - self._svg_event = None - if path is not None: - from pathlib import Path as _P - - _P(path).write_text(svg, encoding="utf-8") - return str(path) - return svg class MoleculeViewer(anywidget.AnyWidget): From bb82e01884cb608da9a21a7c3c010bd9316db341 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 10:54:57 +0200 Subject: [PATCH 22/33] further reverts --- source/npm/qsharp/ux/histogram.tsx | 303 ++++++++++++----------------- source/widgets/js/render_png.mjs | 147 -------------- source/widgets/package.json | 2 +- 3 files changed, 126 insertions(+), 326 deletions(-) delete mode 100644 source/widgets/js/render_png.mjs diff --git a/source/npm/qsharp/ux/histogram.tsx b/source/npm/qsharp/ux/histogram.tsx index 9b8b85e679..d7504232bd 100644 --- a/source/npm/qsharp/ux/histogram.tsx +++ b/source/npm/qsharp/ux/histogram.tsx @@ -92,7 +92,7 @@ function resultToKet(result: string): string { } } -export type HistogramProps = { +export function Histogram(props: { shotCount: number; data: Map; filter: string; @@ -101,14 +101,7 @@ export type HistogramProps = { labels?: "raw" | "kets" | "none"; items?: "all" | "top-10" | "top-25"; sort?: "a-to-z" | "high-to-low" | "low-to-high"; - onSettingsChange?: (settings: { - labels: "raw" | "kets" | "none"; - items: "all" | "top-10" | "top-25"; - sort: "a-to-z" | "high-to-low" | "low-to-high"; - }) => void; -}; - -export function Histogram(props: HistogramProps) { +}) { const [hoverLabel, setHoverLabel] = useState(""); const [scale, setScale] = useState({ zoom: 1.0, offset: 1.0 }); const [menuSelection, setMenuSelection] = useState(() => { @@ -222,26 +215,6 @@ export function Histogram(props: HistogramProps) { setScale({ zoom: 1, offset: 1 }); } gMenu.current.style.display = "none"; - - // Notify parent of settings change - if (props.onSettingsChange) { - const sortValues: ("a-to-z" | "high-to-low" | "low-to-high")[] = [ - "a-to-z", - "high-to-low", - "low-to-high", - ]; - const labelsValues: ("raw" | "kets" | "none")[] = ["raw", "kets", "none"]; - const itemsValues: ("all" | "top-10" | "top-25")[] = [ - "all", - "top-10", - "top-25", - ]; - props.onSettingsChange({ - sort: sortValues[newMenuSelection["sortOrder"] ?? 0], - labels: labelsValues[newMenuSelection["labels"] ?? 0], - items: itemsValues[newMenuSelection["itemCount"] ?? 0], - }); - } } function toggleInfo() { @@ -271,7 +244,7 @@ export function Histogram(props: HistogramProps) { function onWheel(e: WheelEvent): void { // Ctrl+scroll is the event sent by pinch-to-zoom on a trackpad. Shift+scroll is common for - // panning horizontally. + // panning horizontally. See https://danburzo.ro/dom-gestures/ for the messy details. if (!e.ctrlKey && !e.shiftKey) return; // When using a mouse wheel, the deltaY is the scroll amount, but if the shift key is pressed @@ -402,163 +375,137 @@ export function Histogram(props: HistogramProps) { {histogramLabel} - { - - {hoverLabel} - - } + + {hoverLabel} + {/* The settings icon */} - { - - - - - - - - } + + + + + + + {/* The info icon */} - { - - - - - - } + + + + + {/* The menu box */} - { - {/* The info box */} - { - - - - - This histogram shows the frequency of unique 'shot' results. - - - Click the top-left 'settings' icon for display options. - - - You can zoom the chart using the pinch-to-zoom gesture, - - - or use Ctrl+scroll wheel to zoom in/out. - - - To pan left & right, press Shift while zooming. - - - Click on a bar to filter the shot details to that result. - - - Click anywhere in this box to dismiss it. - - - - } + + + + + This histogram shows the frequency of unique 'shot' results. + + + Click the top-left 'settings' icon for display options. + + + You can zoom the chart using the pinch-to-zoom gesture, + + + or use Ctrl+scroll wheel to zoom in/out. + + + To pan left & right, press Shift while zooming. + + + Click on a bar to filter the shot details to that result. + + + Click anywhere in this box to dismiss it. + + + ); diff --git a/source/widgets/js/render_png.mjs b/source/widgets/js/render_png.mjs deleted file mode 100644 index 45ac999e93..0000000000 --- a/source/widgets/js/render_png.mjs +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Headless PNG renderer for MoleculeViewer using Playwright + 3Dmol. -// Reads JSON from stdin, writes PNG to stdout. -// -// Input format: -// { "molecule_data": "...", // XYZ format string -// "cube_data": "...", // optional: Gaussian cube file string -// "iso_value": 0.02, // optional: isovalue for orbital -// "width": 640, // optional: image width -// "height": 480, // optional: image height -// "style": "Sphere" // optional: Sphere|Stick|Line -// } -// -// Requires: playwright (npm), Chromium browser installed via -// npx playwright install chromium - -import { readFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// 3Dmol source is copied alongside this script at build time. -const threeDmolSrc = readFileSync(resolve(__dirname, "3Dmol-min.js"), "utf-8"); - -const input = readFileSync(0, "utf-8"); -const props = JSON.parse(input); - -const { - molecule_data: moleculeData, - cube_data: cubeData, - iso_value: isoValue = 0.02, - width = 640, - height = 480, - style = "Sphere", -} = props; - -if (!moleculeData) { - process.stderr.write("Error: molecule_data is required\n"); - process.exit(1); -} - -// Build self-contained HTML -const html = ` - - - -
- - - -`; - -// Launch Playwright and screenshot -async function render() { - let chromium; - try { - ({ chromium } = await import("playwright")); - } catch { - process.stderr.write( - "Error: playwright is not installed.\n" + - "Install it with: npm install playwright\n" + - "Then install a browser: npx playwright install chromium\n", - ); - process.exit(1); - } - - const browser = await chromium.launch({ - args: ["--no-sandbox", "--disable-gpu"], - }); - try { - const page = await browser.newPage({ - viewport: { width, height }, - }); - - await page.setContent(html, { waitUntil: "domcontentloaded" }); - - // Wait for 3Dmol to finish rendering - await page.waitForFunction("window.__renderDone === true", { - timeout: 15000, - }); - - // Give WebGL a moment to flush - await page.waitForTimeout(500); - - const png = await page.screenshot({ - type: "png", - omitBackground: true, - }); - - process.stdout.write(png); - } finally { - await browser.close(); - } -} - -render().catch((err) => { - process.stderr.write(`Render error: ${err.message}\n`); - process.exit(1); -}); diff --git a/source/widgets/package.json b/source/widgets/package.json index 99b732b050..5926f1dced 100644 --- a/source/widgets/package.json +++ b/source/widgets/package.json @@ -1,7 +1,7 @@ { "scripts": { "dev": "npm run build -- --sourcemap=inline --watch", - "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static && npx esbuild js/render_png.mjs --minify --format=esm --bundle --platform=node --outfile=src/qsharp_widgets/static/render_png.mjs --external:playwright && cp ../../node_modules/3dmol/build/3Dmol-min.js src/qsharp_widgets/static/3Dmol-min.js" + "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static" }, "devDependencies": {} } From 48af0919201aebb8726de3bc46f0f47a370f0669 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 11:00:02 +0200 Subject: [PATCH 23/33] generalize diagram --- samples/notebooks/orbital_entanglement.ipynb | 19 +++++------- ...bitalEntanglement.tsx => entanglement.tsx} | 30 ++++--------------- source/npm/qsharp/ux/index.ts | 6 ++-- source/npm/qsharp/ux/qsharp-ux.css | 2 +- source/widgets/js/index.tsx | 20 ++++++------- source/widgets/src/qsharp_widgets/__init__.py | 6 ++-- 6 files changed, 30 insertions(+), 53 deletions(-) rename source/npm/qsharp/ux/{orbitalEntanglement.tsx => entanglement.tsx} (93%) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index ae391c0da3..8b7042bfea 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -7,13 +7,8 @@ "source": [ "# Orbital Entanglement Chord Diagram — 250 Synthetic Orbitals\n", "\n", - "Demonstrates the `OrbitalEntanglement` JS widget on a large system with\n", - "**250 orbitals**. 50 orbitals form a strongly-coupled core with high\n", - "single-orbital entropies; the remaining 200 have a long tail of weak\n", - "entropy and negligible mutual information.\n", - "\n", - "No quantum chemistry calculation is needed — we build a mock dataset\n", - "with NumPy and pass raw arrays directly to the widget." + "Demonstrates the `Entanglement` JS widget for an orbital-entanglement use case on a large system with\n", + "synthetic single-orbital entropies and mutual information." ] }, { @@ -115,9 +110,9 @@ "source": [ "## 2 — Display the interactive widget\n", "\n", - "The `OrbitalEntanglement` widget renders as an SVG chord diagram\n", - "directly in the notebook output. Arc length encodes single-orbital\n", - "entropy; chord thickness encodes mutual information. The 50 core\n", + "The `Entanglement` widget renders an orbital-entanglement chord diagram\n", + "directly in the notebook output. Arc length encodes single-orbital\n", + "entropy; chord thickness encodes mutual information. The 50 core\n", "orbitals are highlighted with a dark outline." ] }, @@ -128,9 +123,9 @@ "metadata": {}, "outputs": [], "source": [ - "from qsharp_widgets import OrbitalEntanglement\n", + "from qsharp_widgets import Entanglement\n", "\n", - "widget = OrbitalEntanglement(\n", + "widget = Entanglement(\n", " s1_entropies=s1.tolist(),\n", " mutual_information=mi.tolist(),\n", " labels=[str(i) for i in range(N)],\n", diff --git a/source/npm/qsharp/ux/orbitalEntanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx similarity index 93% rename from source/npm/qsharp/ux/orbitalEntanglement.tsx rename to source/npm/qsharp/ux/entanglement.tsx index 11de696e32..809bfffd8b 100644 --- a/source/npm/qsharp/ux/orbitalEntanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. /** - * Orbital entanglement chord diagram. + * Entanglement chord diagram. * * Renders single-orbital entropies and mutual information as an SVG chord * diagram. Arc length is proportional to single-orbital entropy; chord @@ -13,7 +13,7 @@ // Types // --------------------------------------------------------------------------- -export interface OrbitalEntanglementProps { +export interface EntanglementProps { /** Single-orbital entropies, length N. */ s1Entropies: number[]; /** Mutual information matrix, N×N (row-major flat array or nested). */ @@ -133,7 +133,7 @@ function chordPath( // Component // --------------------------------------------------------------------------- -export function OrbitalEntanglement(props: OrbitalEntanglementProps) { +export function Entanglement(props: EntanglementProps) { const { s1Entropies, mutualInformation, @@ -146,7 +146,7 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { miThreshold = 0, s1Vmax = null, miVmax = null, - title = "Orbital Entanglement", + title = "Entanglement", width = 600, height = 660, selectionColor = "var(--qdk-focus-border)", @@ -223,7 +223,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { prevAngle = ang; prevTier = tier[idx]; } - // wrap‑around const firstIdx = indexOrder[0]; const lastIdx = indexOrder[indexOrder.length - 1]; const wrapGap = arcMids[firstIdx] + 360 - arcMids[lastIdx]; @@ -231,7 +230,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { tier[firstIdx] = (tier[lastIdx] + 1) % maxTiers; } - // --- chord computation --- const miRowSums = mutualInformation.map((row) => row.reduce((a, b) => a + b, 0), ); @@ -276,21 +274,16 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { }); } } - // lightest first so darkest draws on top chords.sort((a, b) => a.val - b.val); - // --- selected set --- const selectedSet = new Set(selectedIndices ?? []); - // --- viewBox --- const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; - // Map [-lim, lim] to [0, width/height] with some padding for color bars - const diagramH = height - 60; // leave room for legends + const diagramH = height - 60; const vbPad = lim * 0.05; const vbSize = (lim + vbPad) * 2; - // color-bar dimensions (drawn inside the SVG) const cbY = diagramH + 8; const cbW = width * 0.6; const cbX = (width - cbW) / 2; @@ -304,18 +297,15 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { width="100%" class="qs-orbital-entanglement" > - {/* Title */} {title && ( {title} )} - {/* Diagram group — centred and scaled to fit */} - {/* Chord lines (lightest → darkest) */} {chords.map((ch, ci) => { const c = colormapEval(CHORD_CMAP, ch.val / miMax); const lw = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); @@ -331,7 +321,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { ); })} - {/* Arcs */} {Array.from({ length: n }, (_, i) => ( ))} - {/* Selection outlines */} {Array.from({ length: n }, (_, i) => selectedSet.has(i) ? ( { const mid = arcMids[i]; const t = tier[i]; @@ -389,8 +376,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { })() : null; - // Font size in SVG user units — we're in a scaled group so - // approximate by dividing the pt size by the scale factor. const fsPx = labelFontSize / (Math.min(width, diagramH) / vbSize / 2); return ( @@ -410,8 +395,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { })} - {/* ---- color-bar legends ---- */} - {/* Arc (entropy) color bar */} Single-orbital entropy @@ -441,7 +424,6 @@ export function OrbitalEntanglement(props: OrbitalEntanglementProps) { - {/* Chord (MI) color bar */} ); -} +} \ No newline at end of file diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index f6f82a46ce..77165b4f9d 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -25,9 +25,9 @@ export { setRenderer, Markdown } from "./renderers.js"; export { Atoms, type ZoneLayout, type TraceData } from "./atoms/index.js"; export { MoleculeViewer } from "./chem/index.js"; export { - OrbitalEntanglement, - type OrbitalEntanglementProps, -} from "./orbitalEntanglement.js"; + Entanglement, + type EntanglementProps, +} from "./entanglement.js"; export { ensureTheme, detectThemeChange, diff --git a/source/npm/qsharp/ux/qsharp-ux.css b/source/npm/qsharp/ux/qsharp-ux.css index 02034b091d..01bf071278 100644 --- a/source/npm/qsharp/ux/qsharp-ux.css +++ b/source/npm/qsharp/ux/qsharp-ux.css @@ -266,7 +266,7 @@ modern-normalize (see https://mattbrictson.com/blog/css-normalize-and-reset for fill: var(--qdk-host-foreground); } -/* Orbital entanglement */ +/* Entanglement */ .qs-orbital-entanglement { max-width: 600px; diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index 2c14f61155..b7bd19bc35 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -16,8 +16,8 @@ import { type ZoneLayout, type TraceData, MoleculeViewer, - OrbitalEntanglement, - type OrbitalEntanglementProps, + Entanglement, + type EntanglementProps, } from "qsharp-lang/ux"; import markdownIt from "markdown-it"; import "./widgets.css"; @@ -88,8 +88,8 @@ function render({ model, el }: RenderArgs) { case "MoleculeViewer": renderMoleculeViewer({ model, el }); break; - case "OrbitalEntanglement": - renderOrbitalEntanglement({ model, el }); + case "Entanglement": + renderEntanglement({ model, el }); break; default: throw new Error(`Unknown component type ${componentType}`); @@ -306,11 +306,11 @@ function renderAtoms({ model, el }: RenderArgs) { model.on("change:trace_data", onChange); } -function renderOrbitalEntanglement({ model, el }: RenderArgs) { - /** Read model state and build the full props object for OrbitalEntanglement. */ +function renderEntanglement({ model, el }: RenderArgs) { + /** Read model state and build the full props object for Entanglement. */ function getWidgetProps( - extra?: Partial, - ): OrbitalEntanglementProps { + extra?: Partial, + ): EntanglementProps { const s1Entropies = model.get("s1_entropies") as number[]; const mutualInformation = model.get("mutual_information") as number[][]; const labels = model.get("labels") as string[]; @@ -328,11 +328,11 @@ function renderOrbitalEntanglement({ model, el }: RenderArgs) { selectedIndices: selectedIndices ?? undefined, ...camelOpts, ...extra, - } as OrbitalEntanglementProps; + } as EntanglementProps; } const onChange = () => { - prender(, el); + prender(, el); }; onChange(); diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 63a118363c..9ec53e0d74 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -231,11 +231,11 @@ def __init__(self, machine_layout, trace_data): super().__init__(machine_layout=machine_layout, trace_data=trace_data) -class OrbitalEntanglement(anywidget.AnyWidget): +class Entanglement(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" _css = pathlib.Path(__file__).parent / "static" / "index.css" - comp = traitlets.Unicode("OrbitalEntanglement").tag(sync=True) + comp = traitlets.Unicode("Entanglement").tag(sync=True) s1_entropies = traitlets.List().tag(sync=True) mutual_information = traitlets.List().tag(sync=True) labels = traitlets.List().tag(sync=True) @@ -253,7 +253,7 @@ def __init__( **options, ): """ - Displays an orbital entanglement chord diagram. + Displays an entanglement chord diagram. Can be constructed either from a ``Wavefunction`` object or from raw entropy / mutual-information arrays. From 6a17ac070a904460dddba106f97f3dc65495fc66 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 11:02:25 +0200 Subject: [PATCH 24/33] update sample --- samples/notebooks/orbital_entanglement.ipynb | 56 ++------------------ 1 file changed, 3 insertions(+), 53 deletions(-) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index 8b7042bfea..fef0152e73 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -146,61 +146,11 @@ "id": "93a1c191", "metadata": {}, "source": [ - "## 3 — Export as SVG (light & dark mode)\n", + "## 3 — Notes\n", "\n", - "`export_svg()` renders the diagram server-side via Node.js — the same\n", - "Preact component used by the interactive widget — so fonts and colours\n", - "are deterministic regardless of viewer.\n", + "This example currently demonstrates the interactive `Entanglement` widget only.\n", "\n", - "Use `dark_mode=True` for light text on a dark background, or\n", - "`dark_mode=False` (default) for dark text on a transparent background." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f10a304c", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "# Light mode (default)\n", - "light_path = Path(\"orbital_entanglement_light.svg\")\n", - "saved = widget.export_svg(path=light_path, dark_mode=False)\n", - "print(f\"Light SVG → {saved} ({light_path.stat().st_size / 1024:.1f} KB)\")\n", - "\n", - "# Dark mode\n", - "dark_path = Path(\"orbital_entanglement_dark.svg\")\n", - "saved = widget.export_svg(path=dark_path, dark_mode=True)\n", - "print(f\"Dark SVG → {saved} ({dark_path.stat().st_size / 1024:.1f} KB)\")" - ] - }, - { - "cell_type": "markdown", - "id": "4d2f8950", - "metadata": {}, - "source": [ - "## 4 — Display the exported SVGs inline\n", - "\n", - "Render both the light and dark variants to verify fonts, text\n", - "colour, and background are baked into the SVG correctly." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "842d7931", - "metadata": {}, - "outputs": [], - "source": [ - "from IPython.display import SVG, display, HTML\n", - "\n", - "display(HTML(\"

Light mode

\"))\n", - "display(SVG(filename=str(light_path)))\n", - "\n", - "display(HTML(\"

Dark mode

\"))\n", - "display(SVG(filename=str(dark_path)))" + "SVG export is not wired up for this widget in the current implementation, so the old export example has been removed from this notebook." ] } ], From 9708ba8a39c6899728a462ceda7d1578c6e91c4a Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 12:01:50 +0200 Subject: [PATCH 25/33] fixes --- samples/notebooks/orbital_entanglement.ipynb | 38 +- source/npm/qsharp/ux/entanglement.tsx | 669 +++++++++++++++---- 2 files changed, 558 insertions(+), 149 deletions(-) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index fef0152e73..28848ce13c 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -24,7 +24,16 @@ "execution_count": null, "id": "c033bd86", "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31mThe kernel failed to start as the Python Environment '.venv (Python -1.-1.-1)' is no longer available. Consider selecting another kernel or refreshing the list of Python Environments." + ] + } + ], "source": [ "import numpy as np\n", "\n", @@ -98,8 +107,8 @@ "\n", "print(f\"{N} orbitals, {N_CORE} selected (highlighted)\")\n", "print(f\"Core indices (first 10): {core_idx[:10].tolist()} ...\")\n", - "print(f\"s1 range: {s1.min():.4f} – {s1.max():.4f}\")\n", - "print(f\"MI range: {mi[mi > 0].min():.4f} – {mi.max():.4f}\")\n", + "print(f\"s1 range: {s1.min():.4f} - {s1.max():.4f}\")\n", + "print(f\"MI range: {mi[mi > 0].min():.4f} - {mi.max():.4f}\")\n", "print(f\"Non-zero MI pairs: {(mi > 0).sum() // 2}\")" ] }, @@ -121,7 +130,16 @@ "execution_count": null, "id": "7469867d", "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31mThe kernel failed to start as the Python Environment '.venv (Python -1.-1.-1)' is no longer available. Consider selecting another kernel or refreshing the list of Python Environments." + ] + } + ], "source": [ "from qsharp_widgets import Entanglement\n", "\n", @@ -140,18 +158,6 @@ ")\n", "widget" ] - }, - { - "cell_type": "markdown", - "id": "93a1c191", - "metadata": {}, - "source": [ - "## 3 — Notes\n", - "\n", - "This example currently demonstrates the interactive `Entanglement` widget only.\n", - "\n", - "SVG export is not wired up for this widget in the current implementation, so the old export example has been removed from this notebook." - ] } ], "metadata": { diff --git a/source/npm/qsharp/ux/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx index 809bfffd8b..645d512b3a 100644 --- a/source/npm/qsharp/ux/entanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -2,28 +2,95 @@ // Licensed under the MIT License. /** - * Entanglement chord diagram. + * Generic chord diagram. * - * Renders single-orbital entropies and mutual information as an SVG chord - * diagram. Arc length is proportional to single-orbital entropy; chord - * thickness is proportional to pairwise mutual information. + * Renders per-node scalar values and pairwise edge weights as an SVG chord + * diagram. Arc length is proportional to the node value; chord thickness + * is proportional to pairwise weight. + * + * The diagram is rendered entirely as native SVG so that the markup can be + * serialised to a standalone `.svg` file from the Python widget. + * + * `Entanglement` is a thin wrapper that supplies orbital-specific + * defaults (title, legend labels, colormaps, scale maxima). */ +import { useState, useRef, useEffect } from "preact/hooks"; +import { h } from "preact"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- +export interface ChordDiagramProps { + /** Per-node scalar values (length N). Drives arc colour. */ + nodeValues: number[]; + /** N×N symmetric weight matrix. Drives chord colour / width. */ + pairwiseWeights: number[][]; + /** Node labels (length N). Falls back to "0", "1", … */ + labels?: string[]; + /** Indices of nodes to highlight with an outline. */ + selectedIndices?: number[]; + + // --- visual knobs (all optional with sensible defaults) --- + gapDeg?: number; + radius?: number; + arcWidth?: number; + lineScale?: number | null; + /** Minimum edge weight to draw a chord. */ + edgeThreshold?: number; + /** Clamp for node colour scale. */ + nodeVmax?: number | null; + /** Clamp for edge colour scale. */ + edgeVmax?: number | null; + title?: string | null; + width?: number; + height?: number; + selectionColor?: string; + selectionLinewidth?: number; + /** 3-stop hex colourmap for arcs. */ + nodeColormap?: [string, string, string]; + /** 3-stop hex colourmap for chords. */ + edgeColormap?: [string, string, string]; + /** Legend label for the node colour bar. */ + nodeColorbarLabel?: string | null; + /** Legend label for the edge colour bar. */ + edgeColorbarLabel?: string | null; + /** Prefix shown before the node value on hover (e.g. "S₁="). */ + nodeHoverPrefix?: string; + /** Prefix shown before the edge value on hover (e.g. "MI="). */ + edgeHoverPrefix?: string; + /** + * When `true`, reorder arcs so that selected nodes sit adjacent + * on the ring (labels still show the original names). + */ + groupSelected?: boolean; + /** + * When `true` renders light text on a dark background; when `false` + * renders dark text on a transparent background. Leave `undefined` + * (the default) to inherit from the host page via `--qdk-*` CSS + * custom properties (which map VS Code / Jupyter theme vars), with + * a final fallback to `currentColor` / `transparent`. + */ + darkMode?: boolean; + /** + * When `true`, interactive-only UI elements (e.g. the grouping toggle) + * are suppressed. Used during server-side SVG export. + */ + static?: boolean; + /** + * Callback fired when the user toggles the grouping control. + * The host can use this to sync the new state back to a data model. + */ + onGroupChange?: (grouped: boolean) => void; +} + +/** Convenience alias keeping the old prop names for backward compat. */ export interface EntanglementProps { - /** Single-orbital entropies, length N. */ s1Entropies: number[]; - /** Mutual information matrix, N×N (row-major flat array or nested). */ mutualInformation: number[][]; - /** Orbital labels (length N). Falls back to "0", "1", … */ labels?: string[]; - /** Indices of orbitals to highlight with an outline. */ selectedIndices?: number[]; - - // --- visual knobs (all optional with sensible defaults) --- gapDeg?: number; radius?: number; arcWidth?: number; @@ -36,6 +103,12 @@ export interface EntanglementProps { height?: number; selectionColor?: string; selectionLinewidth?: number; + nodeColormap?: [string, string, string]; + edgeColormap?: [string, string, string]; + groupSelected?: boolean; + darkMode?: boolean; + static?: boolean; + onGroupChange?: (grouped: boolean) => void; } // --------------------------------------------------------------------------- @@ -47,7 +120,7 @@ function deg2xy(deg: number, r: number): [number, number] { return [r * Math.cos(rad), r * Math.sin(rad)]; } -/** Linear interpolation between two RGB‑A colors given as [r,g,b,a]. */ +/** Linear interpolation between two RGB‑A colours given as [r,g,b,a]. */ type RGBA = [number, number, number, number]; function lerpColor(a: RGBA, b: RGBA, t: number): RGBA { @@ -71,7 +144,7 @@ function rgbaToCSS(c: RGBA): string { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; } -/** Evaluate a 3‑stop linear color-map at position t ∈ [0,1]. */ +/** Evaluate a 3‑stop linear colour-map at position t ∈ [0,1]. */ function colormapEval(stops: [string, string, string], t: number): string { const clamped = Math.max(0, Math.min(1, t)); const colors = stops.map(hexToRGBA) as [RGBA, RGBA, RGBA]; @@ -81,8 +154,40 @@ function colormapEval(stops: [string, string, string], t: number): string { return rgbaToCSS(lerpColor(colors[1], colors[2], (clamped - 0.5) * 2)); } -const ARC_CMAP: [string, string, string] = ["#d8d8d8", "#c82020", "#1a1a1a"]; -const CHORD_CMAP: [string, string, string] = ["#d8d8d8", "#2060b0", "#1a1a1a"]; +const DEFAULT_NODE_CMAP: [string, string, string] = [ + "#d8d8d8", + "#c82020", + "#1a1a1a", +]; +const DEFAULT_EDGE_CMAP: [string, string, string] = [ + "#d8d8d8", + "#2060b0", + "#1a1a1a", +]; + +/** + * Detect whether the host background is dark or light by sampling the + * computed background-color of the nearest ancestor with one. + * Returns a high-contrast colour for selection outlines. + */ +function detectSelectionColor(el: Element | null): string { + if (!el || typeof getComputedStyle === "undefined") return "#FFD700"; + let node: Element | null = el; + while (node) { + const bg = getComputedStyle(node).backgroundColor; + if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") { + const m = bg.match(/\d+/g); + if (m) { + const [r, g, b] = m.map(Number); + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + // Use vivid colours that pop against the arc colourmap + return lum > 0.5 ? "#FF8C00" : "#FFD700"; + } + } + node = node.parentElement; + } + return "#FFD700"; +} /** Build an SVG arc‑path for a filled annular segment. */ function arcPath( @@ -133,27 +238,77 @@ function chordPath( // Component // --------------------------------------------------------------------------- -export function Entanglement(props: EntanglementProps) { +export function ChordDiagram(props: ChordDiagramProps) { const { - s1Entropies, - mutualInformation, + nodeValues, + pairwiseWeights, labels: labelsProp, selectedIndices, gapDeg = 3, radius = 1, arcWidth = 0.08, lineScale: lineScaleProp = null, - miThreshold = 0, - s1Vmax = null, - miVmax = null, - title = "Entanglement", + edgeThreshold = 0, + nodeVmax = null, + edgeVmax = null, + title = null, width = 600, height = 660, - selectionColor = "var(--qdk-focus-border)", - selectionLinewidth = 2.5, + selectionColor: selectionColorProp, + selectionLinewidth = 1.2, + nodeColormap = DEFAULT_NODE_CMAP, + edgeColormap = DEFAULT_EDGE_CMAP, + nodeColorbarLabel = null, + edgeColorbarLabel = null, + nodeHoverPrefix = "", + edgeHoverPrefix = "", + groupSelected = false, + darkMode, + static: isStatic = false, + onGroupChange, } = props; - const n = s1Entropies.length; + // --- theme-resolved colours --- + // When darkMode is undefined the component inherits from the host + // environment via --qdk-* CSS custom properties (set by qdk-theme.css + // which maps VS Code / Jupyter / OS theme vars). The final fallback + // is `currentColor` / `transparent` for plain-browser contexts. + // When darkMode is explicitly true/false, concrete hex values are + // used so exported SVGs are fully self-contained. + const FONT_FAMILY = '"Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + const hasExplicitTheme = darkMode !== undefined; + const textColor = hasExplicitTheme + ? darkMode + ? "#e0e0e0" + : "#222222" + : "var(--qdk-host-foreground, currentColor)"; + const bgColor = hasExplicitTheme + ? darkMode + ? "#1e1e1e" + : "transparent" + : "var(--qdk-host-background, transparent)"; + + const n = nodeValues.length; + + // --- hover state --- + const [hoveredIdx, setHoveredIdx] = useState(null); + + // --- grouping toggle (only relevant when there is a selection) --- + const hasSelection = + selectedIndices !== undefined && selectedIndices.length > 0; + const [isGrouped, setIsGrouped] = useState(groupSelected); + // Sync if the prop changes externally + useEffect(() => setIsGrouped(groupSelected), [groupSelected]); + + // --- background-aware selection colour --- + const svgRef = useRef(null); + const [autoSelectionColor, setAutoSelectionColor] = useState("#FFD700"); + useEffect(() => { + if (svgRef.current) { + setAutoSelectionColor(detectSelectionColor(svgRef.current)); + } + }, []); + const selectionColor = selectionColorProp ?? autoSelectionColor; // --- labels --- const labels: string[] = @@ -161,27 +316,29 @@ export function Entanglement(props: EntanglementProps) { ? labelsProp : Array.from({ length: n }, (_, i) => String(i)); - // --- color scales --- - const s1Max = s1Vmax ?? Math.log(4); - const miMax = miVmax ?? Math.log(16); + // --- colour scales --- + const nodeMax = nodeVmax ?? Math.max(...nodeValues, 1); + const edgeMax = + edgeVmax ?? Math.max(...pairwiseWeights.flatMap((row) => row), 1); - const arccolors = s1Entropies.map((v) => colormapEval(ARC_CMAP, v / s1Max)); + const arcColours = nodeValues.map((v) => + colormapEval(nodeColormap, v / nodeMax), + ); // --- line scale --- const maxLw = Math.max(12 * (20 / Math.max(n, 1)) ** 0.5, 2); let lineScale: number; { - let miPeak = 0; + let peak = 0; for (let i = 0; i < n; i++) - for (let j = 0; j < n; j++) - miPeak = Math.max(miPeak, mutualInformation[i][j]); - if (miPeak <= 0) miPeak = 1; + for (let j = 0; j < n; j++) peak = Math.max(peak, pairwiseWeights[i][j]); + if (peak <= 0) peak = 1; lineScale = - lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(miPeak); + lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(peak); } // --- arc geometry --- - const totals = s1Entropies.slice(); + const totals = nodeValues.slice(); let grand = totals.reduce((a, b) => a + b, 0); if (grand === 0) { totals.fill(1); @@ -190,16 +347,37 @@ export function Entanglement(props: EntanglementProps) { const gapTotal = gapDeg * n; const arcDegs = totals.map((t) => ((360 - gapTotal) * t) / grand); + // --- selected set --- + const selectedSet = new Set((selectedIndices ?? []).map(String)); + + // --- ring ordering (group selected orbitals together when requested) --- + const order: number[] = Array.from({ length: n }, (_, i) => i); + if (isGrouped && selectedIndices && selectedIndices.length > 0) { + const sel: number[] = []; + const unsel: number[] = []; + for (let i = 0; i < n; i++) { + if (selectedSet.has(String(i))) { + sel.push(i); + } else { + unsel.push(i); + } + } + order.length = 0; + order.push(...sel, ...unsel); + } + const starts: number[] = new Array(n); - starts[0] = 0; - for (let i = 1; i < n; i++) { - starts[i] = starts[i - 1] + arcDegs[i - 1] + gapDeg; + starts[order[0]] = 0; + for (let p = 1; p < n; p++) { + const prev = order[p - 1]; + const curr = order[p]; + starts[curr] = starts[prev] + arcDegs[prev] + gapDeg; } const arcMids = starts.map((s, i) => s + arcDegs[i] / 2); // --- label tiers (avoid overlapping) --- - const labelFontSize = n <= 20 ? 9 : 7; + const labelFontSize = n <= 20 ? 13.5 : 10.5; const maxLabelLen = Math.max(...labels.map((l) => l.length)); const charDeg = (labelFontSize * 0.7 * maxLabelLen) / Math.max(radius, 0.5); const minSepDeg = charDeg * 0.8; @@ -223,6 +401,7 @@ export function Entanglement(props: EntanglementProps) { prevAngle = ang; prevTier = tier[idx]; } + // wrap‑around const firstIdx = indexOrder[0]; const lastIdx = indexOrder[indexOrder.length - 1]; const wrapGap = arcMids[firstIdx] + 360 - arcMids[lastIdx]; @@ -230,17 +409,16 @@ export function Entanglement(props: EntanglementProps) { tier[firstIdx] = (tier[lastIdx] + 1) % maxTiers; } - const miRowSums = mutualInformation.map((row) => - row.reduce((a, b) => a + b, 0), - ); + // --- chord computation --- + const rowSums = pairwiseWeights.map((row) => row.reduce((a, b) => a + b, 0)); type Conn = { j: number; val: number }; const nodeConns: Conn[][] = Array.from({ length: n }, () => []); for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { if (i === j) continue; - const val = mutualInformation[i][j]; - if (val <= miThreshold) continue; + const val = pairwiseWeights[i][j]; + if (val <= edgeThreshold) continue; nodeConns[i].push({ j, val }); } const mid = arcMids[i]; @@ -254,13 +432,19 @@ export function Entanglement(props: EntanglementProps) { const allocated = new Map(); for (let i = 0; i < n; i++) { for (const { j, val } of nodeConns[i]) { - const span = miRowSums[i] > 0 ? (arcDegs[i] * val) / miRowSums[i] : 0; + const span = rowSums[i] > 0 ? (arcDegs[i] * val) / rowSums[i] : 0; allocated.set(`${i},${j}`, cursor[i] + span / 2); cursor[i] += span; } } - type Chord = { val: number; angleI: number; angleJ: number }; + type Chord = { + i: number; + j: number; + val: number; + angleI: number; + angleJ: number; + }; const chords: Chord[] = []; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { @@ -268,59 +452,164 @@ export function Entanglement(props: EntanglementProps) { const keyJI = `${j},${i}`; if (!allocated.has(keyIJ)) continue; chords.push({ - val: mutualInformation[i][j], + i, + j, + val: pairwiseWeights[i][j], angleI: allocated.get(keyIJ)!, angleJ: allocated.get(keyJI)!, }); } } + // lightest first so darkest draws on top chords.sort((a, b) => a.val - b.val); - const selectedSet = new Set(selectedIndices ?? []); + // --- hover: partition chords --- + const isHovering = hoveredIdx !== null; + const bgChords: Chord[] = []; + const fgChords: Chord[] = []; + const connectedSet = new Set(); + if (isHovering) { + for (const ch of chords) { + if (ch.i === hoveredIdx || ch.j === hoveredIdx) { + fgChords.push(ch); + connectedSet.add(ch.i); + connectedSet.add(ch.j); + } else { + bgChords.push(ch); + } + } + } + // --- viewBox --- const maxOffset = baseOffset + Math.max(0, ...tier) * tierStep + 0.15; const lim = radius + maxOffset; - const diagramH = height - 60; - const vbPad = lim * 0.05; + // Map [-lim, lim] to [0, width/height] — compact legend area + const titleH = 50; // px reserved for title at top + const hasNodeBar = !!nodeColorbarLabel; + const hasEdgeBar = !!edgeColorbarLabel; + const legendH = + hasNodeBar || hasEdgeBar ? (hasNodeBar && hasEdgeBar ? 180 : 100) : 0; + const diagramH = height - legendH - titleH; + const vbPad = lim * 0.04; const vbSize = (lim + vbPad) * 2; + const scale = Math.min(width, diagramH) / vbSize; - const cbY = diagramH + 8; + // Colour-bar dimensions (drawn inside the SVG, close to diagram) + const cbGap = 40; // px between diagram bottom and first bar + const cbY = titleH + diagramH + cbGap; const cbW = width * 0.6; const cbX = (width - cbW) / 2; - const cbH = 12; + const cbH = 10; + const cbSpacing = 68; // vertical distance between the two bars (label + bar + ticks) const numCbStops = 64; + const numTicks = 5; // tick count on each colour bar return ( + {/* Title */} {title && ( - + {title} )} + {/* Group-selected toggle (only when there is a selection; hidden in static SVG export) */} + {hasSelection && !isStatic && ( + { + setIsGrouped((v) => { + const next = !v; + onGroupChange?.(next); + return next; + }); + }} + > + + {isGrouped + ? "Ungroup selected items" + : "Group selected items together"} + + + + + {isGrouped ? "Grouped" : "Ungrouped"} + + + )} + + {/* Diagram group — centred and scaled to fit */} - {chords.map((ch, ci) => { - const c = colormapEval(CHORD_CMAP, ch.val / miMax); - const lw = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); + {/* Chord lines — when hovering, split into dimmed background + bright foreground */} + {(isHovering ? bgChords : chords).map((ch, ci) => { + const c = colormapEval(edgeColormap, ch.val / edgeMax); + const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); + const lw = lwPx / scale; return ( + ); + })} + {/* Highlighted chords for hovered orbital (drawn on top) */} + {fgChords.map((ch, ci) => { + const c = colormapEval(edgeColormap, ch.val / edgeMax); + const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); + const lw = lwPx / scale; + return ( + ); })} + {/* Arcs */} {Array.from({ length: n }, (_, i) => ( setHoveredIdx(i)} + onMouseLeave={() => setHoveredIdx(null)} + style={{ cursor: "pointer" }} /> ))} + {/* Selection outlines */} {Array.from({ length: n }, (_, i) => - selectedSet.has(i) ? ( + selectedSet.has(String(i)) ? ( ) : null, )} + {/* Labels & tick lines */} {Array.from({ length: n }, (_, i) => { const mid = arcMids[i]; const t = tier[i]; @@ -370,96 +666,203 @@ export function Entanglement(props: EntanglementProps) { y1={ry} x2={lx} y2={ly} - class="qs-orbital-entanglement-label-tick" + stroke="#aaaaaa" + stroke-width={0.5 / scale} /> ); })() : null; - const fsPx = labelFontSize / (Math.min(width, diagramH) / vbSize / 2); + // Font size in SVG user units — we're in a scaled group so + // approximate by dividing the pt size by the scale factor. + const fsPx = labelFontSize / scale; + + // When hovering, replace the plain label with value info + const isThisHovered = hoveredIdx === i; + const isConnected = connectedSet.has(i); + let labelText = labels[i]; + let labelOpacity = 1; + if (isHovering) { + if (isThisHovered) { + labelText = `${labels[i]} ${nodeHoverPrefix}${nodeValues[i].toFixed(3)}`; + } else if (isConnected && hoveredIdx !== null) { + labelText = `${labels[i]} ${edgeHoverPrefix}${pairwiseWeights[hoveredIdx][i].toFixed(3)}`; + } else { + labelOpacity = 0.15; + } + } return ( - + {tickLine} - {labels[i]} + {labelText} ); })} - - - Single-orbital entropy - - {Array.from({ length: numCbStops }, (_, k) => { - const t = k / (numCbStops - 1); - return ( - - ); - })} - - 0 - - - {s1Max.toFixed(2)} - - + {/* ---- Colour-bar legends ---- */} + {/* Node value colour bar */} + {nodeColorbarLabel && ( + + + {nodeColorbarLabel} + + {Array.from({ length: numCbStops }, (_, k) => { + const t = k / (numCbStops - 1); + return ( + + ); + })} + {/* Ticks */} + {Array.from({ length: numTicks }, (_, k) => { + const frac = k / (numTicks - 1); + const xPos = cbX + cbW * frac; + const val = nodeMax * frac; + return ( + + + + {val.toFixed(2)} + + + ); + })} + + )} - - - Mutual information - - {Array.from({ length: numCbStops }, (_, k) => { - const t = k / (numCbStops - 1); - return ( - - ); - })} - - 0 - - - {miMax.toFixed(2)} - - + {/* Edge weight colour bar */} + {edgeColorbarLabel && ( + + + {edgeColorbarLabel} + + {Array.from({ length: numCbStops }, (_, k) => { + const t = k / (numCbStops - 1); + return ( + + ); + })} + {/* Ticks */} + {Array.from({ length: numTicks }, (_, k) => { + const frac = k / (numTicks - 1); + const xPos = cbX + cbW * frac; + const val = edgeMax * frac; + return ( + + + + {val.toFixed(2)} + + + ); + })} + + )} ); -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// Orbital Entanglement — convenience wrapper +// --------------------------------------------------------------------------- + +/** + * Orbital entanglement chord diagram. + * + * Thin wrapper around `ChordDiagram` that accepts `s1Entropies` / + * `mutualInformation` and supplies orbital-specific defaults for the + * title, legend labels, colormaps, and scale maxima. + */ +export function Entanglement(props: EntanglementProps) { + const { + s1Entropies, + mutualInformation, + miThreshold, + s1Vmax, + miVmax, + title = "Entanglement", + ...rest + } = props; + + return ( + + ); +} + + From 7d3c5cc08f4e68189d2e633c59a531426a2e30c1 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 12:10:36 +0200 Subject: [PATCH 26/33] remove unused var --- source/npm/qsharp/ux/entanglement.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/source/npm/qsharp/ux/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx index 645d512b3a..c93ba48958 100644 --- a/source/npm/qsharp/ux/entanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -16,7 +16,6 @@ */ import { useState, useRef, useEffect } from "preact/hooks"; -import { h } from "preact"; // --------------------------------------------------------------------------- // Types From 85615ccd99257c4b6c08244e5a3d395670d3710f Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 12:30:09 +0200 Subject: [PATCH 27/33] formatting --- source/npm/qsharp/ux/entanglement.tsx | 2 -- source/npm/qsharp/ux/index.ts | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/source/npm/qsharp/ux/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx index c93ba48958..da699a817a 100644 --- a/source/npm/qsharp/ux/entanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -863,5 +863,3 @@ export function Entanglement(props: EntanglementProps) { /> ); } - - diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index 77165b4f9d..dfdb3b89c2 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -24,10 +24,7 @@ export { Circuit, CircuitPanel } from "./circuit.js"; export { setRenderer, Markdown } from "./renderers.js"; export { Atoms, type ZoneLayout, type TraceData } from "./atoms/index.js"; export { MoleculeViewer } from "./chem/index.js"; -export { - Entanglement, - type EntanglementProps, -} from "./entanglement.js"; +export { Entanglement, type EntanglementProps } from "./entanglement.js"; export { ensureTheme, detectThemeChange, From f6efb996c11f51bc691876d5a69f280863845ba9 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 12:55:41 +0200 Subject: [PATCH 28/33] remove unused package --- package-lock.json | 11 ----------- package.json | 1 - 2 files changed, 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a8078adbd..75bf678caa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,6 @@ "monaco-editor": "^0.44.0", "openai": "^4.83.0", "preact": "^10.20.0", - "preact-render-to-string": "^6.6.6", "prettier": "^3.3.3", "punycode": "^2.3.1", "typescript": "^5.5.4", @@ -5375,16 +5374,6 @@ "url": "https://opencollective.com/preact" } }, - "node_modules/preact-render-to-string": { - "version": "6.6.6", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.6.tgz", - "integrity": "sha512-EfqZJytnjJldV+YaaqhthU2oXsEf5e+6rDv957p+zxAvNfFLQOPfvBOTncscQ+akzu6Wrl7s3Pa0LjUQmWJsGQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "preact": ">=10 || >= 11.0.0-0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index a781d7d471..f2e65a7e96 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "monaco-editor": "^0.44.0", "openai": "^4.83.0", "preact": "^10.20.0", - "preact-render-to-string": "^6.6.6", "prettier": "^3.3.3", "punycode": "^2.3.1", "typescript": "^5.5.4", From c1701ec3ec7a1bff35c801b157cc66b2ff953ffd Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 13:12:45 +0200 Subject: [PATCH 29/33] remove unused css --- source/npm/qsharp/ux/qsharp-ux.css | 53 ------------------------------ 1 file changed, 53 deletions(-) diff --git a/source/npm/qsharp/ux/qsharp-ux.css b/source/npm/qsharp/ux/qsharp-ux.css index 01bf071278..8ae3ceca6a 100644 --- a/source/npm/qsharp/ux/qsharp-ux.css +++ b/source/npm/qsharp/ux/qsharp-ux.css @@ -266,59 +266,6 @@ modern-normalize (see https://mattbrictson.com/blog/css-normalize-and-reset for fill: var(--qdk-host-foreground); } -/* Entanglement */ - -.qs-orbital-entanglement { - max-width: 600px; - color: var(--qdk-host-foreground); - background-color: var(--qdk-host-background); - font-family: var(--qdk-font-family); -} - -.qs-orbital-entanglement-title, -.qs-orbital-entanglement-legend-title, -.qs-orbital-entanglement-legend-value, -.qs-orbital-entanglement-label { - fill: currentColor; -} - -.qs-orbital-entanglement-title { - text-anchor: middle; - font-size: 14px; - font-weight: 600; -} - -.qs-orbital-entanglement-legend-title { - text-anchor: middle; - font-size: 9px; -} - -.qs-orbital-entanglement-legend-value { - font-size: 8px; -} - -.qs-orbital-entanglement-legend-value-end { - text-anchor: end; -} - -.qs-orbital-entanglement-label { - dominant-baseline: central; - font-weight: 600; -} - -.qs-orbital-entanglement-label-start { - text-anchor: start; -} - -.qs-orbital-entanglement-label-end { - text-anchor: end; -} - -.qs-orbital-entanglement-label-tick { - stroke: var(--qdk-mid-gray); - stroke-width: 0.005; -} - /* RE details */ .estimate-details { From ba388eb674899d6d17f7921e9745feefa6c098b8 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 14:20:56 +0200 Subject: [PATCH 30/33] clear output in notebook --- samples/notebooks/orbital_entanglement.ipynb | 22 ++------------------ 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index 28848ce13c..1bfd78e0d7 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -24,16 +24,7 @@ "execution_count": null, "id": "c033bd86", "metadata": {}, - "outputs": [ - { - "ename": "", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31mThe kernel failed to start as the Python Environment '.venv (Python -1.-1.-1)' is no longer available. Consider selecting another kernel or refreshing the list of Python Environments." - ] - } - ], + "outputs": [], "source": [ "import numpy as np\n", "\n", @@ -130,16 +121,7 @@ "execution_count": null, "id": "7469867d", "metadata": {}, - "outputs": [ - { - "ename": "", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31mThe kernel failed to start as the Python Environment '.venv (Python -1.-1.-1)' is no longer available. Consider selecting another kernel or refreshing the list of Python Environments." - ] - } - ], + "outputs": [], "source": [ "from qsharp_widgets import Entanglement\n", "\n", From 41e2baa53ff32919e7f91316259815a8c14d9470 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 16:27:29 +0200 Subject: [PATCH 31/33] add support for multiple highlighted groups --- samples/notebooks/orbital_entanglement.ipynb | 65 +++++++++--- source/npm/qsharp/ux/entanglement.tsx | 98 +++++++++++++++---- source/widgets/js/index.tsx | 3 + source/widgets/src/qsharp_widgets/__init__.py | 12 ++- 4 files changed, 145 insertions(+), 33 deletions(-) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index 1bfd78e0d7..116c930b5d 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -8,7 +8,8 @@ "# Orbital Entanglement Chord Diagram — 250 Synthetic Orbitals\n", "\n", "Demonstrates the `Entanglement` JS widget for an orbital-entanglement use case on a large system with\n", - "synthetic single-orbital entropies and mutual information." + "synthetic single-orbital entropies and mutual information. Five highly entangled clusters are\n", + "highlighted with distinct outline colours and grouped together on the ring." ] }, { @@ -24,7 +25,19 @@ "execution_count": null, "id": "c033bd86", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "250 orbitals, 5 entangled clusters built (10 orbitals each)\n", + "Highlighting 3 clusters: A=[3, 7, 38, 46, 50, 51, 54, 70, 75, 79], B=[130, 132, 133, 135, 150, 159, 161, 169, 171, 174], C=[225, 228, 234, 236, 238, 240, 241, 243, 245, 246]\n", + "s1 range: 0.0000 – 1.3766\n", + "MI range: 0.0000 – 1.4980\n", + "Non-zero MI pairs: 562\n" + ] + } + ], "source": [ "import numpy as np\n", "\n", @@ -54,8 +67,10 @@ "\n", "# 1) Five intra-core clusters of ~10 orbitals each with strong MI\n", "cluster_size = len(core_idx) // 5\n", + "clusters = []\n", "for k in range(5):\n", " cl = core_idx[k * cluster_size : (k + 1) * cluster_size]\n", + " clusters.append(cl)\n", " for ii, i in enumerate(cl):\n", " for j in cl[ii + 1 :]:\n", " val = rng.beta(3, 1.5) * np.log(16.0) * 0.55\n", @@ -96,10 +111,14 @@ "\n", "np.fill_diagonal(mi, 0.0)\n", "\n", - "print(f\"{N} orbitals, {N_CORE} selected (highlighted)\")\n", - "print(f\"Core indices (first 10): {core_idx[:10].tolist()} ...\")\n", - "print(f\"s1 range: {s1.min():.4f} - {s1.max():.4f}\")\n", - "print(f\"MI range: {mi[mi > 0].min():.4f} - {mi.max():.4f}\")\n", + "# All five highly-entangled clusters\n", + "region_a, region_b, region_c, region_d, region_e = clusters\n", + "\n", + "print(f\"{N} orbitals, 5 entangled clusters built ({cluster_size} orbitals each)\")\n", + "for name, region in zip(\"ABCDE\", clusters):\n", + " print(f\" Cluster {name}: {region.tolist()}\")\n", + "print(f\"s1 range: {s1.min():.4f} – {s1.max():.4f}\")\n", + "print(f\"MI range: {mi[mi > 0].min():.4f} – {mi.max():.4f}\")\n", "print(f\"Non-zero MI pairs: {(mi > 0).sum() // 2}\")" ] }, @@ -112,8 +131,10 @@ "\n", "The `Entanglement` widget renders an orbital-entanglement chord diagram\n", "directly in the notebook output. Arc length encodes single-orbital\n", - "entropy; chord thickness encodes mutual information. The 50 core\n", - "orbitals are highlighted with a dark outline." + "entropy; chord thickness encodes mutual information. Five highly\n", + "entangled regions (clusters of strongly-coupled orbitals) are each\n", + "outlined in a different colour and, when the grouping toggle is active,\n", + "placed adjacent on the ring." ] }, { @@ -121,7 +142,23 @@ "execution_count": null, "id": "7469867d", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "72d9d1ea3b184524873a1e34a186d3e4", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from qsharp_widgets import Entanglement\n", "\n", @@ -129,8 +166,14 @@ " s1_entropies=s1.tolist(),\n", " mutual_information=mi.tolist(),\n", " labels=[str(i) for i in range(N)],\n", - " selected_indices=core_idx.tolist(),\n", - " title=f\"Synthetic Orbital Entanglement — {N} orbitals ({N_CORE} core)\",\n", + " groups={\n", + " \"Region A\": region_a.tolist(),\n", + " \"Region B\": region_b.tolist(),\n", + " \"Region C\": region_c.tolist(),\n", + " \"Region D\": region_d.tolist(),\n", + " \"Region E\": region_e.tolist(),\n", + " },\n", + " title=f\"Synthetic Orbital Entanglement — {N} orbitals (5 entangled regions)\",\n", " group_selected=True,\n", " gap_deg=0.6,\n", " arc_width=0.05,\n", diff --git a/source/npm/qsharp/ux/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx index da699a817a..a134c92255 100644 --- a/source/npm/qsharp/ux/entanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -30,6 +30,16 @@ export interface ChordDiagramProps { labels?: string[]; /** Indices of nodes to highlight with an outline. */ selectedIndices?: number[]; + /** + * Named groups of node indices. When provided together with + * `groupSelected`, nodes belonging to each group are placed adjacent + * on the ring in group order. Each group gets a distinct outline + * colour (see `groupColors`). Takes precedence over + * `selectedIndices` for grouping / highlighting when both are given. + */ + groups?: Record; + /** Override outline colours for each group (cycles if fewer than groups). */ + groupColors?: string[]; // --- visual knobs (all optional with sensible defaults) --- gapDeg?: number; @@ -104,6 +114,8 @@ export interface EntanglementProps { selectionLinewidth?: number; nodeColormap?: [string, string, string]; edgeColormap?: [string, string, string]; + groups?: Record; + groupColors?: string[]; groupSelected?: boolean; darkMode?: boolean; static?: boolean; @@ -237,12 +249,28 @@ function chordPath( // Component // --------------------------------------------------------------------------- +// Default palette for multi-group outlines. +const DEFAULT_GROUP_COLORS = [ + "#FFD700", + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + "#DDA0DD", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", +]; + export function ChordDiagram(props: ChordDiagramProps) { const { nodeValues, pairwiseWeights, labels: labelsProp, selectedIndices, + groups, + groupColors: groupColorsProp, gapDeg = 3, radius = 1, arcWidth = 0.08, @@ -293,8 +321,35 @@ export function ChordDiagram(props: ChordDiagramProps) { const [hoveredIdx, setHoveredIdx] = useState(null); // --- grouping toggle (only relevant when there is a selection) --- - const hasSelection = - selectedIndices !== undefined && selectedIndices.length > 0; + // Build the canonical group list: either from `groups` or the legacy + // `selectedIndices` (treated as a single unnamed group). + const groupEntries: [string, number[]][] = []; + if (groups && Object.keys(groups).length > 0) { + for (const [name, indices] of Object.entries(groups)) { + groupEntries.push([name, indices]); + } + } else if (selectedIndices && selectedIndices.length > 0) { + groupEntries.push(["selected", selectedIndices]); + } + + // Map from orbital index → outline colour for that group. + const nodeGroupColor = new Map(); + { + const palette = groupColorsProp ?? DEFAULT_GROUP_COLORS; + let colorIdx = 0; + for (const [, indices] of groupEntries) { + const color = + groupEntries.length === 1 + ? selectionColorProp ?? palette[0] + : palette[colorIdx % palette.length]; + for (const idx of indices) { + nodeGroupColor.set(idx, color); + } + colorIdx++; + } + } + + const hasSelection = nodeGroupColor.size > 0; const [isGrouped, setIsGrouped] = useState(groupSelected); // Sync if the prop changes externally useEffect(() => setIsGrouped(groupSelected), [groupSelected]); @@ -346,23 +401,25 @@ export function ChordDiagram(props: ChordDiagramProps) { const gapTotal = gapDeg * n; const arcDegs = totals.map((t) => ((360 - gapTotal) * t) / grand); - // --- selected set --- - const selectedSet = new Set((selectedIndices ?? []).map(String)); - - // --- ring ordering (group selected orbitals together when requested) --- + // --- ring ordering (group nodes together when requested) --- const order: number[] = Array.from({ length: n }, (_, i) => i); - if (isGrouped && selectedIndices && selectedIndices.length > 0) { - const sel: number[] = []; - const unsel: number[] = []; - for (let i = 0; i < n; i++) { - if (selectedSet.has(String(i))) { - sel.push(i); - } else { - unsel.push(i); + if (isGrouped && groupEntries.length > 0) { + const grouped: number[] = []; + const groupedSet = new Set(); + for (const [, indices] of groupEntries) { + for (const idx of indices) { + if (!groupedSet.has(idx)) { + grouped.push(idx); + groupedSet.add(idx); + } } } + const ungrouped: number[] = []; + for (let i = 0; i < n; i++) { + if (!groupedSet.has(i)) ungrouped.push(i); + } order.length = 0; - order.push(...sel, ...unsel); + order.push(...grouped, ...ungrouped); } const starts: number[] = new Array(n); @@ -627,8 +684,9 @@ export function ChordDiagram(props: ChordDiagramProps) { ))} {/* Selection outlines */} - {Array.from({ length: n }, (_, i) => - selectedSet.has(String(i)) ? ( + {Array.from({ length: n }, (_, i) => { + const gc = nodeGroupColor.get(i); + return gc ? ( - ) : null, - )} + ) : null; + })} {/* Labels & tick lines */} {Array.from({ length: n }, (_, i) => { diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index b7bd19bc35..012fd0676d 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -315,6 +315,7 @@ function renderEntanglement({ model, el }: RenderArgs) { const mutualInformation = model.get("mutual_information") as number[][]; const labels = model.get("labels") as string[]; const selectedIndices = model.get("selected_indices") as number[] | null; + const groups = model.get("groups") as Record | null; const opts = (model.get("options") || {}) as Record; const camelOpts: Record = {}; for (const [k, v] of Object.entries(opts)) { @@ -326,6 +327,7 @@ function renderEntanglement({ model, el }: RenderArgs) { mutualInformation, labels, selectedIndices: selectedIndices ?? undefined, + groups: groups ?? undefined, ...camelOpts, ...extra, } as EntanglementProps; @@ -340,6 +342,7 @@ function renderEntanglement({ model, el }: RenderArgs) { model.on("change:mutual_information", onChange); model.on("change:labels", onChange); model.on("change:selected_indices", onChange); + model.on("change:groups", onChange); model.on("change:options", onChange); } diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 9ec53e0d74..ec6f8f9bed 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -240,6 +240,7 @@ class Entanglement(anywidget.AnyWidget): mutual_information = traitlets.List().tag(sync=True) labels = traitlets.List().tag(sync=True) selected_indices = traitlets.List(allow_none=True, default_value=None).tag(sync=True) + groups = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True) options = traitlets.Dict().tag(sync=True) def __init__( @@ -250,6 +251,7 @@ def __init__( mutual_information=None, labels=None, selected_indices=None, + groups=None, **options, ): """ @@ -273,13 +275,18 @@ def __init__( labels : list[str], optional Orbital labels. Defaults to ``["0", "1", …]``. selected_indices : list[int], optional - Orbital indices to highlight. + Orbital indices to highlight (single group, legacy API). + groups : dict[str, list[int]], optional + Named groups of orbital indices. Each group is rendered with + a distinct outline colour and, when grouped, its members are + placed adjacent on the ring. Takes precedence over + *selected_indices* for grouping when both are provided. **options Forwarded to the JS component as visual knobs (``gap_deg``, ``radius``, ``arc_width``, ``line_scale``, ``mi_threshold``, ``s1_vmax``, ``mi_vmax``, ``title``, ``width``, ``height``, ``selection_color``, - ``selection_linewidth``). + ``selection_linewidth``, ``group_colors``). """ if wavefunction is not None: import numpy as np @@ -315,6 +322,7 @@ def __init__( mutual_information=mutual_information, labels=labels, selected_indices=selected_indices, + groups=groups, options=options, ) From ef339df05a1ec439859abaf482f327e1126e5249 Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 31 Mar 2026 18:03:21 +0200 Subject: [PATCH 32/33] linting --- samples/notebooks/orbital_entanglement.ipynb | 32 ++------------------ source/npm/qsharp/ux/entanglement.tsx | 2 +- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb index 116c930b5d..8d4ca0e3b9 100644 --- a/samples/notebooks/orbital_entanglement.ipynb +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -25,19 +25,7 @@ "execution_count": null, "id": "c033bd86", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "250 orbitals, 5 entangled clusters built (10 orbitals each)\n", - "Highlighting 3 clusters: A=[3, 7, 38, 46, 50, 51, 54, 70, 75, 79], B=[130, 132, 133, 135, 150, 159, 161, 169, 171, 174], C=[225, 228, 234, 236, 238, 240, 241, 243, 245, 246]\n", - "s1 range: 0.0000 – 1.3766\n", - "MI range: 0.0000 – 1.4980\n", - "Non-zero MI pairs: 562\n" - ] - } - ], + "outputs": [], "source": [ "import numpy as np\n", "\n", @@ -142,23 +130,7 @@ "execution_count": null, "id": "7469867d", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "72d9d1ea3b184524873a1e34a186d3e4", - "version_major": 2, - "version_minor": 1 - }, - "text/plain": [ - "" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from qsharp_widgets import Entanglement\n", "\n", diff --git a/source/npm/qsharp/ux/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx index a134c92255..78a6c31e0c 100644 --- a/source/npm/qsharp/ux/entanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -340,7 +340,7 @@ export function ChordDiagram(props: ChordDiagramProps) { for (const [, indices] of groupEntries) { const color = groupEntries.length === 1 - ? selectionColorProp ?? palette[0] + ? (selectionColorProp ?? palette[0]) : palette[colorIdx % palette.length]; for (const idx of indices) { nodeGroupColor.set(idx, color); From 862d3155154bc174465c3e370911cc9fc6ea719a Mon Sep 17 00:00:00 2001 From: Jan Unsleber Date: Tue, 7 Apr 2026 22:32:10 +0200 Subject: [PATCH 33/33] cleaning and fixing limited color maps --- source/npm/qsharp/ux/entanglement.tsx | 171 ++++++++++++++++++++++---- source/npm/qsharp/ux/qdk-theme.css | 18 ++- 2 files changed, 162 insertions(+), 27 deletions(-) diff --git a/source/npm/qsharp/ux/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx index 78a6c31e0c..bedd75072f 100644 --- a/source/npm/qsharp/ux/entanglement.tsx +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -8,9 +8,6 @@ * diagram. Arc length is proportional to the node value; chord thickness * is proportional to pairwise weight. * - * The diagram is rendered entirely as native SVG so that the markup can be - * serialised to a standalone `.svg` file from the Python widget. - * * `Entanglement` is a thin wrapper that supplies orbital-specific * defaults (title, legend labels, colormaps, scale maxima). */ @@ -176,6 +173,45 @@ const DEFAULT_EDGE_CMAP: [string, string, string] = [ "#1a1a1a", ]; +/** + * Convert a CSS computed colour (e.g. "rgb(200, 32, 32)") to "#rrggbb". + * Returns the input unchanged when it already looks like a hex colour. + * Returns `null` when the value cannot be parsed. + */ +function cssColorToHex(css: string): string | null { + const trimmed = css.trim(); + if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed; + if (/^#[0-9a-f]{3}$/i.test(trimmed)) { + const [, r, g, b] = trimmed.split(""); + return `#${r}${r}${g}${g}${b}${b}`; + } + const m = trimmed.match( + /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*[\d.]+)?\s*\)$/, + ); + if (!m) return null; + const hex = (n: number) => n.toString(16).padStart(2, "0"); + return `#${hex(Number(m[1]))}${hex(Number(m[2]))}${hex(Number(m[3]))}`; +} + +/** + * Read a 3-stop colourmap from CSS custom properties on the given element. + * Returns the fallback when the properties are absent or unparseable. + */ +function readCSSColormap( + el: Element, + lo: string, + mid: string, + hi: string, + fallback: [string, string, string], +): [string, string, string] { + const style = getComputedStyle(el); + const cLo = cssColorToHex(style.getPropertyValue(lo)); + const cMid = cssColorToHex(style.getPropertyValue(mid)); + const cHi = cssColorToHex(style.getPropertyValue(hi)); + if (cLo && cMid && cHi) return [cLo, cMid, cHi]; + return fallback; +} + /** * Detect whether the host background is dark or light by sampling the * computed background-color of the nearest ancestor with one. @@ -250,18 +286,54 @@ function chordPath( // --------------------------------------------------------------------------- // Default palette for multi-group outlines. -const DEFAULT_GROUP_COLORS = [ - "#FFD700", - "#FF6B6B", - "#4ECDC4", - "#45B7D1", - "#96CEB4", - "#FFEAA7", - "#DDA0DD", - "#98D8C8", - "#F7DC6F", - "#BB8FCE", -]; +// Used only as a static fallback; at mount time the component generates +// a theme-aware palette for the actual number of groups. +const DEFAULT_GROUP_COLORS = ["#FFD700"]; + +/** + * Generate `n` maximally-spaced highlight colours. + * + * Hues are evenly distributed around the wheel with a golden-angle + * offset so that adjacent groups always contrast well, even for large n. + * Saturation and lightness adapt to the host background luminance so + * outlines remain vivid in both light and dark themes. + */ +function generateGroupPalette(n: number, isDark: boolean): string[] { + if (n <= 0) return []; + const s = isDark ? 75 : 80; // saturation % + const l = isDark ? 65 : 48; // lightness % + const goldenAngle = 137.508; // degrees – spreads hues well + const startHue = 48; // start near gold + const palette: string[] = []; + for (let i = 0; i < n; i++) { + const h = (startHue + i * goldenAngle) % 360; + palette.push(`hsl(${h.toFixed(1)},${s}%,${l}%)`); + } + return palette; +} + +/** + * Measure whether the host background is dark by walking up the DOM. + * Returns true for dark backgrounds, false for light (or unknown). + */ +function detectIsDark(el: Element | null): boolean { + if (!el || typeof getComputedStyle === "undefined") return false; + let node: Element | null = el; + while (node) { + const bg = getComputedStyle(node).backgroundColor; + if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") { + const m = bg.match(/\d+/g); + if (m) { + const lum = + (0.299 * Number(m[0]) + 0.587 * Number(m[1]) + 0.114 * Number(m[2])) / + 255; + return lum <= 0.5; + } + } + node = node.parentElement; + } + return false; +} export function ChordDiagram(props: ChordDiagramProps) { const { @@ -302,8 +374,12 @@ export function ChordDiagram(props: ChordDiagramProps) { // is `currentColor` / `transparent` for plain-browser contexts. // When darkMode is explicitly true/false, concrete hex values are // used so exported SVGs are fully self-contained. - const FONT_FAMILY = '"Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + const FONT_FAMILY_FALLBACK = + '"Segoe UI", Roboto, Helvetica, Arial, sans-serif'; const hasExplicitTheme = darkMode !== undefined; + const fontFamily = hasExplicitTheme + ? FONT_FAMILY_FALLBACK + : `var(--qdk-font-family, ${FONT_FAMILY_FALLBACK})`; const textColor = hasExplicitTheme ? darkMode ? "#e0e0e0" @@ -314,6 +390,12 @@ export function ChordDiagram(props: ChordDiagramProps) { ? "#1e1e1e" : "transparent" : "var(--qdk-host-background, transparent)"; + const mutedColor = hasExplicitTheme + ? darkMode + ? "#666666" + : "#aaaaaa" + : "var(--qdk-foreground-muted, #aaaaaa)"; + const midGray = hasExplicitTheme ? "#888888" : "var(--qdk-mid-gray, #888888)"; const n = nodeValues.length; @@ -333,9 +415,13 @@ export function ChordDiagram(props: ChordDiagramProps) { } // Map from orbital index → outline colour for that group. + // The palette is generated at mount to match the number of groups and + // the host theme. Before mount, fall back to DEFAULT_GROUP_COLORS. + const [autoPalette, setAutoPalette] = + useState(DEFAULT_GROUP_COLORS); + const palette = groupColorsProp ?? autoPalette; const nodeGroupColor = new Map(); { - const palette = groupColorsProp ?? DEFAULT_GROUP_COLORS; let colorIdx = 0; for (const [, indices] of groupEntries) { const color = @@ -357,12 +443,47 @@ export function ChordDiagram(props: ChordDiagramProps) { // --- background-aware selection colour --- const svgRef = useRef(null); const [autoSelectionColor, setAutoSelectionColor] = useState("#FFD700"); + // --- CSS-resolved colormaps (read from --qdk-chord-* custom props) --- + const [resolvedNodeCmap, setResolvedNodeCmap] = + useState<[string, string, string]>(nodeColormap); + const [resolvedEdgeCmap, setResolvedEdgeCmap] = + useState<[string, string, string]>(edgeColormap); useEffect(() => { if (svgRef.current) { + const isDark = detectIsDark(svgRef.current); setAutoSelectionColor(detectSelectionColor(svgRef.current)); + // Generate a theme-aware palette sized to the actual group count. + if (!groupColorsProp) { + const nGroups = Math.max(groupEntries.length, 1); + setAutoPalette(generateGroupPalette(nGroups, isDark)); + } + if (!hasExplicitTheme) { + setResolvedNodeCmap( + readCSSColormap( + svgRef.current, + "--qdk-chord-node-lo", + "--qdk-chord-node-mid", + "--qdk-chord-node-hi", + nodeColormap, + ), + ); + setResolvedEdgeCmap( + readCSSColormap( + svgRef.current, + "--qdk-chord-edge-lo", + "--qdk-chord-edge-mid", + "--qdk-chord-edge-hi", + edgeColormap, + ), + ); + } } }, []); const selectionColor = selectionColorProp ?? autoSelectionColor; + // Use prop colormaps when explicit darkMode is set; otherwise prefer + // CSS-resolved values which respect the host theme. + const activeNodeCmap = hasExplicitTheme ? nodeColormap : resolvedNodeCmap; + const activeEdgeCmap = hasExplicitTheme ? edgeColormap : resolvedEdgeCmap; // --- labels --- const labels: string[] = @@ -376,7 +497,7 @@ export function ChordDiagram(props: ChordDiagramProps) { edgeVmax ?? Math.max(...pairwiseWeights.flatMap((row) => row), 1); const arcColours = nodeValues.map((v) => - colormapEval(nodeColormap, v / nodeMax), + colormapEval(activeNodeCmap, v / nodeMax), ); // --- line scale --- @@ -566,7 +687,7 @@ export function ChordDiagram(props: ChordDiagramProps) { width={width} height={height} class="qs-chord-diagram" - style={{ background: bgColor, fontFamily: FONT_FAMILY }} + style={{ background: bgColor, fontFamily: fontFamily }} ref={svgRef} > {/* Title */} @@ -608,7 +729,7 @@ export function ChordDiagram(props: ChordDiagramProps) { width={80} height={20} rx={10} - fill={isGrouped ? selectionColor : "#888888"} + fill={isGrouped ? selectionColor : midGray} opacity={0.85} /> @@ -632,7 +753,7 @@ export function ChordDiagram(props: ChordDiagramProps) { > {/* Chord lines — when hovering, split into dimmed background + bright foreground */} {(isHovering ? bgChords : chords).map((ch, ci) => { - const c = colormapEval(edgeColormap, ch.val / edgeMax); + const c = colormapEval(activeEdgeCmap, ch.val / edgeMax); const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); const lw = lwPx / scale; return ( @@ -649,7 +770,7 @@ export function ChordDiagram(props: ChordDiagramProps) { })} {/* Highlighted chords for hovered orbital (drawn on top) */} {fgChords.map((ch, ci) => { - const c = colormapEval(edgeColormap, ch.val / edgeMax); + const c = colormapEval(activeEdgeCmap, ch.val / edgeMax); const lwPx = Math.min(Math.sqrt(ch.val) * lineScale, maxLw); const lw = lwPx / scale; return ( @@ -723,7 +844,7 @@ export function ChordDiagram(props: ChordDiagramProps) { y1={ry} x2={lx} y2={ly} - stroke="#aaaaaa" + stroke={mutedColor} stroke-width={0.5 / scale} /> ); @@ -791,7 +912,7 @@ export function ChordDiagram(props: ChordDiagramProps) { y={cbY} width={cbW / numCbStops + 0.5} height={cbH} - fill={colormapEval(nodeColormap, t)} + fill={colormapEval(activeNodeCmap, t)} /> ); })} @@ -846,7 +967,7 @@ export function ChordDiagram(props: ChordDiagramProps) { y={cbY + cbH + cbSpacing} width={cbW / numCbStops + 0.5} height={cbH} - fill={colormapEval(edgeColormap, t)} + fill={colormapEval(activeEdgeCmap, t)} /> ); })} diff --git a/source/npm/qsharp/ux/qdk-theme.css b/source/npm/qsharp/ux/qdk-theme.css index b9185cb57a..f1d49b34ef 100644 --- a/source/npm/qsharp/ux/qdk-theme.css +++ b/source/npm/qsharp/ux/qdk-theme.css @@ -120,9 +120,15 @@ body[data-vscode-theme-kind="vscode-high-contrast-light"] { --qdk-gate-reset: #282; --qdk-atom-fill: #0078d4; --qdk-atom-trail: #fa0; -} -/* Set these variables on the body element if attributes indicate a dark theme choice */ + /* Chord diagram: 3-stop colormaps (low → mid → high) */ + --qdk-chord-node-lo: #d8d8d8; + --qdk-chord-node-mid: #c82020; + --qdk-chord-node-hi: #1a1a1a; + --qdk-chord-edge-lo: #d8d8d8; + --qdk-chord-edge-mid: #2060b0; + --qdk-chord-edge-hi: #1a1a1a; +} body[data-theme="dark"], body[data-jp-theme-light="false"], body[data-vscode-theme-kind="vscode-dark"], @@ -171,4 +177,12 @@ body[data-vscode-theme-kind="vscode-high-contrast"] { --qdk-gate-reset: #8e8; --qdk-atom-fill: #9df; --qdk-atom-trail: #fa0; + + /* Chord diagram: 3-stop colormaps (low → mid → high) */ + --qdk-chord-node-lo: #3a3a3a; + --qdk-chord-node-mid: #e04040; + --qdk-chord-node-hi: #f0f0f0; + --qdk-chord-edge-lo: #3a3a3a; + --qdk-chord-edge-mid: #4090e0; + --qdk-chord-edge-hi: #f0f0f0; }