Files
busbar-designer/static/js/viewport.js
T
wenil d8cb0dc06d Initial commit: Busbar Designer
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).
2026-05-24 18:59:50 +03:00

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