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
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
Rename
+
Change Shape
+
Change Animation
+
Link to HMI Control
+
Change Color
+
Delete
+
@@ -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) {