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 -->
<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">
<!-- SVG schematic inserted by JS -->
</div>
@ -568,112 +571,191 @@ function exportLayout() {
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() {
document.getElementById('sim-container').innerHTML = `
<svg class="schematic" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg">
<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>
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';
<!-- Flow arrows -->
<line class="flow-arrow" x1="130" y1="200" x2="195" y2="200"/>
<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"/>
// 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>`;
<!-- Loading Hopper -->
<rect class="equip" x="40" y="160" width="90" height="80" rx="6"/>
<text class="equip-label" x="85" y="205">Loading</text>
// IN/OUT labels
svg.innerHTML += `<text x="60" y="148" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">IN &#x25B6;</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 -->
<rect class="equip" x="200" y="130" width="140" height="140" rx="12"/>
<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>
document.getElementById('sim-container').innerHTML = '';
document.getElementById('sim-container').appendChild(svg);
<!-- Burner (on drum) -->
<circle class="equip" cx="200" cy="200" r="20"/>
<text class="equip-label" x="200" y="204" font-size="9">&#x1F525;</text>
<circle class="anim-overlay burner-glow" id="sim-burner" cx="200" cy="200" r="24" fill="none" stroke="var(--amber)" stroke-width="4"/>
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';
<!-- Fan (on drum) -->
<circle class="equip" cx="340" cy="150" r="22"/>
<text class="equip-label" x="340" y="154" font-size="9">Fan</text>
<g class="anim-overlay fan-anim" id="sim-fan" transform-origin="340 150">
<line x1="340" y1="130" x2="340" y2="170" stroke="var(--blue)" stroke-width="3"/>
<line x1="320" y1="150" x2="360" y2="150" stroke="var(--blue)" stroke-width="3"/>
<line x1="326" y1="136" x2="354" y2="164" stroke="var(--blue)" stroke-width="3"/>
<line x1="354" y1="136" x2="326" y2="164" stroke="var(--blue)" stroke-width="3"/>
</g>
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);
<!-- Conveyor -->
<rect class="equip" x="410" y="180" width="130" height="40" rx="4"/>
<text class="equip-label" x="475" y="205">Conveyor</text>
<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"/>
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);
<!-- Agitators (on conveyor) -->
<circle class="equip" cx="440" cy="170" r="12"/>
<text class="equip-label" x="440" y="174" font-size="8">A1</text>
<g class="anim-overlay agitator-anim" id="sim-agit1" transform-origin="440 170">
<line x1="440" y1="160" x2="440" y2="180" stroke="var(--blue)" stroke-width="2"/>
<line x1="430" y1="170" x2="450" y2="170" stroke="var(--blue)" stroke-width="2"/>
</g>
// 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);
<circle class="equip" cx="510" cy="170" r="12"/>
<text class="equip-label" x="510" y="174" font-size="8">A2</text>
<g class="anim-overlay agitator-anim" id="sim-agit2" transform-origin="510 170">
<line x1="510" y1="160" x2="510" y2="180" stroke="var(--blue)" stroke-width="2"/>
<line x1="500" y1="170" x2="520" y2="170" stroke="var(--blue)" stroke-width="2"/>
</g>
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);
<!-- Spinner -->
<circle class="equip" cx="475" cy="155" r="14"/>
<text class="equip-label" x="475" y="159" font-size="8">Spin</text>
<g class="anim-overlay fan-anim" id="sim-spinner" transform-origin="475 155" style="animation-duration:0.7s">
<line x1="475" y1="143" x2="475" y2="167" stroke="var(--green)" stroke-width="2"/>
<line x1="463" y1="155" x2="487" y2="155" stroke="var(--green)" stroke-width="2"/>
</g>
// 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);
}
}
<!-- Discharge -->
<rect class="equip" x="580" y="160" width="80" height="60" rx="6"/>
<text class="equip-label" x="620" y="195">Discharge</text>
// 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'); }
}
<!-- Mill -->
<rect class="equip" x="580" y="250" width="70" height="50" rx="6"/>
<text class="equip-label" x="615" y="280">Mill</text>
svg.appendChild(g);
<!-- Shaker -->
<rect class="equip" x="670" y="250" width="90" height="50" rx="6"/>
<text class="equip-label" x="715" y="280">Shaker Sep.</text>
// Drag in edit mode
if (simEditMode) {
g.addEventListener('mousedown', (e) => startDragEquip(e, eq));
g.addEventListener('dblclick', () => renameEquip(eq));
}
});
<line class="flow-arrow" x1="650" y1="280" x2="665" y2="280"/>
<line class="flow-arrow" x1="620" y1="220" x2="620" y2="245"/>
if (simEditMode) {
svg.addEventListener('mousemove', moveDragEquip);
svg.addEventListener('mouseup', stopDragEquip);
svg.addEventListener('mouseleave', stopDragEquip);
}
<!-- Brush -->
<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"/>
updateSchematicAnimations();
}
<!-- Labels -->
<text x="85" y="150" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">IN</text>
<text x="715" y="320" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">OUT</text>
</svg>`;
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();
}
function updateSchematicAnimations() {
// Map card IDs to SVG animation elements
const map = {
'motor_0': 'sim-fan', 'burner_0': 'sim-burner',
'motor_1': 'sim-conveyor', 'motor_2': 'sim-spinner',
'motor_3': 'sim-agit1', 'motor_4': 'sim-agit2'
};
Object.entries(map).forEach(([cardId, svgId]) => {
const el = document.getElementById(svgId);
const equip = getEquipment();
equip.forEach(eq => {
if (!eq.link || !eq.anim) return;
const el = document.getElementById('sim-' + eq.id);
if (!el) return;
const s = simState[cardId];
const s = simState[eq.link];
el.classList.toggle('active', s && s.on);
});
}