pcb-retrace/docs/spyglass.js

235 lines
6.3 KiB
JavaScript

/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/**
* spyglass.js
* Handles zooming, panning, and coordinate adjustment for PCB previews.
* v2.1 - Added Pointer Events for Mobile Support (Pinch & Touch-Drag)
*/
class PcbSpyglass {
/**
* @param {string} canvasId
* @param {string} overlayId
* @param {function} onUpdateCallback - Optional. Called with (x, y) when user drags.
*/
constructor(canvasId, overlayId, onUpdateCallback = null) {
this.canvas = document.getElementById(canvasId);
this.overlay = document.getElementById(overlayId);
this.container = this.canvas ? this.canvas.parentElement : null;
this.onUpdate = onUpdateCallback;
if (!this.canvas) {
console.error("Spyglass: Canvas element not found");
return;
}
this.ctx = this.canvas.getContext('2d');
this.zoom = 2;
this.img = null;
this.x = 0;
this.y = 0;
this.enabled = false;
// Pointer State
this.isDragging = false;
this.lastMouseX = 0;
this.lastMouseY = 0;
this.evCache = []; // Cache for multi-touch
this.prevDiff = -1; // For pinch zoom
// Bind Events
this.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
// Unified Pointer Events (Mouse + Touch)
this.canvas.addEventListener('pointerdown', this.handlePointerDown.bind(this));
this.canvas.addEventListener('pointermove', this.handlePointerMove.bind(this));
this.canvas.addEventListener('pointerup', this.handlePointerUp.bind(this));
this.canvas.addEventListener('pointercancel', this.handlePointerUp.bind(this));
this.canvas.addEventListener('pointerout', this.handlePointerUp.bind(this));
this.canvas.addEventListener('pointerleave', this.handlePointerUp.bind(this));
}
/**
* Sets the image and coordinates.
* @param {ImageBitmap|HTMLImageElement} imgSource
* @param {number} x
* @param {number} y
* @param {boolean} autoCloseOld - If true, .close() is called on the old bitmap (Use false for galleries)
*/
setTarget(imgSource, x, y, autoCloseOld = true) {
if (!imgSource) return;
// MEMORY CLEANUP: Only if requested
if (autoCloseOld && this.img && this.img !== imgSource && typeof this.img.close === 'function') {
try { this.img.close(); } catch(e) {}
}
this.img = imgSource;
this.x = x;
this.y = y;
this.enabled = true;
if (this.container) {
this.container.classList.remove('empty');
this.container.classList.add('active');
}
this.render();
this.updateOverlay();
}
clear() {
this.enabled = false;
this.img = null;
if (this.container) {
this.container.classList.remove('active');
this.container.classList.add('empty');
}
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
if (this.overlay) this.overlay.style.display = 'none';
}
handleWheel(e) {
e.preventDefault();
e.stopPropagation();
const d = e.deltaY > 0 ? -0.5 : 0.5;
this.zoom = Math.max(0.5, Math.min(10, this.zoom + d));
this.render();
this.updateOverlay();
}
// --- POINTER HANDLING (Desktop + Mobile) ---
handlePointerDown(e) {
if (!this.enabled) return;
this.canvas.setPointerCapture(e.pointerId);
this.evCache.push(e);
if (this.evCache.length === 1) {
this.isDragging = true;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
this.canvas.style.cursor = 'grabbing';
}
}
handlePointerMove(e) {
if (!this.enabled) return;
// Update cache
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
if (index > -1) this.evCache[index] = e;
// 1. PINCH ZOOM (2 Fingers)
if (this.evCache.length === 2) {
e.preventDefault();
const dx = this.evCache[0].clientX - this.evCache[1].clientX;
const dy = this.evCache[0].clientY - this.evCache[1].clientY;
const curDiff = Math.hypot(dx, dy);
if (this.prevDiff > 0) {
const diff = curDiff - this.prevDiff;
// PATCH 1: Increased Zoom Speed (0.01 -> 0.1)
this.zoom = Math.max(0.5, Math.min(10, this.zoom + (diff * 0.1)));
this.render();
this.updateOverlay();
}
this.prevDiff = curDiff;
return;
}
// 2. PANNING (1 Finger / Mouse)
if (this.isDragging && this.evCache.length === 1) {
e.preventDefault();
const dx = e.clientX - this.lastMouseX;
const dy = e.clientY - this.lastMouseY;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
// PATCH 2: Speed Multiplier for Touch
// Mouse stays 1:1 for precision, Touch gets 5x speed to traverse faster
const speed = (e.pointerType === 'touch') ? 5 : 1.0;
this.x -= (dx * speed) / this.zoom;
this.y -= (dy * speed) / this.zoom;
this.render();
if (this.onUpdate) {
this.onUpdate(Math.round(this.x), Math.round(this.y));
}
}
}
handlePointerUp(e) {
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
if (index > -1) this.evCache.splice(index, 1);
if (this.evCache.length < 2) this.prevDiff = -1;
if (this.evCache.length === 0) {
this.isDragging = false;
if(this.canvas) this.canvas.style.cursor = 'crosshair';
} else if (this.evCache.length === 1) {
// Reset reference point when switching from pinch to pan
this.lastMouseX = this.evCache[0].clientX;
this.lastMouseY = this.evCache[0].clientY;
}
}
updateOverlay() {
if (this.overlay) {
this.overlay.innerText = this.zoom.toFixed(1) + 'x';
this.overlay.style.display = this.enabled ? 'block' : 'none';
}
}
render() {
if (!this.enabled || !this.img) return;
const w = this.canvas.width;
const h = this.canvas.height;
const srcW = w / this.zoom;
const srcH = h / this.zoom;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, w, h);
try {
this.ctx.drawImage(
this.img,
this.x - srcW / 2,
this.y - srcH / 2,
srcW, srcH,
0, 0, w, h
);
// Crosshair (Red)
const cx = Math.floor(w / 2) + 0.5;
const cy = Math.floor(h / 2) + 0.5;
this.ctx.strokeStyle = '#ff0000';
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'butt';
this.ctx.beginPath();
this.ctx.moveTo(0, cy); this.ctx.lineTo(w, cy);
this.ctx.moveTo(cx, 0); this.ctx.lineTo(cx, h);
this.ctx.stroke();
} catch (e) {
if (e.name !== 'InvalidStateError' && e.name !== 'DOMException') {
console.error("Spyglass Render Error:", e);
}
}
}
}