diff --git a/glue/crumble/draw/config.js b/glue/crumble/draw/config.js index 25c6c739b..2ee965e8d 100644 --- a/glue/crumble/draw/config.js +++ b/glue/crumble/draw/config.js @@ -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}; diff --git a/glue/crumble/draw/main_draw.js b/glue/crumble/draw/main_draw.js index 4636133e2..30e8b7b3a 100644 --- a/glue/crumble/draw/main_draw.js +++ b/glue/crumble/draw/main_draw.js @@ -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"; @@ -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 @@ -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. @@ -278,13 +289,13 @@ 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'; @@ -292,12 +303,12 @@ function draw(ctx, snap) { } 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}`); @@ -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(); @@ -485,6 +497,7 @@ function draw(ctx, snap) { } finally { ctx.restore(); } + return maxTimelineScrollY; } export {xyToPos, draw, setDefensiveDrawEnabled, OFFSET_X, OFFSET_Y} diff --git a/glue/crumble/draw/state_snapshot.js b/glue/crumble/draw/state_snapshot.js index 3c31cb3d1..233a70cd7 100644 --- a/glue/crumble/draw/state_snapshot.js +++ b/glue/crumble/draw/state_snapshot.js @@ -17,8 +17,14 @@ class StateSnapshot { * @param {!number} mouseDownX * @param {!number} mouseDownY * @param {!Array} 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) { this.circuit = circuit.copy(); this.curLayer = curLayer; this.focusedSet = new Map(focusedSet.entries()); @@ -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()); diff --git a/glue/crumble/draw/timeline_viewer.js b/glue/crumble/draw/timeline_viewer.js index 2bab08cfb..c8e63c616 100644 --- a/glue/crumble/draw/timeline_viewer.js +++ b/glue/crumble/draw/timeline_viewer.js @@ -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 @@ -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; @@ -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; @@ -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} \ No newline at end of file +export {drawTimeline} diff --git a/glue/crumble/editor/editor_state.js b/glue/crumble/editor/editor_state.js index 9feef8227..7f1b972ff 100644 --- a/glue/crumble/editor/editor_state.js +++ b/glue/crumble/editor/editor_state.js @@ -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} */ 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) { @@ -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() { diff --git a/glue/crumble/main.js b/glue/crumble/main.js index 7888b9d32..ed3cff4db 100644 --- a/glue/crumble/main.js +++ b/glue/crumble/main.js @@ -1,6 +1,6 @@ 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"; @@ -8,7 +8,6 @@ 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; @@ -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(); }); @@ -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; @@ -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; @@ -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) { + // 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} */ @@ -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(); }); diff --git a/src/stim/diagram/crumble_data.cc b/src/stim/diagram/crumble_data.cc index c81dfb58d..0388719c5 100644 --- a/src/stim/diagram/crumble_data.cc +++ b/src/stim/diagram/crumble_data.cc @@ -584,53 +584,53 @@ std::string stim_draw_internal::make_crumble_html() { )CRUMBLE_PART"); result.append(R"CRUMBLE_PART( )CRUMBLE_PART");