Skip to content
7 changes: 6 additions & 1 deletion glue/crumble/draw/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ const rad = 10;
const OFFSET_X = -pitch + Math.floor(pitch / 4) + 0.5;
const OFFSET_Y = -pitch + Math.floor(pitch / 4) + 0.5;

export {pitch, rad, OFFSET_X, OFFSET_Y};
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 4;

const MAX_QUBIT_COORDINATE = 100;

export {pitch, rad, OFFSET_X, OFFSET_Y, MIN_ZOOM, MAX_ZOOM, MAX_QUBIT_COORDINATE};
27 changes: 20 additions & 7 deletions glue/crumble/draw/main_draw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {pitch, rad, OFFSET_X, OFFSET_Y} from "./config.js"
import {pitch, rad, OFFSET_X, OFFSET_Y, MAX_QUBIT_COORDINATE} from "./config.js"
import {marker_placement} from "../gates/gateset_markers.js";
import {drawTimeline} from "./timeline_viewer.js";
import {PropagatedPauliFrames} from "../circuit/propagated_pauli_frames.js";
Expand Down Expand Up @@ -192,6 +192,15 @@ function defensiveDraw(ctx, body) {
}
}

function switchToScreenCoordinates(ctx) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
}

function switchToTransformationCoordinates(ctx, snap) {
const zoom = snap.viewportZoom;
ctx.setTransform(zoom, 0, 0, zoom, snap.viewportX, snap.viewportY);
}

/**
* @param {!CanvasRenderingContext2D} ctx
* @param {!StateSnapshot} snap
Expand Down Expand Up @@ -254,8 +263,10 @@ function draw(ctx, snap) {
}

defensiveDraw(ctx, () => {
ctx.fillStyle = 'white';
switchToScreenCoordinates(ctx);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
switchToTransformationCoordinates(ctx, snap);

let [focusX, focusY] = xyToPos(snap.curMouseX, snap.curMouseY);

// Draw the background polygons.
Expand All @@ -278,26 +289,26 @@ function draw(ctx, snap) {

// Draw the grid of qubits.
defensiveDraw(ctx, () => {
for (let qx = 0; qx < 100; qx += 0.5) {
for (let qx = 0; qx < MAX_QUBIT_COORDINATE; qx += 0.5) {
let [x, _] = c2dCoordTransform(qx, 0);
let s = `${qx}`;
ctx.fillStyle = 'black';
ctx.fillText(s, x - ctx.measureText(s).width / 2, 15);
}
for (let qy = 0; qy < 100; qy += 0.5) {
for (let qy = 0; qy < MAX_QUBIT_COORDINATE; qy += 0.5) {
let [_, y] = c2dCoordTransform(0, qy);
let s = `${qy}`;
ctx.fillStyle = 'black';
ctx.fillText(s, 18 - ctx.measureText(s).width, y);
}

ctx.strokeStyle = 'black';
for (let qx = 0; qx < 100; qx += 0.5) {
for (let qx = 0; qx < MAX_QUBIT_COORDINATE; qx += 0.5) {
let [x, _] = c2dCoordTransform(qx, 0);
let s = `${qx}`;
ctx.fillStyle = 'black';
ctx.fillText(s, x - ctx.measureText(s).width / 2, 15);
for (let qy = qx % 1; qy < 100; qy += 1) {
for (let qy = qx % 1; qy < MAX_QUBIT_COORDINATE; qy += 1) {
let [x, y] = c2dCoordTransform(qx, qy);
ctx.fillStyle = 'white';
let isUnused = !usedQubitCoordSet.has(`${qx},${qy}`);
Expand Down Expand Up @@ -384,7 +395,8 @@ function draw(ctx, snap) {
});
});

drawTimeline(ctx, snap, propagatedMarkerLayers, qubitDrawCoords, circuit.layers.length);
switchToScreenCoordinates(ctx);
const maxTimelineScrollY = drawTimeline(ctx, snap, propagatedMarkerLayers, qubitDrawCoords, circuit.layers.length);

// Draw scrubber.
ctx.save();
Expand Down Expand Up @@ -485,6 +497,7 @@ function draw(ctx, snap) {
} finally {
ctx.restore();
}
return maxTimelineScrollY;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's super counter-intuitive that the draw command would return maxTimelineScrollY. Provide an alternate route to get at it, like a getMaxTimelineScrollY method, and don't return it here.

Alternatively, declare a DrawSummary struct with a maxTimelineScrollY and return that. And update the docstring of this method to say it's returning that value.

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.

Done. added DrawSummary.

}

export {xyToPos, draw, setDefensiveDrawEnabled, OFFSET_X, OFFSET_Y}
14 changes: 13 additions & 1 deletion glue/crumble/draw/state_snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ class StateSnapshot {
* @param {!number} mouseDownX
* @param {!number} mouseDownY
* @param {!Array<![!number, !number]>} boxHighlightPreview
* @param {!number} viewportX
* @param {!number} viewportY
* @param {!number} viewportZoom
* @param {!number} timelineScrollY
* @param {!number} curMouseScreenX
* @param {!number} curMouseScreenY
*/
constructor(circuit, curLayer, focusedSet, timelineSet, curMouseX, curMouseY, mouseDownX, mouseDownY, boxHighlightPreview) {
constructor(circuit, curLayer, focusedSet, timelineSet, curMouseX, curMouseY, mouseDownX, mouseDownY, boxHighlightPreview, viewportX=0, viewportY=0, viewportZoom=1, timelineScrollY=0, curMouseScreenX=undefined, curMouseScreenY=undefined) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why are these new parameters defaulting to values, when the old ones weren't?

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.

Removed default assignments

this.circuit = circuit.copy();
this.curLayer = curLayer;
this.focusedSet = new Map(focusedSet.entries());
Expand All @@ -28,6 +34,12 @@ class StateSnapshot {
this.mouseDownX = mouseDownX;
this.mouseDownY = mouseDownY;
this.boxHighlightPreview = [...boxHighlightPreview];
this.viewportX = viewportX;
this.viewportY = viewportY;
this.viewportZoom = viewportZoom;
this.timelineScrollY = timelineScrollY;
this.curMouseScreenX = curMouseScreenX;
this.curMouseScreenY = curMouseScreenY;

while (this.circuit.layers.length <= this.curLayer) {
this.circuit.layers.push(new Layer());
Expand Down
33 changes: 27 additions & 6 deletions glue/crumble/draw/timeline_viewer.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {OFFSET_Y, rad} from "./config.js";
import {rad} from "./config.js";
import {stroke_connector_to} from "../gates/gate_draw_util.js"
import {marker_placement} from '../gates/gateset_markers.js';

let TIMELINE_PITCH = 32;
const TIMELINE_PITCH = 32;
const QUBIT_HIGHLIGHT_SIZE = 40;

/**
* @param {!CanvasRenderingContext2D} ctx
Expand Down Expand Up @@ -110,6 +111,7 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun
return x1 - x2;
});

// Calculate base coordinates.
let base_y2xy = new Map();
let prev_y = undefined;
let cur_x = 0;
Expand All @@ -132,6 +134,17 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun
base_y2xy.set(`${x},${y}`, [Math.round(cur_x) + 0.5, Math.round(cur_y) + 0.5]);
}


// Apply vertical scroll offset.
const maxScrollY = Math.max(0, cur_y - ctx.canvas.height + TIMELINE_PITCH); // Restrict scroll based on qubits drawn
const scrollY = Math.max(0, Math.min(snap.timelineScrollY, maxScrollY));

if (scrollY !== 0) {
for (let [key, [x, y]] of base_y2xy) {
base_y2xy.set(key, [x, y - scrollY]);
}
}

let x_pitch = TIMELINE_PITCH + Math.ceil(rad*max_run*0.25);
let num_cols_half = Math.floor(ctx.canvas.width / 4 / x_pitch);
let min_t_free = snap.curLayer - num_cols_half + 1;
Expand Down Expand Up @@ -211,22 +224,30 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun

// Draw links to timeslice viewer.
ctx.globalAlpha = 0.5;
const mouseScreenX = snap.curMouseScreenX;
const mouseScreenY = snap.curMouseScreenY;
const zoom = snap.viewportZoom;

for (let q of qubits) {
let [x0, y0] = qubitTimeCoords(q, min_t_clamp - 1);
let [x1, y1] = timesliceQubitCoordsFunc(q);
if (snap.curMouseX > ctx.canvas.width / 2 && snap.curMouseY >= y0 + OFFSET_Y - TIMELINE_PITCH * 0.55 && snap.curMouseY <= y0 + TIMELINE_PITCH * 0.55 + OFFSET_Y) {
const [wx1, wy1] = timesliceQubitCoordsFunc(q);
// Convert from world to screen coordinates for qubit highlight.
const x1 = wx1 * zoom + snap.viewportX;
const y1 = wy1 * zoom + snap.viewportY;
if (mouseScreenX > ctx.canvas.width / 2 && mouseScreenY >= y0 - TIMELINE_PITCH * 0.55 && mouseScreenY <= y0 + TIMELINE_PITCH * 0.55) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.fillStyle = 'black';
ctx.fillRect(x1 - 20, y1 - 20, 40, 40);
ctx.fillRect(x1 - (QUBIT_HIGHLIGHT_SIZE/2) * zoom, y1 - (QUBIT_HIGHLIGHT_SIZE/2) * zoom, QUBIT_HIGHLIGHT_SIZE * zoom, QUBIT_HIGHLIGHT_SIZE * zoom);
ctx.fillRect(ctx.canvas.width / 2, y0 - TIMELINE_PITCH / 3, ctx.canvas.width / 2, TIMELINE_PITCH * 2 / 3);
}
}
} finally {
ctx.restore();
}
return maxScrollY;
}

export {drawTimeline}
export {drawTimeline}
18 changes: 7 additions & 11 deletions glue/crumble/editor/editor_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ class EditorState {
this.mouseDownX = /** @type {undefined|!number} */ undefined;
this.mouseDownY = /** @type {undefined|!number} */ undefined;
this.obs_val_draw_state = /** @type {!ObservableValue<StateSnapshot>} */ new ObservableValue(this.toSnapshot(undefined));
this.curMouseScreenX = /** @type {undefined|!number} */ undefined;
this.curMouseScreenY = /** @type {undefined|!number} */ undefined;
this.viewportX = 0;
this.viewportY = 0;
this.viewportZoom = 1;
this.timelineScrollY = 0;
}

flipTwoQubitGateOrderAtFocus(preview) {
Expand Down Expand Up @@ -201,17 +207,7 @@ class EditorState {
if (previewCircuit === undefined) {
previewCircuit = this.copyOfCurCircuit();
}
return new StateSnapshot(
previewCircuit,
this.curLayer,
this.focusedSet,
this.timelineSet,
this.curMouseX,
this.curMouseY,
this.mouseDownX,
this.mouseDownY,
this.currentPositionsBoxesByMouseDrag(this.chorder.curModifiers.has("alt")),
);
return new StateSnapshot(previewCircuit, this.curLayer, this.focusedSet, this.timelineSet, this.curMouseX, this.curMouseY, this.mouseDownX, this.mouseDownY, this.currentPositionsBoxesByMouseDrag(this.chorder.curModifiers.has("alt")), this.viewportX, this.viewportY, this.viewportZoom, this.timelineScrollY, this.curMouseScreenX, this.curMouseScreenY);
}

force_redraw() {
Expand Down
105 changes: 94 additions & 11 deletions glue/crumble/main.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {Circuit} from "./circuit/circuit.js"
import {minXY} from "./circuit/layer.js"
import {pitch} from "./draw/config.js"
import {MAX_QUBIT_COORDINATE, MAX_ZOOM, MIN_ZOOM, pitch} from "./draw/config.js"
import {GATE_MAP} from "./gates/gateset.js"
import {EditorState} from "./editor/editor_state.js";
import {initUrlCircuitSync} from "./editor/sync_url_to_state.js";
import {draw} from "./draw/main_draw.js";
import {drawToolbox} from "./keyboard/toolbox.js";
import {Operation} from "./circuit/operation.js";
import {make_mpp_gate} from './gates/gateset_mpp.js';
import {PropagatedPauliFrames} from './circuit/propagated_pauli_frames.js';

const OFFSET_X = -pitch + Math.floor(pitch / 4) + 0.5;
const OFFSET_Y = -pitch + Math.floor(pitch / 4) + 0.5;
Expand Down Expand Up @@ -39,6 +38,14 @@ txtStimCircuit.addEventListener('keydown', ev => ev.stopPropagation());

let editorState = /** @type {!EditorState} */ new EditorState(document.getElementById('cvn'));

function toWorldMouseX(screenX) {
return (screenX - editorState.viewportX) / editorState.viewportZoom + OFFSET_X;
}

function toWorldMouseY(screenY) {
return (screenY - editorState.viewportY) / editorState.viewportZoom + OFFSET_Y;
}

btnExport.addEventListener('click', _ev => {
exportCurrentState();
});
Expand Down Expand Up @@ -144,8 +151,10 @@ function exportCurrentState() {
}

editorState.canvas.addEventListener('mousemove', ev => {
editorState.curMouseX = ev.offsetX + OFFSET_X;
editorState.curMouseY = ev.offsetY + OFFSET_Y;
editorState.curMouseScreenX = ev.offsetX;
editorState.curMouseScreenY = ev.offsetY;
editorState.curMouseX = toWorldMouseX(ev.offsetX);
editorState.curMouseY = toWorldMouseY(ev.offsetY);

// Scrubber.
let w = editorState.canvas.width / 2;
Expand All @@ -159,10 +168,12 @@ editorState.canvas.addEventListener('mousemove', ev => {

let isInScrubber = false;
editorState.canvas.addEventListener('mousedown', ev => {
editorState.curMouseX = ev.offsetX + OFFSET_X;
editorState.curMouseY = ev.offsetY + OFFSET_Y;
editorState.mouseDownX = ev.offsetX + OFFSET_X;
editorState.mouseDownY = ev.offsetY + OFFSET_Y;
editorState.curMouseScreenX = ev.offsetX;
editorState.curMouseScreenY = ev.offsetY;
editorState.curMouseX = toWorldMouseX(ev.offsetX);
editorState.curMouseY = toWorldMouseY(ev.offsetY);
editorState.mouseDownX = toWorldMouseX(ev.offsetX);
editorState.mouseDownY = toWorldMouseY(ev.offsetY);

// Scrubber.
let w = editorState.canvas.width / 2;
Expand All @@ -179,14 +190,80 @@ editorState.canvas.addEventListener('mouseup', ev => {
let highlightedArea = editorState.currentPositionsBoxesByMouseDrag(ev.altKey);
editorState.mouseDownX = undefined;
editorState.mouseDownY = undefined;
editorState.curMouseX = ev.offsetX + OFFSET_X;
editorState.curMouseY = ev.offsetY + OFFSET_Y;
editorState.curMouseScreenX = ev.offsetX;
editorState.curMouseScreenY = ev.offsetY;
editorState.curMouseX = toWorldMouseX(ev.offsetX);
editorState.curMouseY = toWorldMouseY(ev.offsetY);
editorState.changeFocus(highlightedArea, ev.shiftKey, ev.ctrlKey);
if (ev.buttons === 1) {
isInScrubber = false;
}
});

// Make sure qubit grid and timeline don't deviate from the area of interest.
function restrictQubitGridAndTimeline() {
const width = editorState.canvas.width / 2;
const height = editorState.canvas.height;
const zoom = editorState.viewportZoom;
const gridMin = -1 * pitch - OFFSET_X;
const gridMax = MAX_QUBIT_COORDINATE * pitch - OFFSET_X;

editorState.viewportX = Math.max(
width - gridMax * zoom,
Math.min(-gridMin * zoom, editorState.viewportX)
);
editorState.viewportY = Math.max(
height - gridMax * zoom,
Math.min(-gridMin * zoom, editorState.viewportY)
);

editorState.timelineScrollY = Math.max(
0,
editorState.timelineScrollY
);
}

function handleTimelineVerticalScroll(ev) {
editorState.timelineScrollY += ev.deltaY;
restrictQubitGridAndTimeline();
editorState.force_redraw();
return;
}

function handleQubitGridZoomPan(ev) {
if (ev.ctrlKey || ev.metaKey) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the control scheme should be the following:

  1. When you middle-click+drag on either of the views, dragging the mouse should pan. For the timeline view it might be best to keep the X panning locked... but there would be value in the user being able to indicate they want the current layer to be more to the left so they see further into the circuit.

  2. When you scroll the mouse wheel while hovering over the timeslice view, it should zoom around the mouse pointer. (Note you can efficiently pan by zooming out with the mouse in one position then zooming in with the mouse in a new position. This, plus the middle-dragging for panning, obsoletes the need for the modifier keys.)

  3. When you scroll the mouse wheel while hovering over the timeline view, it should scroll the list of qubits ...or zoom the list. I'm not sure. Scrolling makes sense from a "this is a list" perspective and zooming makes sense from "behave the same as the thing right next to you" perspective. Only way to tell would be to try it and see which felt better.

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.

Sure, as for (1), using the middle click+drag does makes sense. Regarding whether the timeline X panning should be locked - the implementation is definitely simpler if we keep it locked, but I agree that allowing for X scroll is something that a user might want. I think I'll give it a go and see how it feels.

(2) that makes sense for the mouse. However, I think it would make using the touchpad (which is what I tested on) unintuitive. I'd argue that using the modifier key for zoom is a better compromise, but I'll give it some more thought tomorrow. What do you think?

(3) if we do decide to go for "scroll is zoom for timeslice" in (2) - then indeed it's not clear whether scrolling for scroll would feel natural here. I will say that with current implementation (scroll is pan) it does feel natural even though it only scrolls the Y axis at the moment.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hm, I didn't consider using a touchpad. It would also make sense to consider pinch actions etc for users with touch screens on their laptops.

I'm fine with timeslice wheeling meaning to scroll rather than to zoom.

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.

Currently pinch works on my touchpad, so I assume it should work similarly on a touch screen - but I'll check.

So to summarize, I think the approach would be:

  1. add middle click + drag to pan both panels; As for timeline - ideally we'll add X panning as well (if it's simple enough. We'll probably want to reset X when user moves between layers for focus.
  2. Leaving the modifier+scroll for zoom for mouse users; touchpad users can use modifier/pinch for zoom; regular scrolling just pans.
  3. since scroll is pan - timeline behaviour will stay the same and feel natural; There will be no zoom in timeline panel.

// Handle zoom.
const zoomMultiplier = ev.deltaY < 0 ? 1.05 : (1 / 1.05);
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, editorState.viewportZoom * zoomMultiplier));
const ratio = newZoom / editorState.viewportZoom;
editorState.viewportZoom = newZoom;

// Center zoom around mouse.
editorState.viewportX = ev.offsetX - (ev.offsetX - editorState.viewportX) * ratio;
editorState.viewportY = ev.offsetY - (ev.offsetY - editorState.viewportY) * ratio;
} else {
// Handle pan.
editorState.viewportX -= ev.deltaX;
editorState.viewportY -= ev.deltaY;
}

editorState.curMouseX = toWorldMouseX(ev.offsetX);
editorState.curMouseY = toWorldMouseY(ev.offsetY);
restrictQubitGridAndTimeline();
editorState.force_redraw();
}

editorState.canvas.addEventListener('wheel', ev => {
ev.preventDefault();
const width = editorState.canvas.width / 2;

if (ev.offsetX > width) {
handleTimelineVerticalScroll(ev);
} else {
handleQubitGridZoomPan(ev);
}
}, { passive: false });

/**
* @return {!Map<!string, !function(preview: !boolean) : void>}
*/
Expand Down Expand Up @@ -504,7 +581,13 @@ editorState.rev.changes().subscribe(() => {
drawToolbox(editorState.chorder.toEvent(false));
});
initUrlCircuitSync(editorState.rev);
editorState.obs_val_draw_state.observable().subscribe(ds => requestAnimationFrame(() => draw(editorState.canvas.getContext('2d'), ds)));
editorState.obs_val_draw_state.observable().subscribe(ds => requestAnimationFrame(() => {
const maxTimelineScrollY = draw(editorState.canvas.getContext('2d'), ds);
// Prevent over-scrolling.
if (editorState.timelineScrollY > maxTimelineScrollY) {
editorState.timelineScrollY = maxTimelineScrollY;
}
}));
window.addEventListener('focus', () => {
editorState.chorder.handleFocusChanged();
});
Expand Down
Loading
Loading