diff --git a/glue/crumble/draw/config.js b/glue/crumble/draw/config.js index 25c6c739b..ad1efed02 100644 --- a/glue/crumble/draw/config.js +++ b/glue/crumble/draw/config.js @@ -3,4 +3,10 @@ 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; +const LABEL_GAP = 20; + +export {pitch, rad, OFFSET_X, OFFSET_Y, MIN_ZOOM, MAX_ZOOM, MAX_QUBIT_COORDINATE, LABEL_GAP}; diff --git a/glue/crumble/draw/main_draw.js b/glue/crumble/draw/main_draw.js index 4636133e2..73ef88662 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, LABEL_GAP} 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,49 @@ function draw(ctx, snap) { } defensiveDraw(ctx, () => { - ctx.fillStyle = 'white'; + switchToScreenCoordinates(ctx); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + // Draw grid tick-mark labels. + defensiveDraw(ctx, () => { + ctx.fillStyle = 'black'; + + let tickMarkInterval = 0.5; + if (snap.viewportZoom < 0.85) tickMarkInterval = 1; + if (snap.viewportZoom < 0.3) tickMarkInterval = 2; + + ctx.save(); + ctx.beginPath(); + ctx.rect(LABEL_GAP, 0, ctx.canvas.width - LABEL_GAP, ctx.canvas.height); + ctx.clip(); + for (let qx = 0; qx < MAX_QUBIT_COORDINATE; qx += tickMarkInterval) { + let [x, _] = c2dCoordTransform(qx, 0); + const screenX = x * snap.viewportZoom + snap.viewportX; + let s = `${qx}`; + ctx.fillText(s, screenX - ctx.measureText(s).width / 2, 15); + } + ctx.restore(); + + ctx.save(); + ctx.beginPath(); + ctx.rect(0, LABEL_GAP, ctx.canvas.width, ctx.canvas.height - LABEL_GAP); + ctx.clip(); + for (let qy = 0; qy < MAX_QUBIT_COORDINATE; qy += tickMarkInterval) { + let [_, y] = c2dCoordTransform(0, qy); + const screenY = y * snap.viewportZoom + snap.viewportY; + let s = `${qy}`; + ctx.fillText(s, 18 - ctx.measureText(s).width, screenY); + } + ctx.restore(); + }); + + // Apply clipping on all content so it doesn't overlap on tick labels. + ctx.save(); + ctx.beginPath(); + ctx.rect(LABEL_GAP, LABEL_GAP, ctx.canvas.width, ctx.canvas.height - LABEL_GAP); + ctx.clip(); + switchToTransformationCoordinates(ctx, snap); + let [focusX, focusY] = xyToPos(snap.curMouseX, snap.curMouseY); // Draw the background polygons. @@ -278,26 +328,9 @@ function draw(ctx, snap) { // Draw the grid of qubits. defensiveDraw(ctx, () => { - for (let qx = 0; qx < 100; 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) { - 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) { - 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 qx = 0; qx < MAX_QUBIT_COORDINATE; qx += 0.5) { + 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 +417,8 @@ function draw(ctx, snap) { }); }); - drawTimeline(ctx, snap, propagatedMarkerLayers, qubitDrawCoords, circuit.layers.length); + switchToScreenCoordinates(ctx); + const timelineDrawSummary = drawTimeline(ctx, snap, propagatedMarkerLayers, qubitDrawCoords, circuit.layers.length); // Draw scrubber. ctx.save(); @@ -485,6 +519,7 @@ function draw(ctx, snap) { } finally { ctx.restore(); } + return timelineDrawSummary; } 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..bfd9f0a4d 100644 --- a/glue/crumble/draw/state_snapshot.js +++ b/glue/crumble/draw/state_snapshot.js @@ -17,8 +17,15 @@ class StateSnapshot { * @param {!number} mouseDownX * @param {!number} mouseDownY * @param {!Array} boxHighlightPreview + * @param {!number} viewportX + * @param {!number} viewportY + * @param {!number} viewportZoom + * @param {!number} timelineOffsetX + * @param {!number} timelineOffsetY + * @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, viewportY, viewportZoom, timelineOffsetX, timelineOffsetY, curMouseScreenX, curMouseScreenY) { this.circuit = circuit.copy(); this.curLayer = curLayer; this.focusedSet = new Map(focusedSet.entries()); @@ -28,6 +35,13 @@ class StateSnapshot { this.mouseDownX = mouseDownX; this.mouseDownY = mouseDownY; this.boxHighlightPreview = [...boxHighlightPreview]; + this.viewportX = viewportX; + this.viewportY = viewportY; + this.viewportZoom = viewportZoom; + this.timelineOffsetX = timelineOffsetX; + this.timelineOffsetY = timelineOffsetY; + 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..525dc9d7f 100644 --- a/glue/crumble/draw/timeline_viewer.js +++ b/glue/crumble/draw/timeline_viewer.js @@ -1,8 +1,23 @@ -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; + +// Timeline panel changes size dynamically. These values are collected during draw and are used for restricting panning outside the relevant bounds. +class DrawSummary { + /** + * @param {!number} minOffsetX - Minimum allowed offsetX for timeline panel + * @param {!number} maxOffsetX - Maximum allowed offsetX for timeline panel + * @param {!number} maxOffsetY - Maximum Y offset for timeline panel + */ + constructor(minOffsetX, maxOffsetX, maxOffsetY) { + this.minOffsetX = minOffsetX; + this.maxOffsetX = maxOffsetX; + this.maxOffsetY = maxOffsetY; + } +} /** * @param {!CanvasRenderingContext2D} ctx @@ -110,6 +125,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,11 +148,31 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun base_y2xy.set(`${x},${y}`, [Math.round(cur_x) + 0.5, Math.round(cur_y) + 0.5]); } - let x_pitch = TIMELINE_PITCH + Math.ceil(rad*max_run*0.25); - let num_cols_half = Math.floor(ctx.canvas.width / 4 / x_pitch); + + const x_pitch = TIMELINE_PITCH + Math.ceil(rad*max_run*0.25); + const num_cols_half = Math.floor(ctx.canvas.width / 4 / x_pitch); + let min_t_free = snap.curLayer - num_cols_half + 1; let min_t_clamp = Math.max(0, Math.min(min_t_free, numLayers - num_cols_half*2 + 1)); - let max_t = Math.min(min_t_clamp + num_cols_half*2 + 2, numLayers); + + + const maxOffsetY = Math.max(0, cur_y - ctx.canvas.height + TIMELINE_PITCH); + const offsetY = Math.max(-maxOffsetY, Math.min(0, snap.timelineOffsetY ?? 0)); + + const lastLayerOffset = (numLayers - 1 - snap.curLayer - (min_t_clamp - min_t_free)) * x_pitch; + const minOffsetX = -Math.max(0, lastLayerOffset - 0.5 * w + 2*x_pitch); + const maxOffsetX = Math.max(0, (min_t_clamp - 1) * x_pitch); + const offsetX = Math.max(minOffsetX, Math.min(maxOffsetX, snap.timelineOffsetX ?? 0)); + + // Apply x/y offset to base coordinates + if (offsetY !== 0 || offsetX !== 0) { + for (let [key, [x, y]] of base_y2xy) { + base_y2xy.set(key, [x + offsetX, y + offsetY]); + } + } + + const label_col_x = w * 1.5 + (min_t_free - 1 - snap.curLayer) * x_pitch; + let t2t = t => { let dt = t - snap.curLayer; dt -= min_t_clamp - min_t_free; @@ -159,17 +195,23 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun try { ctx.clearRect(w, 0, w, ctx.canvas.height); + // Apply clipping to prevent content from overlapping on labels and outside timeline panel. + ctx.save(); + ctx.beginPath(); + ctx.rect(label_col_x, 0, ctx.canvas.width - label_col_x, ctx.canvas.height); + ctx.clip(); + // Draw colored indicators showing Pauli propagation. let hitCounts = new Map(); for (let [mi, p] of propagatedMarkerLayers.entries()) { - drawTimelineMarkers(ctx, snap, qubitTimeCoords, p, mi, min_t_clamp, max_t, x_pitch, hitCounts); + drawTimelineMarkers(ctx, snap, qubitTimeCoords, p, mi, 0, numLayers, x_pitch, hitCounts); } // Draw highlight of current layer. ctx.globalAlpha *= 0.5; ctx.fillStyle = 'black'; { - let x1 = t2t(snap.curLayer) + w * 1.5 - x_pitch / 2; + let x1 = t2t(snap.curLayer) + w * 1.5 - x_pitch / 2 + offsetX; ctx.fillRect(x1, 0, x_pitch, ctx.canvas.height); } ctx.globalAlpha *= 2; @@ -179,26 +221,16 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun // Draw wire lines. for (let q of qubits) { - let [x0, y0] = qubitTimeCoords(q, min_t_clamp - 1); - let [x1, y1] = qubitTimeCoords(q, max_t + 1); + let [x0, y0] = qubitTimeCoords(q, -1); + let [x1, y1] = qubitTimeCoords(q, numLayers); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); } - // Draw wire labels. - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - for (let q of qubits) { - let [x, y] = qubitTimeCoords(q, min_t_clamp - 1); - let qx = snap.circuit.qubitCoordData[q * 2]; - let qy = snap.circuit.qubitCoordData[q * 2 + 1]; - ctx.fillText(`${qx},${qy}:`, x, y); - } - // Draw layers of gates. - for (let time = min_t_clamp; time <= max_t; time++) { + for (let time = 0; time < numLayers; time++) { let qubitsCoordsFuncForLayer = q => qubitTimeCoords(q, time); let layer = snap.circuit.layers[time]; if (layer === undefined) { @@ -209,24 +241,49 @@ function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFun } } + ctx.restore(); // Stop clipping since labels and links to timeslice should be outside clipping area. + + // Draw wire labels. + ctx.strokeStyle = 'black'; + ctx.fillStyle = 'black'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let q of qubits) { + let [x, y] = qubitTimeCoords(q, min_t_clamp - 1); + x -= offsetX; // Labels are frozen on horizontal axis. + let qx = snap.circuit.qubitCoordData[q * 2]; + let qy = snap.circuit.qubitCoordData[q * 2 + 1]; + ctx.fillText(`${qx},${qy}:`, x, y); + } + // 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) { + x0 -= offsetX; // Lines start at frozen position to match labels. + 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 new DrawSummary(minOffsetX, maxOffsetX, maxOffsetY); } -export {drawTimeline} \ No newline at end of file +export {drawTimeline, DrawSummary} diff --git a/glue/crumble/editor/editor_state.js b/glue/crumble/editor/editor_state.js index 9feef8227..3b7c805be 100644 --- a/glue/crumble/editor/editor_state.js +++ b/glue/crumble/editor/editor_state.js @@ -31,6 +31,28 @@ function rotated45Transform(steps) { return (x, y) => [vx[0]*x + vy[0]*y, vx[1]*x + vy[1]*y]; } +const Panels = Object.freeze({ + TIMESLICE: 'timeslice', // qubit grid (left panel) + TIMELINE: 'timeline', // circuit timeline (right panel) +}); + +class PanDragAnchor { + /** + * @param {!number} screenX - X coordinate of drag start + * @param {!number} screenY - Y coordinate of drag start + * @param {!number} offsetX - X offset from drag start + * @param {!number} offsetY - Y offset from drag start + * @param {!string} activeDragPanel - marks which panel is currently used in drag: PanSide.TIMESLICE or PanSide.TIMELINE + */ + constructor(screenX, screenY, offsetX, offsetY, activeDragPanel) { + this.screenX = screenX; + this.screenY = screenY; + this.offsetX = offsetX; + this.offsetY = offsetY; + this.activeDragPanel = activeDragPanel; + } +} + class EditorState { /** * @param {!HTMLCanvasElement} canvas @@ -47,6 +69,15 @@ 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.timelineOffsetX = 0; + this.timelineOffsetY = 0; + this.isPanDragging = false; + this.panDragAnchor = /** @type {undefined|!PanDragAnchor} */ undefined; } flipTwoQubitGateOrderAtFocus(preview) { @@ -201,17 +232,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.timelineOffsetX, this.timelineOffsetY, this.curMouseScreenX, this.curMouseScreenY); } force_redraw() { @@ -314,6 +335,7 @@ class EditorState { */ changeCurLayerTo(newLayer) { this.curLayer = Math.max(newLayer, 0); + this.timelineOffsetX = 0; this.force_redraw(); } @@ -780,4 +802,4 @@ class EditorState { } } -export {EditorState, StateSnapshot} +export {EditorState, StateSnapshot, PanDragAnchor, Panels} diff --git a/glue/crumble/main.js b/glue/crumble/main.js index 7888b9d32..1bbf7b5ee 100644 --- a/glue/crumble/main.js +++ b/glue/crumble/main.js @@ -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 {EditorState, PanDragAnchor, Panels} 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; @@ -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,28 @@ 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; + + // Handle middle click + drag panning. + if (editorState.isPanDragging) { + const anchor = editorState.panDragAnchor; + const newOffsetX = anchor.offsetX + (ev.offsetX - anchor.screenX); + const newOffsetY = anchor.offsetY + (ev.offsetY - anchor.screenY); + if (anchor.activeDragPanel === Panels.TIMESLICE) { + editorState.viewportX = newOffsetX; + editorState.viewportY = newOffsetY; + } else { + editorState.timelineOffsetX = newOffsetX; + editorState.timelineOffsetY = newOffsetY; + } + restrictTimeSliceAndTimelineViews(); + editorState.force_redraw(); + return; + } + + editorState.curMouseX = toWorldMouseX(ev.offsetX); + editorState.curMouseY = toWorldMouseY(ev.offsetY); // Scrubber. let w = editorState.canvas.width / 2; @@ -159,13 +186,31 @@ 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; + const w = editorState.canvas.width / 2; + + // Middle-click initiates pan drag. + if (ev.button === 1) { + ev.preventDefault(); + editorState.isPanDragging = true; + const targetPanel = ev.offsetX > w ? Panels.TIMELINE : Panels.TIMESLICE; + editorState.panDragAnchor = new PanDragAnchor( + ev.offsetX, + ev.offsetY, + targetPanel === Panels.TIMESLICE ? editorState.viewportX : editorState.timelineOffsetX, + targetPanel === Panels.TIMESLICE ? editorState.viewportY : editorState.timelineOffsetY, + targetPanel, + ); + return; + } + + 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; isInScrubber = ev.offsetY < 20 && ev.offsetX > w && ev.buttons === 1; if (isInScrubber) { editorState.changeCurLayerTo(Math.floor((ev.offsetX - w) / 8)); @@ -176,17 +221,87 @@ editorState.canvas.addEventListener('mousedown', ev => { }); editorState.canvas.addEventListener('mouseup', ev => { + if (ev.button === 1) { + editorState.isPanDragging = false; + editorState.panDragAnchor = undefined; + return; + } + 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 restrictTimeSliceAndTimelineViews() { + 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.timelineOffsetY = Math.min(0, editorState.timelineOffsetY); +} + +function handleTimelineVerticalScroll(ev) { + editorState.timelineOffsetX -= ev.deltaX; + editorState.timelineOffsetY -= ev.deltaY; + restrictTimeSliceAndTimelineViews(); + 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); + restrictTimeSliceAndTimelineViews(); + 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 +619,12 @@ 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 drawSummary = draw(editorState.canvas.getContext('2d'), ds); + // Prevent timeline over-panning. + editorState.timelineOffsetX = Math.max(drawSummary.minOffsetX, Math.min(editorState.timelineOffsetX, drawSummary.maxOffsetX)); + editorState.timelineOffsetY = Math.max(editorState.timelineOffsetY, -drawSummary.maxOffsetY); +})); window.addEventListener('focus', () => { editorState.chorder.handleFocusChanged(); }); diff --git a/src/stim/diagram/crumble_data.cc b/src/stim/diagram/crumble_data.cc index c81dfb58d..bb4590b60 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");