Plant operation page: work on phones and tablets held sideways
Deploy test site / deploy (push) Successful in 37s

The header no longer forces a 1,600 px layout: it wraps on tablets and
narrow desktops (all nine KPIs now fit at 1,600 px, one was clipped
before), and on short screens (max-height 560 px, i.e. phones in
landscape) it collapses to one row with a scrolling KPI strip. There the
detail, event log and legend move into a side sheet (Info button, opens
by itself when a station is tapped), so the drawing gets the full height.

The drawing pans and zooms by viewBox, so the sim coordinates are
untouched: pinch and drag on touch, wheel and drag with a mouse, plus
zoom buttons and +/-/0 keys. A drag or pinch never selects the station
under the finger. Uses 100dvh and disables iOS landscape text inflation.
Upright phones get a hint to turn sideways.

Checked in Chrome device emulation at 667x375, 740x360, 844x390,
932x430, 1024x768, 1180x820, 1366x1024, 1258x800, 1600x900, 1920x1080
(no horizontal overflow, zoom controls clear of every station) with real
touch input (tap, tab switching, pinch, drag) and a 10x sim run without
errors.
This commit is contained in:
grabowski committed 2026-10-02 21:33:04 +02:00
1 parent 52bf35f183
commit e23be20b13
3 files changed
+135 -15

No files matched your search

+3 -1
View File
@@ -118,7 +118,9 @@ The detail lives in dedicated documents:
animating the whole plant as a simulated SCADA overview, from PVC intake
and the material lab through extruder, press, trim, optical inspection,
finishing, packaging and fulfillment, with the metal path, utilities and
energy alongside; open it in a browser, every station is clickable
energy alongside; open it in a browser, every station is clickable;
on phones and tablets held sideways it pinch-zooms and opens station
details in a side sheet
- [Test site](site/README.md): `site/` builds https://cnxvinyl.lol/, the
internal test site with demo pages from `docs/` (the plant operation
page first) and any static site we want to share by link; Gitea Actions
+130 -13
View File
@@ -12,22 +12,31 @@
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font:13px/1.4 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header{display:flex;align-items:center;gap:16px;padding:7px 14px;background:var(--panel);border-bottom:1px solid var(--stroke);flex:0 0 auto}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
body{background:var(--bg);color:var(--fg);font:13px/1.4 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden}
header{display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:7px 14px;background:var(--panel);border-bottom:1px solid var(--stroke);flex:0 0 auto}
header h1{font-size:15px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
header h1 span{color:var(--mute);font-weight:400}
.kpis{display:flex;gap:14px;margin-left:auto}
.kpi{display:flex;flex-direction:column;min-width:70px}
.kpis{display:flex;gap:12px;margin-left:auto;min-width:0;max-width:100%;overflow-x:auto;scrollbar-width:none}
.kpis::-webkit-scrollbar{display:none}
.kpi{display:flex;flex-direction:column;min-width:70px;flex:0 0 auto}
.kpi b{font:600 15px var(--mono);white-space:nowrap}
.kpi small{color:var(--mute);font-size:10px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
.ctl{display:flex;gap:6px;align-items:center;margin-left:16px}
.ctl{display:flex;gap:6px;align-items:center;flex:0 0 auto}
button{background:#182238;color:var(--fg);border:1px solid var(--stroke);border-radius:4px;padding:4px 9px;font-size:12px;cursor:pointer;white-space:nowrap}
button:hover{background:#1f2c47}
button.on{background:#244a7a;border-color:#3b74b8}
button.demo{border-color:#5a4a1f;color:#ffd777}
main{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:4px 10px}
svg{width:100%;height:100%}
footer{flex:0 0 210px;display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10px;padding:8px 14px 10px;background:var(--panel);border-top:1px solid var(--stroke);min-height:0}
main{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:4px 10px;position:relative}
svg{width:100%;height:100%;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
svg.zoomed{cursor:grab}
svg.dragging{cursor:grabbing}
.mapctl{position:absolute;right:12px;bottom:8px;display:flex;gap:2px;z-index:2}
.mapctl button{background:rgba(24,34,56,.88);min-width:30px}
#btnInfo,footer .tabs{display:none}
#hint{display:none;position:absolute;top:8px;left:50%;transform:translateX(-50%);background:var(--box);border:1px solid var(--stroke);border-radius:6px;padding:6px 10px;color:var(--mute);font-size:12px;white-space:nowrap;z-index:2}
footer .tabs button.close{margin-left:auto;min-width:32px}
footer{flex:0 0 clamp(150px,26vh,210px);display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10px;padding:8px 14px 10px;background:var(--panel);border-top:1px solid var(--stroke);min-height:0}
.panel{background:var(--box);border:1px solid var(--stroke);border-radius:6px;padding:8px 12px;overflow:auto;min-height:0}
.panel h2{font-size:13px;margin-bottom:4px}
.panel h3{font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin:6px 0 2px}
@@ -45,6 +54,35 @@ footer{flex:0 0 210px;display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10p
.legend i.d{border-top-style:dashed}
.legend .note{grid-column:1/3;color:var(--mute);margin-top:6px;font-size:10.5px}
/* Short screens (phones held sideways): one-row header with a scrolling KPI
strip, the drawing takes the rest, panels open as a sheet on the right. */
@media (max-height:560px){
header{flex-wrap:nowrap;gap:8px;padding:4px 8px}
header h1{font-size:13px}
header h1 span{display:none}
.ctl{gap:4px}
button{padding:4px 7px;font-size:11px;min-height:28px}
.kpis{flex:1 1 0;gap:10px}
.kpi{min-width:56px}
.kpi b{font-size:13px}
.kpi small{font-size:9px}
main{padding:2px 4px}
.mapctl{right:8px;bottom:4px}
body.sheet .mapctl{right:calc(min(420px,55vw) + 8px)}
#btnInfo{display:inline-block}
footer{position:fixed;z-index:5;top:var(--hdrH,37px);right:0;bottom:0;width:min(420px,55vw);display:none;flex-direction:column;gap:6px;padding:6px;border-top:0;border-left:1px solid var(--stroke);box-shadow:-8px 0 24px rgba(0,0,0,.45)}
body.sheet footer{display:flex}
footer .tabs{display:flex;gap:4px;flex:0 0 auto}
footer .panel{display:none;flex:1 1 auto}
body[data-panel=detail] #detail,body[data-panel=log] #logPanel,body[data-panel=legend] #legendPanel{display:block}
}
/* Phones held upright: the plant is too wide, ask for landscape. */
@media (orientation:portrait) and (max-width:600px){
#hint{display:block}
header h1{white-space:normal}
.ctl{flex-wrap:wrap}
}
/* SVG */
.mbox{fill:#121a2b;stroke:#2a3a56;stroke-width:1}
.machine{cursor:pointer}
@@ -111,6 +149,7 @@ footer{flex:0 0 210px;display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10p
<button data-speed="10">10×</button>
<button id="btnSteam" class="demo" title="Demonstrate a steam pressure dip: the press holds in STEAM until the boiler recovers">Steam dip</button>
<button id="btnLot" class="demo" title="Demonstrate a new PVC lot arriving: the lab gate holds it until a test pressing releases it">New PVC lot</button>
<button id="btnInfo" title="Station detail, event log and legend">Info</button>
</div>
<div class="kpis">
<div class="kpi"><b id="kClock">08:00:00</b><small>sim clock</small></div>
@@ -132,15 +171,27 @@ footer{flex:0 0 210px;display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10p
<g id="lTok"></g>
<g id="lTop"></g>
</svg>
<div class="mapctl" id="zoom">
<button id="zOut" title="Zoom out (-)">−</button>
<button id="zIn" title="Zoom in (+). Pinch, wheel and drag also work">+</button>
<button id="zFit" title="Whole plant (0)">Fit</button>
</div>
<div id="hint">Turn the phone sideways for the plant view</div>
</main>
<footer>
<div class="tabs" id="views">
<button data-view="detail">Detail</button>
<button data-view="log">Log</button>
<button data-view="legend">Legend</button>
<button class="close" id="sheetClose" title="Close">×</button>
</div>
<div class="panel" id="detail">
<h2>Click any station</h2>
<p>Every box is a stage of the plant. Click it for what happens there, the checks that gate it, and the planning figures behind the numbers on screen. Values are simulated from the figures in this repository (Newbilt site requirements, scale.md, quality-automation.md, process-flow.md); nothing here is live. Run lengths and approval waits are shortened so stamper changes and gates are visible within minutes.</p>
<p>Every box is a stage of the plant. Click it (tap on a phone or tablet; pinch or use the zoom buttons to get close) for what happens there, the checks that gate it, and the planning figures behind the numbers on screen. Values are simulated from the figures in this repository (Newbilt site requirements, scale.md, quality-automation.md, process-flow.md); nothing here is live. Run lengths and approval waits are shortened so stamper changes and gates are visible within minutes.</p>
</div>
<div class="panel"><h2>Event log</h2><div class="log" id="log"></div></div>
<div class="panel"><h2>Legend</h2>
<div class="panel" id="logPanel"><h2>Event log</h2><div class="log" id="log"></div></div>
<div class="panel" id="legendPanel"><h2>Legend</h2>
<div class="legend">
<div><i style="border-color:#ff5a3c"></i>Saturated steam 10 bar</div>
<div><i class="d" style="border-color:#c96a4e"></i>Condensate return</div>
@@ -162,6 +213,7 @@ footer{flex:0 0 210px;display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10p
'use strict';
const NS='http://www.w3.org/2000/svg';
const $=id=>document.getElementById(id);
const COMPACT=matchMedia('(max-height:560px)');
const L={pipes:$('lPipes'),mach:$('lMach'),tok:$('lTok'),top:$('lTop')};
function el(tag,attrs,parent,text){const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(text!=null)e.textContent=text;(parent||L.mach).appendChild(e);return e;}
const rnd=(a,b)=>a+Math.random()*(b-a);
@@ -716,7 +768,8 @@ const DETAILS={
hvac:{t:'Whole-factory HVAC',w:'The press room is held at 20 to 26 °C and 35 to 60 % RH per the Newbilt site requirements; galvanics, archive and audio rooms have their own zones. Temperature, humidity and particulate per zone are logged with production.',c:['Temperature, RH and PM per zone','Correlate any production window with plant conditions'],f:['~100 to 200 kW peak (estimate)','~1,500 m² conditioned']},
};
let selected=null;
function select(id){
function select(id,quiet){
if(!quiet&&COMPACT.matches)showPanel('detail',true);
if(selected)selected.g.classList.remove('sel');selected=M[id];selected.g.classList.add('sel');
const d=DETAILS[id];if(!d)return;
const p=$('detail');p.innerHTML='';
@@ -733,11 +786,75 @@ $('btnSteam').addEventListener('click',()=>{S.boiler.alarmUntil=S.t+22;log('Boil
$('btnLot').addEventListener('click',()=>{if(S.t<S.lab.testingUntil)return;S.lab.testingUntil=S.t+40;log('Material lab: new PVC lot '+S.lab.nextLot+' received, CoA checked, moisture '+f2(jit(0.04,.01))+' %, held for test pressing','warn');});
document.addEventListener('keydown',e=>{if(e.code==='Space'&&e.target===document.body){e.preventDefault();$('btnPlay').click();}});
/* ------------------------------------------------------------------ touch: pan, zoom, panel sheet */
// The drawing zooms by changing the viewBox (same aspect ratio as the full
// plant), so every coordinate in the sim stays as it is.
const SV=$('svg'),BASE={w:1800,h:740},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.w<BASE.w-0.5);
}
function svgPt(cx,cy){const m=SV.getScreenCTM().inverse();return {x:m.a*cx+m.c*cy+m.e,y:m.b*cx+m.d*cy+m.f};}
function zoomAt(cx,cy,f){
const p=svgPt(cx,cy),nw=Math.min(BASE.w,Math.max(BASE.w/ZMAX,VB.w/f)),r=nw/VB.w;
VB.x=p.x-(p.x-VB.x)*r;VB.y=p.y-(p.y-VB.y)*r;VB.w=nw;applyVB();
}
function zoomMid(f){const b=SV.getBoundingClientRect();zoomAt(b.left+b.width/2,b.top+b.height/2,f);}
function panBy(dx,dy){const u=1/SV.getScreenCTM().a;VB.x-=dx*u;VB.y-=dy*u;applyVB();}
const PTR=new Map();let travel=0,pinch=null;
function pinchState(){const [a,b]=[...PTR.values()];return {d:Math.hypot(a.x-b.x,a.y-b.y),x:(a.x+b.x)/2,y:(a.y+b.y)/2};}
SV.addEventListener('pointerdown',e=>{
if(e.pointerType==='mouse'&&e.button!==0)return;
PTR.set(e.pointerId,{x:e.clientX,y:e.clientY});
if(PTR.size===1)travel=0;
if(PTR.size===2)pinch=pinchState();
});
SV.addEventListener('pointermove',e=>{
const prev=PTR.get(e.pointerId);if(!prev)return;
const cur={x:e.clientX,y:e.clientY};PTR.set(e.pointerId,cur);
if(PTR.size===1){
travel+=Math.abs(cur.x-prev.x)+Math.abs(cur.y-prev.y);
// capture only once it is a drag, so a tap still clicks the station
if(travel>8){if(!SV.hasPointerCapture(e.pointerId))SV.setPointerCapture(e.pointerId);SV.classList.add('dragging');panBy(cur.x-prev.x,cur.y-prev.y);}
}else if(PTR.size===2&&pinch){
travel=99;const n=pinchState();
panBy(n.x-pinch.x,n.y-pinch.y);if(pinch.d>0)zoomAt(n.x,n.y,n.d/pinch.d);pinch=n;
}
});
function ptrUp(e){PTR.delete(e.pointerId);if(PTR.size<2)pinch=null;if(!PTR.size)SV.classList.remove('dragging');}
['pointerup','pointercancel'].forEach(t=>SV.addEventListener(t,ptrUp));
// a drag or pinch must not also select whatever is under the finger
SV.addEventListener('click',e=>{if(travel>8){e.stopPropagation();e.preventDefault();}},true);
SV.addEventListener('wheel',e=>{e.preventDefault();zoomAt(e.clientX,e.clientY,Math.exp(-e.deltaY*(e.deltaMode?0.05:0.0015)));},{passive:false});
$('zIn').addEventListener('click',()=>zoomMid(1.5));
$('zOut').addEventListener('click',()=>zoomMid(1/1.5));
$('zFit').addEventListener('click',()=>{VB.x=0;VB.y=0;VB.w=BASE.w;applyVB();});
document.addEventListener('keydown',e=>{
if(e.target!==document.body)return;
if(e.key==='+'||e.key==='=')zoomMid(1.5);else if(e.key==='-')zoomMid(1/1.5);else if(e.key==='0')$('zFit').click();
});
// Short screens: Detail / Log / Legend open one at a time as a sheet.
function showPanel(name,keepOpen){
// a View button toggles its panel; picking a station always opens Detail
const open=!!name&&(keepOpen||!(document.body.dataset.panel===name&&document.body.classList.contains('sheet')));
if(open)document.body.dataset.panel=name;
document.body.classList.toggle('sheet',open);
document.querySelectorAll('#views button[data-view]').forEach(b=>b.classList.toggle('on',b.dataset.view===document.body.dataset.panel));
$('btnInfo').classList.toggle('on',open);
}
document.querySelectorAll('#views button[data-view]').forEach(b=>b.addEventListener('click',()=>showPanel(b.dataset.view,true)));
$('sheetClose').addEventListener('click',()=>showPanel(null));
$('btnInfo').addEventListener('click',()=>showPanel(document.body.dataset.panel||'detail'));
const HDR=document.querySelector('header');
new ResizeObserver(()=>document.documentElement.style.setProperty('--hdrH',HDR.offsetHeight+'px')).observe(HDR);
/* ------------------------------------------------------------------ bootstrap */
S.press.rack.push({id:'PR-26-041',stamper:'S-2261',color:PALETTE[41%PALETTE.length]});
log('Shift start 08:00. Boiler at pressure, chiller online, HVAC in range.','ok');
log('Material lab: lot 26-0912 released (moisture 0.03 %, heat stability OK, ΔE 0.3)','ok');
select('press');
select('press',true);
let last=performance.now();
function frame(now){
+2 -1
View File
@@ -57,7 +57,8 @@
<span>The whole plant animated, from PVC intake and the material lab
through extruder, press, trim, optical inspection, finishing,
packaging and fulfillment, with the metal path, utilities and energy
alongside. Every station is clickable.</span>
alongside. Every station is clickable; on a phone, turn it sideways
and pinch to zoom.</span>
</a>
</li>
</ul>