d8cb0dc06d
Web tool for designing nickel/copper busbars over cylindrical-cell battery packs (21700, 18650) in hex holders. Flask + build123d backend exports STEP/DXF/SVG; vanilla JS frontend with live preview, multi-project SQLite persistence, snapshot history. Deploy scripts in deploy/ (proxmox-lxc.sh, install.sh, update.sh).
321 lines
11 KiB
JavaScript
321 lines
11 KiB
JavaScript
/* viewport.js — 2D canvas viewer with real mm scale, pan, zoom, cell picking.
|
|
*
|
|
* Coordinates in app state are millimetres; we keep a pan (tx, ty in px) and a
|
|
* scale (px-per-mm). Y axis is flipped on render so +Y points up like CAD.
|
|
*/
|
|
|
|
const Viewport = (() => {
|
|
let canvas, ctx, state, params;
|
|
let scale = 4; // px per mm
|
|
let tx = 0, ty = 0; // pan offset, in pixels (added after scaling)
|
|
let isPanning = false;
|
|
let panStart = null;
|
|
let cellHoverId = null;
|
|
|
|
// Callbacks set by app.js.
|
|
let onCellClick = () => {};
|
|
let onCursorMove = () => {};
|
|
let onZoomChange = () => {};
|
|
|
|
function init(canvasEl, stateRef, paramsRef, handlers) {
|
|
canvas = canvasEl;
|
|
ctx = canvas.getContext("2d");
|
|
state = stateRef;
|
|
params = paramsRef;
|
|
onCellClick = handlers.onCellClick || onCellClick;
|
|
onCursorMove = handlers.onCursorMove || onCursorMove;
|
|
onZoomChange = handlers.onZoomChange || onZoomChange;
|
|
|
|
window.addEventListener("resize", _resize);
|
|
// Catch any layout change (flex re-flow, sidebar toggle, etc.) — the
|
|
// canvas backing-store must match its CSS size or content draws blurry /
|
|
// mis-positioned ('cells off-screen' bug).
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
new ResizeObserver(_resize).observe(canvas);
|
|
}
|
|
canvas.addEventListener("mousedown", _onMouseDown);
|
|
canvas.addEventListener("mousemove", _onMouseMove);
|
|
canvas.addEventListener("mouseup", _onMouseUp);
|
|
canvas.addEventListener("mouseleave", _onMouseUp);
|
|
canvas.addEventListener("wheel", _onWheel, { passive: false });
|
|
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
|
|
_resize();
|
|
}
|
|
|
|
function _resize() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const r = canvas.getBoundingClientRect();
|
|
canvas.width = Math.max(1, Math.round(r.width * dpr));
|
|
canvas.height = Math.max(1, Math.round(r.height * dpr));
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // user space = CSS pixels
|
|
render();
|
|
}
|
|
|
|
function fitToContent() {
|
|
// Defer one frame so any pending layout (flex re-flow when busbar list
|
|
// grows, preview pane appears, etc.) is computed before we measure
|
|
// canvas dimensions. Without this, clientWidth/Height can be 0 → cells
|
|
// end up off-screen.
|
|
requestAnimationFrame(_fitToContentNow);
|
|
}
|
|
|
|
function _fitToContentNow() {
|
|
_resize();
|
|
if (!state.cells || state.cells.length === 0) {
|
|
tx = canvas.clientWidth / 2;
|
|
ty = canvas.clientHeight / 2;
|
|
scale = 4;
|
|
onZoomChange(scale);
|
|
render();
|
|
return;
|
|
}
|
|
const cellRadius = (params.cellDia || 21.2) / 2;
|
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
for (const c of state.cells) {
|
|
if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x;
|
|
if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y;
|
|
}
|
|
minX -= cellRadius; maxX += cellRadius;
|
|
minY -= cellRadius; maxY += cellRadius;
|
|
const w = maxX - minX, h = maxY - minY;
|
|
const margin = 40;
|
|
const sx = (canvas.clientWidth - 2 * margin) / w;
|
|
const sy = (canvas.clientHeight - 2 * margin) / h;
|
|
scale = Math.max(0.5, Math.min(40, Math.min(sx, sy)));
|
|
// World point at canvas center should be (minX+w/2, minY+h/2).
|
|
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
|
tx = canvas.clientWidth / 2 - cx * scale;
|
|
ty = canvas.clientHeight / 2 + cy * scale; // +cy*scale because Y is flipped
|
|
onZoomChange(scale);
|
|
render();
|
|
}
|
|
|
|
// World (mm) → screen (px)
|
|
function w2s(x, y) {
|
|
return { x: x * scale + tx, y: -y * scale + ty };
|
|
}
|
|
// Screen → world
|
|
function s2w(px, py) {
|
|
return { x: (px - tx) / scale, y: -(py - ty) / scale };
|
|
}
|
|
|
|
function render() {
|
|
if (!ctx) return;
|
|
const W = canvas.clientWidth, H = canvas.clientHeight;
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
_drawGrid(W, H);
|
|
_drawCells();
|
|
_drawBusbars();
|
|
_drawCellLabels(); // labels on top so the cross-slit punch doesn't eat them
|
|
_drawSelection();
|
|
}
|
|
|
|
function _drawGrid(W, H) {
|
|
// Minor grid every 5 mm, major every 50 mm.
|
|
const minor = 5, major = 50;
|
|
const tl = s2w(0, 0), br = s2w(W, H);
|
|
const xMin = Math.floor(Math.min(tl.x, br.x) / minor) * minor;
|
|
const xMax = Math.ceil (Math.max(tl.x, br.x) / minor) * minor;
|
|
const yMin = Math.floor(Math.min(tl.y, br.y) / minor) * minor;
|
|
const yMax = Math.ceil (Math.max(tl.y, br.y) / minor) * minor;
|
|
|
|
ctx.lineWidth = 1;
|
|
for (let x = xMin; x <= xMax; x += minor) {
|
|
const p = w2s(x, 0);
|
|
ctx.strokeStyle = (Math.abs(x % major) < 1e-6) ? "#2a3140" : "#1f2530";
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, H); ctx.stroke();
|
|
}
|
|
for (let y = yMin; y <= yMax; y += minor) {
|
|
const p = w2s(0, y);
|
|
ctx.strokeStyle = (Math.abs(y % major) < 1e-6) ? "#2a3140" : "#1f2530";
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, p.y); ctx.lineTo(W, p.y); ctx.stroke();
|
|
}
|
|
// Origin marker.
|
|
const o = w2s(0, 0);
|
|
ctx.strokeStyle = "#ff6b6b";
|
|
ctx.beginPath();
|
|
ctx.moveTo(o.x - 8, o.y); ctx.lineTo(o.x + 8, o.y);
|
|
ctx.moveTo(o.x, o.y - 8); ctx.lineTo(o.x, o.y + 8);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function _drawCells() {
|
|
const r = (params.cellDia || 21.2) / 2;
|
|
const o = (params.openingDia || 15.2) / 2;
|
|
const hexW = (params.cellDia || 21.2) + 2 * 0.8;
|
|
const hexPt = hexW / 2 / Math.cos(Math.PI / 6);
|
|
ctx.lineWidth = 1;
|
|
|
|
// For cells assigned to a busbar we draw NOTHING here — the busbar layer
|
|
// owns those pixels entirely (fill + cross slit + label). Drawing hex /
|
|
// circle outlines under the busbar muddies the preview.
|
|
for (const c of state.cells) {
|
|
if (Groups.findByCell(state, c.id) != null) continue;
|
|
|
|
const p = w2s(c.x, c.y);
|
|
const rPx = r * scale;
|
|
const oPx = o * scale;
|
|
|
|
// Hex outline.
|
|
ctx.beginPath();
|
|
for (let i = 0; i < 6; i++) {
|
|
const a = Math.PI / 2 + i * Math.PI / 3;
|
|
const hx = p.x + hexPt * scale * Math.cos(a);
|
|
const hy = p.y - hexPt * scale * Math.sin(a);
|
|
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
|
|
}
|
|
ctx.closePath();
|
|
ctx.strokeStyle = "#262d3a";
|
|
ctx.stroke();
|
|
|
|
// Cell circle + opening hint (so the user sees the welding window).
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, rPx, 0, Math.PI * 2);
|
|
ctx.fillStyle = _withAlpha("#4a5365", 0.18);
|
|
ctx.fill();
|
|
ctx.strokeStyle = "#4a5365";
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = _withAlpha("#4a5365", 0.35);
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, oPx, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function _drawCellLabels() {
|
|
if (scale <= 2) return;
|
|
ctx.font = `${Math.max(9, Math.min(14, scale * 1.5))}px -apple-system, system-ui, sans-serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
for (const c of state.cells) {
|
|
const p = w2s(c.x, c.y);
|
|
const inBusbar = Groups.findByCell(state, c.id) != null;
|
|
ctx.fillStyle = inBusbar ? "#1a1a1a" : "#cdd5e0";
|
|
ctx.fillText(String(c.id), p.x, p.y);
|
|
}
|
|
}
|
|
|
|
function _drawBusbars() {
|
|
if (!state.busbars.length) return;
|
|
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
ctx.save();
|
|
// Set transform so we can draw busbar Path2D in world (mm) coords.
|
|
// screen.x = scale*world.x + tx ; screen.y = -scale*world.y + ty
|
|
ctx.setTransform(dpr * scale, 0, 0, -dpr * scale, dpr * tx, dpr * ty);
|
|
|
|
for (const bb of state.busbars) {
|
|
const params2 = {
|
|
padRadius: params.padRadius,
|
|
holeRadius: params.holeRadius,
|
|
stripWidth: params.stripWidth,
|
|
};
|
|
const body = Geometry.busbarPath(bb, cellsById, params2);
|
|
const holes = Geometry.busbarHolesPath(bb, cellsById, params2);
|
|
|
|
// Body: union semantics — fill nonzero so overlapping rectangles/discs
|
|
// don't carve evenodd holes. Higher opacity than v1 so the panel reads
|
|
// as a single object, not a translucent veil.
|
|
ctx.fillStyle = _withAlpha(bb.color, 0.75);
|
|
ctx.fill(body, "nonzero");
|
|
|
|
// Holes: punch via destination-out so the dark canvas shows through.
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "destination-out";
|
|
ctx.fill(holes, "nonzero");
|
|
ctx.restore();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function _drawSelection() {
|
|
if (!state.selection.size) return;
|
|
const r = ((params.cellDia || 21.2) / 2 + 1.5);
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeStyle = "#f08a24";
|
|
for (const id of state.selection) {
|
|
const c = state.cells.find((cc) => cc.id === id);
|
|
if (!c) continue;
|
|
const p = w2s(c.x, c.y);
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, r * scale, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function _withAlpha(hex, a) {
|
|
const s = hex.replace("#", "");
|
|
const r = parseInt(s.slice(0, 2), 16);
|
|
const g = parseInt(s.slice(2, 4), 16);
|
|
const b = parseInt(s.slice(4, 6), 16);
|
|
return `rgba(${r},${g},${b},${a})`;
|
|
}
|
|
|
|
function _pickCell(px, py) {
|
|
const r = (params.cellDia || 21.2) / 2;
|
|
let best = null, bestDist = Infinity;
|
|
for (const c of state.cells) {
|
|
const p = w2s(c.x, c.y);
|
|
const d = Math.hypot(p.x - px, p.y - py);
|
|
if (d < r * scale && d < bestDist) { best = c; bestDist = d; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function _onMouseDown(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
|
if (e.button === 2 || e.button === 1) {
|
|
isPanning = true;
|
|
panStart = { px, py, tx, ty };
|
|
canvas.style.cursor = "grabbing";
|
|
return;
|
|
}
|
|
if (e.button === 0) {
|
|
const c = _pickCell(px, py);
|
|
if (c) onCellClick(c.id, { shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey });
|
|
}
|
|
}
|
|
|
|
function _onMouseMove(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
|
if (isPanning && panStart) {
|
|
tx = panStart.tx + (px - panStart.px);
|
|
ty = panStart.ty + (py - panStart.py);
|
|
render();
|
|
return;
|
|
}
|
|
const w = s2w(px, py);
|
|
onCursorMove(w.x, w.y);
|
|
}
|
|
|
|
function _onMouseUp() {
|
|
isPanning = false;
|
|
panStart = null;
|
|
canvas.style.cursor = "crosshair";
|
|
}
|
|
|
|
function _onWheel(e) {
|
|
e.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
|
const pre = s2w(px, py);
|
|
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
|
scale = Math.max(0.5, Math.min(80, scale * factor));
|
|
// Keep cursor mm-point stationary.
|
|
tx = px - pre.x * scale;
|
|
ty = py + pre.y * scale;
|
|
onZoomChange(scale);
|
|
render();
|
|
}
|
|
|
|
return { init, render, fitToContent };
|
|
})();
|