Files
electronics-inventory/public/views/grid-setup.mjs

261 lines
7.7 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; // index of corner being dragged, or -1
#panning = false;
#pan_last = { x: 0, y: 0 };
#rows = 4;
#cols = 6;
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 };
}
#find_handle(sp, radius = 18) {
if (!this.#corners) return -1;
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;
}
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;
} 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));
this.#corners[this.#drag_idx] = 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;
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);
});
ctx.restore();
}
}