From 748f8d52382a061bb67957aad489c5c738f180a7 Mon Sep 17 00:00:00 2001 From: Richard Sauer Date: Wed, 8 Apr 2026 12:28:24 +1000 Subject: [PATCH] Full schematic editor: shapes, animations, labels, arrows, temp probes - New shape types: label (text), arrow (flow), temp_probe, output_indicator - Right-click context menu: change shape, animation, color, link to HMI control - Toolbar: add rect/circle/label/arrow/temp probe/output, delete selected - IN/OUT now draggable label elements - Temp probes with pulse animation and color coding - Arrows with adjustable length and rotation - Custom colors on any element (fill + stroke) - All positions/sizes/rotations saved to layout JSON Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/editor.html | 239 +++++++++++++++++++++++++++++++++++------- 1 file changed, 202 insertions(+), 37 deletions(-) diff --git a/templates/editor.html b/templates/editor.html index 993ead5..2960a9d 100644 --- a/templates/editor.html +++ b/templates/editor.html @@ -116,6 +116,7 @@ body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var( .conveyor-anim{animation:scroll-belt 0.5s linear infinite} .discharge-anim{animation:fall-particles 1s ease-out infinite} .agitator-anim{animation:spin 2s linear infinite} +.pulse-anim{animation:pulse-glow 1.5s ease-in-out infinite} /* Help panel */ .help-panel{position:fixed;right:0;top:48px;width:400px;height:calc(100vh - 48px);background:var(--card);border-left:2px solid var(--border);z-index:150;display:none;flex-direction:column;overflow-y:auto;padding:24px} @@ -126,6 +127,12 @@ body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var( .help-panel .help-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center} /* Inline edit */ +/* Sim toolbar + context menu */ +.sim-toolbar button{padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:var(--card);color:var(--text2);font-size:11px;cursor:pointer} +.sim-toolbar button:hover{background:var(--border);color:var(--text)} +.eq-ctx-item{padding:8px 16px;font-size:13px;cursor:pointer;color:var(--text)} +.eq-ctx-item:hover{background:var(--border)} + .inline-edit{background:transparent;border:none;border-bottom:2px solid var(--blue);color:var(--text);font-size:inherit;font-family:inherit;text-align:center;outline:none;width:100%} /* Popup */ @@ -187,6 +194,26 @@ body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var( MACHINE SIMULATION + + +
@@ -586,6 +613,15 @@ const defaultEquipment = [ {id:'eq_mill', label:'Mill', shape:'rect', x:580, y:250, w:70, h:50, anim:null, link:'output_4'}, {id:'eq_shaker', label:'Shaker Sep.',shape:'rect', x:670, y:250, w:90, h:50, anim:null, link:'output_5'}, {id:'eq_brush', label:'Brush', shape:'rect', x:670, y:160, w:60, h:40, anim:null, link:'output_1'}, + {id:'eq_in', label:'IN \u25B6', shape:'label', x:60, y:148, anim:null, link:null, color:'#4a5670'}, + {id:'eq_out', label:'\u25B6 OUT', shape:'label', x:740, y:320, anim:null, link:null, color:'#4a5670'}, + {id:'eq_t_heat', label:'T1', shape:'circle', x:230, y:180, r:10, anim:'pulse', link:'temp_0', color:'#ff4444'}, + {id:'eq_t_prod1', label:'T2', shape:'circle', x:310, y:200, r:10, anim:'pulse', link:'temp_1', color:'#ff8844'}, + {id:'eq_t_prod2', label:'T3', shape:'circle', x:460, y:195, r:10, anim:'pulse', link:'temp_2', color:'#ff8844'}, + {id:'eq_t_exh', label:'T4', shape:'circle', x:350, y:130, r:10, anim:'pulse', link:'temp_3', color:'#44aaff'}, + {id:'eq_arrow1', label:'', shape:'arrow', x:130, y:200, w:60, anim:null, link:null}, + {id:'eq_arrow2', label:'', shape:'arrow', x:345, y:200, w:55, anim:null, link:null}, + {id:'eq_arrow3', label:'', shape:'arrow', x:545, y:190, w:30, anim:null, link:null}, ]; let simEditMode = false; @@ -599,10 +635,103 @@ function getEquipment() { function toggleSimEdit() { simEditMode = !simEditMode; + selectedEquip = null; const btn = document.getElementById('btn-sim-edit'); btn.textContent = simEditMode ? 'DONE EDITING' : 'EDIT LAYOUT'; btn.style.background = simEditMode ? 'var(--amber)' : 'var(--card)'; btn.style.color = simEditMode ? '#000' : 'var(--text2)'; + document.getElementById('sim-toolbar').style.display = simEditMode ? 'flex' : 'none'; + hideCtx(); + renderSchematic(); +} + +// Context menu +let ctxEquip = null; +function showCtx(e, eq) { + e.preventDefault(); + ctxEquip = eq; + const m = document.getElementById('eq-context'); + m.style.display = 'block'; + m.style.left = e.clientX + 'px'; + m.style.top = e.clientY + 'px'; +} +function hideCtx() { + document.getElementById('eq-context').style.display = 'none'; + ctxEquip = null; +} +document.addEventListener('click', e => { if (!e.target.closest('#eq-context')) hideCtx(); }); + +function ctxAction(action) { + if (!ctxEquip) return; + const eq = ctxEquip; + hideCtx(); + + if (action === 'rename') { + renameEquip(eq); + } else if (action === 'shape') { + const shapes = ['rect', 'circle', 'label', 'arrow']; + const cur = shapes.indexOf(eq.shape); + const choice = prompt('Shape — type one of: rect, circle, label, arrow\nCurrent: ' + eq.shape, eq.shape); + if (choice && shapes.includes(choice)) { + eq.shape = choice; + if (choice === 'rect' && !eq.w) { eq.w = 80; eq.h = 60; } + if (choice === 'circle' && !eq.r) { eq.r = 20; } + saveEquip(eq); renderSchematic(); + } + } else if (action === 'anim') { + const anims = ['none', 'fan', 'agitator', 'burner', 'conveyor', 'pulse']; + const choice = prompt('Animation — type one of: none, fan, agitator, burner, conveyor, pulse\nCurrent: ' + (eq.anim || 'none'), eq.anim || 'none'); + if (choice && anims.includes(choice)) { + eq.anim = choice === 'none' ? null : choice; + saveEquip(eq); renderSchematic(); + } + } else if (action === 'link') { + // Show all available HMI card IDs + const ids = []; + layout.pages.forEach(p => p.cards.forEach(c => ids.push(c.id + ' (' + c.label + ')'))); + const choice = prompt('Link to HMI control ID:\n\nAvailable:\n' + ids.join('\n') + '\n\nCurrent: ' + (eq.link || 'none') + '\nType ID or "none":', eq.link || ''); + if (choice !== null) { + eq.link = choice === 'none' || choice === '' ? null : choice.split(' ')[0]; + saveEquip(eq); renderSchematic(); + } + } else if (action === 'color') { + const choice = prompt('Color hex (e.g. #ff4444, #44aaff, #00c853):\nCurrent: ' + (eq.color || 'default'), eq.color || '#7a8baa'); + if (choice) { eq.color = choice; saveEquip(eq); renderSchematic(); } + } else if (action === 'delete') { + deleteEquipById(eq.id); + } +} + +function addEquip(type) { + if (!layout.equipment) layout.equipment = getEquipment(); + const id = 'eq_' + Date.now(); + const base = {id, label:'New', rot:0, anim:null, link:null, color:null}; + + if (type === 'rect') { + layout.equipment.push({...base, shape:'rect', x:350, y:150, w:80, h:60}); + } else if (type === 'circle') { + layout.equipment.push({...base, shape:'circle', x:400, y:200, r:25}); + } else if (type === 'label') { + layout.equipment.push({...base, shape:'label', label:'Label', x:400, y:100}); + } else if (type === 'arrow') { + layout.equipment.push({...base, shape:'arrow', x:300, y:200, w:80, label:''}); + } else if (type === 'temp_probe') { + layout.equipment.push({...base, shape:'circle', label:'T', r:14, x:380, y:180, color:'#ff4444', anim:'pulse', link:null}); + } else if (type === 'output_indicator') { + layout.equipment.push({...base, shape:'rect', label:'Output', x:360, y:160, w:60, h:35, color:'#00c853', link:null}); + } + renderSchematic(); +} + +function deleteSelectedEquip() { + if (!selectedEquip) { alert('Click an element first'); return; } + deleteEquipById(selectedEquip.id); +} + +function deleteEquipById(id) { + if (!layout.equipment) layout.equipment = getEquipment(); + layout.equipment = layout.equipment.filter(e => e.id !== id); + if (selectedEquip && selectedEquip.id === id) selectedEquip = null; renderSchematic(); } @@ -638,57 +767,92 @@ function renderSchematic() { const svg = mkSvgEl('svg', {'class':'schematic', viewBox:'0 0 800 400', id:'sim-svg'}); svg.innerHTML = ``; - 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 => { if (eq.rot === undefined) eq.rot = 0; const g = mkSvgEl('g', {'data-eq-id': eq.id}); const isSel = selectedEquip && selectedEquip.id === eq.id; + const col = eq.color || null; + const editStroke = simEditMode ? {stroke: isSel?'var(--blue)':'var(--amber)', 'stroke-width': isSel?'3':'2', 'stroke-dasharray': isSel?'':'4 4'} : {}; - // Apply rotation around center + // Rotation 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.shape === 'rect' || eq.shape === 'arrow') { 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'; + // ── RECT ── if (eq.shape === 'rect') { - 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}); + const w=eq.w||80, h=eq.h||60; + const rectAttrs = {'class':'equip', x:eq.x, y:eq.y, width:w, height:h, rx:'6', ...editStroke}; + if (col) { rectAttrs.fill = col; rectAttrs['fill-opacity'] = '0.2'; rectAttrs.stroke = col; rectAttrs['stroke-width'] = '2'; rectAttrs['stroke-dasharray'] = ''; } + g.appendChild(mkSvgEl('rect', rectAttrs)); + const t = mkSvgEl('text', {'class':'equip-label', x:eq.x+w/2, y:eq.y+h/2+5, ...(col?{fill:col}:{})}); t.textContent = eq.label; g.appendChild(t); if (eq.anim === 'conveyor') { 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'})); + x1:eq.x+10, y1:eq.y+h-5, x2:eq.x+w-10, y2:eq.y+h-5, + stroke: col||'var(--green)', 'stroke-width':'3', 'stroke-dasharray':'10 10'})); + } else if (eq.anim === 'pulse') { + g.appendChild(mkSvgEl('rect', {'class':'anim-overlay pulse-anim', id:'sim-'+eq.id, + x:eq.x, y:eq.y, width:w, height:h, rx:'6', + fill:'none', stroke: col||'var(--green)', 'stroke-width':'3'})); } - } 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'}); + + // ── CIRCLE ── + } else if (eq.shape === 'circle') { + const r = eq.r||20; + const circAttrs = {'class':'equip', cx:eq.x, cy:eq.y, r, ...editStroke}; + if (col) { circAttrs.fill = col; circAttrs['fill-opacity'] = '0.25'; circAttrs.stroke = col; circAttrs['stroke-width'] = '2'; circAttrs['stroke-dasharray'] = ''; } + g.appendChild(mkSvgEl('circle', circAttrs)); + const t = mkSvgEl('text', {'class':'equip-label', x:eq.x, y:eq.y+4, 'font-size':r<15?'8':'10', ...(col?{fill:col}:{})}); 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'})); + cx:eq.x, cy:eq.y, r:r+4, fill:'none', stroke:col||'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 = ``; + ag.innerHTML = ``; g.appendChild(ag); + } else if (eq.anim === 'pulse') { + g.appendChild(mkSvgEl('circle', {'class':'anim-overlay pulse-anim', id:'sim-'+eq.id, + cx:eq.x, cy:eq.y, r:r+3, fill:'none', stroke:col||'var(--green)', 'stroke-width':'2'})); + } + + // ── LABEL ── + } else if (eq.shape === 'label') { + const t = mkSvgEl('text', {x:eq.x, y:eq.y, fill:col||'var(--dim)', 'font-size':'12', 'text-anchor':'middle', 'font-family':'sans-serif', 'font-weight':'600'}); + t.textContent = eq.label; g.appendChild(t); + if (simEditMode) { + // Invisible hit area for dragging + g.appendChild(mkSvgEl('rect', {x:eq.x-30, y:eq.y-12, width:60, height:18, fill:'transparent', ...editStroke})); + } + + // ── ARROW ── + } else if (eq.shape === 'arrow') { + const w = eq.w || 60; + g.appendChild(mkSvgEl('line', {'class':'flow-arrow', x1:eq.x, y1:eq.y, x2:eq.x+w, y2:eq.y, + fill:'none', stroke:col||'var(--dim)', 'stroke-width':'2', 'marker-end':'url(#arrowhead)'})); + if (eq.label) { + const t = mkSvgEl('text', {x:eq.x+w/2, y:eq.y-6, fill:col||'var(--dim)', 'font-size':'9', 'text-anchor':'middle', 'font-family':'sans-serif'}); + t.textContent = eq.label; g.appendChild(t); + } + if (simEditMode) { + g.appendChild(mkSvgEl('rect', {x:eq.x-2, y:eq.y-6, width:w+4, height:12, fill:'transparent', ...editStroke})); } } + svg.appendChild(g); + // Edit mode events if (simEditMode) { g.addEventListener('mousedown', e => { if (resizeHandle) return; + hideCtx(); selectedEquip = eq; dragEquip = eq; const p = svgPt(e); @@ -697,35 +861,36 @@ function renderSchematic() { renderSchematic(); }); g.addEventListener('dblclick', () => renameEquip(eq)); + g.addEventListener('contextmenu', e => showCtx(e, eq)); } }); // Draw selection handles for selected equipment if (simEditMode && selectedEquip) { const eq = selectedEquip; + const addRotHandle = (hx, hy, rcx, rcy) => { + svg.appendChild(mkSvgEl('line', {x1:hx, y1:hy+6, x2:hx, y2:hy-14, stroke:'var(--amber)', 'stroke-width':'1', 'stroke-dasharray':'3 2'})); + const rh = mkSvgEl('circle', {cx:hx, cy:hy-20, r:'6', fill:'var(--amber)', stroke:'#fff', 'stroke-width':'1.5', cursor:'crosshair'}); + rh.addEventListener('mousedown', e => { e.stopPropagation(); resizeHandle='rot'; dragEquip=eq; dragOffset={cx:rcx,cy:rcy}; }); + svg.appendChild(rh); + }; + if (eq.shape === 'rect') { - const w = eq.w||80, h = eq.h||60; - // Width handle (right middle) + const w=eq.w||80, h=eq.h||60; 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) + addRotHandle(eq.x+w/2, eq.y, eq.x+w/2, eq.y+h/2); + } else if (eq.shape === 'circle') { + const r=eq.r||20; 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); + addRotHandle(eq.x, eq.y-r, eq.x, eq.y); + } else if (eq.shape === 'arrow') { + // Length handle at end of arrow + handle(svg, eq.x+(eq.w||60), eq.y, 'w', eq, 'ew-resize'); + addRotHandle(eq.x+(eq.w||60)/2, eq.y, eq.x+(eq.w||60)/2, eq.y); } + // Labels: no resize handles, just move + rotate not needed } if (simEditMode) {