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