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-08 12:10:57 +10:00
< script src = "static/sortable.min.js" > < / 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)}
.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 */
.add-palette{display:none;padding:8px;gap:8px;border-top:1px solid var(--border);flex-shrink:0}
.edit-mode .add-palette{display:flex}
.add-palette button{flex:1;padding:10px;border:2px dashed var(--dim);border-radius:8px;background:none;color:var(--text2);font-size:13px;cursor:pointer}
.add-palette button:hover{border-color:var(--blue);color:var(--blue)}
/* 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 */
@keyframes spin{100%{transform-origin:center;transform:rotate(360deg)}}
@keyframes pulse-glow{0%,100%{opacity:0.3}50%{opacity:1}}
@keyframes scroll-belt{100%{stroke-dashoffset:-20}}
@keyframes fall-particles{0%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(30px)}}
.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}
/* 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 */
.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 */
#popup-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:none;align-items:center;justify-content:center;z-index:50}
#popup-overlay.active{display:flex}
.popup{background:var(--card);border:3px solid var(--blue);border-radius:16px;padding:24px;width:500px;display:flex;flex-direction:column;align-items:center;gap:16px}
.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}
< / 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" >
< select id = "user-select" > < / select >
< button onclick = "addUser()" > + Add Person< / button >
< 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 >
< div class = "add-palette" id = "add-palette" >
< button onclick = "addCard('temp')" > + Temperature< / button >
< button onclick = "addCard('motor')" > + Motor< / button >
< button onclick = "addCard('output')" > + Output< / button >
< button onclick = "addCard('burner')" > + Burner< / button >
< / div >
< / 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 >
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'
let currentUser = localStorage.getItem('bfa-user') || '';
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();
}
function initUsers() {
const sel = document.getElementById('user-select');
sel.innerHTML = '';
(layout.users || []).forEach(u => {
const o = document.createElement('option');
o.value = u; o.textContent = u;
if (u === currentUser) o.selected = true;
sel.appendChild(o);
});
sel.onchange = () => { currentUser = sel.value; localStorage.setItem('bfa-user', currentUser); };
}
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};
}));
}
async function addUser() {
const name = prompt('Enter name:');
if (!name) return;
2026-04-08 12:10:57 +10:00
await fetch('api/users', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name})});
2026-04-08 11:58:50 +10:00
layout.users.push(name);
currentUser = name;
localStorage.setItem('bfa-user', name);
initUsers();
}
// ══════════════════════════════════════════════════════
// 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');
document.getElementById('add-palette').style.display = m === 'edit' ? 'flex' : 'none';
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);
});
if (mode === 'edit') initSortable();
}
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');
// 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" >
< 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 > `;
if (c.type === 'temp') {
const color = c.color || '#ff8844';
el.innerHTML += `< div class = "card-label" onclick = "if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}" > ${c.label}< / div > `;
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" >
< span class = "card-label" onclick = "if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}" > ${c.label}< / span >
< 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') {
el.innerHTML += `< div class = "card-label" onclick = "if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}" > ${c.label}< / div > `;
el.innerHTML += `< div class = "card-value" > ${s.on ? 'ON' : 'OFF'}< / div > `;
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
} else { // output
el.innerHTML += `< div class = "card-label" onclick = "if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}" style = "text-align:center" > ${c.label}< / div > `;
el.innerHTML += `< div class = "card-value" > ${s.on ? 'ON' : 'OFF'}< / div > `;
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
}
return el;
}
// ══════════════════════════════════════════════════════
// 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) {
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'},
burner: {label: 'Burner'}
};
const card = {id, type, width: 'half', ...defaults[type]};
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();
}
function renameCard(id) {
const page = layout.pages[currentPage];
const card = page.cards.find(c => c.id === id);
if (!card) return;
const name = prompt('New name:', card.label);
if (name) { card.label = name; renderCards(); }
}
// ══════════════════════════════════════════════════════
// SIMULATION (PREVIEW MODE)
// ══════════════════════════════════════════════════════
function toggleSim(id) {
if (!simState[id]) simState[id] = {on: false, value: 0};
simState[id].on = !simState[id].on;
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;
}
// ══════════════════════════════════════════════════════
// 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'},
{id:'eq_discharge',label:'Discharge', shape:'rect', x:580, y:160, w:80, h:60, anim:null, link:null},
{id:'eq_mill', label:'Mill', shape:'rect', x:580, y:250, w:70, h:50, anim:null, link:'output_4'},
{id:'eq_shaker', label:'Shaker Sep.',shape:'rect', x:670, y:250, w:90, h:50, anim:null, link:'output_5'},
{id:'eq_brush', label:'Brush', shape:'rect', x:670, y:160, w:60, h:40, anim:null, link:'output_1'},
];
let simEditMode = false;
let dragEquip = null, dragOffset = {x:0, y:0};
function getEquipment() {
return layout.equipment || JSON.parse(JSON.stringify(defaultEquipment));
}
function toggleSimEdit() {
simEditMode = !simEditMode;
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)';
renderSchematic();
}
2026-04-08 11:58:50 +10:00
function renderSchematic() {
2026-04-08 12:14:20 +10:00
const equip = getEquipment();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'schematic');
svg.setAttribute('viewBox', '0 0 800 400');
svg.id = 'sim-svg';
// Defs for arrows
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 > `;
// IN/OUT labels
svg.innerHTML += `< text x = "60" y = "148" fill = "var(--dim)" font-size = "10" text-anchor = "middle" font-family = "sans-serif" > IN ▶ < / text > `;
svg.innerHTML += `< text x = "740" y = "320" fill = "var(--dim)" font-size = "10" text-anchor = "middle" font-family = "sans-serif" > ▶ OUT< / text > `;
document.getElementById('sim-container').innerHTML = '';
document.getElementById('sim-container').appendChild(svg);
equip.forEach(eq => {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.dataset.eqId = eq.id;
g.style.cursor = simEditMode ? 'grab' : 'default';
if (eq.shape === 'rect') {
const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
r.setAttribute('class', 'equip');
r.setAttribute('x', eq.x); r.setAttribute('y', eq.y);
r.setAttribute('width', eq.w); r.setAttribute('height', eq.h);
r.setAttribute('rx', '6');
g.appendChild(r);
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
t.setAttribute('class', 'equip-label');
t.setAttribute('x', eq.x + eq.w/2); t.setAttribute('y', eq.y + eq.h/2 + 5);
t.textContent = eq.label;
g.appendChild(t);
// Animation overlay
if (eq.anim === 'conveyor') {
const l = document.createElementNS('http://www.w3.org/2000/svg', 'line');
l.setAttribute('class', 'anim-overlay conveyor-anim');
l.id = 'sim-' + eq.id;
l.setAttribute('x1', eq.x+10); l.setAttribute('y1', eq.y+eq.h-5);
l.setAttribute('x2', eq.x+eq.w-10); l.setAttribute('y2', eq.y+eq.h-5);
l.setAttribute('stroke', 'var(--green)'); l.setAttribute('stroke-width', '3');
l.setAttribute('stroke-dasharray', '10 10');
g.appendChild(l);
}
} else { // circle
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('class', 'equip');
c.setAttribute('cx', eq.x); c.setAttribute('cy', eq.y); c.setAttribute('r', eq.r || 20);
g.appendChild(c);
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
t.setAttribute('class', 'equip-label');
t.setAttribute('x', eq.x); t.setAttribute('y', eq.y + 4);
t.setAttribute('font-size', eq.r < 15 ? ' 8 ' : ' 9 ' ) ;
t.textContent = eq.label;
g.appendChild(t);
// Animation overlays
if (eq.anim === 'burner') {
const ac = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
ac.setAttribute('class', 'anim-overlay burner-glow');
ac.id = 'sim-' + eq.id;
ac.setAttribute('cx', eq.x); ac.setAttribute('cy', eq.y);
ac.setAttribute('r', (eq.r||20)+4);
ac.setAttribute('fill', 'none'); ac.setAttribute('stroke', 'var(--amber)'); ac.setAttribute('stroke-width', '4');
g.appendChild(ac);
} else if (eq.anim === 'fan' || eq.anim === 'agitator') {
const ag = document.createElementNS('http://www.w3.org/2000/svg', 'g');
ag.setAttribute('class', `anim-overlay ${eq.anim === 'fan' ? 'fan-anim' : 'agitator-anim'}`);
ag.id = 'sim-' + eq.id;
ag.setAttribute('transform-origin', `${eq.x} ${eq.y}`);
const r = eq.r || 20;
const col = eq.anim === 'fan' ? 'var(--blue)' : 'var(--blue)';
ag.innerHTML = `< line x1 = "${eq.x}" y1 = "${eq.y-r}" x2 = "${eq.x}" y2 = "${eq.y+r}" stroke = "${col}" stroke-width = "2" / >
< line x1 = "${eq.x-r}" y1 = "${eq.y}" x2 = "${eq.x+r}" y2 = "${eq.y}" stroke = "${col}" stroke-width = "2" / > `;
g.appendChild(ag);
}
}
// Edit mode: border highlight
if (simEditMode) {
const outline = g.querySelector('.equip');
if (outline) { outline.setAttribute('stroke', 'var(--amber)'); outline.setAttribute('stroke-width', '2'); outline.setAttribute('stroke-dasharray', '4 4'); }
}
svg.appendChild(g);
// Drag in edit mode
if (simEditMode) {
g.addEventListener('mousedown', (e) => startDragEquip(e, eq));
g.addEventListener('dblclick', () => renameEquip(eq));
}
});
if (simEditMode) {
svg.addEventListener('mousemove', moveDragEquip);
svg.addEventListener('mouseup', stopDragEquip);
svg.addEventListener('mouseleave', stopDragEquip);
}
updateSchematicAnimations();
}
function startDragEquip(e, eq) {
if (!simEditMode) return;
dragEquip = eq;
const svg = document.getElementById('sim-svg');
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
dragOffset.x = svgPt.x - eq.x;
dragOffset.y = svgPt.y - eq.y;
e.preventDefault();
}
function moveDragEquip(e) {
if (!dragEquip) return;
const svg = document.getElementById('sim-svg');
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
dragEquip.x = Math.round(svgPt.x - dragOffset.x);
dragEquip.y = Math.round(svgPt.y - dragOffset.y);
// Save to layout
layout.equipment = getEquipment();
const eq = layout.equipment.find(e => e.id === dragEquip.id);
if (eq) { eq.x = dragEquip.x; eq.y = dragEquip.y; }
renderSchematic();
}
function stopDragEquip() { dragEquip = null; }
function renameEquip(eq) {
const name = prompt('Rename equipment:', eq.label);
if (!name) return;
eq.label = name;
if (!layout.equipment) layout.equipment = getEquipment();
const e = layout.equipment.find(x => x.id === eq.id);
if (e) e.label = name;
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 => {
if (!eq.link || !eq.anim) return;
const el = document.getElementById('sim-' + eq.id);
2026-04-08 11:58:50 +10:00
if (!el) return;
2026-04-08 12:14:20 +10:00
const s = simState[eq.link];
2026-04-08 11:58:50 +10:00
el.classList.toggle('active', s & & s.on);
});
}
// ══════════════════════════════════════════════════════
// BOOT
// ══════════════════════════════════════════════════════
init();
< / script >
< / body >
< / html >