Editable machine simulation: drag equipment, rename labels, data-driven SVG

This commit is contained in:
Richard Sauer 2026-04-08 12:14:20 +10:00
parent 5c33a29bc7
commit 5f8e7c0ffd

View File

@ -183,7 +183,10 @@ body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(
<!-- RIGHT: MACHINE SIM --> <!-- RIGHT: MACHINE SIM -->
<div class="panel"> <div class="panel">
<div class="panel-header">MACHINE SIMULATION</div> <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>
<div class="sim-container" id="sim-container"> <div class="sim-container" id="sim-container">
<!-- SVG schematic inserted by JS --> <!-- SVG schematic inserted by JS -->
</div> </div>
@ -568,112 +571,191 @@ function exportLayout() {
function toggleHelp() { document.getElementById('help-panel').classList.toggle('open'); } function toggleHelp() { document.getElementById('help-panel').classList.toggle('open'); }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// MACHINE SCHEMATIC (SVG) // MACHINE SCHEMATIC — data-driven, editable
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
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();
}
function renderSchematic() { function renderSchematic() {
document.getElementById('sim-container').innerHTML = ` const equip = getEquipment();
<svg class="schematic" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg"> const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
<defs> svg.setAttribute('class', 'schematic');
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"> svg.setAttribute('viewBox', '0 0 800 400');
<polygon points="0 0, 8 3, 0 6" fill="var(--dim)"/> svg.id = 'sim-svg';
</marker>
</defs>
<!-- Flow arrows --> // Defs for arrows
<line class="flow-arrow" x1="130" y1="200" x2="195" y2="200"/> 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>`;
<line class="flow-arrow" x1="340" y1="200" x2="405" y2="200"/>
<line class="flow-arrow" x1="540" y1="200" x2="575" y2="200"/>
<line class="flow-arrow" x1="540" y1="280" x2="575" y2="280"/>
<!-- Loading Hopper --> // IN/OUT labels
<rect class="equip" x="40" y="160" width="90" height="80" rx="6"/> svg.innerHTML += `<text x="60" y="148" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">IN &#x25B6;</text>`;
<text class="equip-label" x="85" y="205">Loading</text> svg.innerHTML += `<text x="740" y="320" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">&#x25B6; OUT</text>`;
<!-- Dryer Drum --> document.getElementById('sim-container').innerHTML = '';
<rect class="equip" x="200" y="130" width="140" height="140" rx="12"/> document.getElementById('sim-container').appendChild(svg);
<text class="equip-label" x="270" y="195">Dryer</text>
<text class="equip-label" x="270" y="215" font-size="10" fill="var(--dim)">Drum</text>
<!-- Burner (on drum) --> equip.forEach(eq => {
<circle class="equip" cx="200" cy="200" r="20"/> const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
<text class="equip-label" x="200" y="204" font-size="9">&#x1F525;</text> g.dataset.eqId = eq.id;
<circle class="anim-overlay burner-glow" id="sim-burner" cx="200" cy="200" r="24" fill="none" stroke="var(--amber)" stroke-width="4"/> g.style.cursor = simEditMode ? 'grab' : 'default';
<!-- Fan (on drum) --> if (eq.shape === 'rect') {
<circle class="equip" cx="340" cy="150" r="22"/> const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
<text class="equip-label" x="340" y="154" font-size="9">Fan</text> r.setAttribute('class', 'equip');
<g class="anim-overlay fan-anim" id="sim-fan" transform-origin="340 150"> r.setAttribute('x', eq.x); r.setAttribute('y', eq.y);
<line x1="340" y1="130" x2="340" y2="170" stroke="var(--blue)" stroke-width="3"/> r.setAttribute('width', eq.w); r.setAttribute('height', eq.h);
<line x1="320" y1="150" x2="360" y2="150" stroke="var(--blue)" stroke-width="3"/> r.setAttribute('rx', '6');
<line x1="326" y1="136" x2="354" y2="164" stroke="var(--blue)" stroke-width="3"/> g.appendChild(r);
<line x1="354" y1="136" x2="326" y2="164" stroke="var(--blue)" stroke-width="3"/>
</g>
<!-- Conveyor --> const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
<rect class="equip" x="410" y="180" width="130" height="40" rx="4"/> t.setAttribute('class', 'equip-label');
<text class="equip-label" x="475" y="205">Conveyor</text> t.setAttribute('x', eq.x + eq.w/2); t.setAttribute('y', eq.y + eq.h/2 + 5);
<line class="anim-overlay conveyor-anim" id="sim-conveyor" x1="420" y1="210" x2="530" y2="210" stroke="var(--green)" stroke-width="3" stroke-dasharray="10 10"/> t.textContent = eq.label;
g.appendChild(t);
<!-- Agitators (on conveyor) --> // Animation overlay
<circle class="equip" cx="440" cy="170" r="12"/> if (eq.anim === 'conveyor') {
<text class="equip-label" x="440" y="174" font-size="8">A1</text> const l = document.createElementNS('http://www.w3.org/2000/svg', 'line');
<g class="anim-overlay agitator-anim" id="sim-agit1" transform-origin="440 170"> l.setAttribute('class', 'anim-overlay conveyor-anim');
<line x1="440" y1="160" x2="440" y2="180" stroke="var(--blue)" stroke-width="2"/> l.id = 'sim-' + eq.id;
<line x1="430" y1="170" x2="450" y2="170" stroke="var(--blue)" stroke-width="2"/> l.setAttribute('x1', eq.x+10); l.setAttribute('y1', eq.y+eq.h-5);
</g> 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);
<circle class="equip" cx="510" cy="170" r="12"/> const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
<text class="equip-label" x="510" y="174" font-size="8">A2</text> t.setAttribute('class', 'equip-label');
<g class="anim-overlay agitator-anim" id="sim-agit2" transform-origin="510 170"> t.setAttribute('x', eq.x); t.setAttribute('y', eq.y + 4);
<line x1="510" y1="160" x2="510" y2="180" stroke="var(--blue)" stroke-width="2"/> t.setAttribute('font-size', eq.r < 15 ? '8' : '9');
<line x1="500" y1="170" x2="520" y2="170" stroke="var(--blue)" stroke-width="2"/> t.textContent = eq.label;
</g> g.appendChild(t);
<!-- Spinner --> // Animation overlays
<circle class="equip" cx="475" cy="155" r="14"/> if (eq.anim === 'burner') {
<text class="equip-label" x="475" y="159" font-size="8">Spin</text> const ac = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
<g class="anim-overlay fan-anim" id="sim-spinner" transform-origin="475 155" style="animation-duration:0.7s"> ac.setAttribute('class', 'anim-overlay burner-glow');
<line x1="475" y1="143" x2="475" y2="167" stroke="var(--green)" stroke-width="2"/> ac.id = 'sim-' + eq.id;
<line x1="463" y1="155" x2="487" y2="155" stroke="var(--green)" stroke-width="2"/> ac.setAttribute('cx', eq.x); ac.setAttribute('cy', eq.y);
</g> 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);
}
}
<!-- Discharge --> // Edit mode: border highlight
<rect class="equip" x="580" y="160" width="80" height="60" rx="6"/> if (simEditMode) {
<text class="equip-label" x="620" y="195">Discharge</text> const outline = g.querySelector('.equip');
if (outline) { outline.setAttribute('stroke', 'var(--amber)'); outline.setAttribute('stroke-width', '2'); outline.setAttribute('stroke-dasharray', '4 4'); }
}
<!-- Mill --> svg.appendChild(g);
<rect class="equip" x="580" y="250" width="70" height="50" rx="6"/>
<text class="equip-label" x="615" y="280">Mill</text>
<!-- Shaker --> // Drag in edit mode
<rect class="equip" x="670" y="250" width="90" height="50" rx="6"/> if (simEditMode) {
<text class="equip-label" x="715" y="280">Shaker Sep.</text> g.addEventListener('mousedown', (e) => startDragEquip(e, eq));
g.addEventListener('dblclick', () => renameEquip(eq));
}
});
<line class="flow-arrow" x1="650" y1="280" x2="665" y2="280"/> if (simEditMode) {
<line class="flow-arrow" x1="620" y1="220" x2="620" y2="245"/> svg.addEventListener('mousemove', moveDragEquip);
svg.addEventListener('mouseup', stopDragEquip);
svg.addEventListener('mouseleave', stopDragEquip);
}
<!-- Brush --> updateSchematicAnimations();
<rect class="equip" x="670" y="160" width="60" height="40" rx="4"/> }
<text class="equip-label" x="700" y="184" font-size="9">Brush</text>
<line class="flow-arrow" x1="660" y1="190" x2="665" y2="190"/>
<!-- Labels --> function startDragEquip(e, eq) {
<text x="85" y="150" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">IN</text> if (!simEditMode) return;
<text x="715" y="320" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">OUT</text> dragEquip = eq;
</svg>`; 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();
} }
function updateSchematicAnimations() { function updateSchematicAnimations() {
// Map card IDs to SVG animation elements const equip = getEquipment();
const map = { equip.forEach(eq => {
'motor_0': 'sim-fan', 'burner_0': 'sim-burner', if (!eq.link || !eq.anim) return;
'motor_1': 'sim-conveyor', 'motor_2': 'sim-spinner', const el = document.getElementById('sim-' + eq.id);
'motor_3': 'sim-agit1', 'motor_4': 'sim-agit2'
};
Object.entries(map).forEach(([cardId, svgId]) => {
const el = document.getElementById(svgId);
if (!el) return; if (!el) return;
const s = simState[cardId]; const s = simState[eq.link];
el.classList.toggle('active', s && s.on); el.classList.toggle('active', s && s.on);
}); });
} }