// Grid setup canvas UI — handles corner selection, pan, and zoom export class Grid_Setup { #canvas; #ctx; #img = null; #scale = 1; // image px → world px (CSS px at cam_z=1) #css_w = 0; #css_h = 0; // Camera: world → screen (screen = world * cam_z + cam_offset) #cam_x = 0; #cam_y = 0; #cam_z = 1; #corners = null; // in IMAGE coordinates #drag_idx = -1; // 0-3: corners, 4-7: edge midpoints (top,right,bottom,left) #drag_prev_img = null; // previous image-space position for midpoint delta tracking #panning = false; #pan_last = { x: 0, y: 0 }; #rows = 4; #cols = 6; // Edge pairs for midpoint handles: midpoint (idx-4) moves corners [a, b] static #MIDPOINT_EDGES = [[0,1],[1,2],[2,3],[3,0]]; constructor(canvas_el) { this.#canvas = canvas_el; this.#ctx = canvas_el.getContext('2d'); canvas_el.addEventListener('mousedown', e => this.#on_down(e)); canvas_el.addEventListener('mousemove', e => this.#on_move(e)); canvas_el.addEventListener('mouseup', e => this.#on_up(e)); canvas_el.addEventListener('mouseleave', () => { this.#drag_idx = -1; this.#panning = false; }); canvas_el.addEventListener('wheel', e => this.#on_wheel(e), { passive: false }); canvas_el.addEventListener('touchstart', e => { e.preventDefault(); this.#on_down(e.touches[0], true); }, { passive: false }); canvas_el.addEventListener('touchmove', e => { e.preventDefault(); this.#on_move(e.touches[0], true); }, { passive: false }); canvas_el.addEventListener('touchend', () => { this.#drag_idx = -1; this.#panning = false; }); } load_image(url) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { this.#img = img; const max_w = this.#canvas.parentElement.clientWidth || 800; const max_h = Math.floor(window.innerHeight * 0.65); // Canvas fills the available space this.#css_w = max_w; this.#css_h = max_h; // Scale: fit image within canvas with slight padding this.#scale = Math.min( (max_w * 0.9) / img.width, (max_h * 0.9) / img.height, ); const dpr = window.devicePixelRatio || 1; this.#canvas.width = this.#css_w * dpr; this.#canvas.height = this.#css_h * dpr; this.#canvas.style.width = this.#css_w + 'px'; this.#canvas.style.height = this.#css_h + 'px'; this.#ctx.scale(dpr, dpr); // Camera: start centered, image fitted within canvas const img_w = img.width * this.#scale; const img_h = img.height * this.#scale; this.#cam_z = 1; this.#cam_x = (max_w - img_w) / 2; this.#cam_y = (max_h - img_h) / 2; // Default corners: 15% inset in image coords const mx = img.width * 0.15; const my = img.height * 0.15; this.#corners = [ { x: mx, y: my }, // TL { x: img.width - mx, y: my }, // TR { x: img.width - mx, y: img.height - my }, // BR { x: mx, y: img.height - my }, // BL ]; this.#draw(); resolve({ width: img.width, height: img.height }); }; img.onerror = reject; img.src = url; }); } set_rows(n) { this.#rows = n; this.#draw(); } set_cols(n) { this.#cols = n; this.#draw(); } set_corners(corners) { if (!corners || corners.length !== 4) return; this.#corners = corners.map(c => ({ x: c.x, y: c.y })); this.#draw(); } get_corners() { return this.#corners?.map(c => ({ x: Math.round(c.x), y: Math.round(c.y) })); } // Raw CSS pixel position on canvas #screen_pos(e) { const r = this.#canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } // Screen → world (CSS px at cam_z=1, i.e. the coordinate space image is drawn in) #to_world(sp) { return { x: (sp.x - this.#cam_x) / this.#cam_z, y: (sp.y - this.#cam_y) / this.#cam_z, }; } // Image coords → world coords #img_to_world(pt) { return { x: pt.x * this.#scale, y: pt.y * this.#scale }; } // World coords → image coords #world_to_img(pt) { return { x: pt.x / this.#scale, y: pt.y / this.#scale }; } // Image coords → screen coords (for hit testing) #img_to_screen(pt) { const w = this.#img_to_world(pt); return { x: w.x * this.#cam_z + this.#cam_x, y: w.y * this.#cam_z + this.#cam_y }; } #get_midpoints() { return Grid_Setup.#MIDPOINT_EDGES.map(([a, b]) => ({ x: (this.#corners[a].x + this.#corners[b].x) / 2, y: (this.#corners[a].y + this.#corners[b].y) / 2, })); } #find_handle(sp, radius = 18) { if (!this.#corners) return -1; // Corners take priority for (let i = 0; i < 4; i++) { const s = this.#img_to_screen(this.#corners[i]); if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < radius**2) return i; } // Midpoints const mids = this.#get_midpoints(); for (let i = 0; i < 4; i++) { const s = this.#img_to_screen(mids[i]); if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < (radius * 0.85)**2) return i + 4; } return -1; } #on_down(e, is_touch = false) { if (!this.#corners) return; const sp = this.#screen_pos(e); const hit = this.#find_handle(sp); if (hit !== -1) { this.#drag_idx = hit; if (hit >= 4) { this.#drag_prev_img = this.#world_to_img(this.#to_world(sp)); } } else { this.#panning = true; this.#pan_last = sp; if (!is_touch) { this.#canvas.style.cursor = 'grabbing'; } } } #on_move(e, is_touch = false) { const sp = this.#screen_pos(e); if (this.#drag_idx !== -1) { const img_pos = this.#world_to_img(this.#to_world(sp)); if (this.#drag_idx < 4) { this.#corners[this.#drag_idx] = img_pos; } else { const [a, b] = Grid_Setup.#MIDPOINT_EDGES[this.#drag_idx - 4]; const dx = img_pos.x - this.#drag_prev_img.x; const dy = img_pos.y - this.#drag_prev_img.y; this.#corners[a] = { x: this.#corners[a].x + dx, y: this.#corners[a].y + dy }; this.#corners[b] = { x: this.#corners[b].x + dx, y: this.#corners[b].y + dy }; this.#drag_prev_img = img_pos; } this.#draw(); } else if (this.#panning) { this.#cam_x += sp.x - this.#pan_last.x; this.#cam_y += sp.y - this.#pan_last.y; this.#pan_last = sp; this.#draw(); } else if (!is_touch) { const cursor = this.#find_handle(sp) !== -1 ? 'grab' : 'default'; this.#canvas.style.cursor = cursor; } } #on_up(e) { this.#drag_idx = -1; this.#drag_prev_img = null; if (this.#panning) { this.#panning = false; const sp = this.#screen_pos(e); this.#canvas.style.cursor = this.#find_handle(sp) !== -1 ? 'grab' : 'default'; } } #on_wheel(e) { e.preventDefault(); const sp = this.#screen_pos(e); const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; const wx = (sp.x - this.#cam_x) / this.#cam_z; const wy = (sp.y - this.#cam_y) / this.#cam_z; this.#cam_z = Math.max(0.1, Math.min(20, this.#cam_z * factor)); this.#cam_x = sp.x - wx * this.#cam_z; this.#cam_y = sp.y - wy * this.#cam_z; this.#draw(); } #draw() { const ctx = this.#ctx; if (!this.#img || !this.#corners) return; const W = this.#css_w, H = this.#css_h; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0e0e0e'; ctx.fillRect(0, 0, W, H); ctx.save(); ctx.translate(this.#cam_x, this.#cam_y); ctx.scale(this.#cam_z, this.#cam_z); // Image drawn in world space at its scaled dimensions const img_w = this.#img.width * this.#scale; const img_h = this.#img.height * this.#scale; ctx.drawImage(this.#img, 0, 0, img_w, img_h); // Everything below is in world coords (image coords × #scale) const dp = this.#corners.map(c => this.#img_to_world(c)); const rows = this.#rows, cols = this.#cols; // Outline ctx.beginPath(); ctx.moveTo(dp[0].x, dp[0].y); dp.slice(1).forEach(p => ctx.lineTo(p.x, p.y)); ctx.closePath(); ctx.strokeStyle = 'rgba(91,156,246,0.9)'; ctx.lineWidth = 2 / this.#cam_z; ctx.stroke(); // Grid lines function lerp(a, b, t) { return { x: a.x + (b.x-a.x)*t, y: a.y + (b.y-a.y)*t }; } ctx.strokeStyle = 'rgba(91,156,246,0.45)'; ctx.lineWidth = 1 / this.#cam_z; for (let r = 1; r < rows; r++) { const t = r / rows; const a = lerp(dp[0], dp[3], t), b = lerp(dp[1], dp[2], t); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } for (let c = 1; c < cols; c++) { const t = c / cols; const a = lerp(dp[0], dp[1], t), b = lerp(dp[3], dp[2], t); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } // Corner handles — fixed screen size regardless of zoom const handle_r = 10 / this.#cam_z; const font_size = Math.round(9 / this.#cam_z); const COLORS = ['#f6a65b', '#f65b9c', '#5bf69c', '#5b9cf6']; const LABELS = ['TL', 'TR', 'BR', 'BL']; dp.forEach((pt, i) => { ctx.beginPath(); ctx.arc(pt.x, pt.y, handle_r, 0, Math.PI*2); ctx.fillStyle = COLORS[i]; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 2 / this.#cam_z; ctx.stroke(); ctx.fillStyle = '#fff'; ctx.font = `bold ${font_size}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(LABELS[i], pt.x, pt.y); }); // Midpoint handles — smaller, white with blue stroke const mid_r = 7 / this.#cam_z; const mids = this.#get_midpoints().map(m => this.#img_to_world(m)); mids.forEach(pt => { ctx.beginPath(); ctx.arc(pt.x, pt.y, mid_r, 0, Math.PI*2); ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.fill(); ctx.strokeStyle = 'rgba(91,156,246,0.9)'; ctx.lineWidth = 2 / this.#cam_z; ctx.stroke(); }); ctx.restore(); } }