2026-04-08 11:58:50 +10:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > BFA Banana Dryer — HMI Design Tool< / title >
2026-04-09 13:14:16 +10:00
< script src = "static/sortable.min.js?v=3" > < / script >
2026-04-08 11:58:50 +10:00
< style >
*{margin:0;padding:0;box-sizing:border-box;user-select:none;-webkit-tap-highlight-color:transparent}
:root{--bg:#0a0e17;--card:#131a2b;--border:#1e2a45;--text:#e8ecf4;--text2:#7a8baa;--dim:#4a5670;--blue:#2d7ff9;--green:#00c853;--amber:#ffab00;--red:#ff1744;--thot:#ff4444;--twarm:#ff8844;--tcool:#44aaff;--track:#1a2240}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column}
/* Top toolbar */
.toolbar{display:flex;align-items:center;justify-content:space-between;height:48px;padding:0 16px;background:#111827;border-bottom:1px solid var(--border);flex-shrink:0}
.toolbar .title{font-size:18px;font-weight:700}
.toolbar .sub{font-size:13px;color:var(--text2);margin-left:16px}
.toolbar-right{display:flex;align-items:center;gap:12px}
.toolbar select,.toolbar button,.toolbar input{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-size:13px;cursor:pointer}
.toolbar button:hover{background:var(--border)}
.toolbar .mode-btn{padding:6px 16px;font-weight:600}
.toolbar .mode-btn.active{background:var(--blue);border-color:var(--blue)}
.toolbar .save-btn{background:var(--green);border-color:var(--green);color:#000;font-weight:600}
.toolbar .help-btn{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700}
/* Split screen */
.split{display:flex;flex:1;overflow:hidden}
.panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
.panel-left{border-right:2px solid var(--border)}
.panel-header{height:36px;display:flex;align-items:center;padding:0 12px;background:#0d1220;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text2);gap:8px;flex-shrink:0}
.panel-header .tag{padding:2px 8px;border-radius:4px;font-size:11px}
.panel-header .tag-edit{background:rgba(255,171,0,0.15);color:var(--amber)}
.panel-header .tag-preview{background:rgba(0,200,83,0.15);color:var(--green)}
/* HMI Tabs */
.hmi-tabs{display:flex;height:42px;background:#0d1220;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto}
.hmi-tab{padding:0 20px;font-size:15px;font-weight:600;color:var(--text2);border:none;background:none;cursor:pointer;position:relative;white-space:nowrap}
.hmi-tab.active{color:var(--blue)}
.hmi-tab.active::after{content:'';position:absolute;bottom:0;left:8px;right:8px;height:3px;background:var(--blue);border-radius:2px 2px 0 0}
.hmi-tab .comment-badge{position:absolute;top:4px;right:4px;width:16px;height:16px;border-radius:50%;background:var(--amber);color:#000;font-size:9px;display:flex;align-items:center;justify-content:center;font-weight:700}
.hmi-tab-add{color:var(--dim);font-size:20px;padding:0 12px;cursor:pointer;border:none;background:none}
/* Card grid */
.card-grid{flex:1;display:flex;flex-wrap:wrap;align-content:flex-start;padding:8px;gap:8px;overflow-y:auto}
/* Cards */
.hmi-card{border-radius:10px;padding:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;transition:all 0.15s;position:relative;border:1px solid var(--border);min-height:80px}
.hmi-card.half{width:calc(50% - 4px);height:calc(33.3% - 6px)}
.hmi-card.full{width:100%;height:calc(33.3% - 6px)}
/* Card types */
.hmi-card.type-temp{background:var(--card)}
.hmi-card.type-motor,.hmi-card.type-output,.hmi-card.type-burner{background:var(--red)}
.hmi-card.on.type-motor,.hmi-card.on.type-output{background:var(--green)}
.hmi-card.on.type-burner{background:var(--amber)}
2026-04-09 13:14:16 +10:00
.hmi-card.type-automation{background:var(--red);border:none}
.hmi-card.on.type-automation{background:var(--green)}
2026-04-08 11:58:50 +10:00
.hmi-card .card-label{font-size:18px;color:var(--text2);text-align:center}
.hmi-card .card-value{font-size:28px;font-weight:700}
.hmi-card .card-sub{font-size:12px;color:var(--dim)}
.hmi-card .card-bar{width:80%;height:4px;background:var(--track);border-radius:2px;overflow:hidden}
.hmi-card .card-bar-fill{height:100%;border-radius:2px;transition:width 0.5s}
/* Edit mode overlays */
.edit-overlay{display:none}
.edit-mode .edit-overlay{display:flex}
.hmi-card .edit-overlay{position:absolute;top:4px;right:4px;gap:4px}
.hmi-card .edit-btn{width:24px;height:24px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text2);font-size:12px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.hmi-card .edit-btn:hover{background:var(--red);color:var(--text)}
.hmi-card .comment-btn:hover{background:var(--amber);color:#000}
.hmi-card .drag-handle{position:absolute;top:4px;left:4px;cursor:grab;color:var(--dim);font-size:14px}
.edit-mode .hmi-card{border:2px dashed var(--dim)}
.edit-mode .hmi-card:hover{border-color:var(--blue)}
.sortable-ghost{opacity:0.4;border:2px dashed var(--blue) !important}
/* Add card palette */
2026-04-09 13:14:16 +10:00
/* Add card placeholder */
.add-placeholder{width:calc(50% - 4px);height:calc(33.3% - 6px);border:2px dashed var(--dim);border-radius:10px;display:none;align-items:center;justify-content:center;cursor:pointer;transition:all 0.15s;position:relative}
.edit-mode .add-placeholder{display:flex}
.add-placeholder:hover{border-color:var(--blue);background:rgba(45,127,249,0.05)}
.add-placeholder .plus{font-size:36px;color:var(--dim)}
.add-placeholder:hover .plus{color:var(--blue)}
/* Add menu dropdown */
.add-menu{display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--card);border:1px solid var(--border);border-radius:10px;padding:6px;z-index:20;min-width:160px;box-shadow:0 8px 24px rgba(0,0,0,0.5)}
.add-menu.open{display:flex;flex-direction:column;gap:2px}
.add-menu button{padding:10px 16px;border:none;border-radius:6px;background:none;color:var(--text);font-size:14px;text-align:left;cursor:pointer}
.add-menu button:hover{background:var(--border)}
2026-04-08 11:58:50 +10:00
/* Comment badge on cards */
.hmi-card .card-comment-badge{position:absolute;bottom:4px;right:4px;width:20px;height:20px;border-radius:50%;background:var(--amber);color:#000;font-size:10px;font-weight:700;display:none;align-items:center;justify-content:center}
.hmi-card .card-comment-badge.has-comments{display:flex}
/* Comment panel */
.comment-panel{position:fixed;right:0;top:48px;width:360px;height:calc(100vh - 48px);background:var(--card);border-left:2px solid var(--border);z-index:100;display:none;flex-direction:column}
.comment-panel.open{display:flex}
.comment-panel-header{padding:12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.comment-panel-header h3{font-size:16px}
.comment-list{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
.comment-item{background:var(--bg);border-radius:8px;padding:10px}
.comment-item .comment-user{font-size:12px;font-weight:600;color:var(--blue)}
.comment-item .comment-time{font-size:10px;color:var(--dim);margin-left:8px}
.comment-item .comment-text{font-size:14px;margin-top:4px}
.comment-input{display:flex;gap:8px;padding:12px;border-top:1px solid var(--border)}
.comment-input input{flex:1;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:14px}
.comment-input button{padding:8px 16px;border:none;border-radius:6px;background:var(--blue);color:var(--text);font-weight:600;cursor:pointer}
/* Machine simulation panel */
.sim-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#080c14}
/* SVG Schematic */
.schematic{width:90%;max-height:90%}
.schematic .equip{fill:var(--card);stroke:var(--border);stroke-width:2}
.schematic .equip-label{fill:var(--text2);font-size:12px;text-anchor:middle;font-family:'Segoe UI',sans-serif}
.schematic .flow-arrow{fill:none;stroke:var(--dim);stroke-width:2;marker-end:url(#arrowhead)}
.schematic .anim-overlay{opacity:0;transition:opacity 0.3s}
.schematic .anim-overlay.active{opacity:1}
/* Animations */
2026-04-08 12:57:07 +10:00
@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
2026-04-08 12:52:45 +10:00
@keyframes pulse-glow{0%,100%{opacity:0.4}50%{opacity:1}}
2026-04-08 11:58:50 +10:00
@keyframes scroll-belt{100%{stroke-dashoffset:-20}}
@keyframes fall-particles{0%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(30px)}}
2026-04-08 12:52:45 +10:00
@keyframes blink{0%,100%{opacity:1}50%{opacity:0.1}}
@keyframes vibrate{0%,100%{transform:translateX(0)}25%{transform:translateX(-2px)}75%{transform:translateX(2px)}}
@keyframes glow-pulse{0%,100%{filter:drop-shadow(0 0 2px currentColor)}50%{filter:drop-shadow(0 0 12px currentColor)}}
@keyframes flow-dash{100%{stroke-dashoffset:-30}}
2026-04-08 11:58:50 +10:00
.fan-anim{animation:spin 1s linear infinite}
.burner-glow{animation:pulse-glow 1.5s ease-in-out infinite}
.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}
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
.pulse-anim{animation:pulse-glow 1.5s ease-in-out infinite}
2026-04-08 12:52:45 +10:00
.blink-anim{animation:blink 1s step-end infinite}
.vibrate-anim{animation:vibrate 0.1s linear infinite}
.glow-anim{animation:glow-pulse 2s ease-in-out infinite}
.flow-anim{animation:flow-dash 1s linear infinite}
/* Static OFF state for linked elements */
.equip-off{fill:var(--red) !important;fill-opacity:0.3 !important;stroke:var(--red) !important}
.equip-on{stroke-width:2 !important}
.static-off-ring{fill:none;stroke:var(--red);stroke-width:2;opacity:0.6}
2026-04-08 11:58:50 +10:00
/* 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}
.help-panel.open{display:flex}
.help-panel h2{font-size:20px;margin-bottom:16px;color:var(--text)}
.help-panel h3{font-size:16px;margin-top:16px;margin-bottom:8px;color:var(--blue)}
.help-panel p{font-size:14px;color:var(--text2);line-height:1.6;margin-bottom:8px}
.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 */
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
/* 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)}
2026-04-08 11:58:50 +10:00
.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 */
2026-04-09 13:14:16 +10:00
#popup-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.85);display:none;align-items:center;justify-content:center;z-index:500}
2026-04-08 11:58:50 +10:00
#popup-overlay.active{display:flex}
2026-04-09 13:14:16 +10:00
.popup{z-index:501;background:#0d1220;border:none;border-radius:16px;padding:24px;width:500px;display:flex;flex-direction:column;align-items:center;gap:16px;box-shadow:0 16px 48px rgba(0,0,0,0.8)}
2026-04-08 11:58:50 +10:00
.popup .title{font-size:24px;color:var(--text2)}
.popup .live{font-size:32px;font-weight:700}
.popup .sp-row{display:flex;align-items:center;gap:20px}
.popup .sp-btn{width:70px;height:70px;border:2px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.popup .sp-btn:active{background:var(--border)}
.popup .sp-val{font-size:32px;min-width:80px;text-align:center}
.popup .close-btn{width:160px;height:44px;border:none;border-radius:8px;background:var(--blue);color:var(--text);font-size:16px;font-weight:600;cursor:pointer}
2026-04-09 13:14:16 +10:00
/* Automation editor */
.auto-editor{width:700px;max-width:95vw;max-height:80vh;overflow-y:auto;background:#0d1220;border-radius:16px;padding:24px}
.auto-editor h3{font-size:20px;margin-bottom:12px}
.auto-rule{display:flex;gap:8px;align-items:center;padding:10px;background:var(--bg);border-radius:8px;margin-bottom:8px}
.auto-rule select,.auto-rule input{padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-size:14px}
.auto-rule select{min-width:120px}
.auto-rule input[type=number]{width:80px}
.auto-rule .rule-del{width:32px;height:32px;border:none;border-radius:6px;background:rgba(255,23,68,0.2);color:var(--red);font-size:16px;cursor:pointer;flex-shrink:0}
.auto-rule .rule-del:hover{background:var(--red);color:#fff}
.auto-add-rule{width:100%;padding:12px;border:2px dashed var(--dim);border-radius:8px;background:none;color:var(--text2);font-size:14px;cursor:pointer;margin:8px 0}
.auto-add-rule:hover{border-color:var(--blue);color:var(--blue)}
.auto-btns{display:flex;gap:12px;justify-content:center;margin-top:16px}
.auto-btns button{padding:10px 24px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer}
.auto-btns .save-btn{background:var(--green);color:#000}
.auto-btns .cancel-btn{background:var(--card);color:var(--text);border:1px solid var(--border)}
/* Card properties editor */
.props-editor{width:420px;max-width:95vw}
.props-editor h3{font-size:20px;margin-bottom:16px}
.prop-row{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.prop-row label{width:100px;font-size:13px;color:var(--text2);flex-shrink:0;text-align:right}
.prop-row input,.prop-row select{flex:1;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-size:14px}
.prop-row input[type=color]{width:50px;height:38px;padding:2px;cursor:pointer;flex:0}
.prop-row input[type=range]{flex:1}
.prop-preview{display:inline-block;width:24px;height:24px;border-radius:4px;border:1px solid var(--border);vertical-align:middle;margin-left:8px}
2026-04-08 11:58:50 +10:00
< / style >
< / head >
< body >
<!-- TOOLBAR -->
< div class = "toolbar" >
< div style = "display:flex;align-items:center" >
< span class = "title" > BFA Banana Dryer< / span >
< span class = "sub" > HMI Design Tool< / span >
< / div >
< div class = "toolbar-right" >
2026-04-08 19:25:32 +10:00
< span style = "color:var(--blue);font-weight:600" > {{ user }}< / span >
< a href = "logout" style = "color:var(--dim);text-decoration:none;font-size:12px" > Logout< / a >
2026-04-08 11:58:50 +10:00
< span style = "color:var(--dim)" > |< / span >
< button class = "mode-btn active" id = "btn-edit" onclick = "setMode('edit')" > EDIT< / button >
< button class = "mode-btn" id = "btn-preview" onclick = "setMode('preview')" > PREVIEW< / button >
< span style = "color:var(--dim)" > |< / span >
< button class = "save-btn" onclick = "saveLayout()" > Save< / button >
< button onclick = "exportLayout()" > Export JSON< / button >
< button class = "help-btn" onclick = "toggleHelp()" > ?< / button >
< / div >
< / div >
<!-- SPLIT SCREEN -->
< div class = "split" >
<!-- LEFT: HMI -->
< div class = "panel panel-left" id = "hmi-panel" >
< div class = "panel-header" >
HMI LAYOUT
< span class = "tag tag-edit edit-overlay" > EDIT MODE< / span >
< span class = "tag tag-preview" id = "preview-tag" style = "display:none" > PREVIEW MODE< / span >
< / div >
< div class = "hmi-tabs" id = "hmi-tabs" > < / div >
< div class = "card-grid edit-mode" id = "card-grid" > < / div >
2026-04-09 13:14:16 +10:00
<!-- add palette removed — inline + buttons in grid instead -->
2026-04-08 11:58:50 +10:00
< / div >
<!-- RIGHT: MACHINE SIM -->
< div class = "panel" >
2026-04-08 12:14:20 +10:00
< div class = "panel-header" >
MACHINE SIMULATION
< button id = "btn-sim-edit" onclick = "toggleSimEdit()" style = "margin-left:auto;padding:4px 12px;border:1px solid var(--border);border-radius:4px;background:var(--card);color:var(--text2);font-size:11px;cursor:pointer" > EDIT LAYOUT< / button >
< / div >
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
< div class = "sim-toolbar" id = "sim-toolbar" style = "display:none;padding:6px 8px;gap:6px;background:#0d1220;border-bottom:1px solid var(--border);flex-wrap:wrap;flex-shrink:0" >
< button onclick = "addEquip('rect')" > + Rectangle< / button >
< button onclick = "addEquip('circle')" > + Circle< / button >
< button onclick = "addEquip('label')" > + Label< / button >
< button onclick = "addEquip('arrow')" > + Arrow< / button >
< span style = "color:var(--dim)" > |< / span >
< button onclick = "addEquip('temp_probe')" > + Temp Probe< / button >
< button onclick = "addEquip('output_indicator')" > + Output< / button >
< span style = "color:var(--dim)" > |< / span >
< button onclick = "deleteSelectedEquip()" style = "color:var(--red)" > Delete Selected< / button >
< / div >
<!-- Context menu for equipment properties -->
< div id = "eq-context" style = "display:none;position:fixed;z-index:300;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:4px 0;min-width:180px;box-shadow:0 8px 24px rgba(0,0,0,0.5)" >
< div class = "eq-ctx-item" onclick = "ctxAction('rename')" > Rename< / div >
< div class = "eq-ctx-item" onclick = "ctxAction('shape')" > Change Shape< / div >
< div class = "eq-ctx-item" onclick = "ctxAction('anim')" > Change Animation< / div >
< div class = "eq-ctx-item" onclick = "ctxAction('link')" > Link to HMI Control< / div >
< div class = "eq-ctx-item" onclick = "ctxAction('color')" > Change Color< / div >
< div class = "eq-ctx-item" onclick = "ctxAction('delete')" style = "color:var(--red)" > Delete< / div >
< / div >
2026-04-08 11:58:50 +10:00
< div class = "sim-container" id = "sim-container" >
<!-- SVG schematic inserted by JS -->
< / div >
< / div >
< / div >
<!-- COMMENT PANEL -->
< div class = "comment-panel" id = "comment-panel" >
< div class = "comment-panel-header" >
< h3 id = "comment-target-name" > Comments< / h3 >
< button onclick = "closeComments()" style = "background:none;border:none;color:var(--text);font-size:18px;cursor:pointer" > ✕ < / button >
< / div >
< div class = "comment-list" id = "comment-list" > < / div >
< div class = "comment-input" >
< input id = "comment-text" placeholder = "Add a comment..." onkeydown = "if(event.key==='Enter')submitComment()" >
< button onclick = "submitComment()" > Post< / button >
< / div >
< / div >
<!-- HELP PANEL -->
< div class = "help-panel" id = "help-panel" >
< button class = "help-close" onclick = "toggleHelp()" > ✕ < / button >
< h2 > How to Use This Tool< / h2 >
< h3 > Switching Modes< / h3 >
< p > Use the < b > EDIT< / b > and < b > PREVIEW< / b > buttons in the toolbar. In Edit mode you can rearrange and modify controls. In Preview mode the HMI works like the real device.< / p >
< h3 > Rearranging Controls< / h3 >
< p > In Edit mode, < b > drag any card< / b > by its handle (top-left corner) to move it within a page or between pages (drag to the tab name).< / p >
< h3 > Adding Controls< / h3 >
< p > In Edit mode, use the < b > + Temperature< / b > , < b > + Motor< / b > , < b > + Output< / b > , or < b > + Burner< / b > buttons at the bottom to add a new control to the current page.< / p >
< h3 > Renaming< / h3 >
< p > In Edit mode, < b > click on any card's name< / b > to edit it. Press Enter to confirm.< / p >
< h3 > Deleting< / h3 >
< p > In Edit mode, click the < b > ✕ < / b > button on the top-right of a card to remove it.< / p >
< h3 > Pages / Tabs< / h3 >
< p > Click the < b > +< / b > button after the last tab to add a new page. Right-click a tab name to rename or delete it.< / p >
< h3 > Comments< / h3 >
< p > Click the < b > 💬 < / b > button on any card to open the comment panel. Type your comment and click Post. Everyone can see all comments.< / p >
< h3 > Saving< / h3 >
< p > Click < b > Save< / b > to save the current layout to the server. Click < b > Export JSON< / b > to download the layout file.< / p >
< h3 > Machine Simulation< / h3 >
< p > The right panel shows a schematic of the dryer. When you toggle controls in Preview mode, the equipment animations respond (fan spins, conveyor moves, burner glows, etc.).< / p >
< h3 > Choosing Your Name< / h3 >
< p > Use the dropdown in the toolbar to select your name. Click < b > + Add Person< / b > if you're not in the list.< / p >
< / div >
<!-- POPUP -->
< div id = "popup-overlay" onclick = "if(event.target===this)closePopup()" >
< div class = "popup" id = "popup" > < / div >
< / div >
< script >
// ══════════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════════
let layout = null;
let currentPage = 0;
let mode = 'edit'; // 'edit' or 'preview'
2026-04-08 19:25:32 +10:00
let currentUser = '{{ user }}';
2026-04-08 11:58:50 +10:00
let simState = {}; // id -> {on:bool, value:number}
let commentTarget = null;
let sortableInstance = null;
// ══════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════
async function init() {
2026-04-08 12:10:57 +10:00
const resp = await fetch('api/layout');
2026-04-08 11:58:50 +10:00
layout = await resp.json();
if (!currentUser & & layout.users.length > 0) currentUser = layout.users[0];
initUsers();
initSimState();
renderTabs();
renderCards();
renderSchematic();
}
2026-04-08 19:25:32 +10:00
function initUsers() { /* handled by login page */ }
2026-04-08 11:58:50 +10:00
function initSimState() {
layout.pages.forEach(p => p.cards.forEach(c => {
if (c.type === 'temp') simState[c.id] = {on: true, value: c.sp_default || 70};
else if (c.type === 'motor') simState[c.id] = {on: false, value: c.sp_default || 50};
else if (c.type === 'burner') simState[c.id] = {on: false, value: 0};
else simState[c.id] = {on: false, value: 0};
}));
}
2026-04-08 19:25:32 +10:00
function addUser() { window.location.href = 'logout'; }
2026-04-08 11:58:50 +10:00
// ══════════════════════════════════════════════════════
// MODE
// ══════════════════════════════════════════════════════
function setMode(m) {
mode = m;
document.getElementById('btn-edit').classList.toggle('active', m === 'edit');
document.getElementById('btn-preview').classList.toggle('active', m === 'preview');
document.getElementById('card-grid').classList.toggle('edit-mode', m === 'edit');
document.getElementById('preview-tag').style.display = m === 'preview' ? 'inline' : 'none';
document.querySelectorAll('.edit-overlay').forEach(e => e.style.display = m === 'edit' ? 'flex' : 'none');
2026-04-09 13:14:16 +10:00
// add-palette removed — inline + buttons handle this now
2026-04-08 11:58:50 +10:00
if (m === 'edit') initSortable();
else if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; }
}
// ══════════════════════════════════════════════════════
// TABS
// ══════════════════════════════════════════════════════
function renderTabs() {
const container = document.getElementById('hmi-tabs');
container.innerHTML = '';
layout.pages.forEach((p, i) => {
const btn = document.createElement('button');
btn.className = 'hmi-tab' + (i === currentPage ? ' active' : '');
btn.textContent = p.name;
btn.onclick = () => { currentPage = i; renderTabs(); renderCards(); };
btn.oncontextmenu = (e) => { e.preventDefault(); tabContextMenu(i); };
// Comment badge for page
const pageComments = (layout.comments || []).filter(c => c.target === 'page:' + p.id);
if (pageComments.length > 0) {
const badge = document.createElement('span');
badge.className = 'comment-badge';
badge.textContent = pageComments.length;
btn.appendChild(badge);
}
container.appendChild(btn);
});
const addBtn = document.createElement('button');
addBtn.className = 'hmi-tab-add';
addBtn.textContent = '+';
addBtn.onclick = addPage;
container.appendChild(addBtn);
}
function addPage() {
const name = prompt('New page name:', 'NEW PAGE');
if (!name) return;
layout.pages.push({id: 'p' + Date.now(), name: name.toUpperCase(), cards: []});
currentPage = layout.pages.length - 1;
renderTabs(); renderCards();
}
function tabContextMenu(i) {
const action = prompt(`Page "${layout.pages[i].name}"\n\nType "rename" to rename, "delete" to delete, or "comment" to add a comment:`);
if (!action) return;
if (action.toLowerCase() === 'rename') {
const name = prompt('New name:', layout.pages[i].name);
if (name) { layout.pages[i].name = name.toUpperCase(); renderTabs(); }
} else if (action.toLowerCase() === 'delete') {
if (layout.pages.length < = 1) { alert('Must keep at least one page.'); return; }
if (confirm(`Delete page "${layout.pages[i].name}"?`)) {
layout.pages.splice(i, 1);
if (currentPage >= layout.pages.length) currentPage = layout.pages.length - 1;
renderTabs(); renderCards();
}
} else if (action.toLowerCase() === 'comment') {
openComments('page:' + layout.pages[i].id, layout.pages[i].name);
}
}
// ══════════════════════════════════════════════════════
// CARDS
// ══════════════════════════════════════════════════════
function renderCards() {
const grid = document.getElementById('card-grid');
grid.innerHTML = '';
const page = layout.pages[currentPage];
if (!page) return;
page.cards.forEach(c => {
const el = createCardEl(c);
grid.appendChild(el);
});
2026-04-09 13:14:16 +10:00
// In edit mode, fill remaining slots with + placeholders (up to 6 slots per page)
if (mode === 'edit') {
const halfCards = page.cards.filter(c => c.width !== 'full').length;
const fullCards = page.cards.filter(c => c.width === 'full').length;
const slotsUsed = halfCards + fullCards * 2;
const emptySlots = Math.max(1, 6 - slotsUsed); // always show at least 1
for (let i = 0; i < emptySlots ; i + + ) {
const ph = document.createElement('div');
ph.className = 'add-placeholder';
ph.innerHTML = `< span class = "plus" > +< / span >
< div class = "add-menu" id = "addmenu-${i}" >
< button onclick = "addCard('temp')" > Temperature< / button >
< button onclick = "addCard('motor')" > Motor / Speed< / button >
< button onclick = "addCard('output')" > Output / Switch< / button >
< button onclick = "addCard('burner')" > Burner< / button >
< button onclick = "addCard('automation')" > Automation< / button >
< / div > `;
ph.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') return;
// Toggle the menu
const menu = ph.querySelector('.add-menu');
document.querySelectorAll('.add-menu.open').forEach(m => { if(m!==menu) m.classList.remove('open'); });
menu.classList.toggle('open');
e.stopPropagation();
});
grid.appendChild(ph);
}
initSortable();
}
2026-04-08 11:58:50 +10:00
}
function createCardEl(c) {
const el = document.createElement('div');
el.className = `hmi-card ${c.width || 'half'} type-${c.type}`;
el.dataset.id = c.id;
const s = simState[c.id] || {on: false, value: 0};
if (c.type !== 'temp' & & s.on) el.classList.add('on');
2026-04-09 13:32:17 +10:00
// Apply custom colors
if (c.type !== 'temp') {
const onColor = c.activeColor || (c.type === 'burner' ? 'var(--amber)' : 'var(--green)');
const offColor = c.inactiveColor || 'var(--red)';
el.style.background = s.on ? onColor : offColor;
}
if (c.textColor) el.style.color = c.textColor;
2026-04-08 11:58:50 +10:00
// Drag handle (edit mode)
el.innerHTML = `< span class = "drag-handle edit-overlay" > ☰ < / span > `;
// Edit buttons
el.innerHTML += `< div class = "edit-overlay" style = "position:absolute;top:4px;right:4px;gap:4px" >
2026-04-09 13:14:16 +10:00
< button class = "edit-btn" onclick = "event.stopPropagation();editCardProps('${c.id}')" title = "Edit" style = "background:var(--blue);color:#fff" > ✎ < / button >
< button class = "edit-btn" onclick = "event.stopPropagation();toggleCardSize('${c.id}')" title = "Resize" > ${c.width==='full'?'◧':'◻'}< / button >
2026-04-08 11:58:50 +10:00
< button class = "edit-btn comment-btn" onclick = "event.stopPropagation();openComments('${c.id}','${c.label}')" > 💬 < / button >
< button class = "edit-btn" onclick = "event.stopPropagation();deleteCard('${c.id}')" > ✕ < / button >
< / div > `;
// Comment badge
const commentCount = (layout.comments || []).filter(x => x.target === c.id).length;
el.innerHTML += `< div class = "card-comment-badge ${commentCount > 0 ? 'has-comments' : ''}" > ${commentCount}< / div > `;
2026-04-09 13:14:16 +10:00
const cfs = c.fontSize || 18;
2026-04-08 11:58:50 +10:00
if (c.type === 'temp') {
const color = c.color || '#ff8844';
2026-04-09 13:14:16 +10:00
el.innerHTML += `< div class = "card-label" style = "font-size:${cfs}px" onclick = "if(mode==='edit'){event.stopPropagation();editCardProps('${c.id}')}" > ${c.label}< / div > `;
2026-04-08 11:58:50 +10:00
el.innerHTML += `< div class = "card-value" style = "color:${color}" > ${Math.round(s.value)} ° C< / div > `;
el.innerHTML += `< div class = "card-bar" > < div class = "card-bar-fill" style = "width:${Math.min(100,s.value/2)}%;background:${color}" > < / div > < / div > `;
el.innerHTML += `< div class = "card-sub" > SP: ${c.sp_default || 70} ° C< / div > `;
el.onclick = () => { if (mode === 'preview') openTempPopup(c); };
} else if (c.type === 'motor') {
el.innerHTML += `< div style = "display:flex;gap:10px;align-items:center" >
2026-04-09 13:14:16 +10:00
< span class = "card-label" style = "font-size:${cfs}px" onclick = "if(mode==='edit'){event.stopPropagation();editCardProps('${c.id}')}" > ${c.label}< / span >
2026-04-08 11:58:50 +10:00
< span style = "font-size:18px;color:var(--text2)" > ${c.sp_default || 50}%< / span >
< / div > `;
el.innerHTML += `< div class = "card-value" > ${s.on ? 'ON' : 'OFF'}< / div > `;
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
} else if (c.type === 'burner') {
2026-04-09 13:14:16 +10:00
el.innerHTML += `< div class = "card-label" style = "font-size:${cfs}px" onclick = "if(mode==='edit'){event.stopPropagation();editCardProps('${c.id}')}" > ${c.label}< / div > `;
2026-04-08 11:58:50 +10:00
el.innerHTML += `< div class = "card-value" > ${s.on ? 'ON' : 'OFF'}< / div > `;
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
2026-04-09 13:14:16 +10:00
} else if (c.type === 'automation') {
// Automation card — combines outputs, speeds, temp setpoints
el.innerHTML += `< div class = "card-label" onclick = "if(mode==='edit'){event.stopPropagation();editCardProps('${c.id}')}" style = "font-size:${Math.max(cfs,20)}px;font-weight:700" > ${c.label}< / div > `;
const rules = c.rules || [];
if (rules.length === 0) {
el.innerHTML += `< div class = "card-sub" style = "text-align:center" > No rules configured< br > Click to edit in edit mode< / div > `;
} else {
let rulesHtml = '< div style = "width:100%;font-size:12px;color:var(--text2);text-align:left;padding:0 8px;overflow:hidden" > ';
rules.forEach(r => {
const icon = r.type === 'temp' ? '🌡' : r.type === 'motor' ? '⚙' : r.type === 'output' ? '⚡' : '●';
rulesHtml += `< div style = "padding:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" > ${icon} ${r.label || r.target}: ${r.action || ''} ${r.value !== undefined ? r.value : ''}< / div > `;
});
rulesHtml += '< / div > ';
el.innerHTML += rulesHtml;
}
el.onclick = () => {
if (mode === 'edit') { event.stopPropagation(); editAutomation(c); }
else if (mode === 'preview') toggleSim(c.id);
};
2026-04-08 11:58:50 +10:00
} else { // output
2026-04-09 13:14:16 +10:00
el.innerHTML += `< div class = "card-label" onclick = "if(mode==='edit'){event.stopPropagation();editCardProps('${c.id}')}" style = "text-align:center;font-size:${cfs}px" > ${c.label}< / div > `;
2026-04-08 11:58:50 +10:00
el.innerHTML += `< div class = "card-value" > ${s.on ? 'ON' : 'OFF'}< / div > `;
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
}
return el;
}
2026-04-09 13:14:16 +10:00
let autoEditCard = null;
function editAutomation(c) {
autoEditCard = c;
// Gather all available controls from the layout
const allControls = [];
layout.pages.forEach(p => p.cards.forEach(card => {
if (card.id !== c.id) allControls.push(card);
}));
const popup = document.getElementById('popup');
popup.style.borderColor = 'var(--border)';
let rulesHtml = '';
(c.rules || []).forEach((r, i) => {
rulesHtml += buildRuleRow(i, r, allControls);
});
popup.innerHTML = `
< div class = "auto-editor" >
< h3 > Edit Automation: ${c.label}< / h3 >
< p style = "color:var(--text2);font-size:13px;margin-bottom:16px" >
When this automation is activated, all these actions happen at once.
Add what should turn on, set speeds, or adjust temperatures.
< / p >
< div id = "auto-rules" > ${rulesHtml}< / div >
< button class = "auto-add-rule" onclick = "addAutoRule()" > + Add another action< / button >
< div class = "auto-btns" >
< button class = "cancel-btn" onclick = "closePopup()" > Cancel< / button >
< button class = "save-btn" onclick = "saveAutoRules()" > Save< / button >
< / div >
< / div > `;
document.getElementById('popup-overlay').classList.add('active');
}
function buildRuleRow(idx, rule, controls) {
if (!controls) {
controls = [];
layout.pages.forEach(p => p.cards.forEach(c => { if(autoEditCard & & c.id !== autoEditCard.id) controls.push(c); }));
}
// Group controls by type
const temps = controls.filter(c => c.type === 'temp');
const motors = controls.filter(c => c.type === 'motor');
const outputs = controls.filter(c => c.type === 'output' || c.type === 'burner');
// What to control dropdown
let targetOpts = '< option value = "" > -- Select --< / option > ';
if (temps.length) {
targetOpts += '< optgroup label = "Temperatures" > ';
temps.forEach(c => targetOpts += `< option value = "${c.id}" data-type = "temp" $ { rule . target = ==c.id?'selected':''} > ${c.label}< / option > `);
targetOpts += '< / optgroup > ';
}
if (motors.length) {
targetOpts += '< optgroup label = "Motors / Speeds" > ';
motors.forEach(c => targetOpts += `< option value = "${c.id}" data-type = "motor" $ { rule . target = ==c.id?'selected':''} > ${c.label}< / option > `);
targetOpts += '< / optgroup > ';
}
if (outputs.length) {
targetOpts += '< optgroup label = "Outputs / Switches" > ';
outputs.forEach(c => targetOpts += `< option value = "${c.id}" data-type = "output" $ { rule . target = ==c.id?'selected':''} > ${c.label}< / option > `);
targetOpts += '< / optgroup > ';
}
// Action depends on type
const isTemp = rule.type === 'temp';
const isMotor = rule.type === 'motor';
const isOutput = rule.type === 'output' || rule.type === 'burner';
let actionOpts = '';
if (isTemp) {
actionOpts = `< option value = "set" $ { rule . action = =='set'?'selected':''} > Set to< / option > `;
} else if (isMotor) {
actionOpts = `
< option value = "on" $ { rule . action = =='on'?'selected':''} > Turn ON< / option >
< option value = "off" $ { rule . action = =='off'?'selected':''} > Turn OFF< / option >
< option value = "set" $ { rule . action = =='set'?'selected':''} > Set speed to< / option > `;
} else {
actionOpts = `
< option value = "on" $ { rule . action = =='on'?'selected':''} > Turn ON< / option >
< option value = "off" $ { rule . action = =='off'?'selected':''} > Turn OFF< / option > `;
}
const showValue = rule.action === 'set';
const valueUnit = isTemp ? '° C' : '%';
const valueMax = isTemp ? 200 : 100;
const valueStep = isTemp ? 1 : 5;
return `
< div class = "auto-rule" data-idx = "${idx}" >
< select onchange = "autoRuleTargetChanged(this, ${idx})" > ${targetOpts}< / select >
< select class = "rule-action" data-idx = "${idx}" onchange = "autoRuleActionChanged(${idx})" > ${actionOpts}< / select >
< span class = "rule-value-wrap" style = "display:${showValue?'flex':'none'};align-items:center;gap:6px" >
< input type = "number" class = "rule-value" data-idx = "${idx}" value = "${rule.value||0}" min = "0" max = "${valueMax}" step = "${valueStep}" >
< span style = "color:var(--text2);font-size:13px" > ${valueUnit}< / span >
< / span >
< button class = "rule-del" onclick = "removeAutoRule(${idx})" > ✕ < / button >
< / div > `;
}
function autoRuleTargetChanged(sel, idx) {
const opt = sel.options[sel.selectedIndex];
const type = opt.dataset.type || 'output';
// Store temporarily so we can rebuild the action dropdown
const rules = collectAutoRules();
if (rules[idx]) {
rules[idx].target = sel.value;
rules[idx].type = type;
rules[idx].label = opt.textContent;
// Reset action based on type
if (type === 'temp') rules[idx].action = 'set';
else rules[idx].action = 'on';
}
autoEditCard.rules = rules;
editAutomation(autoEditCard); // re-render the whole popup
}
function autoRuleActionChanged(idx) {
const actionSel = document.querySelector(`.rule-action[data-idx="${idx}"]`);
const valueWrap = document.querySelectorAll('.rule-value-wrap')[idx];
if (actionSel & & valueWrap) {
valueWrap.style.display = actionSel.value === 'set' ? 'flex' : 'none';
}
}
function addAutoRule() {
if (!autoEditCard) return;
const rules = collectAutoRules();
rules.push({type:'output', target:'', label:'', action:'on', value:0});
autoEditCard.rules = rules;
editAutomation(autoEditCard);
}
function removeAutoRule(idx) {
if (!autoEditCard) return;
const rules = collectAutoRules();
rules.splice(idx, 1);
autoEditCard.rules = rules;
editAutomation(autoEditCard);
}
function collectAutoRules() {
const rows = document.querySelectorAll('.auto-rule');
const rules = [];
rows.forEach(row => {
const targetSel = row.querySelector('select');
const actionSel = row.querySelector('.rule-action');
const valueInput = row.querySelector('.rule-value');
const opt = targetSel.options[targetSel.selectedIndex];
rules.push({
type: opt?.dataset?.type || 'output',
target: targetSel.value,
label: opt?.textContent || '',
action: actionSel?.value || 'on',
value: actionSel?.value === 'set' ? parseInt(valueInput?.value || 0) : undefined
});
});
return rules;
}
function saveAutoRules() {
if (!autoEditCard) return;
autoEditCard.rules = collectAutoRules().filter(r => r.target); // remove empty rows
closePopup();
renderCards();
}
2026-04-08 11:58:50 +10:00
// ══════════════════════════════════════════════════════
// DRAG & DROP
// ══════════════════════════════════════════════════════
function initSortable() {
if (sortableInstance) sortableInstance.destroy();
const grid = document.getElementById('card-grid');
sortableInstance = new Sortable(grid, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'sortable-ghost',
onEnd: (evt) => {
const page = layout.pages[currentPage];
const [moved] = page.cards.splice(evt.oldIndex, 1);
page.cards.splice(evt.newIndex, 0, moved);
}
});
}
// ══════════════════════════════════════════════════════
// CARD ACTIONS
// ══════════════════════════════════════════════════════
function addCard(type) {
2026-04-09 13:14:16 +10:00
// Close any open add menus
document.querySelectorAll('.add-menu.open').forEach(m => m.classList.remove('open'));
2026-04-08 11:58:50 +10:00
const id = type + '_' + Date.now();
const defaults = {
temp: {label: 'New Temp', color: '#ff8844', sp_default: 70},
motor: {label: 'New Motor', sp_default: 50},
output: {label: 'New Output'},
2026-04-09 13:14:16 +10:00
burner: {label: 'Burner'},
automation: {label: 'New Automation', width: 'full', rules: []}
2026-04-08 11:58:50 +10:00
};
2026-04-09 13:14:16 +10:00
const card = {id, type, width: defaults[type]?.width || 'half', ...defaults[type]};
2026-04-08 11:58:50 +10:00
layout.pages[currentPage].cards.push(card);
simState[id] = {on: false, value: card.sp_default || 0};
renderCards();
}
function deleteCard(id) {
if (!confirm('Delete this control?')) return;
const page = layout.pages[currentPage];
page.cards = page.cards.filter(c => c.id !== id);
layout.comments = (layout.comments || []).filter(c => c.target !== id);
renderCards();
}
2026-04-09 13:14:16 +10:00
function editCardProps(id) {
const card = findCard(id);
if (!card) return;
const p = document.getElementById('popup');
p.style.borderColor = 'transparent';
let typeOpts = ['temp','motor','output','burner','automation'].map(t =>
`< option value = "${t}" $ { card . type = ==t?'selected':''} > ${t.charAt(0).toUpperCase()+t.slice(1)}< / option > `
).join('');
2026-04-09 13:42:21 +10:00
// Build Modbus address options
const modbusRegOpts = [
{label:'-- None --', value:''},
{label:'%MW0 - Heat Input Temp', value:'MW0'},
{label:'%MW1 - Product 1 Temp', value:'MW1'},
{label:'%MW2 - Product 2 Temp', value:'MW2'},
{label:'%MW3 - Exhaust Temp', value:'MW3'},
{label:'%MW10 - Heat SP', value:'MW10'},
{label:'%MW11 - Prod1 SP', value:'MW11'},
{label:'%MW12 - Prod2 SP', value:'MW12'},
{label:'%MW13 - Exhaust SP', value:'MW13'},
{label:'%MW20 - Fan Speed', value:'MW20'},
{label:'%MW21 - Conveyor Speed', value:'MW21'},
{label:'%MW22 - Agitator 1 Speed', value:'MW22'},
{label:'%MW23 - Agitator 2 Speed', value:'MW23'},
{label:'%MW24 - Spinner Speed', value:'MW24'},
{label:'%MW25 - Heater Stage', value:'MW25'},
{label:'%M1 - Discharge Agitator', value:'M1'},
{label:'%M2 - Brush', value:'M2'},
{label:'%M3 - Loading Conveyor', value:'M3'},
{label:'%M4 - Discharge Conveyor', value:'M4'},
{label:'%M5 - Mill', value:'M5'},
{label:'%M6 - Shaker Separator', value:'M6'},
{label:'Custom Register', value:'custom'},
].map(o => `< option value = "${o.value}" $ { card . modbusAddr = ==o.value?'selected':''} > ${o.label}< / option > `).join('');
let extraFields = `
< div class = "prop-row" >
< label > PLC Address< / label >
< select id = "prop-modbus" > ${modbusRegOpts}< / select >
< / div > `;
2026-04-09 13:14:16 +10:00
if (card.type === 'temp') {
2026-04-09 13:42:21 +10:00
extraFields += `
2026-04-09 13:14:16 +10:00
< div class = "prop-row" > < label > Setpoint< / label > < input type = "number" id = "prop-sp" value = "${card.sp_default||70}" min = "0" max = "200" > < span style = "color:var(--text2)" > ° C< / span > < / div > `;
} else if (card.type === 'motor') {
2026-04-09 13:42:21 +10:00
extraFields += `
2026-04-09 13:14:16 +10:00
< div class = "prop-row" > < label > Speed SP< / label > < input type = "number" id = "prop-sp" value = "${card.sp_default||50}" min = "0" max = "100" step = "5" > < span style = "color:var(--text2)" > %< / span > < / div > `;
2026-04-09 13:42:21 +10:00
} else if (card.type === 'output' || card.type === 'burner') {
extraFields += `
< div class = "prop-row" > < label > ON Value< / label > < input type = "number" id = "prop-onval" value = "${card.onValue!==undefined?card.onValue:1}" min = "0" max = "65535" > < / div >
< div class = "prop-row" > < label > OFF Value< / label > < input type = "number" id = "prop-offval" value = "${card.offValue!==undefined?card.offValue:0}" min = "0" max = "65535" > < / div > `;
2026-04-09 13:14:16 +10:00
}
p.innerHTML = `
< div class = "props-editor" >
< h3 > Edit Card< / h3 >
< div class = "prop-row" >
< label > Name< / label >
< input type = "text" id = "prop-name" value = "${card.label}" >
< / div >
< div class = "prop-row" >
< label > Type< / label >
< select id = "prop-type" > ${typeOpts}< / select >
< / div >
< div class = "prop-row" >
< label > Size< / label >
< select id = "prop-width" >
< option value = "half" $ { card . width ! = = ' full ' ? ' selected ' : ' ' } > Half width< / option >
< option value = "full" $ { card . width = =='full'?'selected':''} > Full width< / option >
< / select >
< / div >
< div class = "prop-row" >
2026-04-09 13:32:17 +10:00
< label > Text Color< / label >
< input type = "color" id = "prop-textcolor" value = "${card.textColor||'#e8ecf4'}" >
< span class = "prop-preview" style = "background:${card.textColor||'#e8ecf4'}" > < / span >
< / div >
< div class = "prop-row" >
< label > ON Color< / label >
< input type = "color" id = "prop-activecolor" value = "${card.activeColor||(card.type==='burner'?'#ffab00':'#00c853')}" >
< span class = "prop-preview" style = "background:${card.activeColor||(card.type==='burner'?'#ffab00':'#00c853')}" > < / span >
< / div >
< div class = "prop-row" >
< label > OFF Color< / label >
< input type = "color" id = "prop-inactivecolor" value = "${card.inactiveColor||'#ff1744'}" >
< span class = "prop-preview" style = "background:${card.inactiveColor||'#ff1744'}" > < / span >
< / div >
< div class = "prop-row" >
< label > Accent Color< / label >
2026-04-09 13:14:16 +10:00
< input type = "color" id = "prop-color" value = "${card.color||'#ff8844'}" >
< span class = "prop-preview" id = "prop-color-preview" style = "background:${card.color||'#ff8844'}" > < / span >
< / div >
< div class = "prop-row" >
< label > Font Size< / label >
2026-04-09 13:32:17 +10:00
< input type = "range" id = "prop-fontsize" min = "12" max = "48" value = "${card.fontSize||18}" >
2026-04-09 13:14:16 +10:00
< span id = "prop-fontsize-val" style = "color:var(--text2);min-width:30px" > ${card.fontSize||18}px< / span >
< / div >
${extraFields}
< div class = "auto-btns" >
< button class = "cancel-btn" onclick = "closePopup()" > Cancel< / button >
< button class = "save-btn" onclick = "saveCardProps('${id}')" > Save< / button >
< / div >
< / div > `;
// Live preview for color picker
document.getElementById('prop-color').addEventListener('input', e => {
document.getElementById('prop-color-preview').style.background = e.target.value;
});
document.getElementById('prop-fontsize').addEventListener('input', e => {
document.getElementById('prop-fontsize-val').textContent = e.target.value + 'px';
});
document.getElementById('popup-overlay').classList.add('active');
}
function saveCardProps(id) {
const card = findCard(id);
if (!card) return;
card.label = document.getElementById('prop-name').value || card.label;
card.type = document.getElementById('prop-type').value;
card.width = document.getElementById('prop-width').value;
const color = document.getElementById('prop-color').value;
card.color = color || null;
2026-04-09 13:32:17 +10:00
card.textColor = document.getElementById('prop-textcolor').value || null;
card.activeColor = document.getElementById('prop-activecolor').value || null;
card.inactiveColor = document.getElementById('prop-inactivecolor').value || null;
2026-04-09 13:14:16 +10:00
card.fontSize = parseInt(document.getElementById('prop-fontsize').value) || 18;
2026-04-09 13:42:21 +10:00
card.modbusAddr = document.getElementById('prop-modbus').value || null;
2026-04-09 13:14:16 +10:00
const spEl = document.getElementById('prop-sp');
if (spEl) card.sp_default = parseInt(spEl.value) || 0;
2026-04-09 13:42:21 +10:00
const onEl = document.getElementById('prop-onval');
if (onEl) card.onValue = parseInt(onEl.value);
const offEl = document.getElementById('prop-offval');
if (offEl) card.offValue = parseInt(offEl.value);
2026-04-09 13:14:16 +10:00
closePopup();
renderCards();
}
function toggleCardSize(id) {
2026-04-08 16:28:06 +10:00
const page = layout.pages[currentPage];
const card = page.cards.find(c => c.id === id);
if (!card) return;
2026-04-09 13:14:16 +10:00
card.width = card.width === 'full' ? 'half' : 'full';
renderCards();
2026-04-08 16:28:06 +10:00
}
2026-04-08 11:58:50 +10:00
// ══════════════════════════════════════════════════════
// SIMULATION (PREVIEW MODE)
// ══════════════════════════════════════════════════════
function toggleSim(id) {
if (!simState[id]) simState[id] = {on: false, value: 0};
simState[id].on = !simState[id].on;
2026-04-09 13:14:16 +10:00
// Burner turns on Hot Fan automatically
const card = findCard(id);
if (card & & card.type === 'burner') {
const fanCard = findCardByLabel('Hot Fan');
if (fanCard) {
if (!simState[fanCard.id]) simState[fanCard.id] = {on: false, value: fanCard.sp_default || 50};
simState[fanCard.id].on = simState[id].on;
}
}
2026-04-10 06:46:00 +10:00
// Automation logic:
// - Multiple automations can be ON at the same time
// - Turning ON applies this automation's rules
// - Turning OFF only removes outputs that no OTHER active automation also needs
if (card & & card.type === 'automation' & & card.rules) {
if (simState[id].on) {
// Activating: just apply this automation's rules on top of whatever is already on
2026-04-09 13:32:17 +10:00
card.rules.forEach(r => {
if (!r.target) return;
if (!simState[r.target]) simState[r.target] = {on: false, value: 0};
2026-04-09 13:14:16 +10:00
if (r.action === 'on') simState[r.target].on = true;
else if (r.action === 'off') simState[r.target].on = false;
else if (r.action === 'set') { simState[r.target].on = true; simState[r.target].value = r.value || 0; }
2026-04-09 13:32:17 +10:00
});
2026-04-10 06:46:00 +10:00
} else {
// Deactivating: collect all targets that OTHER active automations still need
const keepOn = new Set();
2026-04-09 13:32:17 +10:00
layout.pages.forEach(p => p.cards.forEach(c => {
if (c.type === 'automation' & & c.id !== id & & simState[c.id] & & simState[c.id].on) {
2026-04-10 06:46:00 +10:00
(c.rules || []).forEach(r => {
if (r.target & & (r.action === 'on' || r.action === 'set')) keepOn.add(r.target);
});
2026-04-09 13:32:17 +10:00
}
}));
2026-04-10 06:46:00 +10:00
// Only turn off outputs unique to this automation
2026-04-09 13:32:17 +10:00
card.rules.forEach(r => {
2026-04-10 06:46:00 +10:00
if (r.target & & !keepOn.has(r.target) & & r.action !== 'off') {
2026-04-09 13:32:17 +10:00
if (simState[r.target]) simState[r.target].on = false;
}
});
}
2026-04-09 13:14:16 +10:00
}
2026-04-08 11:58:50 +10:00
renderCards();
updateSchematicAnimations();
}
function openTempPopup(c) {
const p = document.getElementById('popup');
const s = simState[c.id] || {value: 70};
p.style.borderColor = c.color || 'var(--blue)';
p.innerHTML = `
< div class = "title" > ${c.label}< / div >
< div class = "live" style = "color:${c.color || 'var(--twarm)'}" > ${Math.round(s.value)} ° C< / div >
< div style = "font-size:14px;color:var(--dim)" > SETPOINT< / div >
< div class = "sp-row" >
< button class = "sp-btn" onclick = "adjTempSP('${c.id}',-1)" > -< / button >
< div class = "sp-val" id = "sp-pv" > ${c.sp_default || 70}< / div >
< button class = "sp-btn" onclick = "adjTempSP('${c.id}',1)" > +< / button >
< / div >
< button class = "close-btn" onclick = "closePopup()" > CLOSE< / button > `;
document.getElementById('popup-overlay').classList.add('active');
}
function adjTempSP(id, d) {
const card = findCard(id);
if (!card) return;
card.sp_default = Math.max(0, Math.min(200, (card.sp_default || 70) + d));
document.getElementById('sp-pv').textContent = card.sp_default;
if (simState[id]) simState[id].value = card.sp_default;
renderCards();
}
function closePopup() { document.getElementById('popup-overlay').classList.remove('active'); }
function findCard(id) {
for (const p of layout.pages) { const c = p.cards.find(x => x.id === id); if (c) return c; }
return null;
}
2026-04-09 13:14:16 +10:00
function findCardByLabel(label) {
for (const p of layout.pages) { const c = p.cards.find(x => x.label === label); if (c) return c; }
return null;
}
2026-04-08 11:58:50 +10:00
// ══════════════════════════════════════════════════════
// COMMENTS
// ══════════════════════════════════════════════════════
function openComments(target, name) {
commentTarget = target;
document.getElementById('comment-target-name').textContent = 'Comments: ' + name;
const list = document.getElementById('comment-list');
const comments = (layout.comments || []).filter(c => c.target === target);
list.innerHTML = comments.map(c => `
< div class = "comment-item" >
< span class = "comment-user" > ${c.user}< / span >
< span class = "comment-time" > ${c.time}< / span >
< div class = "comment-text" > ${c.text}< / div >
< / div >
`).join('') || '< p style = "color:var(--dim);font-size:13px" > No comments yet.< / p > ';
document.getElementById('comment-panel').classList.add('open');
document.getElementById('comment-text').focus();
}
function closeComments() { document.getElementById('comment-panel').classList.remove('open'); commentTarget = null; }
async function submitComment() {
const text = document.getElementById('comment-text').value.trim();
if (!text || !commentTarget) return;
2026-04-08 12:10:57 +10:00
const resp = await fetch('api/comment', {
2026-04-08 11:58:50 +10:00
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({target: commentTarget, user: currentUser, text})
});
const comment = await resp.json();
layout.comments.push(comment);
document.getElementById('comment-text').value = '';
openComments(commentTarget, document.getElementById('comment-target-name').textContent.replace('Comments: ', ''));
renderCards(); renderTabs();
}
// ══════════════════════════════════════════════════════
// SAVE / EXPORT
// ══════════════════════════════════════════════════════
async function saveLayout() {
2026-04-08 12:10:57 +10:00
await fetch('api/layout', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(layout)});
2026-04-08 11:58:50 +10:00
alert('Layout saved!');
}
function exportLayout() {
const blob = new Blob([JSON.stringify(layout, null, 2)], {type: 'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'bfa-dryer-layout.json'; a.click();
}
function toggleHelp() { document.getElementById('help-panel').classList.toggle('open'); }
// ══════════════════════════════════════════════════════
2026-04-08 12:14:20 +10:00
// MACHINE SCHEMATIC — data-driven, editable
2026-04-08 11:58:50 +10:00
// ══════════════════════════════════════════════════════
2026-04-08 12:14:20 +10:00
const defaultEquipment = [
{id:'eq_loading', label:'Loading', shape:'rect', x:40, y:160, w:90, h:80, anim:null, link:null},
{id:'eq_drum', label:'Dryer Drum', shape:'rect', x:200, y:130, w:140, h:140, anim:null, link:null},
{id:'eq_burner', label:'Burner', shape:'circle', x:200, y:200, r:20, anim:'burner', link:'burner_0'},
{id:'eq_fan', label:'Fan', shape:'circle', x:340, y:150, r:22, anim:'fan', link:'motor_0'},
{id:'eq_conveyor', label:'Conveyor', shape:'rect', x:410, y:180, w:130, h:40, anim:'conveyor', link:'motor_1'},
{id:'eq_agit1', label:'A1', shape:'circle', x:440, y:170, r:12, anim:'agitator', link:'motor_3'},
{id:'eq_agit2', label:'A2', shape:'circle', x:510, y:170, r:12, anim:'agitator', link:'motor_4'},
{id:'eq_spinner', label:'Spin', shape:'circle', x:475, y:155, r:14, anim:'fan', link:'motor_2'},
2026-04-08 12:57:07 +10:00
{id:'eq_discharge',label:'Discharge', shape:'rect', x:580, y:160, w:80, h:60, anim:'flow', link:'output_0'},
{id:'eq_mill', label:'Mill', shape:'rect', x:580, y:250, w:70, h:50, anim:'vibrate', link:'output_4'},
{id:'eq_shaker', label:'Shaker Sep.',shape:'rect', x:670, y:250, w:90, h:50, anim:'vibrate', link:'output_5'},
{id:'eq_brush', label:'Brush', shape:'rect', x:670, y:160, w:60, h:40, anim:'glow', link:'output_1'},
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
{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},
2026-04-08 12:14:20 +10:00
];
let simEditMode = false;
let dragEquip = null, dragOffset = {x:0, y:0};
2026-04-08 12:18:37 +10:00
let selectedEquip = null;
let resizeHandle = null; // 'w','h','r','rot'
2026-04-08 12:14:20 +10:00
function getEquipment() {
return layout.equipment || JSON.parse(JSON.stringify(defaultEquipment));
}
function toggleSimEdit() {
simEditMode = !simEditMode;
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
selectedEquip = null;
2026-04-08 12:14:20 +10:00
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)';
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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;
}
2026-04-09 13:14:16 +10:00
document.addEventListener('click', e => {
if (!e.target.closest('#eq-context')) hideCtx();
if (!e.target.closest('.add-placeholder')) document.querySelectorAll('.add-menu.open').forEach(m => m.classList.remove('open'));
});
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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') {
2026-04-08 12:52:45 +10:00
const anims = ['none', 'fan', 'agitator', 'burner', 'conveyor', 'pulse', 'blink', 'vibrate', 'glow', 'flow'];
const choice = prompt('Animation — type one of:\n none, fan, agitator, burner, conveyor,\n pulse, blink, vibrate, glow, flow\n\nCurrent: ' + (eq.anim || 'none'), eq.anim || 'none');
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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;
2026-04-08 12:14:20 +10:00
renderSchematic();
}
2026-04-08 12:18:37 +10:00
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);
}
2026-04-08 11:58:50 +10:00
function renderSchematic() {
2026-04-08 12:14:20 +10:00
const equip = getEquipment();
2026-04-08 12:18:37 +10:00
const container = document.getElementById('sim-container');
container.innerHTML = '';
2026-04-08 12:14:20 +10:00
2026-04-08 12:18:37 +10:00
const svg = mkSvgEl('svg', {'class':'schematic', viewBox:'0 0 800 400', id:'sim-svg'});
2026-04-08 12:14:20 +10:00
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 > `;
2026-04-08 12:18:37 +10:00
container.appendChild(svg);
2026-04-08 12:14:20 +10:00
equip.forEach(eq => {
2026-04-08 12:18:37 +10:00
if (eq.rot === undefined) eq.rot = 0;
const g = mkSvgEl('g', {'data-eq-id': eq.id});
const isSel = selectedEquip & & selectedEquip.id === eq.id;
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
const col = eq.color || null;
const editStroke = simEditMode ? {stroke: isSel?'var(--blue)':'var(--amber)', 'stroke-width': isSel?'3':'2', 'stroke-dasharray': isSel?'':'4 4'} : {};
2026-04-08 12:18:37 +10:00
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
// Rotation center
2026-04-08 12:18:37 +10:00
let cx, cy;
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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; }
2026-04-08 12:18:37 +10:00
if (eq.rot) g.setAttribute('transform', `rotate(${eq.rot} ${cx} ${cy})`);
2026-04-08 12:14:20 +10:00
g.style.cursor = simEditMode ? 'grab' : 'default';
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
// ── RECT ──
2026-04-08 12:14:20 +10:00
if (eq.shape === 'rect') {
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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}:{})});
2026-04-08 12:18:37 +10:00
t.textContent = eq.label; g.appendChild(t);
2026-04-08 12:52:45 +10:00
// Static OFF ring for linked elements
if (eq.link) {
g.appendChild(mkSvgEl('rect', {'class':'static-off-ring', id:'off-'+eq.id,
x:eq.x-1, y:eq.y-1, width:w+2, height:h+2, rx:'7', fill:'none'}));
}
// Animation overlay (only visible when ON)
const ac = col||'var(--green)';
2026-04-08 12:14:20 +10:00
if (eq.anim === 'conveyor') {
2026-04-08 12:18:37 +10:00
g.appendChild(mkSvgEl('line', {'class':'anim-overlay conveyor-anim', id:'sim-'+eq.id,
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
x1:eq.x+10, y1:eq.y+h-5, x2:eq.x+w-10, y2:eq.y+h-5,
2026-04-08 12:52:45 +10:00
stroke:ac, 'stroke-width':'3', 'stroke-dasharray':'10 10'}));
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
} else if (eq.anim === 'pulse') {
g.appendChild(mkSvgEl('rect', {'class':'anim-overlay pulse-anim', id:'sim-'+eq.id,
2026-04-08 12:52:45 +10:00
x:eq.x, y:eq.y, width:w, height:h, rx:'6', fill:'none', stroke:ac, 'stroke-width':'3'}));
} else if (eq.anim === 'blink') {
g.appendChild(mkSvgEl('rect', {'class':'anim-overlay blink-anim', id:'sim-'+eq.id,
x:eq.x, y:eq.y, width:w, height:h, rx:'6', fill:ac, 'fill-opacity':'0.3'}));
} else if (eq.anim === 'vibrate') {
g.appendChild(mkSvgEl('rect', {'class':'anim-overlay vibrate-anim', id:'sim-'+eq.id,
x:eq.x+2, y:eq.y+2, width:w-4, height:h-4, rx:'4', fill:'none', stroke:ac, 'stroke-width':'2'}));
} else if (eq.anim === 'glow') {
g.appendChild(mkSvgEl('rect', {'class':'anim-overlay glow-anim', id:'sim-'+eq.id,
x:eq.x-2, y:eq.y-2, width:w+4, height:h+4, rx:'8', fill:'none', stroke:ac, 'stroke-width':'3', color:ac}));
} else if (eq.anim === 'flow') {
g.appendChild(mkSvgEl('rect', {'class':'anim-overlay flow-anim', id:'sim-'+eq.id,
x:eq.x, y:eq.y, width:w, height:h, rx:'6', fill:'none', stroke:ac, 'stroke-width':'2', 'stroke-dasharray':'8 8'}));
2026-04-08 12:14:20 +10:00
}
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
// ── 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 } : { } ) } ) ;
2026-04-08 12:18:37 +10:00
t.textContent = eq.label; g.appendChild(t);
2026-04-08 12:52:45 +10:00
// Static OFF ring
if (eq.link) {
g.appendChild(mkSvgEl('circle', {'class':'static-off-ring', id:'off-'+eq.id,
cx:eq.x, cy:eq.y, r:r+3}));
}
const cc = col||'var(--green)';
2026-04-08 12:14:20 +10:00
if (eq.anim === 'burner') {
2026-04-08 19:25:32 +10:00
// Flame SVG with flicker animation
const fg = mkSvgEl('g', {'class':'anim-overlay', id:'sim-'+eq.id});
fg.setAttribute('transform', `translate(${eq.x},${eq.y})`);
// Outer flame (orange)
fg.appendChild(mkSvgEl('path', {
d:`M 0 ${-r*0.3} Q ${-r*0.5} ${-r*0.8} ${-r*0.2} ${-r*1.2} Q 0 ${-r*1.5} ${r*0.2} ${-r*1.2} Q ${r*0.5} ${-r*0.8} 0 ${-r*0.3} Z`,
fill:'var(--amber)', opacity:'0.8', 'class':'burner-glow'
}));
// Inner flame (red-orange)
fg.appendChild(mkSvgEl('path', {
d:`M 0 ${-r*0.2} Q ${-r*0.25} ${-r*0.5} ${-r*0.1} ${-r*0.85} Q 0 ${-r*1.05} ${r*0.1} ${-r*0.85} Q ${r*0.25} ${-r*0.5} 0 ${-r*0.2} Z`,
fill:'var(--red)', opacity:'0.9', 'class':'burner-glow', style:'animation-delay:0.2s'
}));
// Core (bright)
fg.appendChild(mkSvgEl('ellipse', {
cx:0, cy: -r*0.4, rx: r*0.12, ry: r*0.25,
fill:'#ffdd44', opacity:'0.9', 'class':'burner-glow', style:'animation-delay:0.4s'
}));
g.appendChild(fg);
2026-04-08 12:57:07 +10:00
} else if (eq.anim === 'fan') {
// Fan: 4 curved blades spinning inside the circle
const ag = mkSvgEl('g', {'class':'anim-overlay', id:'sim-'+eq.id});
const inner = mkSvgEl('g', {'class':'fan-anim', style:`transform-origin:0 0`});
const fc = col||'var(--blue)';
const b = r * 0.8;
// 4 fan blades as arcs
for (let i = 0; i < 4 ; i + + ) {
const a = (i * 90) * Math.PI / 180;
const x1 = Math.cos(a) * b * 0.2, y1 = Math.sin(a) * b * 0.2;
const x2 = Math.cos(a) * b, y2 = Math.sin(a) * b;
const cx1 = Math.cos(a + 0.6) * b * 0.6, cy1 = Math.sin(a + 0.6) * b * 0.6;
inner.appendChild(mkSvgEl('path', {
d: `M ${x1} ${y1} Q ${cx1} ${cy1} ${x2} ${y2}`,
fill:'none', stroke:fc, 'stroke-width':'3', 'stroke-linecap':'round'
}));
}
// Center dot
inner.appendChild(mkSvgEl('circle', {cx:0, cy:0, r:r*0.15, fill:fc}));
ag.setAttribute('transform', `translate(${eq.x},${eq.y})`);
ag.appendChild(inner);
g.appendChild(ag);
} else if (eq.anim === 'agitator') {
// Agitator: 3 flat paddles spinning slower
const ag = mkSvgEl('g', {'class':'anim-overlay', id:'sim-'+eq.id});
const inner = mkSvgEl('g', {'class':'agitator-anim', style:`transform-origin:0 0`});
const ac = col||'var(--amber)';
const pr = r * 0.75;
// 3 rectangular paddles
for (let i = 0; i < 3 ; i + + ) {
const a = (i * 120) * Math.PI / 180;
const x1 = Math.cos(a) * pr, y1 = Math.sin(a) * pr;
// Paddle: thick line from center to edge
inner.appendChild(mkSvgEl('line', {
x1: 0, y1: 0, x2: x1, y2: y1,
stroke:ac, 'stroke-width':'4', 'stroke-linecap':'round'
}));
// Paddle head: small rect at end
inner.appendChild(mkSvgEl('rect', {
x: x1 - 3, y: y1 - 5, width: 6, height: 10,
fill:ac, rx:'2',
transform: `rotate(${i*120} ${x1} ${y1})`
}));
}
// Center hub
inner.appendChild(mkSvgEl('circle', {cx:0, cy:0, r:r*0.2, fill:ac, stroke:ac, 'stroke-width':'1'}));
ag.setAttribute('transform', `translate(${eq.x},${eq.y})`);
ag.appendChild(inner);
2026-04-08 12:14:20 +10:00
g.appendChild(ag);
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
} else if (eq.anim === 'pulse') {
g.appendChild(mkSvgEl('circle', {'class':'anim-overlay pulse-anim', id:'sim-'+eq.id,
2026-04-08 12:52:45 +10:00
cx:eq.x, cy:eq.y, r:r+3, fill:'none', stroke:cc, 'stroke-width':'2'}));
} else if (eq.anim === 'blink') {
g.appendChild(mkSvgEl('circle', {'class':'anim-overlay blink-anim', id:'sim-'+eq.id,
cx:eq.x, cy:eq.y, r:r, fill:cc, 'fill-opacity':'0.3'}));
} else if (eq.anim === 'vibrate') {
g.appendChild(mkSvgEl('circle', {'class':'anim-overlay vibrate-anim', id:'sim-'+eq.id,
cx:eq.x, cy:eq.y, r:r-2, fill:'none', stroke:cc, 'stroke-width':'2'}));
} else if (eq.anim === 'glow') {
g.appendChild(mkSvgEl('circle', {'class':'anim-overlay glow-anim', id:'sim-'+eq.id,
cx:eq.x, cy:eq.y, r:r+4, fill:'none', stroke:cc, 'stroke-width':'3', color:cc}));
} else if (eq.anim === 'flow') {
g.appendChild(mkSvgEl('circle', {'class':'anim-overlay flow-anim', id:'sim-'+eq.id,
cx:eq.x, cy:eq.y, r:r, fill:'none', stroke:cc, 'stroke-width':'2', 'stroke-dasharray':'6 6'}));
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
}
// ── 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}));
2026-04-08 12:14:20 +10:00
}
}
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
2026-04-08 12:14:20 +10:00
svg.appendChild(g);
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
// Edit mode events
2026-04-08 12:14:20 +10:00
if (simEditMode) {
2026-04-08 12:18:37 +10:00
g.addEventListener('mousedown', e => {
if (resizeHandle) return;
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
hideCtx();
2026-04-08 12:18:37 +10:00
selectedEquip = eq;
dragEquip = eq;
const p = svgPt(e);
dragOffset = {x: p.x - eq.x, y: p.y - eq.y};
e.preventDefault();
renderSchematic();
});
2026-04-08 12:14:20 +10:00
g.addEventListener('dblclick', () => renameEquip(eq));
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
g.addEventListener('contextmenu', e => showCtx(e, eq));
2026-04-08 12:14:20 +10:00
}
});
2026-04-08 12:18:37 +10:00
// Draw selection handles for selected equipment
if (simEditMode & & selectedEquip) {
const eq = selectedEquip;
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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);
};
2026-04-08 12:18:37 +10:00
if (eq.shape === 'rect') {
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
const w=eq.w||80, h=eq.h||60;
2026-04-08 12:18:37 +10:00
handle(svg, eq.x+w, eq.y+h/2, 'w', eq, 'ew-resize');
handle(svg, eq.x+w/2, eq.y+h, 'h', eq, 'ns-resize');
handle(svg, eq.x+w, eq.y+h, 'wh', eq, 'nwse-resize');
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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;
2026-04-08 12:18:37 +10:00
handle(svg, eq.x+r, eq.y, 'r', eq, 'ew-resize');
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
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);
2026-04-08 12:18:37 +10:00
}
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) <noreply@anthropic.com>
2026-04-08 12:28:24 +10:00
// Labels: no resize handles, just move + rotate not needed
2026-04-08 12:14:20 +10:00
}
2026-04-08 12:18:37 +10:00
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(); } });
}
2026-04-08 12:14:20 +10:00
updateSchematicAnimations();
}
2026-04-08 12:18:37 +10:00
function onSvgMouseMove(e) {
2026-04-08 12:14:20 +10:00
if (!dragEquip) return;
2026-04-08 12:18:37 +10:00
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();
}
2026-04-08 12:14:20 +10:00
}
2026-04-08 12:18:37 +10:00
function onSvgMouseUp() { dragEquip = null; resizeHandle = null; }
2026-04-08 12:14:20 +10:00
function renameEquip(eq) {
const name = prompt('Rename equipment:', eq.label);
if (!name) return;
eq.label = name;
2026-04-08 12:18:37 +10:00
saveEquip(eq);
2026-04-08 12:14:20 +10:00
renderSchematic();
2026-04-08 11:58:50 +10:00
}
function updateSchematicAnimations() {
2026-04-08 12:14:20 +10:00
const equip = getEquipment();
equip.forEach(eq => {
2026-04-08 12:52:45 +10:00
if (!eq.link) return;
2026-04-08 12:14:20 +10:00
const s = simState[eq.link];
2026-04-08 12:52:45 +10:00
const isOn = s & & s.on;
// Update the animation overlay
const animEl = document.getElementById('sim-' + eq.id);
if (animEl) animEl.classList.toggle('active', isOn);
// Update the main shape — show red when OFF, normal when ON
const g = document.querySelector(`[data-eq-id="${eq.id}"]`);
if (!g) return;
const shape = g.querySelector('.equip');
if (!shape) return;
// Static OFF indicator (red tint)
const offRing = document.getElementById('off-' + eq.id);
if (!simEditMode) {
if (isOn) {
shape.classList.remove('equip-off');
shape.classList.add('equip-on');
if (offRing) offRing.style.display = 'none';
} else {
shape.classList.add('equip-off');
shape.classList.remove('equip-on');
if (offRing) offRing.style.display = '';
}
}
2026-04-08 11:58:50 +10:00
});
}
// ══════════════════════════════════════════════════════
// BOOT
// ══════════════════════════════════════════════════════
init();
< / script >
< / body >
< / html >