From d87475558d8e0f9519c36d526db24cb1b3dee671 Mon Sep 17 00:00:00 2001 From: Richard Sauer Date: Wed, 8 Apr 2026 12:18:37 +1000 Subject: [PATCH] Resize + rotate equipment blocks in schematic editor --- templates/editor.html | 240 ++++++++++++++++++++++++------------------ 1 file changed, 136 insertions(+), 104 deletions(-) diff --git a/templates/editor.html b/templates/editor.html index b59d04b..993ead5 100644 --- a/templates/editor.html +++ b/templates/editor.html @@ -590,6 +590,8 @@ const defaultEquipment = [ let simEditMode = false; let dragEquip = null, dragOffset = {x:0, y:0}; +let selectedEquip = null; +let resizeHandle = null; // 'w','h','r','rot' function getEquipment() { return layout.equipment || JSON.parse(JSON.stringify(defaultEquipment)); @@ -604,148 +606,178 @@ function toggleSimEdit() { renderSchematic(); } +function svgPt(e) { + const svg = document.getElementById('sim-svg'); + const pt = svg.createSVGPoint(); + pt.x = e.clientX; pt.y = e.clientY; + return pt.matrixTransform(svg.getScreenCTM().inverse()); +} + +function saveEquip(eq) { + if (!layout.equipment) layout.equipment = getEquipment(); + const e = layout.equipment.find(x => x.id === eq.id); + if (e) Object.assign(e, {x:eq.x, y:eq.y, w:eq.w, h:eq.h, r:eq.r, rot:eq.rot, label:eq.label}); +} + +function mkSvgEl(tag, attrs) { + const el = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [k,v] of Object.entries(attrs)) el.setAttribute(k, v); + return el; +} + +function handle(svg, cx, cy, type, eq, cursor) { + const h = mkSvgEl('circle', {cx, cy, r:'6', fill:'var(--blue)', stroke:'#fff', 'stroke-width':'1.5', cursor, 'data-handle':type}); + h.addEventListener('mousedown', e => { e.stopPropagation(); resizeHandle = type; dragEquip = eq; dragOffset = svgPt(e); }); + svg.appendChild(h); +} + function renderSchematic() { const equip = getEquipment(); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('class', 'schematic'); - svg.setAttribute('viewBox', '0 0 800 400'); - svg.id = 'sim-svg'; + const container = document.getElementById('sim-container'); + container.innerHTML = ''; - // Defs for arrows + const svg = mkSvgEl('svg', {'class':'schematic', viewBox:'0 0 800 400', id:'sim-svg'}); svg.innerHTML = ``; - - // IN/OUT labels - svg.innerHTML += `IN ▶`; - svg.innerHTML += `▶ OUT`; - - document.getElementById('sim-container').innerHTML = ''; - document.getElementById('sim-container').appendChild(svg); + svg.appendChild(mkSvgEl('text', {x:'60',y:'148',fill:'var(--dim)','font-size':'10','text-anchor':'middle','font-family':'sans-serif'})).textContent = 'IN \u25B6'; + svg.appendChild(mkSvgEl('text', {x:'740',y:'320',fill:'var(--dim)','font-size':'10','text-anchor':'middle','font-family':'sans-serif'})).textContent = '\u25B6 OUT'; + container.appendChild(svg); equip.forEach(eq => { - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - g.dataset.eqId = eq.id; + if (eq.rot === undefined) eq.rot = 0; + const g = mkSvgEl('g', {'data-eq-id': eq.id}); + const isSel = selectedEquip && selectedEquip.id === eq.id; + + // Apply rotation around center + let cx, cy; + if (eq.shape === 'rect') { + cx = eq.x + (eq.w||80)/2; cy = eq.y + (eq.h||60)/2; + } else { + cx = eq.x; cy = eq.y; + } + if (eq.rot) g.setAttribute('transform', `rotate(${eq.rot} ${cx} ${cy})`); g.style.cursor = simEditMode ? 'grab' : 'default'; if (eq.shape === 'rect') { - const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - r.setAttribute('class', 'equip'); - r.setAttribute('x', eq.x); r.setAttribute('y', eq.y); - r.setAttribute('width', eq.w); r.setAttribute('height', eq.h); - r.setAttribute('rx', '6'); - g.appendChild(r); + g.appendChild(mkSvgEl('rect', {'class':'equip', x:eq.x, y:eq.y, width:eq.w||80, height:eq.h||60, rx:'6', + ...(simEditMode ? {stroke: isSel?'var(--blue)':'var(--amber)', 'stroke-width': isSel?'3':'2', 'stroke-dasharray': isSel?'':'4 4'} : {})})); + const t = mkSvgEl('text', {'class':'equip-label', x:eq.x+(eq.w||80)/2, y:eq.y+(eq.h||60)/2+5}); + t.textContent = eq.label; g.appendChild(t); - const t = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - t.setAttribute('class', 'equip-label'); - t.setAttribute('x', eq.x + eq.w/2); t.setAttribute('y', eq.y + eq.h/2 + 5); - t.textContent = eq.label; - g.appendChild(t); - - // Animation overlay if (eq.anim === 'conveyor') { - const l = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - l.setAttribute('class', 'anim-overlay conveyor-anim'); - l.id = 'sim-' + eq.id; - l.setAttribute('x1', eq.x+10); l.setAttribute('y1', eq.y+eq.h-5); - l.setAttribute('x2', eq.x+eq.w-10); l.setAttribute('y2', eq.y+eq.h-5); - l.setAttribute('stroke', 'var(--green)'); l.setAttribute('stroke-width', '3'); - l.setAttribute('stroke-dasharray', '10 10'); - g.appendChild(l); + g.appendChild(mkSvgEl('line', {'class':'anim-overlay conveyor-anim', id:'sim-'+eq.id, + x1:eq.x+10, y1:eq.y+(eq.h||60)-5, x2:eq.x+(eq.w||80)-10, y2:eq.y+(eq.h||60)-5, + stroke:'var(--green)', 'stroke-width':'3', 'stroke-dasharray':'10 10'})); } - } else { // circle - const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - c.setAttribute('class', 'equip'); - c.setAttribute('cx', eq.x); c.setAttribute('cy', eq.y); c.setAttribute('r', eq.r || 20); - g.appendChild(c); + } else { + const r = eq.r || 20; + g.appendChild(mkSvgEl('circle', {'class':'equip', cx:eq.x, cy:eq.y, r, + ...(simEditMode ? {stroke: isSel?'var(--blue)':'var(--amber)', 'stroke-width': isSel?'3':'2', 'stroke-dasharray': isSel?'':'4 4'} : {})})); + const t = mkSvgEl('text', {'class':'equip-label', x:eq.x, y:eq.y+4, 'font-size': r<15?'8':'9'}); + t.textContent = eq.label; g.appendChild(t); - const t = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - t.setAttribute('class', 'equip-label'); - t.setAttribute('x', eq.x); t.setAttribute('y', eq.y + 4); - t.setAttribute('font-size', eq.r < 15 ? '8' : '9'); - t.textContent = eq.label; - g.appendChild(t); - - // Animation overlays if (eq.anim === 'burner') { - const ac = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - ac.setAttribute('class', 'anim-overlay burner-glow'); - ac.id = 'sim-' + eq.id; - ac.setAttribute('cx', eq.x); ac.setAttribute('cy', eq.y); - ac.setAttribute('r', (eq.r||20)+4); - ac.setAttribute('fill', 'none'); ac.setAttribute('stroke', 'var(--amber)'); ac.setAttribute('stroke-width', '4'); - g.appendChild(ac); + g.appendChild(mkSvgEl('circle', {'class':'anim-overlay burner-glow', id:'sim-'+eq.id, + cx:eq.x, cy:eq.y, r:r+4, fill:'none', stroke:'var(--amber)', 'stroke-width':'4'})); } else if (eq.anim === 'fan' || eq.anim === 'agitator') { - const ag = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - ag.setAttribute('class', `anim-overlay ${eq.anim === 'fan' ? 'fan-anim' : 'agitator-anim'}`); - ag.id = 'sim-' + eq.id; - ag.setAttribute('transform-origin', `${eq.x} ${eq.y}`); - const r = eq.r || 20; - const col = eq.anim === 'fan' ? 'var(--blue)' : 'var(--blue)'; - ag.innerHTML = ` - `; + const ag = mkSvgEl('g', {'class':`anim-overlay ${eq.anim==='fan'?'fan-anim':'agitator-anim'}`, id:'sim-'+eq.id, 'transform-origin':`${eq.x} ${eq.y}`}); + ag.innerHTML = ``; g.appendChild(ag); } } - - // Edit mode: border highlight - if (simEditMode) { - const outline = g.querySelector('.equip'); - if (outline) { outline.setAttribute('stroke', 'var(--amber)'); outline.setAttribute('stroke-width', '2'); outline.setAttribute('stroke-dasharray', '4 4'); } - } - svg.appendChild(g); - // Drag in edit mode if (simEditMode) { - g.addEventListener('mousedown', (e) => startDragEquip(e, eq)); + g.addEventListener('mousedown', e => { + if (resizeHandle) return; + selectedEquip = eq; + dragEquip = eq; + const p = svgPt(e); + dragOffset = {x: p.x - eq.x, y: p.y - eq.y}; + e.preventDefault(); + renderSchematic(); + }); g.addEventListener('dblclick', () => renameEquip(eq)); } }); - if (simEditMode) { - svg.addEventListener('mousemove', moveDragEquip); - svg.addEventListener('mouseup', stopDragEquip); - svg.addEventListener('mouseleave', stopDragEquip); + // Draw selection handles for selected equipment + if (simEditMode && selectedEquip) { + const eq = selectedEquip; + if (eq.shape === 'rect') { + const w = eq.w||80, h = eq.h||60; + // Width handle (right middle) + handle(svg, eq.x+w, eq.y+h/2, 'w', eq, 'ew-resize'); + // Height handle (bottom middle) + handle(svg, eq.x+w/2, eq.y+h, 'h', eq, 'ns-resize'); + // Corner handle (bottom-right for both) + handle(svg, eq.x+w, eq.y+h, 'wh', eq, 'nwse-resize'); + // Rotation handle (above top center) + const rotH = mkSvgEl('circle', {cx:eq.x+w/2, cy:eq.y-25, r:'6', fill:'var(--amber)', stroke:'#fff', 'stroke-width':'1.5', cursor:'crosshair', 'data-handle':'rot'}); + svg.appendChild(mkSvgEl('line', {x1:eq.x+w/2, y1:eq.y, x2:eq.x+w/2, y2:eq.y-19, stroke:'var(--amber)', 'stroke-width':'1', 'stroke-dasharray':'3 2'})); + rotH.addEventListener('mousedown', e => { e.stopPropagation(); resizeHandle = 'rot'; dragEquip = eq; dragOffset = {cx:eq.x+w/2, cy:eq.y+(eq.h||60)/2}; }); + svg.appendChild(rotH); + } else { + const r = eq.r || 20; + // Radius handle (right edge) + handle(svg, eq.x+r, eq.y, 'r', eq, 'ew-resize'); + // Rotation handle (above) + const rotH = mkSvgEl('circle', {cx:eq.x, cy:eq.y-r-20, r:'6', fill:'var(--amber)', stroke:'#fff', 'stroke-width':'1.5', cursor:'crosshair'}); + svg.appendChild(mkSvgEl('line', {x1:eq.x, y1:eq.y-r, x2:eq.x, y2:eq.y-r-14, stroke:'var(--amber)', 'stroke-width':'1', 'stroke-dasharray':'3 2'})); + rotH.addEventListener('mousedown', e => { e.stopPropagation(); resizeHandle = 'rot'; dragEquip = eq; dragOffset = {cx:eq.x, cy:eq.y}; }); + svg.appendChild(rotH); + } } + if (simEditMode) { + svg.addEventListener('mousemove', onSvgMouseMove); + svg.addEventListener('mouseup', onSvgMouseUp); + svg.addEventListener('mouseleave', onSvgMouseUp); + // Click on empty space deselects + svg.addEventListener('click', e => { if (e.target === svg) { selectedEquip = null; renderSchematic(); } }); + } updateSchematicAnimations(); } -function startDragEquip(e, eq) { - if (!simEditMode) return; - dragEquip = eq; - const svg = document.getElementById('sim-svg'); - const pt = svg.createSVGPoint(); - pt.x = e.clientX; pt.y = e.clientY; - const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse()); - dragOffset.x = svgPt.x - eq.x; - dragOffset.y = svgPt.y - eq.y; - e.preventDefault(); -} - -function moveDragEquip(e) { +function onSvgMouseMove(e) { if (!dragEquip) return; - const svg = document.getElementById('sim-svg'); - const pt = svg.createSVGPoint(); - pt.x = e.clientX; pt.y = e.clientY; - const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse()); - dragEquip.x = Math.round(svgPt.x - dragOffset.x); - dragEquip.y = Math.round(svgPt.y - dragOffset.y); - // Save to layout - layout.equipment = getEquipment(); - const eq = layout.equipment.find(e => e.id === dragEquip.id); - if (eq) { eq.x = dragEquip.x; eq.y = dragEquip.y; } - renderSchematic(); + const p = svgPt(e); + + if (resizeHandle === 'rot') { + // Rotation: angle from center to mouse + const cx = dragOffset.cx, cy = dragOffset.cy; + const angle = Math.round(Math.atan2(p.x - cx, -(p.y - cy)) * 180 / Math.PI); + dragEquip.rot = angle; + saveEquip(dragEquip); + renderSchematic(); + } else if (resizeHandle === 'w') { + dragEquip.w = Math.max(30, Math.round(p.x - dragEquip.x)); + saveEquip(dragEquip); renderSchematic(); + } else if (resizeHandle === 'h') { + dragEquip.h = Math.max(20, Math.round(p.y - dragEquip.y)); + saveEquip(dragEquip); renderSchematic(); + } else if (resizeHandle === 'wh') { + dragEquip.w = Math.max(30, Math.round(p.x - dragEquip.x)); + dragEquip.h = Math.max(20, Math.round(p.y - dragEquip.y)); + saveEquip(dragEquip); renderSchematic(); + } else if (resizeHandle === 'r') { + dragEquip.r = Math.max(8, Math.round(Math.sqrt((p.x-dragEquip.x)**2 + (p.y-dragEquip.y)**2))); + saveEquip(dragEquip); renderSchematic(); + } else if (dragEquip && !resizeHandle) { + // Move + dragEquip.x = Math.round(p.x - dragOffset.x); + dragEquip.y = Math.round(p.y - dragOffset.y); + saveEquip(dragEquip); + renderSchematic(); + } } -function stopDragEquip() { dragEquip = null; } +function onSvgMouseUp() { dragEquip = null; resizeHandle = null; } function renameEquip(eq) { const name = prompt('Rename equipment:', eq.label); if (!name) return; eq.label = name; - if (!layout.equipment) layout.equipment = getEquipment(); - const e = layout.equipment.find(x => x.id === eq.id); - if (e) e.label = name; + saveEquip(eq); renderSchematic(); }