Files
electronics-inventory/public/views/grid-setup.mjs
mikael-lovqvists-claude-agent 046fe99c72 Fix canvas coordinate mismatch and handle jump-on-grab in Grid_Setup
- Canvas width now read via getBoundingClientRect after setting style.width=100%,
  avoiding the parentElement.clientWidth padding issue that made css_w exceed
  the actual rendered width and broke hit-testing
- All handle drags (corners + midpoints) now use relative delta via drag_prev_img
  instead of absolute cursor position, preventing handle teleport on grab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:20:09 +00:00

309 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
// Let CSS determine the width, then read back the actual rendered value.
// Using parentElement.clientWidth directly would include the parent's
// padding, causing css_w to exceed the real content area and making
// getBoundingClientRect() return a different width than css_w.
this.#canvas.style.width = '100%';
const css_w = this.#canvas.getBoundingClientRect().width || 800;
const css_h = Math.floor(window.innerHeight * 0.65);
this.#css_w = css_w;
this.#css_h = css_h;
// Scale: fit image within canvas with slight padding
this.#scale = Math.min(
(css_w * 0.9) / img.width,
(css_h * 0.9) / img.height,
);
const dpr = window.devicePixelRatio || 1;
this.#canvas.width = css_w * dpr;
this.#canvas.height = css_h * dpr;
this.#canvas.style.width = css_w + 'px';
this.#canvas.style.height = 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 = (css_w - img_w) / 2;
this.#cam_y = (css_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;
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));
const dx = img_pos.x - this.#drag_prev_img.x;
const dy = img_pos.y - this.#drag_prev_img.y;
if (this.#drag_idx < 4) {
const i = this.#drag_idx;
this.#corners[i] = { x: this.#corners[i].x + dx, y: this.#corners[i].y + dy };
} else {
const [a, b] = Grid_Setup.#MIDPOINT_EDGES[this.#drag_idx - 4];
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();
}
}