Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 13 additions & 1 deletion frontend/src/components/panels/PanelComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand Down Expand Up @@ -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 (
<PanelWrapper>
<InspectorView operationDefinition={selectedOperation} onClear={() => setSelectedOperation(undefined)} />
{selectedOperation ? (
<InspectorView
operationDefinition={selectedOperation}
onClear={() => setSelectedOperation(undefined)}
/>
) : (
<DiracInspectorView circuit={activeCircuit} />
)}
</PanelWrapper>
);
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/useQuantikzExport.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/circuitIndex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ElementaryQuantumGateDto,
ElementSelectorDto,
getRegisterSize,
getSelectorKey,
Expand Down Expand Up @@ -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);
}
104 changes: 104 additions & 0 deletions frontend/src/lib/quantumAngle.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
121 changes: 121 additions & 0 deletions frontend/src/lib/quantumAngle.ts
Original file line number Diff line number Diff line change
@@ -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<ResolveAngleOptions['normalize']>): 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;
}
Loading
Loading