Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions source/widgets/js/bloch.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* Copyright (c) Microsoft Corporation.
Licensed under the MIT License. */

/*
* The Bloch sphere renders matrices that require KaTeX's fonts for correct
* spacing and scalable delimiters. Keep these fonts out of the shared widget
* stylesheet so widgets that predate the Bloch sphere retain their original
* CSS payload.
*/
@import "../../npm/qsharp/ux/qsharp-ux.css";
@import "../../npm/qsharp/ux/bloch/bloch.css";
@import "./widgets.css";
@import "katex/dist/katex.min.css";
Comment thread
ScottCarda-MS marked this conversation as resolved.
23 changes: 23 additions & 0 deletions source/widgets/js/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MoleculeViewer,
Entanglement,
type EntanglementProps,
BlochSphere,
} from "qsharp-lang/ux";
import markdownIt from "markdown-it";
import "./widgets.css";
Expand Down Expand Up @@ -91,6 +92,9 @@ function render({ model, el }: RenderArgs) {
case "Entanglement":
renderEntanglement({ model, el });
break;
case "BlochSphere":
renderBlochSphere({ model, el });
break;
default:
throw new Error(`Unknown component type ${componentType}`);
}
Expand Down Expand Up @@ -289,6 +293,25 @@ function renderCircuit({ model, el }: RenderArgs) {
model.on("change:circuit_json", onChange);
}

function renderBlochSphere({ model, el }: RenderArgs) {
const onChange = () => {
const initialGates = model.get("initial_gates") as string;
prender(
<BlochSphere
initialGates={initialGates}
onGatesChanged={(gates) => {
model.set("gates", gates);
model.save_changes();
}}
></BlochSphere>,
el,
);
};

onChange();
model.on("change:initial_gates", onChange);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This listener rerenders the wrapper when Python changes initial_gates, but the underlying BlochSphere reads initialGates only inside its mount-only useEffect([]). Since Preact preserves the mounted component when rendering it again, the updated prop is not applied.

I reproduced this with the wheel built from this PR:

from IPython.display import display
from qsharp_widgets import BlochSphere

widget = BlochSphere("X")
display(widget)

The widget's gate-program text field initially shows X. Then run:

widget.initial_gates = "H"

print("initial_gates:", widget.initial_gates)
print("gates:", widget.gates)

Observed:

initial_gates: H
gates: X

The rendered gate-program field also remains X, rather than changing to H.

Could the core component respond to subsequent initialGates prop changes, or should initial_gates not be exposed as a synchronized mutable trait?

BlochSphere remains X after initial_gates changes to H

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really sure what to do about this. The initial_gates are meant to be a memory of how the bloch sphere was initialized, so maybe it should be a read-only field. But not sure what the behavior of the gates field then. It updates as the user interacts with an adds/removes gates via the GUI, but I don't think the GUI should update if the user edits the gates field. So should they both be read-only?

I also don't know what the desired behavior would be if we have multiple instances of a bloch object rendered.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me neither :). Probably something to discuss with the team?

}

function renderAtoms({ model, el }: RenderArgs) {
const onChange = () => {
const machineLayout = model.get("machine_layout") as ZoneLayout;
Expand Down
66 changes: 66 additions & 0 deletions source/widgets/js/subset-katex-fonts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { readFile, writeFile } from "node:fs/promises";

const cssPath = new URL(
"../src/qsharp_widgets/static/bloch.css",
import.meta.url,
);

// Chromium loads only these faces for the complete set of expressions emitted
// by the Bloch sphere. Keep this list descriptor-specific: KaTeX uses the same
// family name for multiple styles and weights.
const requiredFaces = new Set([
"KaTeX_Main|normal|400",
"KaTeX_Math|italic|400",
"KaTeX_Size3|normal|400",
"KaTeX_Size4|normal|400",
]);

const css = await readFile(cssPath, "utf8");
const fontFacePattern = /@font-face\{[^}]*\}/g;
const foundRequiredFaces = new Set();
let totalFaces = 0;
let removedFaces = 0;

const subsetCss = css.replace(fontFacePattern, (fontFace) => {
totalFaces += 1;

const family = readDescriptor(fontFace, "font-family");
const style = readDescriptor(fontFace, "font-style") ?? "normal";
const weight = readDescriptor(fontFace, "font-weight") ?? "400";
const key = `${family}|${style}|${weight}`;

if (requiredFaces.has(key)) {
foundRequiredFaces.add(key);
return fontFace;
}

removedFaces += 1;
return "";
});

const missingFaces = [...requiredFaces].filter(
(face) => !foundRequiredFaces.has(face),
);
if (missingFaces.length > 0) {
throw new Error(
`KaTeX CSS is missing required Bloch font faces: ${missingFaces.join(", ")}`,
);
}
if (totalFaces === 0 || removedFaces === 0) {
throw new Error(
`Expected bundled KaTeX font faces to subset; found ${totalFaces}, removed ${removedFaces}`,
);
}

await writeFile(cssPath, subsetCss);
console.log(
`Subset KaTeX fonts in bloch.css: kept ${foundRequiredFaces.size}, removed ${removedFaces}`,
);

function readDescriptor(fontFace, descriptor) {
const match = fontFace.match(new RegExp(`${descriptor}:([^;}]+)`));
return match?.[1];
}
5 changes: 3 additions & 2 deletions source/widgets/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"scripts": {
"dev": "npm run build -- --sourcemap=inline --watch",
"build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static"
"dev": "npm run build:bundle -- --sourcemap=inline --watch",
"build": "npm run build:bundle && node js/subset-katex-fonts.mjs",
"build:bundle": "npx esbuild js/index.tsx js/bloch.css --minify --format=esm --bundle --outdir=src/qsharp_widgets/static --loader:.woff2=dataurl --loader:.woff=empty --loader:.ttf=empty"
},
"devDependencies": {}
}
5 changes: 4 additions & 1 deletion source/widgets/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ artifacts = ["src/qsharp_widgets/static/*"]

[tool.hatch.build.hooks.jupyter-builder]
build-function = "hatch_jupyter_builder.npm_builder"
ensured-targets = ["src/qsharp_widgets/static/index.js"]
ensured-targets = [
"src/qsharp_widgets/static/index.js",
"src/qsharp_widgets/static/bloch.css",
]
# skip-if-exists = ["src/qsharp_widgets/static/index.js"]
dependencies = ["hatch-jupyter-builder>=0.5.0"]

Expand Down
35 changes: 32 additions & 3 deletions source/widgets/src/qsharp_widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import anywidget
import traitlets


try:
__version__ = importlib.metadata.version("qsharp_widgets")
except importlib.metadata.PackageNotFoundError:
Expand Down Expand Up @@ -213,6 +212,32 @@ def __init__(self, circuit):
self.layout.overflow = "visible scroll"


class BlochSphere(anywidget.AnyWidget):
_esm = pathlib.Path(__file__).parent / "static" / "index.js"
_css = pathlib.Path(__file__).parent / "static" / "bloch.css"

comp = traitlets.Unicode("BlochSphere").tag(sync=True)
initial_gates = traitlets.Unicode("").tag(sync=True)
gates = traitlets.Unicode("").tag(sync=True)

def __init__(self, initial_gates=""):
"""
This function displays an interactive Bloch sphere for exploring
single-qubit states and gates.

Parameters:
- initial_gates (optional): a whitespace-separated sequence of gate
tokens to replay when the widget is first shown. Fixed gates are
X, Y, Z, H, S, T, and SX; adjoints use a trailing apostrophe
(S', T', SX'); rotations are Rx(angle), Ry(angle), and Rz(angle)
with the angle in radians. For example: "X H Z" or "H Rx(1.5708) S'".

The current gate sequence can be read back at any time from the `gates`
trait, which stays in sync as gates are applied in the widget.
"""
super().__init__(initial_gates=initial_gates, gates=initial_gates)


class Atoms(anywidget.AnyWidget):
_esm = pathlib.Path(__file__).parent / "static" / "index.js"
_css = pathlib.Path(__file__).parent / "static" / "index.css"
Expand All @@ -233,7 +258,9 @@ class Entanglement(anywidget.AnyWidget):
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
)
groups = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True)
options = traitlets.Dict().tag(sync=True)

Expand Down Expand Up @@ -340,7 +367,9 @@ def __init__(
raw_s1.tolist() if hasattr(raw_s1, "tolist") else list(raw_s1)
)
mutual_information = (
raw_mi.tolist() if hasattr(raw_mi, "tolist") else [list(row) for row in raw_mi]
raw_mi.tolist()
if hasattr(raw_mi, "tolist")
else [list(row) for row in raw_mi]
)
n = len(s1_entropies)
if labels is None:
Expand Down
Loading