261 lines
7.7 KiB
JavaScript
261 lines
7.7 KiB
JavaScript
// 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();
|
||
}
|
||
}
|