diff --git a/samples/notebooks/orbital_entanglement.ipynb b/samples/notebooks/orbital_entanglement.ipynb new file mode 100644 index 0000000000..8d4ca0e3b9 --- /dev/null +++ b/samples/notebooks/orbital_entanglement.ipynb @@ -0,0 +1,181 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "4ce65e98", + "metadata": {}, + "source": [ + "# 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. Five highly entangled clusters are\n", + "highlighted with distinct outline colours and grouped together on the ring." + ] + }, + { + "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", + "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", + " 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", + "# 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}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b35327c0", + "metadata": {}, + "source": [ + "## 2 — Display the interactive widget\n", + "\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. 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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7469867d", + "metadata": {}, + "outputs": [], + "source": [ + "from qsharp_widgets import Entanglement\n", + "\n", + "widget = Entanglement(\n", + " s1_entropies=s1.tolist(),\n", + " mutual_information=mi.tolist(),\n", + " labels=[str(i) for i in range(N)],\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", + " mi_threshold=0.01,\n", + " width=800,\n", + " height=880,\n", + ")\n", + "widget" + ] + } + ], + "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/entanglement.tsx b/source/npm/qsharp/ux/entanglement.tsx new file mode 100644 index 0000000000..bedd75072f --- /dev/null +++ b/source/npm/qsharp/ux/entanglement.tsx @@ -0,0 +1,1044 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Generic 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. + * + * `Entanglement` is a thin wrapper that supplies orbital-specific + * defaults (title, legend labels, colormaps, scale maxima). + */ + +import { useState, useRef, useEffect } from "preact/hooks"; + +// --------------------------------------------------------------------------- +// 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[]; + /** + * 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; + 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 { + s1Entropies: number[]; + mutualInformation: number[][]; + labels?: string[]; + selectedIndices?: number[]; + 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; + nodeColormap?: [string, string, string]; + edgeColormap?: [string, string, string]; + groups?: Record; + groupColors?: string[]; + groupSelected?: boolean; + darkMode?: boolean; + static?: boolean; + onGroupChange?: (grouped: boolean) => void; +} + +// --------------------------------------------------------------------------- +// 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 DEFAULT_NODE_CMAP: [string, string, string] = [ + "#d8d8d8", + "#c82020", + "#1a1a1a", +]; +const DEFAULT_EDGE_CMAP: [string, string, string] = [ + "#d8d8d8", + "#2060b0", + "#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. + * 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 +// --------------------------------------------------------------------------- + +// Default palette for multi-group outlines. +// 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 { + nodeValues, + pairwiseWeights, + labels: labelsProp, + selectedIndices, + groups, + groupColors: groupColorsProp, + gapDeg = 3, + radius = 1, + arcWidth = 0.08, + lineScale: lineScaleProp = null, + edgeThreshold = 0, + nodeVmax = null, + edgeVmax = null, + title = null, + 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, + } = 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_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" + : "#222222" + : "var(--qdk-host-foreground, currentColor)"; + const bgColor = hasExplicitTheme + ? darkMode + ? "#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; + + // --- hover state --- + const [hoveredIdx, setHoveredIdx] = useState(null); + + // --- grouping toggle (only relevant when there is a selection) --- + // 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. + // 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(); + { + 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]); + + // --- 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[] = + labelsProp && labelsProp.length === n + ? labelsProp + : 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 arcColours = nodeValues.map((v) => + colormapEval(activeNodeCmap, v / nodeMax), + ); + + // --- line scale --- + const maxLw = Math.max(12 * (20 / Math.max(n, 1)) ** 0.5, 2); + let lineScale: number; + { + let peak = 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; + lineScale = + lineScaleProp !== null ? lineScaleProp : maxLw / Math.sqrt(peak); + } + + // --- arc geometry --- + const totals = nodeValues.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); + + // --- ring ordering (group nodes together when requested) --- + const order: number[] = Array.from({ length: n }, (_, i) => 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(...grouped, ...ungrouped); + } + + 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; + } + + 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 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 = pairwiseWeights[i][j]; + if (val <= edgeThreshold) 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 = rowSums[i] > 0 ? (arcDegs[i] * val) / rowSums[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: 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); + + // --- 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; + // 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; + + // 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} + + )} + + {/* 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(activeEdgeCmap, 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(activeEdgeCmap, 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) => { + const gc = nodeGroupColor.get(i); + return gc ? ( + + ) : 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]} ${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} + + {labelText} + + + ); + })} + + + {/* ---- 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)} + + + ); + })} + + )} + + {/* 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)} + + + ); + })} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + ); +} diff --git a/source/npm/qsharp/ux/index.ts b/source/npm/qsharp/ux/index.ts index bec89d0339..dfdb3b89c2 100644 --- a/source/npm/qsharp/ux/index.ts +++ b/source/npm/qsharp/ux/index.ts @@ -24,6 +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 { ensureTheme, detectThemeChange, diff --git a/source/npm/qsharp/ux/qdk-theme.css b/source/npm/qsharp/ux/qdk-theme.css index b70044aa96..ac38f60c36 100644 --- a/source/npm/qsharp/ux/qdk-theme.css +++ b/source/npm/qsharp/ux/qdk-theme.css @@ -135,9 +135,15 @@ body[data-vscode-theme-kind="vscode-high-contrast-light"] { --qdk-circuit-ket-fill: #005fb8; --qdk-circuit-ket-text: #ffffff; --qdk-circuit-ket-hover: #0258a8; -} -/* 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"], @@ -187,6 +193,14 @@ body[data-vscode-theme-kind="vscode-high-contrast"] { --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; + /* Circuit diagram: unitary gate box colors */ --qdk-circuit-unitary-fill: #313131; --qdk-circuit-unitary-text: #cccccc; diff --git a/source/widgets/js/index.tsx b/source/widgets/js/index.tsx index 6041bd5020..012fd0676d 100644 --- a/source/widgets/js/index.tsx +++ b/source/widgets/js/index.tsx @@ -16,6 +16,8 @@ import { type ZoneLayout, type TraceData, MoleculeViewer, + Entanglement, + type EntanglementProps, } from "qsharp-lang/ux"; import markdownIt from "markdown-it"; import "./widgets.css"; @@ -86,6 +88,9 @@ function render({ model, el }: RenderArgs) { case "MoleculeViewer": renderMoleculeViewer({ model, el }); break; + case "Entanglement": + renderEntanglement({ model, el }); + break; default: throw new Error(`Unknown component type ${componentType}`); } @@ -301,6 +306,46 @@ function renderAtoms({ model, el }: RenderArgs) { model.on("change:trace_data", onChange); } +function renderEntanglement({ model, el }: RenderArgs) { + /** Read model state and build the full props object for Entanglement. */ + function getWidgetProps( + 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[]; + 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)) { + camelOpts[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v; + } + + return { + s1Entropies, + mutualInformation, + labels, + selectedIndices: selectedIndices ?? undefined, + groups: groups ?? undefined, + ...camelOpts, + ...extra, + } as EntanglementProps; + } + + const onChange = () => { + 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:groups", onChange); + model.on("change:options", onChange); +} + 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 d61f2feab5..8b17145706 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: @@ -224,6 +231,102 @@ def __init__(self, machine_layout, trace_data): super().__init__(machine_layout=machine_layout, trace_data=trace_data) +class Entanglement(anywidget.AnyWidget): + _esm = pathlib.Path(__file__).parent / "static" / "index.js" + _css = pathlib.Path(__file__).parent / "static" / "index.css" + + 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) + 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__( + self, + wavefunction=None, + *, + s1_entropies=None, + mutual_information=None, + labels=None, + selected_indices=None, + groups=None, + **options, + ): + """ + Displays an 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 (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``, ``group_colors``). + """ + 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))] + + super().__init__( + s1_entropies=s1_entropies, + mutual_information=mutual_information, + labels=labels, + selected_indices=selected_indices, + groups=groups, + options=options, + ) + + class MoleculeViewer(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" _css = pathlib.Path(__file__).parent / "static" / "index.css"