diff --git a/frontend/src/components/panels/PanelComponents.tsx b/frontend/src/components/panels/PanelComponents.tsx index b0656934..6b51fe89 100644 --- a/frontend/src/components/panels/PanelComponents.tsx +++ b/frontend/src/components/panels/PanelComponents.tsx @@ -5,8 +5,10 @@ import { TextEditorView } from '@/views/text-editor-view/TextEditorView'; import { ProjectManagerView } from '@/views/project-manager-view/ProjectManagerView'; import { ResultsView } from '@/views/results-view/ResultsView'; import { InspectorView } from '@/views/inspector-view/InspectorView'; +import { DiracInspectorView } from '@/views/inspector-view/DiracInspectorView'; import { useFileSelect } from '@/hooks/useFileSelect'; import { useProject } from '@/contexts/ProjectContext.tsx'; +import { useCircuitTabs } from '@/contexts/CircuitTabsContext.tsx'; import React from 'react'; const PanelWrapper = ({ children }: { children: React.ReactNode }) => ( @@ -34,9 +36,19 @@ export const LibraryPanel = () => { export const InspectorPanel = () => { const { selectedOperation, setSelectedOperation } = usePanelData(); + const { activeCircuit } = useCircuitTabs(); + + // A gate being inspected takes over the panel; clearing it (the X) falls back to the default Dirac notation of the active circuit. return ( - setSelectedOperation(undefined)} /> + {selectedOperation ? ( + setSelectedOperation(undefined)} + /> + ) : ( + + )} ); }; diff --git a/frontend/src/hooks/useQuantikzExport.ts b/frontend/src/hooks/useQuantikzExport.ts index 66eebfdd..ac727f8a 100644 --- a/frontend/src/hooks/useQuantikzExport.ts +++ b/frontend/src/hooks/useQuantikzExport.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { CircuitResponse } from '@/api/dto/circuit.ts'; -import { toQuantikz, toStandaloneQuantikzDocument } from '@/views/circuit-view/util/quantikzMapper.ts'; +import { toQuantikz, toStandaloneQuantikzDocument } from '@/notation/quantikz/quantikzMapper.ts'; export type ExportStatus = 'idle' | 'copied' | 'error'; diff --git a/frontend/src/lib/circuitIndex.ts b/frontend/src/lib/circuitIndex.ts index ac5f017d..ef10ced3 100644 --- a/frontend/src/lib/circuitIndex.ts +++ b/frontend/src/lib/circuitIndex.ts @@ -1,4 +1,5 @@ import { + ElementaryQuantumGateDto, ElementSelectorDto, getRegisterSize, getSelectorKey, @@ -55,3 +56,24 @@ export function buildWireIndex(registers: RegisterResponse[], mode: CircuitIndex getWireIndex: (selector) => wireIndexBySelectorKey.get(getSelectorKey(selector)), }; } + +/** + * Returns all qubit operands of a gate in semantic order: + * controls first, followed by targets. + * + * The returned array is a new array and does not mutate the gate. + */ +export function getGateOperands(gate: ElementaryQuantumGateDto): ElementSelectorDto[] { + return [...gate.controlQubits, ...gate.targetQubits]; +} + +/** + * Resolves element selectors to their global wire indices. + * + * Selectors that are not present in the index are omitted. The order of all successfully resolved selectors is preserved. + */ +export function resolveWireIndices(wireIndex: WireIndex, selectors: readonly ElementSelectorDto[]): number[] { + return selectors + .map((selector) => wireIndex.getWireIndex(selector)) + .filter((index): index is number => index !== undefined); +} diff --git a/frontend/src/lib/quantumAngle.test.ts b/frontend/src/lib/quantumAngle.test.ts new file mode 100644 index 00000000..31a1757f --- /dev/null +++ b/frontend/src/lib/quantumAngle.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { angleToLatex, angleToUnicode, resolveAngle } from './quantumAngle'; + +describe('resolveAngle', () => { + it('recognizes the common rotation constants as π multiples', () => { + expect(resolveAngle(Math.PI)).toEqual({ kind: 'pi', numerator: 1, denominator: 1 }); + expect(resolveAngle(Math.PI / 2)).toEqual({ kind: 'pi', numerator: 1, denominator: 2 }); + expect(resolveAngle(Math.PI / 4)).toEqual({ kind: 'pi', numerator: 1, denominator: 4 }); + expect(resolveAngle(Math.PI / 8)).toEqual({ kind: 'pi', numerator: 1, denominator: 8 }); + // τ == 2π is modeled as the π multiple 2/1. + expect(resolveAngle(2 * Math.PI)).toEqual({ kind: 'pi', numerator: 2, denominator: 1 }); + }); + + it('recognizes deep power-of-two (QFT/phase) denominators', () => { + expect(resolveAngle(Math.PI / 16)).toEqual({ kind: 'pi', numerator: 1, denominator: 16 }); + expect(resolveAngle(Math.PI / 32)).toEqual({ kind: 'pi', numerator: 1, denominator: 32 }); + expect(resolveAngle(Math.PI / 128)).toEqual({ kind: 'pi', numerator: 1, denominator: 128 }); + }); + + it('recognizes negative constants', () => { + expect(resolveAngle(-Math.PI / 4)).toEqual({ kind: 'pi', numerator: -1, denominator: 4 }); + expect(resolveAngle(-2 * Math.PI)).toEqual({ kind: 'pi', numerator: -2, denominator: 1 }); + }); + + it('models an exact zero rotation as its own kind', () => { + expect(resolveAngle(0)).toEqual({ kind: 'zero' }); + expect(resolveAngle(1e-12)).toEqual({ kind: 'zero' }); + }); + + it('returns a plain number for values without a common π match', () => { + expect(resolveAngle(1.23)).toEqual({ kind: 'number', radians: 1.23 }); + // π/5 is a valid fraction but not in the curated common denominators. + expect(resolveAngle(Math.PI / 5)).toEqual({ kind: 'number', radians: Math.PI / 5 }); + }); + + it('applies the tolerance in radians, independent of denominator', () => { + const almostHalfPi = Math.PI / 2 + 1e-4; + + // Loose radian tolerance snaps it to π/2 ... + expect(resolveAngle(almostHalfPi, { tolerance: 1e-3 })).toEqual({ kind: 'pi', numerator: 1, denominator: 2 }); + // ... the default (strict) tolerance keeps it numeric. + expect(resolveAngle(almostHalfPi)).toEqual({ kind: 'number', radians: almostHalfPi }); + }); + + it('honors a configurable denominator set', () => { + // π/5 only matches when 5 is an allowed denominator. + expect(resolveAngle(Math.PI / 5, { denominators: [1, 5] })).toEqual({ + kind: 'pi', + numerator: 1, + denominator: 5, + }); + }); + + describe('normalize', () => { + it('does not fold by default', () => { + expect(resolveAngle(3 * Math.PI)).toEqual({ kind: 'pi', numerator: 3, denominator: 1 }); + }); + + it("folds onto (-π, π] with '2pi'", () => { + expect(resolveAngle(2 * Math.PI, { normalize: '2pi' })).toEqual({ kind: 'zero' }); + expect(resolveAngle(3 * Math.PI, { normalize: '2pi' })).toEqual({ + kind: 'pi', + numerator: 1, + denominator: 1, + }); + }); + + it("folds onto (-2π, 2π] with '4pi', preserving the 4π periodicity", () => { + // 3π ≡ -π under 4π periodicity (and stays distinct from π). + expect(resolveAngle(3 * Math.PI, { normalize: '4pi' })).toEqual({ + kind: 'pi', + numerator: -1, + denominator: 1, + }); + expect(resolveAngle(4 * Math.PI, { normalize: '4pi' })).toEqual({ kind: 'zero' }); + }); + }); +}); + +describe('angle renderers', () => { + it('renders π multiples as LaTeX', () => { + expect(angleToLatex(resolveAngle(Math.PI))).toBe(String.raw`\pi`); + expect(angleToLatex(resolveAngle(Math.PI / 2))).toBe(String.raw`\frac{\pi}{2}`); + expect(angleToLatex(resolveAngle(-Math.PI / 4))).toBe(String.raw`-\frac{\pi}{4}`); + expect(angleToLatex(resolveAngle(2 * Math.PI))).toBe(String.raw`2\pi`); + }); + + it('renders π multiples as Unicode', () => { + expect(angleToUnicode(resolveAngle(Math.PI))).toBe('π'); + expect(angleToUnicode(resolveAngle(Math.PI / 2))).toBe('π/2'); + expect(angleToUnicode(resolveAngle(-Math.PI / 4))).toBe('-π/4'); + expect(angleToUnicode(resolveAngle(2 * Math.PI))).toBe('2π'); + }); + + it('renders zero as 0 in every format', () => { + expect(angleToLatex({ kind: 'zero' })).toBe('0'); + expect(angleToUnicode({ kind: 'zero' })).toBe('0'); + }); + + it('rounds non-matching values to two decimals', () => { + expect(angleToLatex(resolveAngle(1.23456))).toBe('1.23'); + expect(angleToUnicode(resolveAngle(1.23456))).toBe('1.23'); + }); +}); diff --git a/frontend/src/lib/quantumAngle.ts b/frontend/src/lib/quantumAngle.ts new file mode 100644 index 00000000..dd1e3df4 --- /dev/null +++ b/frontend/src/lib/quantumAngle.ts @@ -0,0 +1,121 @@ +// Default π denominators used for recognition. Chosen for common quantum-gate angles: powers of two plus 3, 6, and 12. +// Pass custom `denominators` for other π fractions. +const DEFAULT_PI_DENOMINATORS = [1, 2, 3, 4, 6, 8, 12, 16, 32, 64, 128]; +const DEFAULT_TOLERANCE = 1e-9; + +const TWO_PI = 2 * Math.PI; +const FOUR_PI = 4 * Math.PI; + +/** + * Format-independent, symbolic representation of a rotation angle — the shared "model" that + * every output format (LaTeX, Unicode, OpenQASM, …) is rendered from. + * + * - zero: an exact (within tolerance) zero rotation. + * - pi: a rational multiple of π, stored in lowest terms (`denominator ≥ 1`, `numerator ≠ 0`). + * - number: any angle that does not match a known π multiple keeps the full-precision value. + */ +export type QuantumAngle = + | { kind: 'zero' } + | { kind: 'pi'; numerator: number; denominator: number } + | { kind: 'number'; radians: number }; + +export interface ResolveAngleOptions { + /** True radian tolerance when matching against a π multiple. Defaults to 1e-9. */ + tolerance?: number; + /** Denominators of π to try, in ascending order. Defaults to a curated common set. */ + denominators?: number[]; + /** + * Canonicalize the angle before recognition. + * - 'none': use θ as-is. + * - '2pi': θ mod 2π. + * - '4pi': θ mod 4π. + */ + normalize?: 'none' | '2pi' | '4pi'; +} + +/** + * Recognizes a rotation angle (in radians) as a symbolic {@link QuantumAngle}. + * + * This is the single, output-format-agnostic resolver shared by all mappers/displays: it only decides *what* the angle is, never *how* it is written. + * Values that are not a common multiple of π are returned as `{ kind: 'number' }`. + */ +export function resolveAngle(radians: number, options: ResolveAngleOptions = {}): QuantumAngle { + const tolerance = options.tolerance ?? DEFAULT_TOLERANCE; + const denominators = options.denominators ?? DEFAULT_PI_DENOMINATORS; + + if (!Number.isFinite(radians)) return { kind: 'number', radians }; + + const normalized = normalizeRadians(radians, options.normalize ?? 'none'); + + if (Math.abs(normalized) < tolerance) return { kind: 'zero' }; + + for (const denominator of denominators) { + const numerator = Math.round((normalized * denominator) / Math.PI); + + if (numerator === 0) continue; + + // Compare in radians so `tolerance` means the same thing regardless of denominator. + const candidate = (numerator * Math.PI) / denominator; + if (Math.abs(normalized - candidate) < tolerance) { + const divisor = gcd(Math.abs(numerator), denominator); + return { kind: 'pi', numerator: numerator / divisor, denominator: denominator / divisor }; + } + } + + return { kind: 'number', radians: normalized }; +} + +/** Folds an angle onto a canonical window (see {@link ResolveAngleOptions.normalize}). In Quantum systems a rotation of 4 pi equals the identity. */ +function normalizeRadians(radians: number, mode: NonNullable): number { + if (mode === 'none') return radians; + + const period = mode === '4pi' ? FOUR_PI : TWO_PI; + const half = period / 2; + + let wrapped = radians % period; // (-period, period) + if (wrapped > half) wrapped -= period; + else if (wrapped <= -half) wrapped += period; + + return wrapped; +} + +/** Renders a {@link QuantumAngle} as a LaTeX math string (e.g. `\frac{\pi}{2}`, `2\pi`). */ +export function angleToLatex(angle: QuantumAngle): string { + if (angle.kind === 'zero') return '0'; + if (angle.kind === 'number') return formatDecimal(angle.radians); + + const sign = angle.numerator < 0 ? '-' : ''; + const absNumerator = Math.abs(angle.numerator); + const piTerm = absNumerator === 1 ? String.raw`\pi` : String.raw`${absNumerator}\pi`; + + return angle.denominator === 1 ? `${sign}${piTerm}` : String.raw`${sign}\frac{${piTerm}}{${angle.denominator}}`; +} + +/** Renders a {@link QuantumAngle} as a Unicode string for plain UI display (e.g. `π/2`, `2π`). */ +export function angleToUnicode(angle: QuantumAngle): string { + if (angle.kind === 'zero') return '0'; + if (angle.kind === 'number') return formatDecimal(angle.radians); + + const sign = angle.numerator < 0 ? '-' : ''; + const absNumerator = Math.abs(angle.numerator); + const piTerm = absNumerator === 1 ? 'π' : `${absNumerator}π`; + + return angle.denominator === 1 ? `${sign}${piTerm}` : `${sign}${piTerm}/${angle.denominator}`; +} + +/** Rounds a plain angle to two decimals for display; `?` for non-finite input. */ +function formatDecimal(radians: number): string { + if (!Number.isFinite(radians)) return '?'; + return Number(radians.toFixed(2)).toString(); +} + +function gcd(a: number, b: number): number { + let x = a; + let y = b; + + while (y !== 0) { + [x, y] = [y, x % y]; + } + + return x || 1; +} diff --git a/frontend/src/notation/dirac/labeledMapper.test.ts b/frontend/src/notation/dirac/labeledMapper.test.ts new file mode 100644 index 00000000..9f89988e --- /dev/null +++ b/frontend/src/notation/dirac/labeledMapper.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { toLabeledDirac } from './labeledMapper.ts'; +import { + CircuitResponse, + ElementaryQuantumGateDto, + ElementSelectorDto, + LayerResponse, + QuantumOperationDto, + QuantumRegisterResponse, +} from '@/api/dto/circuit.ts'; + +const quantumRegister = (id: string, numberOfQubits: number, name = id): QuantumRegisterResponse => ({ + id, + name, + type: 'Quantum_Register', + numberOfQubits, +}); + +const sel = (registerId: string, index: number): ElementSelectorDto => ({ registerId, index }); + +const gate = ( + identifier: string, + targetQubits: ElementSelectorDto[], + controlQubits: ElementSelectorDto[] = [], + overrides: Partial = {}, +): ElementaryQuantumGateDto => ({ + type: 'ELEMENTARY_QUANTUM_GATE', + identifier: identifier as ElementaryQuantumGateDto['identifier'], + inverseForm: false, + targetQubits, + controlQubits, + rotationAngle: 0, + ...overrides, +}); + +const layer = (...ops: QuantumOperationDto[]): LayerResponse => ({ quantumOperations: ops }); + +const circuit = (registers: QuantumRegisterResponse[], layers: LayerResponse[]): CircuitResponse => ({ + id: 'test-circuit', + registers, + layers, +}); + +describe('toLabeledDirac', () => { + it('renders a single-qubit gate composed with a labeled ket', () => { + // H(q0) + const result = toLabeledDirac(circuit([quantumRegister('q', 1)], [layer(gate('H', [sel('q', 0)]))])); + + expect(result).toBe(String.raw`\mathrm{H}_{q_{0}} \cdot \lvert 0\rangle_{q_{0}}`); + }); + + it('renders the H; CNOT example (last-applied gate leftmost, \\cdot composition)', () => { + // H(q0); CNOT(q0, q1) + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 2)], + [layer(gate('H', [sel('q', 0)])), layer(gate('CX', [sel('q', 1)], [sel('q', 0)]))], + ), + ); + + expect(result).toBe( + String.raw`\mathrm{CNOT}_{q_{0} q_{1}} \cdot \mathrm{H}_{q_{0}} \cdot \lvert 00\rangle_{q_{0} q_{1}}`, + ); + }); + + it('preserves gate-local operand order (control=q1, target=q0 stays q_1 q_0)', () => { + // CNOT(control=q1, target=q0). Operand order must stay q_1 q_0. + const cnot = gate('CX', [sel('q', 0)], [sel('q', 1)]); + + const result = toLabeledDirac(circuit([quantumRegister('q', 2)], [layer(cnot)])); + + expect(result).toBe(String.raw`\mathrm{CNOT}_{q_{1} q_{0}} \cdot \lvert 00\rangle_{q_{0} q_{1}}`); + }); + + it('renders the CCNOT example with space-separated operand labels', () => { + // CCNOT with controls q0, q2 and target q1. + const ccnot = gate('CCX', [sel('q', 1)], [sel('q', 0), sel('q', 2)]); + + const result = toLabeledDirac(circuit([quantumRegister('q', 3)], [layer(ccnot)])); + + expect(result).toBe(String.raw`\mathrm{CCNOT}_{q_{0} q_{2} q_{1}} \cdot \lvert 000\rangle_{q_{0} q_{1} q_{2}}`); + }); + + it('renders a rotation gate with an upright braced axis and \\!\\left parentheses', () => { + // RZ(pi/2) on q1 of a two-qubit register. + const result = toLabeledDirac( + circuit([quantumRegister('q', 2)], [layer(gate('RZ', [sel('q', 1)], [], { rotationAngle: Math.PI / 2 }))]), + ); + + expect(result).toBe( + String.raw`\mathrm{R}_{z}\!\left(\frac{\pi}{2}\right)_{q_{1}} \cdot \lvert 00\rangle_{q_{0} q_{1}}`, + ); + }); + + it('ignores inverseForm for plain gates', () => { + const result = toLabeledDirac( + circuit([quantumRegister('q', 1)], [layer(gate('T', [sel('q', 0)], [], { inverseForm: true }))]), + ); + + expect(result).toBe(String.raw`\mathrm{T}_{q_{0}} \cdot \lvert 0\rangle_{q_{0}}`); + }); + + it('ignores inverseForm for rotations', () => { + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 1)], + [layer(gate('RZ', [sel('q', 0)], [], { rotationAngle: Math.PI / 2, inverseForm: true }))], + ), + ); + + expect(result).toBe( + String.raw`\mathrm{R}_{z}\!\left(\frac{\pi}{2}\right)_{q_{0}} \cdot \lvert 0\rangle_{q_{0}}`, + ); + }); + + it('sets multi-character register names upright, in both operator and ket labels', () => { + const result = toLabeledDirac( + circuit([quantumRegister('ancilla', 1)], [layer(gate('H', [sel('ancilla', 0)]))]), + ); + + expect(result).toBe(String.raw`\mathrm{H}_{\text{ancilla}_{0}} \cdot \lvert 0\rangle_{\text{ancilla}_{0}}`); + }); + + it('escapes LaTeX-special characters in register names', () => { + const result = toLabeledDirac(circuit([quantumRegister('q_a', 1)], [layer(gate('H', [sel('q_a', 0)]))])); + + expect(result).toBe(String.raw`\mathrm{H}_{\text{q\_a}_{0}} \cdot \lvert 0\rangle_{\text{q\_a}_{0}}`); + }); + + it('skips non-unitary operations such as measurements', () => { + const measurement: QuantumOperationDto = { + type: 'MEASUREMENT', + identifier: 'MEASURE', + inverseForm: false, + targetQubits: [sel('q', 0)], + controlQubits: [], + classicBits: [], + }; + + const result = toLabeledDirac(circuit([quantumRegister('q', 1)], [layer(measurement)])); + + // Only the labeled initial state remains; no operator was rendered. + expect(result).toBe(String.raw`\lvert 0\rangle_{q_{0}}`); + }); + + it('breaks per layer in the layered layout', () => { + // H(q0); CNOT(q0, q1). Two layers are rendered on separate lines. + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 2)], + [layer(gate('H', [sel('q', 0)])), layer(gate('CX', [sel('q', 1)], [sel('q', 0)]))], + ), + 'layered', + ); + + expect(result).toBe( + [ + '\\begin{aligned}', + String.raw`& \left(\mathrm{CNOT}_{q_{0} q_{1}}\right) \\`, + String.raw`& \cdot \left(\mathrm{H}_{q_{0}}\right) \\`, + String.raw`& \cdot \lvert 00\rangle_{q_{0} q_{1}}`, + '\\end{aligned}', + ].join('\n'), + ); + }); + + it('orders gates within a layer by ascending qubit index', () => { + // One layer with gates stored as q2, q0, q1. Output is sorted as q0, q1, q2. + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 3)], + [layer(gate('H', [sel('q', 2)]), gate('X', [sel('q', 0)]), gate('Y', [sel('q', 1)]))], + ), + ); + + expect(result).toBe( + String.raw`\mathrm{X}_{q_{0}} \cdot \mathrm{Y}_{q_{1}} \cdot \mathrm{H}_{q_{2}} \cdot \lvert 000\rangle_{q_{0} q_{1} q_{2}}`, + ); + }); + + it('returns an empty string when there are no qubits', () => { + expect(toLabeledDirac(circuit([], []))).toBe(''); + }); +}); diff --git a/frontend/src/notation/dirac/labeledMapper.ts b/frontend/src/notation/dirac/labeledMapper.ts new file mode 100644 index 00000000..e075e692 --- /dev/null +++ b/frontend/src/notation/dirac/labeledMapper.ts @@ -0,0 +1,80 @@ +import { + CircuitResponse, + ElementaryQuantumGateDto, + ElementSelectorDto, + isQuantumRegister, + RegisterResponse, +} from '@/api/dto/circuit.ts'; +import { gateSymbol } from '@/notation/dirac/symbols.ts'; +import { assembleDirac, buildLayerGroups, Layout } from '@/notation/dirac/layout.ts'; +import { buildWireIndex, getGateOperands, resolveWireIndices, WireIndex } from '@/lib/circuitIndex.ts'; +import { escapeLatexText } from '@/notation/latex/escape.ts'; + +type LabelResolver = (selector: ElementSelectorDto) => string; + +/** + * Exports a circuit as labeled Dirac notation. + */ +export function toLabeledDirac(circuit: CircuitResponse, layout: Layout = 'inline'): string { + const resolveLabel = buildLabelResolver(circuit.registers); + + const ket = renderInitialState(circuit.registers, resolveLabel); + if (!ket) return ''; + + const wireIndex = buildWireIndex(circuit.registers, 'quantum'); + const orderKey = (gate: ElementaryQuantumGateDto) => topmostWire(gate, wireIndex); + + const layerGroups = buildLayerGroups(circuit, (gate) => renderOperator(gate, resolveLabel), orderKey); + + return assembleDirac(layerGroups, ket, layout); +} + +/** + * Creates LaTeX labels for qubit selectors. + */ +function buildLabelResolver(registers: RegisterResponse[]): LabelResolver { + const nameById = new Map(registers.map((register) => [register.id, register.name])); + + return (selector) => { + const name = nameById.get(selector.registerId) ?? selector.registerId; + // Keep one letter or digit italic. Escape longer names inside \text{}. + const base = /^[a-zA-Z0-9]$/.test(name) ? name : String.raw`\text{${escapeLatexText(name)}}`; + + return `${base}_{${selector.index}}`; + }; +} + +/** + * Renders the all-zero initial state with qubit labels. + */ +function renderInitialState(registers: RegisterResponse[], resolveLabel: LabelResolver): string { + const labels: string[] = []; + + for (const register of registers) { + if (!isQuantumRegister(register)) continue; + + for (let index = 0; index < register.numberOfQubits; index++) { + labels.push(resolveLabel({ registerId: register.id, index })); + } + } + + if (labels.length === 0) return ''; + + return String.raw`\lvert ${'0'.repeat(labels.length)}\rangle_{${labels.join(' ')}}`; +} + +function renderOperator(gate: ElementaryQuantumGateDto, resolveLabel: LabelResolver): string { + const symbol = gateSymbol(gate); + + // Keep operand order: controls first, then targets. + const labels = getGateOperands(gate).map(resolveLabel).join(' '); + + return `${symbol}_{${labels}}`; +} + +// Used to order gates inside a layer. +function topmostWire(gate: ElementaryQuantumGateDto, wireIndex: WireIndex): number { + const wires = resolveWireIndices(wireIndex, getGateOperands(gate)); + + return wires.length > 0 ? Math.min(...wires) : Number.MAX_SAFE_INTEGER; +} diff --git a/frontend/src/notation/dirac/layout.test.ts b/frontend/src/notation/dirac/layout.test.ts new file mode 100644 index 00000000..e14fee08 --- /dev/null +++ b/frontend/src/notation/dirac/layout.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { assembleDirac } from './layout.ts'; + +describe('assembleDirac', () => { + it('joins everything into a single product for the inline layout', () => { + const result = assembleDirac([['A', 'B'], ['C'], ['D', 'E', 'F']], 'K', 'inline'); + + expect(result).toBe(String.raw`A \cdot B \cdot C \cdot D \cdot E \cdot F \cdot K`); + }); + + it('breaks after each layer for the layered layout', () => { + const result = assembleDirac([['A', 'B'], ['C'], ['D', 'E', 'F']], 'K', 'layered'); + + expect(result).toBe( + [ + '\\begin{aligned}', + String.raw`& \left(A \cdot B\right) \\`, + String.raw`& \cdot \left(C\right) \\`, + String.raw`& \cdot \left(D \cdot E \cdot F\right) \\`, + String.raw`& \cdot K`, + '\\end{aligned}', + ].join('\n'), + ); + }); + + it('renders just the ket when there are no operators', () => { + expect(assembleDirac([], 'K', 'inline')).toBe('K'); + expect(assembleDirac([], 'K', 'layered')).toBe('\\begin{aligned}\n& K\n\\end{aligned}'); + }); +}); diff --git a/frontend/src/notation/dirac/layout.ts b/frontend/src/notation/dirac/layout.ts new file mode 100644 index 00000000..da1cfecf --- /dev/null +++ b/frontend/src/notation/dirac/layout.ts @@ -0,0 +1,56 @@ +import { CircuitResponse, ElementaryQuantumGateDto } from '@/api/dto/circuit.ts'; + +const COMPOSITION = String.raw` \cdot `; + +export type Layout = 'inline' | 'layered'; + +/** + * Builds operator groups from last layer to first. + * Empty gate tokens and empty layers are skipped. + * `orderKey` sorts gates inside a layer for stable output. + */ +export function buildLayerGroups( + circuit: CircuitResponse, + renderOperation: (gate: ElementaryQuantumGateDto) => string, + orderKey?: (gate: ElementaryQuantumGateDto) => number, +): string[][] { + const layerGroups: string[][] = []; + + for (let layerIdx = circuit.layers.length - 1; layerIdx >= 0; layerIdx--) { + const gates = circuit.layers[layerIdx].quantumOperations.filter( + (operation): operation is ElementaryQuantumGateDto => operation.type === 'ELEMENTARY_QUANTUM_GATE', + ); + + if (orderKey) gates.sort((a, b) => orderKey(a) - orderKey(b)); + + const tokens = gates.map(renderOperation).filter((token) => token.length > 0); + + if (tokens.length > 0) layerGroups.push(tokens); + } + + return layerGroups; +} + +/** + * Builds the final Dirac product. + * `inline` returns one line; `layered` returns an aligned block with one row per layer. + */ +export function assembleDirac(layerGroups: string[][], ket: string, layout: Layout): string { + const groups = layerGroups.filter((tokens) => tokens.length > 0); + + if (layout === 'inline') { + return [...groups.flat(), ket].join(COMPOSITION); + } + + const lines = [...groups.map(groupLayer), ket]; + const rows = lines.map((line, index) => (index === 0 ? `& ${line}` : String.raw`& \cdot ${line}`)); + + return `\\begin{aligned}\n${rows.join(' \\\\\n')}\n\\end{aligned}`; +} + +// A single pre-grouped factor does not need another pair of parentheses. +function groupLayer(tokens: string[]): string { + if (tokens.length === 1 && tokens[0].startsWith(String.raw`\left(`)) return tokens[0]; + + return String.raw`\left(${tokens.join(COMPOSITION)}\right)`; +} diff --git a/frontend/src/notation/dirac/symbols.ts b/frontend/src/notation/dirac/symbols.ts new file mode 100644 index 00000000..c4d2e9b8 --- /dev/null +++ b/frontend/src/notation/dirac/symbols.ts @@ -0,0 +1,34 @@ +import { ElementaryQuantumGateDto } from '@/api/dto/circuit.ts'; +import { angleToLatex, resolveAngle } from '@/lib/quantumAngle.ts'; + +/** + * Renders an upright Dirac operator symbol without qubit labels. + * Controlled X gates are rendered as CNOT or CCNOT. + */ +export function gateSymbol(gate: ElementaryQuantumGateDto): string { + const identifier = gate.identifier.toUpperCase(); + const controlCount = gate.controlQubits.length; + + if (identifier === 'X' || identifier === 'CX' || identifier === 'CCX') { + if (controlCount === 1) return String.raw`\mathrm{CNOT}`; + if (controlCount === 2) return String.raw`\mathrm{CCNOT}`; + + return String.raw`\mathrm{X}`; + } + + if (identifier === 'CZ') return String.raw`\mathrm{CZ}`; + if (identifier === 'SWAP') return String.raw`\mathrm{SWAP}`; + + if (identifier === 'RX' || identifier === 'RY' || identifier === 'RZ') { + if (gate.rotationAngle === undefined || gate.rotationAngle === null) { + return String.raw`\mathrm{${identifier}}`; + } + + const axis = identifier[1].toLowerCase(); + const angle = angleToLatex(resolveAngle(gate.rotationAngle)); + + return String.raw`\mathrm{R}_{${axis}}\!\left(${angle}\right)`; + } + + return String.raw`\mathrm{${identifier}}`; +} diff --git a/frontend/src/notation/latex/escape.ts b/frontend/src/notation/latex/escape.ts new file mode 100644 index 00000000..f99e4c11 --- /dev/null +++ b/frontend/src/notation/latex/escape.ts @@ -0,0 +1,16 @@ +/** + * Escapes text for use inside LaTeX text commands. + */ +export function escapeLatexText(value: string): string { + return value + .replaceAll('\\', String.raw`\textbackslash{}`) + .replaceAll('&', String.raw`\&`) + .replaceAll('%', String.raw`\%`) + .replaceAll('$', String.raw`\$`) + .replaceAll('#', String.raw`\#`) + .replaceAll('_', String.raw`\_`) + .replaceAll('{', String.raw`\{`) + .replaceAll('}', String.raw`\}`) + .replaceAll('~', String.raw`\textasciitilde{}`) + .replaceAll('^', String.raw`\textasciicircum{}`); +} diff --git a/frontend/src/views/circuit-view/util/quantikzMapper.ts b/frontend/src/notation/quantikz/quantikzMapper.ts similarity index 67% rename from frontend/src/views/circuit-view/util/quantikzMapper.ts rename to frontend/src/notation/quantikz/quantikzMapper.ts index f744c909..3f9ce60a 100644 --- a/frontend/src/views/circuit-view/util/quantikzMapper.ts +++ b/frontend/src/notation/quantikz/quantikzMapper.ts @@ -7,16 +7,15 @@ import { QuantumOperationDto, RegisterResponse, } from '@/api/dto/circuit.ts'; -import { buildWireIndex, WireIndex } from '@/lib/circuitIndex.ts'; +import { buildWireIndex, resolveWireIndices, WireIndex } from '@/lib/circuitIndex.ts'; +import { angleToLatex, resolveAngle } from '@/lib/quantumAngle.ts'; +import { escapeLatexText } from '@/notation/latex/escape.ts'; const ROTATION_GATES = new Set(['RX', 'RY', 'RZ']); -const PI_FRACTION_DENOMINATORS = [1, 2, 3, 4, 6, 8, 12, 16]; -const ANGLE_TOLERANCE = 1e-8; const TRAILING_COLUMNS = 1; // Keep trailing wire column so the rendered circuit does not end directly at the last gate. /** - * Exports circuits using Quantikz2 syntax - * (quantikz package v1.0+, loaded via \usetikzlibrary{quantikz2}). + * Exports a circuit using Quantikz2 syntax. */ export function toQuantikz(circuit: CircuitResponse): string { const wireIndex = buildWireIndex(circuit.registers); @@ -48,6 +47,9 @@ export function toStandaloneQuantikzDocument(circuit: CircuitResponse): string { return toStandaloneDocument(toQuantikz(circuit)); } +/** + * Wraps Quantikz code in a minimal standalone LaTeX document. + */ export function toStandaloneDocument(latexCode: string): string { return [ String.raw`\documentclass[tikz,border=2pt]{standalone}`, @@ -133,9 +135,9 @@ function applyElementaryGate( layerIdx: number, ): void { const identifier = gate.identifier.toUpperCase(); - const targetWires = getTargetWires(wireIndex, gate); + const targetWires = resolveWireIndices(wireIndex, gate.targetQubits); - // Do not allow multitarget gates + // Only SWAP supports multiple targets. if (identifier !== 'SWAP' && targetWires.length !== 1) { return; } @@ -160,7 +162,7 @@ function applySwapGate(grid: string[][], targetWires: number[], layerIdx: number const [topWire, bottomWire] = [...targetWires].sort((a, b) => a - b); - // Quantikz draws SWAP as a swap marker plus a target-X marker on the connected wire. + // Quantikz draws SWAP with one swap marker and one target-X marker. grid[topWire][layerIdx] = String.raw`\swap{${bottomWire - topWire}}`; grid[bottomWire][layerIdx] = String.raw`\targX{}`; } @@ -212,19 +214,13 @@ function applyControls( } } -function getTargetWires(wireIndex: WireIndex, gate: ElementaryQuantumGateDto): number[] { - return gate.targetQubits - .map((target) => wireIndex.getWireIndex(target)) - .filter((wireIdx): wireIdx is number => wireIdx !== undefined); -} - function gateLabel(gate: ElementaryQuantumGateDto): string { const identifier = gate.identifier.toUpperCase(); if (ROTATION_GATES.has(identifier)) { const axis = identifier[1]; - // \ensuremath works with both quantikz versions where gate labels may be handled in text or math mode. - return String.raw`\ensuremath{R_${axis}(${formatAngle(gate.rotationAngle)})}`; + // Works when gate labels are handled in text or math mode. + return String.raw`\ensuremath{R_${axis}(${angleToLatex(resolveAngle(gate.rotationAngle))})}`; } if (identifier === 'CZ') { @@ -237,71 +233,3 @@ function gateLabel(gate: ElementaryQuantumGateDto): string { function isControlledXGate(identifier: string, gate: ElementaryQuantumGateDto): boolean { return ['X', 'CX', 'CCX'].includes(identifier) && Boolean(gate.controlQubits?.length); } - -// Prefer symbolic π fractions for common rotation angles; fall back to a compact decimal. -function formatAngle(angle: number | null | undefined): string { - if (angle === null || angle === undefined) { - return '?'; - } - - if (Math.abs(angle) < ANGLE_TOLERANCE) { - return '0'; - } - - const piRatio = angle / Math.PI; - - for (const denominator of PI_FRACTION_DENOMINATORS) { - const numerator = Math.round(piRatio * denominator); - - if (numerator === 0) continue; - - if (Math.abs(piRatio - numerator / denominator) < ANGLE_TOLERANCE) { - return formatPiFraction(numerator, denominator); - } - } - - return Number(angle.toFixed(2)).toString(); -} - -function formatPiFraction(numerator: number, denominator: number): string { - const divisor = gcd(Math.abs(numerator), denominator); - const reducedNumerator = numerator / divisor; - const reducedDenominator = denominator / divisor; - const sign = reducedNumerator < 0 ? '-' : ''; - const absNumerator = Math.abs(reducedNumerator); - - if (reducedDenominator === 1) { - return absNumerator === 1 ? String.raw`${sign}\pi` : String.raw`${sign}${absNumerator}\pi`; - } - - if (absNumerator === 1) { - return String.raw`${sign}\frac{\pi}{${reducedDenominator}}`; - } - - return String.raw`${sign}\frac{${absNumerator}\pi}{${reducedDenominator}}`; -} - -function gcd(a: number, b: number): number { - let x = a; - let y = b; - - while (y !== 0) { - [x, y] = [y, x % y]; - } - - return x || 1; -} - -function escapeLatexText(value: string): string { - return value - .replaceAll('\\', String.raw`\textbackslash{}`) - .replaceAll('&', String.raw`\&`) - .replaceAll('%', String.raw`\%`) - .replaceAll('$', String.raw`\$`) - .replaceAll('#', String.raw`\#`) - .replaceAll('_', String.raw`\_`) - .replaceAll('{', String.raw`\{`) - .replaceAll('}', String.raw`\}`) - .replaceAll('~', String.raw`\textasciitilde{}`) - .replaceAll('^', String.raw`\textasciicircum{}`); -} diff --git a/frontend/src/store/tabs/tabsSlice.ts b/frontend/src/store/tabs/tabsSlice.ts index 456710a8..31c514ec 100644 --- a/frontend/src/store/tabs/tabsSlice.ts +++ b/frontend/src/store/tabs/tabsSlice.ts @@ -236,9 +236,10 @@ export const tabsSlice = createSlice({ const group = state.groups.find((g) => g.id === targetGroupId); if (!group) return; + const tab = action.payload.tab; const newTab: Tab = { - ...action.payload.tab, - language: getLanguageByExtension(action.payload.tab.title), + ...tab, + language: getLanguageByExtension(tab.title), }; const exists = group.openTabs.find((t) => t.id === action.payload.tab.id); diff --git a/frontend/src/views/circuit-view/components/CircuitTabBar.tsx b/frontend/src/views/circuit-view/components/CircuitTabBar.tsx index 93652cac..3fa3bd93 100644 --- a/frontend/src/views/circuit-view/components/CircuitTabBar.tsx +++ b/frontend/src/views/circuit-view/components/CircuitTabBar.tsx @@ -1,19 +1,28 @@ import { TabBar } from '@/components/TabBar.tsx'; import { EditorTabLabel } from '@/views/text-editor-view/components/tabs/EditorTabLabel.tsx'; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu.tsx'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu.tsx'; import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; import { useAppSelector } from '@/hooks/useAppSelector.ts'; import { moveTab, setActiveTab, setDragging } from '@/store/tabs/tabsSlice.ts'; import { safeCloseAll, safeCloseOthers, safeCloseTab } from '@/store/tabs/tabsThunks.ts'; +import { usePanelData } from '@/contexts/panel/PanelDataContext.ts'; +import { canInspectWithDirac } from '@/views/inspector-view/diracInspect.ts'; /** - * The circuit's file-tab bar. It shares the open-file tabs with the editor but only exposes - * the actions that have a meaning for the single circuit panel: closing tabs (which close the - * shared file) and drag-to-reorder. The editor's split/move/group actions are intentionally - * left out — those rearrange the code-editor panes, which the circuit panel does not have. + * Renders the shared file tabs for the circuit panel. + * + * Supports Dirac inspection, closing and reordering, but omits editor-specific + * split and group actions. */ export function CircuitTabBar() { const dispatch = useAppDispatch(); + const { setSelectedOperation } = usePanelData(); const activeGroupId = useAppSelector((state) => state.tabs.activeGroupId); const group = useAppSelector((state) => state.tabs.groups.find((candidate) => candidate.id === activeGroupId)); const dirtyFiles = useAppSelector((state) => state.tabs.dirtyFiles); @@ -21,6 +30,12 @@ export function CircuitTabBar() { if (!group || group.openTabs.length === 0) return null; const groupId = group.id; + // Activate the circuit and clear the selected gate to show the default Dirac inspection. + const inspectWithDirac = (tabId: string) => { + dispatch(setActiveTab({ tabId, groupId })); + setSelectedOperation(undefined); + }; + return ( + {canInspectWithDirac(tab.title) && ( + <> + inspectWithDirac(tab.id)}> + Inspect with Dirac + + + + )} dispatch(safeCloseTab({ tabId: tab.id, groupId }))}> Close diff --git a/frontend/src/views/circuit-view/util/angle.test.ts b/frontend/src/views/circuit-view/util/angle.test.ts index 0e89ded8..9b1cd7a4 100644 --- a/frontend/src/views/circuit-view/util/angle.test.ts +++ b/frontend/src/views/circuit-view/util/angle.test.ts @@ -26,9 +26,9 @@ describe('formatRotationAngle', () => { expect(formatRotationAngle(3 * Math.PI)).toBe('3π'); }); - it('falls back to an integer or a 2-decimal number', () => { + it('falls back to an integer or a rounded 2-decimal number', () => { expect(formatRotationAngle(1)).toBe('1'); - expect(formatRotationAngle(1.5)).toBe('1.50'); + expect(formatRotationAngle(1.5)).toBe('1.5'); expect(formatRotationAngle(1.5708)).toBe('1.57'); // close to π/2 but not exact expect(formatRotationAngle(-0.123456)).toBe('-0.12'); }); diff --git a/frontend/src/views/circuit-view/util/angle.ts b/frontend/src/views/circuit-view/util/angle.ts index 2552a852..0f5dcdb9 100644 --- a/frontend/src/views/circuit-view/util/angle.ts +++ b/frontend/src/views/circuit-view/util/angle.ts @@ -1,21 +1,25 @@ +import { angleToUnicode, resolveAngle } from '@/lib/quantumAngle.ts'; + +const EPSILON = 1e-9; +// Denominators of π the gate box recognizes (up to 12), mirroring the backend's formatAngle. +const PI_DENOMINATORS = Array.from({ length: 12 }, (_, index) => index + 1); + /** * Formats a rotation angle (in radians) for compact display on a gate box. * - * Mirrors the backend's `ElementaryQuantumGate.formatAngle` so the circuit and the - * generated OpenQASM stay consistent: the named constants τ (= 2π) and e (euler) are - * recognized first, then rational multiples of π with a denominator up to 12 - * (e.g. "π", "2π", "π/2", "-π/4", "2π/3"); anything else falls back to an integer or a - * 2-decimal number. The stored angle keeps full precision — only the label is rounded. + * Mirrors the backend's `ElementaryQuantumGate.formatAngle` so the circuit label and the generated + * OpenQASM stay consistent: the named constants τ (= 2π) and e (euler) are recognized first — these + * are backend-specific and deliberately not part of the shared resolver — then the shared + * {@link resolveAngle} handles rational multiples of π (denominator up to 12); anything else falls + * back to a plain number. The stored angle keeps full precision — only the label is rounded. */ export function formatRotationAngle(angle: number): string { if (!Number.isFinite(angle) || angle === 0) return '0'; - return tryNamedConstant(angle) ?? tryPiMultiple(angle) ?? formatPlainNumber(angle); + return tryNamedConstant(angle) ?? angleToUnicode(resolveAngle(angle, { denominators: PI_DENOMINATORS })); } /** Matches the QASM parser's named constants, with a small tolerance for round-trip matching. */ -const EPSILON = 1e-9; - function tryNamedConstant(angle: number): string | null { if (Math.abs(angle - 2 * Math.PI) < EPSILON) return 'τ'; if (Math.abs(angle + 2 * Math.PI) < EPSILON) return '-τ'; @@ -23,35 +27,3 @@ function tryNamedConstant(angle: number): string | null { if (Math.abs(angle + Math.E) < EPSILON) return '-e'; return null; } - -/** Tries to express the angle as a rational multiple of π (denominator up to 12). */ -function tryPiMultiple(angle: number): string | null { - const ratio = angle / Math.PI; - for (let denominator = 1; denominator <= 12; denominator++) { - const scaled = ratio * denominator; - const numerator = Math.round(scaled); - if (numerator !== 0 && Math.abs(scaled - numerator) < EPSILON) { - const divisor = gcd(Math.abs(numerator), denominator); - return buildPiTerm(numerator / divisor, denominator / divisor); - } - } - return null; -} - -function buildPiTerm(numerator: number, denominator: number): string { - const sign = numerator < 0 ? '-' : ''; - const magnitude = Math.abs(numerator); - const piPart = magnitude === 1 ? 'π' : `${magnitude}π`; - return denominator === 1 ? `${sign}${piPart}` : `${sign}${piPart}/${denominator}`; -} - -function formatPlainNumber(angle: number): string { - return Number.isInteger(angle) ? String(angle) : angle.toFixed(2); -} - -function gcd(a: number, b: number): number { - while (b !== 0) { - [a, b] = [b, a % b]; - } - return a; -} diff --git a/frontend/src/views/inspector-view/DiracInspectorView.tsx b/frontend/src/views/inspector-view/DiracInspectorView.tsx new file mode 100644 index 00000000..f80d5d19 --- /dev/null +++ b/frontend/src/views/inspector-view/DiracInspectorView.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react'; +import { BlockMath } from 'react-katex'; +import 'katex/dist/katex.min.css'; +import { Microscope, Copy, Check } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle.tsx'; +import { CircuitResponse } from '@/api/dto/circuit.ts'; +import { toLabeledDirac } from '@/notation/dirac/labeledMapper.ts'; +import { Layout } from '@/notation/dirac/layout.ts'; + +interface DiracInspectorViewProps { + circuit: CircuitResponse | undefined; +} + +const toDisplayMath = (latex: string): string => `\\[\n${latex}\n\\]`; + +// Mirrors InspectorView's SafeBlockMath: KaTeX throws on malformed input, so isolate the failure. +function SafeBlockMath({ math }: Readonly<{ math: string }>) { + try { + return ; + } catch (error) { + console.error('LaTeX rendering error:', error); + return
Error rendering LaTeX: {math}
; + } +} + +/** + * Read-only Dirac notation of a circuit, styled to sit inside the Inspector panel. This is the + * Inspector's default view; it is replaced by a gate's details while a gate is inspected. + */ +export function DiracInspectorView({ circuit }: Readonly) { + const [layout, setLayout] = useState('inline'); + const [copied, setCopied] = useState(false); + + const latex = useMemo(() => (circuit ? toLabeledDirac(circuit, layout) : ''), [circuit, layout]); + + if (!latex) { + return ( + + + +

Nothing to inspect.

+
+
+ ); + } + + const copyLatex = async () => { + try { + await navigator.clipboard.writeText(toDisplayMath(latex)); + setCopied(true); + globalThis.setTimeout(() => setCopied(false), 2000); + toast.success('LaTeX copied to clipboard'); + } catch (error) { + console.error('Failed to copy LaTeX:', error); + toast.error('Could not copy LaTeX'); + } + }; + + return ( + + +
+ Dirac Notation + + value && setLayout(value as Layout)} + aria-label="Notation layout" + > + + Inline + + + Layered + + +
+
+ +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/frontend/src/views/inspector-view/diracInspect.ts b/frontend/src/views/inspector-view/diracInspect.ts new file mode 100644 index 00000000..25a3f162 --- /dev/null +++ b/frontend/src/views/inspector-view/diracInspect.ts @@ -0,0 +1,4 @@ +/** The Dirac notation is only meaningful for OpenQASM circuits. */ +export function canInspectWithDirac(fileName: string): boolean { + return fileName.toLowerCase().endsWith('.qasm'); +}