Files
electronics-inventory/public/views/grid-setup.mjs
mikael-lovqvists-claude-agent 67369b56be Add edge midpoint drag handles to Grid_Setup
Drag indices 4-7 correspond to top/right/bottom/left edge midpoints.
Dragging a midpoint applies the delta to both adjacent corners, making
it easier to align bins with rounded corners where corner handles may
be obscured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:27:35 +00:00

305 lines
9.3 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;
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();
}
}