Resize + rotate equipment blocks in schematic editor

This commit is contained in:
Richard Sauer 2026-04-08 12:18:37 +10:00
parent 5f8e7c0ffd
commit d87475558d

View File

@ -590,6 +590,8 @@ const defaultEquipment = [
let simEditMode = false; let simEditMode = false;
let dragEquip = null, dragOffset = {x:0, y:0}; let dragEquip = null, dragOffset = {x:0, y:0};
let selectedEquip = null;
let resizeHandle = null; // 'w','h','r','rot'
function getEquipment() { function getEquipment() {
return layout.equipment || JSON.parse(JSON.stringify(defaultEquipment)); return layout.equipment || JSON.parse(JSON.stringify(defaultEquipment));
@ -604,148 +606,178 @@ function toggleSimEdit() {
renderSchematic(); 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() { function renderSchematic() {
const equip = getEquipment(); const equip = getEquipment();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const container = document.getElementById('sim-container');
svg.setAttribute('class', 'schematic'); container.innerHTML = '';
svg.setAttribute('viewBox', '0 0 800 400');
svg.id = 'sim-svg';
// Defs for arrows const svg = mkSvgEl('svg', {'class':'schematic', viewBox:'0 0 800 400', id:'sim-svg'});
svg.innerHTML = `<defs><marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="var(--dim)"/></marker></defs>`; svg.innerHTML = `<defs><marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="var(--dim)"/></marker></defs>`;
svg.appendChild(mkSvgEl('text', {x:'60',y:'148',fill:'var(--dim)','font-size':'10','text-anchor':'middle','font-family':'sans-serif'})).textContent = 'IN \u25B6';
// IN/OUT labels svg.appendChild(mkSvgEl('text', {x:'740',y:'320',fill:'var(--dim)','font-size':'10','text-anchor':'middle','font-family':'sans-serif'})).textContent = '\u25B6 OUT';
svg.innerHTML += `<text x="60" y="148" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">IN &#x25B6;</text>`; container.appendChild(svg);
svg.innerHTML += `<text x="740" y="320" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">&#x25B6; OUT</text>`;
document.getElementById('sim-container').innerHTML = '';
document.getElementById('sim-container').appendChild(svg);
equip.forEach(eq => { equip.forEach(eq => {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); if (eq.rot === undefined) eq.rot = 0;
g.dataset.eqId = eq.id; 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'; g.style.cursor = simEditMode ? 'grab' : 'default';
if (eq.shape === 'rect') { if (eq.shape === 'rect') {
const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); g.appendChild(mkSvgEl('rect', {'class':'equip', x:eq.x, y:eq.y, width:eq.w||80, height:eq.h||60, rx:'6',
r.setAttribute('class', 'equip'); ...(simEditMode ? {stroke: isSel?'var(--blue)':'var(--amber)', 'stroke-width': isSel?'3':'2', 'stroke-dasharray': isSel?'':'4 4'} : {})}));
r.setAttribute('x', eq.x); r.setAttribute('y', eq.y); const t = mkSvgEl('text', {'class':'equip-label', x:eq.x+(eq.w||80)/2, y:eq.y+(eq.h||60)/2+5});
r.setAttribute('width', eq.w); r.setAttribute('height', eq.h); t.textContent = eq.label; g.appendChild(t);
r.setAttribute('rx', '6');
g.appendChild(r);
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') { if (eq.anim === 'conveyor') {
const l = document.createElementNS('http://www.w3.org/2000/svg', 'line'); g.appendChild(mkSvgEl('line', {'class':'anim-overlay conveyor-anim', id:'sim-'+eq.id,
l.setAttribute('class', 'anim-overlay conveyor-anim'); 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,
l.id = 'sim-' + eq.id; stroke:'var(--green)', 'stroke-width':'3', 'stroke-dasharray':'10 10'}));
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);
} }
} else { // circle } else {
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);
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);
} 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 r = eq.r || 20;
const col = eq.anim === 'fan' ? 'var(--blue)' : 'var(--blue)'; g.appendChild(mkSvgEl('circle', {'class':'equip', cx:eq.x, cy:eq.y, r,
ag.innerHTML = `<line x1="${eq.x}" y1="${eq.y-r}" x2="${eq.x}" y2="${eq.y+r}" stroke="${col}" stroke-width="2"/> ...(simEditMode ? {stroke: isSel?'var(--blue)':'var(--amber)', 'stroke-width': isSel?'3':'2', 'stroke-dasharray': isSel?'':'4 4'} : {})}));
<line x1="${eq.x-r}" y1="${eq.y}" x2="${eq.x+r}" y2="${eq.y}" stroke="${col}" stroke-width="2"/>`; 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);
if (eq.anim === 'burner') {
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 = mkSvgEl('g', {'class':`anim-overlay ${eq.anim==='fan'?'fan-anim':'agitator-anim'}`, id:'sim-'+eq.id, 'transform-origin':`${eq.x} ${eq.y}`});
ag.innerHTML = `<line x1="${eq.x}" y1="${eq.y-r}" x2="${eq.x}" y2="${eq.y+r}" stroke="var(--blue)" stroke-width="2"/><line x1="${eq.x-r}" y1="${eq.y}" x2="${eq.x+r}" y2="${eq.y}" stroke="var(--blue)" stroke-width="2"/>`;
g.appendChild(ag); 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); svg.appendChild(g);
// Drag in edit mode
if (simEditMode) { 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)); g.addEventListener('dblclick', () => renameEquip(eq));
} }
}); });
if (simEditMode) { // Draw selection handles for selected equipment
svg.addEventListener('mousemove', moveDragEquip); if (simEditMode && selectedEquip) {
svg.addEventListener('mouseup', stopDragEquip); const eq = selectedEquip;
svg.addEventListener('mouseleave', stopDragEquip); 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(); updateSchematicAnimations();
} }
function startDragEquip(e, eq) { function onSvgMouseMove(e) {
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) {
if (!dragEquip) return; if (!dragEquip) return;
const svg = document.getElementById('sim-svg'); const p = svgPt(e);
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY; if (resizeHandle === 'rot') {
const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse()); // Rotation: angle from center to mouse
dragEquip.x = Math.round(svgPt.x - dragOffset.x); const cx = dragOffset.cx, cy = dragOffset.cy;
dragEquip.y = Math.round(svgPt.y - dragOffset.y); const angle = Math.round(Math.atan2(p.x - cx, -(p.y - cy)) * 180 / Math.PI);
// Save to layout dragEquip.rot = angle;
layout.equipment = getEquipment(); saveEquip(dragEquip);
const eq = layout.equipment.find(e => e.id === dragEquip.id);
if (eq) { eq.x = dragEquip.x; eq.y = dragEquip.y; }
renderSchematic(); 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) { function renameEquip(eq) {
const name = prompt('Rename equipment:', eq.label); const name = prompt('Rename equipment:', eq.label);
if (!name) return; if (!name) return;
eq.label = name; eq.label = name;
if (!layout.equipment) layout.equipment = getEquipment(); saveEquip(eq);
const e = layout.equipment.find(x => x.id === eq.id);
if (e) e.label = name;
renderSchematic(); renderSchematic();
} }