+
+
@@ -298,6 +305,15 @@ const pick=a=>a[Math.floor(Math.random()*a.length)];
const f0=v=>Math.round(v).toString(),f1=v=>v.toFixed(1),f2=v=>v.toFixed(2);
// first-order lag, stable for any step size
const lag=(v,t,k,ds)=>v+(t-v)*(1-Math.exp(-k*ds));
+// Change-only writes: re-writing an unchanged text or attribute still makes the browser re-lay-out the SVG.
+const setT=(e,v)=>{v=String(v);if(e.__t!==v){e.__t=v;e.textContent=v;}};
+const setA=(e,k,v)=>{v=String(v);const c=e.__a||(e.__a={});if(c[k]!==v){c[k]=v;e.setAttribute(k,v);}};
+const setS=(e,k,v)=>{const c=e.__s||(e.__s={});if(c[k]!==v){c[k]=v;e.style[k]=v;}};
+// Everything that moves every frame (pipe flow, tokens, records, screw, bubbles) is drawn on two canvases,
+// one under and one over the SVG; the SVG keeps machines and labels. See "effects" further down.
+const FX={flows:[],screws:[],bubbles:[]},SPR=[];let fxDirty=true;
+function spr(kind,o){const t=Object.assign({kind,x:0,y:0,dead:false,cls:{}},o);
+ t.remove=()=>{t.dead=true;};t.classList={add:c=>{t.cls[c]=true;}};SPR.push(t);return t;}
const MIN=60,HOUR=3600,DAY=86400;
function dur(s){s=Math.max(0,s);if(s<100)return f0(s)+' s';if(s<6000)return f0(s/60)+' min';if(s<2*DAY)return f1(s/HOUR)+' h';return f1(s/DAY)+' d';}
const PALETTE=['#e0552f','#3aa0ff','#ffbf1f','#3ddc84','#c86bff','#ff7ab6','#59d9c9'];
@@ -484,7 +500,7 @@ function pressEnter(P,st){P.state=st;P.tIn=0;
if(st==='LOAD'){const T=P.set.T;if(!materialOk(T)){P.state='NOPVC';log(P.name+': no released '+(T.kind==='black'?'black':'colour')+' compound, holding','alarm');return;}
P.cakeKg=T.wt/1000+rnd(0.02,0.04);consume(T,P.cakeKg);P.cake=true;P.peakT=0;P.loadD=rnd(4,6.5);S.labels.pairs--;
if(S.labels.pairs<=0){S.labels.pairs=1800;S.labels.lot='L-'+(parseInt(S.labels.lot.slice(2))+1);log('Label oven: new pre-dried stack, lot '+S.labels.lot,'info');}
- if(vis()){const lb=el('circle',{r:3,fill:T.label,stroke:'#e6ecf7','stroke-width':1},L.tok);mover('l_'+P.id,lb,120,m=>m.node.remove());}}
+ if(vis()){const lb=spr('dot',{r:3,fill:T.label,stroke:'#e6ecf7',sw:1});mover('l_'+P.id,lb,120,m=>m.node.remove());}}
if(st==='STEAM')P.cake=false;
}
function pressTick(P,ds){
@@ -650,11 +666,9 @@ function drawPress(m){const g=el('g',{class:'machine',id:'m_'+m.id});const {x,y,
el('rect',{x:x+8,y:y+38,width:6,height:84,fill:'#2a3a56'},g);
el('rect',{x:x+w-14,y:y+38,width:6,height:84,fill:'#2a3a56'},g);
const px=x+20,pw=w-40;
- m.botPl=el('rect',{x:px,y:y+100,width:pw,height:16,class:'platen'},g);
el('rect',{x:px+8,y:y+97,width:pw-16,height:3,fill:'#b9c2cc'},g);
- m.topPl=el('rect',{x:px,y:y+38,width:pw,height:16,class:'platen'},g);
- m.topSt=el('rect',{x:px+8,y:y+54,width:pw-16,height:3,fill:'#b9c2cc'},g);
- m.cake=el('g',{visibility:'hidden'},g);el('circle',{cx:x+w/2,cy:y+90,r:7,fill:'#3d3a33',stroke:'#8a7a55'},m.cake);
+ // platens, top stamper and cake move every cycle: drawn on the overlay canvas (drawFx), not in the SVG
+ m.plat={px,pw,yb:y+100,cx:x+w/2,cy:y+90};
m.pY=[y+38,y+80];
m.stateEl=el('text',{x:x+w/2,y:y+138,class:'mtag','text-anchor':'middle',style:'font-weight:700;fill:var(--ink)'},g,'OFF');
m.tagEls=[0,1,2].map(i=>el('text',{x:x+7,y:y+154+i*13,class:'mtag'},g,''));
@@ -671,7 +685,7 @@ function drawVex(m){const g=el('g',{class:'machine',id:'m_'+m.id});const {x,y,w,
el('text',{x:x+w-8,y:y+82,class:'small','text-anchor':'end'},g,'cake B ▶');
m.zoneEls=[0,1,2].map(i=>el('rect',{x:x+10+i*40,y:y+88,width:38,height:26,class:'zone',style:'fill:var(--z0)'},g));
m.zoneT=[0,1,2].map(i=>el('text',{x:x+29+i*40,y:y+105,class:'ctxt','text-anchor':'middle'},g,''));
- el('line',{x1:x+10,y1:y+120,x2:x+w-10,y2:y+120,class:'screw'},g);
+ FX.screws.push({x1:x+10,x2:x+w-10,y:y+120});
m.g=g;g.addEventListener('click',()=>select(m.id));}
function drawPppu(m){m.tagY=m.y+126;const g=drawBox(m);
m.pos=[0,1,2,3].map(i=>{const px=m.x+7+(i&1)*68,py=m.y+36+(i>>1)*40;const r=el('rect',{x:px,y:py,width:64,height:36,rx:2,class:'pos'},g);
@@ -680,7 +694,7 @@ function drawPdpu(m){m.tagY=m.y+126;const g=drawBox(m);
m.cells=[0,1].map(i=>{const cx=m.x+7+i*74,cy=m.y+38,cw=70,ch=76;const cg=el('g',{class:'cell'},g);
el('rect',{x:cx,y:cy,width:cw,height:ch,rx:2,class:'cellr'},cg);
el('rect',{x:cx+3,y:cy+20,width:cw-6,height:ch-23,class:'liquid'},cg);
- for(let b=0;b<3;b++)el('circle',{cx:cx+10+b*18,cy:cy+ch-5,r:1.6,class:'bub',style:'animation-delay:'+rnd(0,2.2).toFixed(2)+'s'},cg);
+ for(let b=0;b<3;b++)FX.bubbles.push({x:cx+10+b*18,y:cy+ch-5,d:rnd(0,2.2),cell:(m.id==='pdpu2'?2:0)+i});
el('rect',{x:cx+cw-11,y:cy+22,width:6,height:50,class:'thk'},cg);
const tv=el('rect',{x:cx+cw-11,y:cy+72,width:6,height:0,class:'thkv'},cg);
const t=[0,1,2,3].map(k=>el('text',{x:cx+4,y:cy+12+k*15,class:'ctxt'},cg,''));
@@ -697,9 +711,21 @@ const GATES={g1:gate('g1',160,116,'gate 1'),g2:gate('g2',330,116,'gate 2'),g3:ga
function flash(id){if(S.quiet)return;GATES[id].flash=2;GATES[id].g.classList.add('ok');}
/* pipes and routes */
+// Routes are polylines (M/H/V/L only) measured here, so a token never asks the DOM for a point on a
+// path: getPointAtLength after a transform write forces a layout of the whole SVG on every call.
const PATHS={};
-function route(id,d,cls){PATHS[id]=el('path',{d,class:'flow '+cls},L.pipes);PATHS[id].len=PATHS[id].getTotalLength();}
-function pipe(d,cls,thin){el('path',{d,class:'pipe '+cls},L.pipes);el('path',{d,class:'flow '+cls+(thin?' thin':'')},L.pipes);}
+function poly(d){if(/[^MHVL\d\s,.\-]/.test(d))throw new Error('route needs M/H/V/L only: '+d);
+ const segs=[],re=/([MHVL])\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+))?/g;let x=0,y=0,len=0,sx=0,sy=0,m;
+ while((m=re.exec(d))){const c=m[1],a=+m[2],b=m[3]==null?0:+m[3];
+ if(c==='M'){x=sx=a;y=sy=b;continue;}const nx=c==='V'?x:a,ny=c==='H'?y:c==='V'?a:b,l=Math.hypot(nx-x,ny-y);
+ segs.push({x0:x,y0:y,x1:nx,y1:ny,l0:len,l});len+=l;x=nx;y=ny;}
+ return {segs,len,sx,sy};}
+function ptAt(p,pos){const g0=p.segs;if(!g0.length)return {x:p.sx,y:p.sy};
+ for(const g of g0)if(pos<=g.l0+g.l){const k=g.l?Math.max(0,pos-g.l0)/g.l:0;return {x:g.x0+(g.x1-g.x0)*k,y:g.y0+(g.y1-g.y0)*k};}
+ const g=g0[g0.length-1];return {x:g.x1,y:g.y1};}
+function flow(d,cls){const c=cls.split(' ');FX.flows.push({k:'flow',c:c[0],thin:c.includes('thin'),p2:new Path2D(d)});}
+function route(id,d,cls){PATHS[id]=poly(d);flow(d,cls);}
+function pipe(d,cls,thin){FX.flows.push({k:'pipe',c:cls,p2:new Path2D(d)});flow(d,cls+(thin?' thin':''));}
// pipe rack y 616 to 650, drops into each press from below
pipe('M100,676 V616 H1074','steam');['456','740','912','1074'].forEach(x=>pipe(`M${x},616 V512`,'steam'));
pipe('M1080,621 H112 V676','cond',true);
@@ -710,8 +736,8 @@ pipe('M574,676 V636 H444 V512','hyd');pipe('M574,636 H728 V512','hyd');
pipe('M300,676 V644 H2088 V34 H845 V46','chw',true);pipe('M1013,34 V46','chw',true);
pipe('M716,676 V650 H2094 V40 H544 V46','di',true);pipe('M1162,40 V46','di',true);
// record conveyor and edge-trim collection
-el('path',{d:'M370,590 H1772',class:'conv'},L.pipes);el('path',{d:'M370,590 H1772',class:'convd'},L.pipes);
-el('path',{d:'M392,574 H1104',class:'flow scrap thin'},L.pipes);
+FX.flows.push({k:'conv',p2:new Path2D('M370,590 H1772')},{k:'convd',p2:new Path2D('M370,590 H1772')});
+flow('M392,574 H1104','scrap thin');
// material
route('s_v','M47,436 V444 H272 V428','mat thin');route('s_r','M107,436 V444 H272 V428','reg thin');route('s_c','M167,436 V444 H272 V428','col thin');
route('f_vex','M272,316 V308 H554 V366','mat thin');route('f_c1','M272,316 V308 H859 V316','mat thin');route('f_c2','M272,316 V308 H1021 V316','mat thin');
@@ -728,15 +754,10 @@ route('carton','M1685,408 V430 H1483 V452','route thin');
/* ================================================================ tokens */
const movers=[];
-function mover(pid,node,v,onEnd){const p=PATHS[pid];const m={p,node,pos:0,v,onEnd};movers.push(m);place(node,p.getPointAtLength(0));return m;}
-function place(node,pt){node.setAttribute('transform',`translate(${pt.x.toFixed(1)},${pt.y.toFixed(1)})`);}
-function dot(fill,r){return el('circle',{r:r||2.4,fill},L.tok);}
-function shape(kind){const g=el('g',{},L.tok);
- if(kind==='L'){el('circle',{r:7,fill:'#1a1408',stroke:'#c9a24a'},g);el('circle',{r:2.4,fill:'#c9a24a'},g);}
- else if(kind==='Ls'){el('circle',{r:7,fill:'#d9dee6',stroke:'#fff'},g);el('circle',{r:2.4,fill:'#8a97b0'},g);}
- else if(kind==='S'){el('circle',{r:7,fill:'#b9c2cc',stroke:'#e6ecf7'},g);el('circle',{r:4.5,fill:'none',stroke:'#7d8794'},g);el('circle',{r:1.6,fill:'#3a4658'},g);}
- else{el('circle',{r:7,fill:'#8e98a6',stroke:'#c8d2e4'},g);el('circle',{r:2.4,fill:'#c8d2e4'},g);}
- return g;}
+function mover(pid,node,v,onEnd){const p=PATHS[pid];const m={p,node,pos:0,v,onEnd};movers.push(m);place(node,ptAt(p,0));return m;}
+function place(node,pt){node.x=pt.x;node.y=pt.y;}
+function dot(fill,r){return spr('dot',{r:r||2.4,fill});}
+function shape(kind){return spr('shape',{k:kind});}
const hops=[];
function ctr(id){const m=M[id];return m?{x:m.x+m.w/2,y:m.y+m.h/2}:null;}
function hop(from,to,it){if(!visHop()||!from)return;const a=ctr(from),b=ctr(to);if(!a||!b)return;
@@ -744,7 +765,7 @@ function hop(from,to,it){if(!visHop()||!from)return;const a=ctr(from),b=ctr(to);
function routeHop(pid,kind){mover(pid,shape(kind),320,m=>m.node.remove());}
const RECS=[];const CONV0=370,CONVEND=1772,CONVY=590;const DROPX={pDA:392,pDB:676,pC1:839,pC2:1001};
const STATIONS=[{id:'optical',at:1185,dwell:2.2},{id:'finish',at:1329,dwell:2.5},{id:'pack',at:1483,dwell:3},{id:'ship',at:1672,dwell:0.5}];
-function mkRec(P,tp){const T=P.set.T;const g=el('g',{class:'rec'+(tp?' tp':'')},L.tok);el('circle',{r:9,class:'disc',style:'fill:'+T.vinyl},g);el('circle',{r:3.2,fill:T.label},g);
+function mkRec(P,tp){const T=P.set.T;const g=spr('rec',{r:9,lr:3.2,vinyl:T.vinyl,label:T.label});if(tp)g.classList.add('tp');
const r={node:g,T,tp,ph:'trim',dwell:6,x:DROPX[P.id],y:544,pos:0,visited:{},n:P.cyc,pname:P.name};RECS.push(r);place(g,r);return r;}
function recTick(ds){
const onC=RECS.filter(r=>r.ph==='conv'&&!r.gone).sort((a,b)=>b.pos-a.pos);
@@ -764,7 +785,7 @@ function recStation(st,r){const T=r.T;
log('Optical: '+r.pname+' record #'+r.n+' rejected ('+d+'), label punched, to the granulator','alarm');
mover('reject',r.node,90,m=>{S.punched++;S.punchUntil=S.t+8;m.node.remove();mover('p2g',dot('#c2b280',2.6),40,mm=>{mm.node.remove();S.gran.bin+=T.wt/1000;});});return;}
S.opticalLast='pass #'+r.n;
- if(r.tp||Math.random()<0.02){const g=el('g',{class:'rec'+(r.tp?' tp':'')},L.tok);el('circle',{r:7,class:'disc',style:'fill:'+T.vinyl},g);el('circle',{r:2.4,fill:T.label},g);
+ if(r.tp||Math.random()<0.02){const g=spr('rec',{r:7,lr:2.4,vinyl:T.vinyl,label:T.label});if(r.tp)g.classList.add('tp');
const tp=r.tp;mover('sample',g,60,m=>{m.node.remove();S.samples++;const sc=jit(98.2,1.2);S.audioLast=f1(sc);if(tp)S.tpToArtist++;
log('Audio compare: '+T.sid+(tp?' test pressing':' record #'+r.n)+' score '+f1(sc)+', clicks '+f0(rnd(0,3))+'/side, noise floor OK'+(tp?', to the artist':''),tp?'warn':'ok');});
if(r.tp){r.gone=true;r.node.remove();}}
@@ -788,7 +809,7 @@ function matTick(){
if(G.run&&S.t>=G.end){S.silo.r=Math.min(S.silo.rcap,S.silo.r+G.run*0.99);G.total+=G.run;log('Granulator 400: '+f1(G.run)+' kg ground to regrind, to the silo','info');
if(visHop())for(let i=0;i<3;i++)setTimeout(()=>mover('regrind',dot('#8ea36f',2.6),220,m=>m.node.remove()),i*250);G.run=0;}
if(!G.run&&G.bin>=12&&shiftOn()){G.run=G.bin;G.bin=0;G.end=S.t+G.run/225*HOUR;
- if(visHop())mover('trimcart',el('rect',{x:-7,y:-5,width:14,height:10,fill:'#5a4f33',stroke:'#c2b280'},L.tok),160,m=>m.node.remove());}
+ if(visHop())mover('trimcart',spr('rect',{w:14,h:10,fill:'#5a4f33',stroke:'#c2b280'}),160,m=>m.node.remove());}
}
/* ================================================================ utilities */
@@ -830,46 +851,44 @@ function tick(ds){S.t+=ds;
/* ================================================================ render */
let feedAcc=0,cartonAcc=0;
function animate(dt){
- for(let i=movers.length-1;i>=0;i--){const m=movers[i];m.pos+=m.v*dt;if(m.pos>=m.p.len){place(m.node,m.p.getPointAtLength(m.p.len));movers.splice(i,1);m.onEnd&&m.onEnd(m);}else place(m.node,m.p.getPointAtLength(m.pos));}
+ for(let i=movers.length-1;i>=0;i--){const m=movers[i];m.pos+=m.v*dt;if(m.pos>=m.p.len){place(m.node,ptAt(m.p,m.p.len));movers.splice(i,1);m.onEnd&&m.onEnd(m);}else place(m.node,ptAt(m.p,m.pos));}
for(let i=hops.length-1;i>=0;i--){const h=hops[i];h.t+=dt;const k=Math.min(1,h.t/h.d);place(h.n,{x:h.a.x+(h.b.x-h.a.x)*k,y:h.a.y+(h.b.y-h.a.y)*k-Math.sin(k*Math.PI)*18});if(k>=1){h.n.remove();hops.splice(i,1);}}
RECS.forEach(r=>{if(!r.gone)place(r.node,r.ph==='conv'?{x:CONV0+r.pos,y:CONVY}:{x:r.x,y:r.y});});
if(S.running&&!S.quiet&&S.speed<=600){feedAcc+=dt;
if(feedAcc>0.7&&PRESS.some(running)){feedAcc=0;const k=pick(['s_v','s_v','s_r','s_c','f_vex','f_c1','f_c2']);
const col=k==='s_r'?'#8ea36f':k==='s_c'?'#c86bff':'#b9c2d0';mover(k,dot(col),110,m=>m.node.remove());
if(S.ex35&&Math.random()<0.5)mover('f_ex35',dot('#c86bff'),110,m=>{m.node.remove();mover('ex2vex',dot('#c86bff'),20,mm=>mm.node.remove());});}
- cartonAcc+=dt;if(cartonAcc>7){cartonAcc=0;mover('carton',el('rect',{x:-6,y:-4,width:12,height:8,fill:'#8a6d3b',stroke:'#c9a24a'},L.tok),50,m=>m.node.remove());}}
+ cartonAcc+=dt;if(cartonAcc>7){cartonAcc=0;mover('carton',spr('rect',{w:12,h:8,fill:'#8a6d3b',stroke:'#c9a24a'}),50,m=>m.node.remove());}}
for(const k in GATES){const G=GATES[k];if(G.flash>0){G.flash-=dt;if(G.flash<=0)G.g.classList.remove('ok');}}
- PRESS.forEach(P=>{const m=M[P.id];const ty=m.pY[0]+(m.pY[1]-m.pY[0])*(1-P.open);m.topPl.setAttribute('y',ty.toFixed(1));m.topSt.setAttribute('y',(ty+16).toFixed(1));
- const heat=P.state==='STEAM',cool=P.state==='COOL';m.topPl.classList.toggle('heat',heat);m.botPl.classList.toggle('heat',heat);m.topPl.classList.toggle('cool',cool);m.botPl.classList.toggle('cool',cool);
- m.cake.setAttribute('visibility',P.cake?'visible':'hidden');});
+ PRESS.forEach(P=>{const k=1-Math.exp(-6*dt);P.__h=(P.__h||0)+((P.state==='STEAM'?1:0)-(P.__h||0))*k;P.__c=(P.__c||0)+((P.state==='COOL'?1:0)-(P.__c||0))*k;});
}
-const lampSet=(id,cls)=>{const m=M[id];if(m&&m.lamp)m.lamp.setAttribute('class','lamp '+cls);};
+const lampSet=(id,cls)=>{const m=M[id];if(m&&m.lamp)setA(m.lamp,'class','lamp '+cls);};
function pressLabel(P){if(P.state==='STOP')return 'STOP';if(P.state==='STEAM'&&P.waiting)return 'STEAM · WAIT';if(P.state==='CHANGE')return P.restart?'WARM-UP':'STAMPER CHANGE';if(P.state==='NOPVC')return 'NO PVC';return P.state;}
function renderTags(){
- MACH.forEach(m=>{if(m.tags&&m.tagEls)m.tags.forEach((fn,i)=>{if(m.tagEls[i])m.tagEls[i].textContent=fn();});if(m.barEl)m.barEl.setAttribute('width',((m.w-14)*stFrac(m.st)).toFixed(1));});
+ MACH.forEach(m=>{if(m.tags&&m.tagEls)m.tags.forEach((fn,i)=>{if(m.tagEls[i])setT(m.tagEls[i],fn());});if(m.barEl)setA(m.barEl,'width',((m.w-14)*stFrac(m.st)).toFixed(1));});
[['silo_v',S.silo.v/S.silo.vcap],['silo_r',S.silo.r/S.silo.rcap],['silo_c',S.silo.c/S.silo.ccap]].forEach(([id,l])=>{const m=M[id];l=Math.max(0,Math.min(1,l));const hh=(m.h-32)*l;
- m.fillEl.setAttribute('y',(m.y+m.h-30-hh).toFixed(1));m.fillEl.setAttribute('height',hh.toFixed(1));m.lvlEl.textContent=f0(l*100)+' %';m.lamp.setAttribute('class','lamp '+(l<0.1?'alarm':l<0.25?'warn':'run'));});
+ setA(m.fillEl,'y',(m.y+m.h-30-hh).toFixed(1));setA(m.fillEl,'height',hh.toFixed(1));setT(m.lvlEl,f0(l*100)+' %');setA(m.lamp,'class','lamp '+(l<0.1?'alarm':l<0.25?'warn':'run'));});
PRESS.forEach(P=>{const m=M[P.id];const T=P.set&&P.set.T;
- m.stateEl.textContent=pressLabel(P);const amber=P.waiting||['CHANGE','UNMOUNT'].includes(P.state)||P.mode==='tp';
- m.stateEl.style.fill=P.state==='NOPVC'||P.state==='STOP'?'var(--alarm)':amber?'var(--warn)':(P.state==='IDLE'||P.state==='OFF')?'var(--mute)':'var(--ink)';
- m.tagEls[0].textContent=running(P)?'F '+f0(P.force)+' t · '+f0(P.moldT)+' °C':P.state==='STOP'?P.stopWhy:P.state==='OFF'?'night: shift 06:00':'mould '+f0(P.moldT)+' °C';
- m.tagEls[1].textContent=T?T.id+' · '+P.set.k:(P.state==='OFF'?'':'waiting for a set');
- m.tagEls[2].textContent=!T?'':P.mode==='tp'?'test press '+(TP_N-P.tpLeft)+'/'+TP_N:P.mode==='fa'?'first article':'run '+T.cycles+'/'+T.target;
+ setT(m.stateEl,pressLabel(P));const amber=P.waiting||['CHANGE','UNMOUNT'].includes(P.state)||P.mode==='tp';
+ setS(m.stateEl,'fill',P.state==='NOPVC'||P.state==='STOP'?'var(--alarm)':amber?'var(--warn)':(P.state==='IDLE'||P.state==='OFF')?'var(--mute)':'var(--ink)');
+ setT(m.tagEls[0],running(P)?'F '+f0(P.force)+' t · '+f0(P.moldT)+' °C':P.state==='STOP'?P.stopWhy:P.state==='OFF'?'night: shift 06:00':'mould '+f0(P.moldT)+' °C');
+ setT(m.tagEls[1],T?T.id+' · '+P.set.k:(P.state==='OFF'?'':'waiting for a set'));
+ setT(m.tagEls[2],!T?'':P.mode==='tp'?'test press '+(TP_N-P.tpLeft)+'/'+TP_N:P.mode==='fa'?'first article':'run '+T.cycles+'/'+T.target);
lampSet(P.id,P.state==='NOPVC'||P.state==='STOP'?'alarm':amber?'warn':running(P)?'run':'idle');});
['tA','tB','tC1','tC2'].forEach((id,i)=>lampSet(id,running(PRESS[i])?'run':'idle'));
const dRun=PRESS.slice(0,2).some(running);
- M.vex.zoneT.forEach((t,i)=>t.textContent=f0(dRun?jit([132,146,156][i],1):[92,96,98][i])+'°');
- M.vex.zoneEls.forEach((z,i)=>{z.style.fill=dRun?'var(--z'+(i+1)+')':'var(--z0)';});lampSet('vex',dRun?'run':'idle');lampSet('ex35',S.ex35?'run':'idle');
- M.pppu.pos.forEach((p,i)=>{const w=ST.pppu.slots[i];p.r.setAttribute('class','pos'+(w?' on':''));p.a.textContent=w?w.it.T.sid+w.it.side+' 20 A':'free';p.b.textContent=w?dur(w.end-S.t):'';});
+ M.vex.zoneT.forEach((t,i)=>setT(t,f0(dRun?jit([132,146,156][i],1):[92,96,98][i])+'°'));
+ M.vex.zoneEls.forEach((z,i)=>{setS(z,'fill',dRun?'var(--z'+(i+1)+')':'var(--z0)');});lampSet('vex',dRun?'run':'idle');lampSet('ex35',S.ex35?'run':'idle');
+ M.pppu.pos.forEach((p,i)=>{const w=ST.pppu.slots[i];setA(p.r,'class','pos'+(w?' on':''));setT(p.a,w?w.it.T.sid+w.it.side+' 20 A':'free');setT(p.b,w?dur(w.end-S.t):'');});
lampSet('pppu',ST.pppu.slots.some(Boolean)?'run':ST.pppu.q.length?'warn':'idle');
[0,1].forEach(u=>{const m=M['pdpu'+(u+1)];let any=false,done=false;
- m.cells.forEach((cv,i)=>{const c=CELLS[u*2+i];const j=c.job;cv.g.setAttribute('class','cell'+(c.ph==='plate'?' on':c.ph==='done'?' done':''));
+ m.cells.forEach((cv,i)=>{const c=CELLS[u*2+i];const j=c.job;setA(cv.g,'class','cell'+(c.ph==='plate'?' on':c.ph==='done'?' done':''));
if(c.ph!=='idle')any=true;if(c.ph==='done')done=true;
- const hh=50*(j?Math.min(1,c.mass/c.target):0);cv.tv.setAttribute('y',(cv.cy+72-hh).toFixed(1));cv.tv.setAttribute('height',hh.toFixed(1));
- cv.t[0].textContent=j?jl(j).replace(' ',''):'idle';
- cv.t[1].textContent=c.ph==='plate'?f0(c.I)+' A':c.ph==='load'?'load':c.ph==='unload'?'unload':c.ph==='done'?'DONE':'';
- cv.t[2].textContent=j?f2(c.mass/massFor(1))+' mm':'';
- cv.t[3].textContent=j&&c.ph!=='done'?dur(cellLeft(c)):c.ph==='done'?'waits':'';});
+ const hh=50*(j?Math.min(1,c.mass/c.target):0);setA(cv.tv,'y',(cv.cy+72-hh).toFixed(1));setA(cv.tv,'height',hh.toFixed(1));
+ setT(cv.t[0],j?jl(j).replace(' ',''):'idle');
+ setT(cv.t[1],c.ph==='plate'?f0(c.I)+' A':c.ph==='load'?'load':c.ph==='unload'?'unload':c.ph==='done'?'DONE':'');
+ setT(cv.t[2],j?f2(c.mass/massFor(1))+' mm':'');
+ setT(cv.t[3],j&&c.ph!=='done'?dur(cellLeft(c)):c.ph==='done'?'waits':'');});
lampSet('pdpu'+(u+1),done?'warn':any?'run':'idle');});
['pasu','pmpb','qc','vsf','vsp','vsc'].forEach(id=>lampSet(id,ST[id].slots[0]?'run':ST[id].q.length?'warn':'idle'));
[['audio','intake'],['master','master'],['cut','cut']].forEach(([id,st])=>lampSet(id,cntStage(st)?'run':'idle'));
@@ -879,13 +898,13 @@ function renderTags(){
lampSet('lab',S.lab.test||S.lab.queue.length?'warn':'run');lampSet('oven',anyRun?'run':'idle');lampSet('carton','run');
lampSet('boiler',S.boiler.p<9.5?'alarm':S.boiler.p<9.65?'warn':'run');lampSet('chiller',S.chiller.sup>24?'warn':'run');
['comp','diro','zld','energy','trace','hvac','exh'].forEach(id=>lampSet(id,'run'));lampSet('hyd',dRun?'run':'idle');
- GATES.g1.c.textContent=cntStage('gate1')+' waiting';GATES.g2.c.textContent=cntStage('gate2')+' waiting';GATES.g3.c.textContent=TITLES.filter(T=>T.tp==='sent').length+' waiting';
- $('kClock').textContent=clockStr();$('kShift').textContent=shiftOn()?(hourOf()<14?'1 (06-14)':'2 (14-22)'):'night';
- $('kPress').textContent=PRESS.filter(running).length+'/4';
- $('kRate').textContent=S.cyc.length;$('kGood').textContent=S.day.good;$('kRej').textContent=S.day.rej;$('kYield').textContent=yieldStr();
- $('kGalv').textContent=galvCount();$('kRack').textContent=rackReady();
- $('kSteam').textContent=f2(S.boiler.p)+' bar';$('kSteam').style.color=S.boiler.p<9.5?'#ff3b3b':'';
- $('kLoad').textContent=f0(S.energy.load)+' kW';$('kPV').textContent=f0(S.energy.pv)+' kW';
+ setT(GATES.g1.c,cntStage('gate1')+' waiting');setT(GATES.g2.c,cntStage('gate2')+' waiting');setT(GATES.g3.c,TITLES.filter(T=>T.tp==='sent').length+' waiting');
+ setT($('kClock'),clockStr());setT($('kShift'),shiftOn()?(hourOf()<14?'1 (06-14)':'2 (14-22)'):'night');
+ setT($('kPress'),PRESS.filter(running).length+'/4');
+ setT($('kRate'),S.cyc.length);setT($('kGood'),S.day.good);setT($('kRej'),S.day.rej);setT($('kYield'),yieldStr());
+ setT($('kGalv'),galvCount());setT($('kRack'),rackReady());
+ setT($('kSteam'),f2(S.boiler.p)+' bar');setS($('kSteam'),'color',S.boiler.p<9.5?'#ff3b3b':'');
+ setT($('kLoad'),f0(S.energy.load)+' kW');setT($('kPV'),f0(S.energy.pv)+' kW');
}
/* ================================================================ log */
@@ -960,7 +979,7 @@ document.addEventListener('keydown',e=>{if(e.code==='Space'&&e.target===document
const SV=$('svg'),BASE={w:2100,h:820},ZMAX=5,VB={x:0,y:0,w:BASE.w};
function applyVB(){VB.w=Math.min(BASE.w,Math.max(BASE.w/ZMAX,VB.w));const h=VB.w*BASE.h/BASE.w;
VB.x=Math.min(BASE.w-VB.w,Math.max(0,VB.x));VB.y=Math.min(BASE.h-h,Math.max(0,VB.y));
- SV.setAttribute('viewBox',VB.x.toFixed(1)+' '+VB.y.toFixed(1)+' '+VB.w.toFixed(1)+' '+h.toFixed(1));SV.classList.toggle('zoomed',VB.wshowPanel(document.body.dataset.panel|
const HDR=document.querySelector('header');
new ResizeObserver(()=>document.documentElement.style.setProperty('--hdrH',HDR.offsetHeight+'px')).observe(HDR);
+/* ================================================================ effects
+ Two canvases sized to the SVG box and drawn in viewBox units with the same xMidYMid-meet transform as
+ the SVG, so pan and zoom need nothing extra. Under the SVG: pipes, flow dashes and the conveyor (under
+ the machines, as before). Over it: tokens, records, extruder screw, plating bubbles. Nothing here
+ touches the DOM per frame, which is what made the page lag (forced layouts of the whole SVG). */
+const CU=$('fxUnder'),CO=$('fxOver'),XU=CU.getContext('2d'),XO=CO.getContext('2d'),BOX={w:1,h:1,d:1};
+let fxT=0,screwPh=0,COL={};
+function fxColors(){const de=document.documentElement,cs=getComputedStyle(de),lt=de.getAttribute('data-theme')==='light';
+ COL={plat:lt?{b:[199,209,223],h:[224,112,79],c:[111,163,216],s:'#8e9db4'}:{b:[42,58,86],h:[179,64,44],c:[44,91,143],s:'#4a5f85'},pipeA:lt?0.32:0.28,conv:lt?'#dfe5ee':'#1c2740',convd:lt?'#b5c1d4':'#2e4066',screw:lt?'#7d8aa0':'#6d7d99',bub:lt?'#3f97cc':'#8fd3ff'};
+ ['steam','cond','cw','cwr','hyd','chw','di','mat','reg','col','metal','scrap','route','lbl'].forEach(k=>{COL[k]=cs.getPropertyValue('--p-'+k).trim();});fxDirty=true;}
+function fxResize(){const mr=SV.parentNode.getBoundingClientRect(),sr=SV.getBoundingClientRect();
+ BOX.w=Math.max(1,sr.width);BOX.h=Math.max(1,sr.height);BOX.d=Math.min(2,window.devicePixelRatio||1);
+ for(const c of [CU,CO]){c.style.left=(sr.left-mr.left)+'px';c.style.top=(sr.top-mr.top)+'px';c.style.width=BOX.w+'px';c.style.height=BOX.h+'px';
+ c.width=Math.round(BOX.w*BOX.d);c.height=Math.round(BOX.h*BOX.d);}fxDirty=true;}
+function fxBegin(x){const vh=VB.w*BASE.h/BASE.w,k=Math.min(BOX.w/VB.w,BOX.h/vh),d=BOX.d;
+ x.setTransform(1,0,0,1,0,0);x.clearRect(0,0,x.canvas.width,x.canvas.height);
+ x.setTransform(d*k,0,0,d*k,d*((BOX.w-VB.w*k)/2-VB.x*k),d*((BOX.h-vh*k)/2-VB.y*k));}
+function circ(x,cx,cy,r,fill,stroke,sw){x.beginPath();x.arc(cx,cy,r,0,6.2832);if(fill){x.fillStyle=fill;x.fill();}if(stroke){x.lineWidth=sw||1;x.strokeStyle=stroke;x.stroke();}}
+const SHAPES={L:[[7,'#1a1408','#c9a24a'],[2.4,'#c9a24a']],Ls:[[7,'#d9dee6','#fff'],[2.4,'#8a97b0']],
+ S:[[7,'#b9c2cc','#e6ecf7'],[4.5,null,'#7d8794'],[1.6,'#3a4658']],M:[[7,'#8e98a6','#c8d2e4'],[2.4,'#c8d2e4']]};
+function drawFx(dt){fxT+=dt;screwPh+=dt*(S.vex.rpm>1?17.8:0);
+ const u=XU;fxBegin(u);u.lineJoin='round';
+ for(const f of FX.flows){
+ if(f.k==='pipe'){u.globalAlpha=COL.pipeA;u.strokeStyle=COL[f.c];u.lineWidth=5;u.lineCap='round';u.setLineDash([]);}
+ else if(f.k==='conv'){u.globalAlpha=1;u.strokeStyle=COL.conv;u.lineWidth=14;u.lineCap='butt';u.setLineDash([]);}
+ else if(f.k==='convd'){u.globalAlpha=1;u.strokeStyle=COL.convd;u.lineWidth=14;u.lineCap='butt';u.setLineDash([3,9]);u.lineDashOffset=-(fxT*10%12);}
+ else{u.globalAlpha=1;u.strokeStyle=COL[f.c];u.lineCap='round';
+ if(f.thin){u.lineWidth=1.4;u.setLineDash([3,7]);u.lineDashOffset=-(fxT*13.3%10);}else{u.lineWidth=2.2;u.setLineDash([7,9]);u.lineDashOffset=-(fxT*13.3%16);}}
+ u.stroke(f.p2);}
+ u.globalAlpha=1;
+ const o=XO;fxBegin(o);o.setLineDash([6,6]);o.lineDashOffset=-(screwPh%12);o.lineWidth=2;o.strokeStyle=COL.screw;o.lineCap='butt';
+ for(const w of FX.screws){o.beginPath();o.moveTo(w.x1,w.y);o.lineTo(w.x2,w.y);o.stroke();}
+ o.setLineDash([]);o.globalAlpha=0.7;
+ for(const b of FX.bubbles){const c=CELLS[b.cell];if(!c||c.ph!=='plate')continue;const k=(((fxT-b.d)%2.2)+2.2)%2.2/2.2;circ(o,b.x,b.y-30*k,1.6,COL.bub);}
+ o.globalAlpha=1;
+ // presses: platen colour eases to steam red / cooling blue like the old 0.5 s CSS transition
+ for(const P of PRESS){const m=M[P.id],q=m.plat;if(!q)continue;const ty=m.pY[0]+(m.pY[1]-m.pY[0])*(1-P.open);
+ const h=P.__h||0,c=P.__c||0,C=COL.plat,rgb=[0,1,2].map(i=>Math.round(C.b[i]*(1-h-c)+C.h[i]*h+C.c[i]*c));
+ o.fillStyle='rgb('+rgb+')';o.strokeStyle=C.s;o.lineWidth=1;
+ o.fillRect(q.px,q.yb,q.pw,16);o.strokeRect(q.px,q.yb,q.pw,16);o.fillRect(q.px,ty,q.pw,16);o.strokeRect(q.px,ty,q.pw,16);
+ o.fillStyle='#b9c2cc';o.fillRect(q.px+8,ty+16,q.pw-16,3);if(P.cake)circ(o,q.cx,q.cy,7,'#3d3a33','#8a7a55',1);}
+ let j=0;for(const t of SPR)if(!t.dead)SPR[j++]=t;SPR.length=j;
+ for(const t of SPR){
+ if(t.kind==='dot')circ(o,t.x,t.y,t.r,t.fill,t.stroke,t.sw);
+ else if(t.kind==='rect'){o.fillStyle=t.fill;o.fillRect(t.x-t.w/2,t.y-t.h/2,t.w,t.h);o.lineWidth=1;o.strokeStyle=t.stroke;o.strokeRect(t.x-t.w/2,t.y-t.h/2,t.w,t.h);}
+ else if(t.kind==='rec'){const st=t.cls.rej?['#ff3b3b',2]:t.cls.tp?['#ffbf1f',1.5]:['#3a3f4a',1];circ(o,t.x,t.y,t.r,t.vinyl,st[0],st[1]);circ(o,t.x,t.y,t.lr,t.label);}
+ else for(const [r,fl,st] of SHAPES[t.k]||SHAPES.M)circ(o,t.x,t.y,r,fl,st,1);}}
+new ResizeObserver(fxResize).observe(SV);
+new MutationObserver(fxColors).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});
+fxResize();fxColors();
+
/* ================================================================ seed, then two days of warm-up so the page opens mid-shift */
function seedSets(T,used){T.made={A:T.setsTarget,B:T.setsTarget};T.pairs=T.setsTarget;T.mothers={A:{T,side:'A',gen:'M'},B:{T,side:'B',gen:'M'}};
for(let k=1;k<=T.setsTarget;k++)RACK.push({T,k,used:k===1?used:0});}
@@ -1016,9 +1086,10 @@ let last=performance.now(),tagAcc=0;
function frame(now){const dt=Math.min(0.1,(now-last)/1000);last=now;
if(S.running){const ds=dt*S.speed;const sub=S.speed>=3600?1:S.speed>=600?0.5:0.25;const n=Math.ceil(ds/sub);const h=ds/n;for(let i=0;i=0.2){tagAcc=0;renderTags();}
+ if(S.running||fxDirty){fxDirty=false;drawFx(S.running?dt:0);}
requestAnimationFrame(frame);}
requestAnimationFrame(frame);
-window.__plant={S,TITLES,RACK,PRESS,CELLS,ST,PLATEQ,M,tick};
+window.__plant={S,TITLES,RACK,PRESS,CELLS,ST,PLATEQ,M,tick,SPR,FX,PATHS,XU,XO};
})();
(function(){
var b=document.getElementById("themeBtn"),de=document.documentElement;