← back to Ticket System
office.html
727 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fleet Office — live ticket floor</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root{ --bg:#06070c; --panel:rgba(11,13,20,0.82); --solid:rgba(9,11,17,0.92); --line:rgba(255,255,255,0.09);
--ink:#eef1f7; --dim:#7f8798; --faint:#586074;
--blue:#5aa9f0; --red:#ff5f6d; --open:#aab4c6; --green:#34d9a0; --purple:#c9a4f2; --amber:#f5b74a; }
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#06070c;color:var(--ink);
font:13px/1.4 'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
-webkit-font-smoothing:antialiased;overflow:hidden}
.mono{font-family:'JetBrains Mono',ui-monospace,monospace;font-variant-numeric:tabular-nums}
#c{position:fixed;inset:0;display:block}
.hud{position:fixed;pointer-events:none;z-index:10}
.glass{background:var(--panel);border:1px solid var(--line);border-radius:12px;
backdrop-filter:blur(14px) saturate(1.2);-webkit-backdrop-filter:blur(14px) saturate(1.2);
box-shadow:0 10px 40px #000b, inset 0 1px 0 rgba(255,255,255,0.05)}
/* ── top command bar ── */
#top{top:0;left:0;right:0;height:46px;padding:0 18px;display:flex;gap:14px;align-items:center;
background:var(--solid);border-bottom:1px solid var(--line);backdrop-filter:blur(14px);pointer-events:auto}
#top .brand{color:#fff;font-size:13px;font-weight:800;letter-spacing:.22em}
#top .brand i{color:var(--blue);font-style:normal;margin-right:7px;text-shadow:0 0 14px var(--blue)}
.pills{display:flex;gap:7px;align-items:center;margin-left:6px}
.pill{display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:7px;
background:rgba(255,255,255,0.04);border:1px solid var(--line);font-size:11px;color:var(--dim);
text-transform:uppercase;letter-spacing:.08em}
.pill i.d{width:7px;height:7px;border-radius:2px;box-shadow:0 0 8px currentColor}
.pill b{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:#fff;letter-spacing:0}
.pill.run{border-color:rgba(52,217,160,0.4);background:rgba(52,217,160,0.07)}
.pill.run i.d{animation:runblink 1.6s ease-in-out infinite}
@keyframes runblink{0%,100%{opacity:.45}50%{opacity:1}}
#top .age{margin-left:auto;font-size:10px;color:var(--faint);letter-spacing:.14em;text-transform:uppercase}
#top .age b{font-family:'JetBrains Mono',monospace;color:var(--dim);font-weight:600}
/* ── legend / workload ── */
.legend{position:fixed;top:60px;left:18px;z-index:10;padding:12px 14px;width:214px;pointer-events:auto}
.lhd{font-size:10px;letter-spacing:.18em;color:var(--faint);text-transform:uppercase;margin-bottom:9px}
.lrow{display:grid;grid-template-columns:9px 1fr auto;align-items:center;gap:9px;margin:7px 0}
.lrow .lc{width:9px;height:9px;border-radius:2px;background:var(--c);box-shadow:0 0 9px var(--c)}
.lrow .ln{font-size:12px;color:var(--ink);text-transform:capitalize}
.lrow .lv{font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;color:#fff}
.lbar{grid-column:1/4;height:3px;border-radius:2px;background:rgba(255,255,255,0.06);overflow:hidden;margin-top:-2px}
.lbar i{display:block;height:100%;width:0;background:var(--c);box-shadow:0 0 8px var(--c);transition:width .6s cubic-bezier(.4,0,.2,1)}
/* ── controls ── */
.ctl{position:fixed;top:60px;right:18px;z-index:10;display:flex;flex-direction:column;gap:6px;
width:150px;padding:11px;pointer-events:auto}
.csec{font-size:10px;letter-spacing:.18em;color:var(--faint);text-transform:uppercase;margin:4px 2px 2px}
.csec:first-child{margin-top:0}
.ctl button{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,0.03);border:1px solid var(--line);
color:#cdd4e2;border-radius:8px;padding:8px 11px;cursor:pointer;font:inherit;font-size:12px;
letter-spacing:.02em;transition:.14s;text-align:left}
.ctl button::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--faint);flex:0 0 auto;transition:.14s}
.ctl button:hover{border-color:rgba(255,255,255,0.22);color:#fff;background:rgba(255,255,255,0.06)}
.ctl button.on{color:#fff;border-color:var(--blue);background:rgba(90,169,240,0.12)}
.ctl button.on::before{background:var(--blue);box-shadow:0 0 9px var(--blue)}
#b-run.on,#b-procs.on{color:#fff;border-color:var(--green);background:rgba(52,217,160,0.12)}
#b-run.on::before,#b-procs.on::before{background:var(--green);box-shadow:0 0 9px var(--green)}
#b-ping{color:var(--purple);border-color:rgba(201,164,242,0.4)}
#b-ping::before{background:var(--purple);box-shadow:0 0 9px var(--purple)}
#b-reset::before,#b-ping::before{display:none}
#b-reset,#b-ping{justify-content:center;margin-top:3px}
/* ── activity log ── */
#ticker{bottom:0;left:0;right:0;padding:14px 18px 15px;
background:linear-gradient(#06070c00,#06070cf5 60%);pointer-events:none}
#ticker .lbl{color:var(--faint);letter-spacing:.2em;font-size:10px;text-transform:uppercase;margin-bottom:7px}
#log{display:flex;flex-direction:column;gap:3px;max-width:820px}
.lgrow{display:grid;grid-template-columns:44px 190px 78px 1fr;gap:12px;align-items:baseline;
font-size:12px;white-space:nowrap;overflow:hidden}
.lgrow .t{font-family:'JetBrains Mono',monospace;color:var(--faint);font-size:11px}
.lgrow .ag{color:var(--blue);font-weight:600;overflow:hidden;text-overflow:ellipsis}
.lgrow .tk{font-family:'JetBrains Mono',monospace;color:var(--purple);font-size:11px}
.lgrow .mx{color:#c2c9d6;overflow:hidden;text-overflow:ellipsis}
.lgrow.muted{color:var(--faint);grid-template-columns:1fr}
.lgrow.fresh .mx{color:#fff}
/* ── tooltip ── */
#tip{position:fixed;z-index:11;max-width:360px;pointer-events:none;display:none;padding:11px 13px;border-left:3px solid var(--blue)}
#tip .h{color:var(--blue);font-weight:700;margin-bottom:3px;letter-spacing:.04em;font-family:'JetBrains Mono',monospace;font-size:12px}
#tip .s{color:var(--dim);font-size:11px;margin-top:3px}
#tip .t{color:#dfe5f0;margin-top:6px;font-size:12px;line-height:1.5}
#err{position:fixed;bottom:78px;left:18px;z-index:12;color:var(--red);display:none;font-size:12px}
/* ── boot curtain ── */
#boot{position:fixed;inset:0;z-index:20;background:#06070c;display:flex;align-items:center;justify-content:center;
flex-direction:column;gap:16px;transition:opacity .8s;letter-spacing:.34em;color:var(--blue);font-size:12px;font-weight:600}
#boot .r{width:140px;height:2px;background:rgba(255,255,255,0.08);overflow:hidden;border-radius:2px}
#boot .r i{display:block;height:100%;width:38%;background:linear-gradient(90deg,var(--blue),var(--purple));
animation:sweep 1s infinite;box-shadow:0 0 14px var(--blue)}
@keyframes sweep{0%{transform:translateX(-100%)}100%{transform:translateX(360%)}}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="boot"><div>ENTERING FLEET OFFICE</div><div class="r"><i></i></div></div>
<div class="hud" id="top">
<span class="brand"><i>◆</i>FLEET OFFICE</span>
<div class="pills">
<span class="pill"><i class="d" style="background:var(--open);color:var(--open)"></i>rooms <b id="s-rooms">0</b></span>
<span class="pill"><i class="d" style="background:var(--blue);color:var(--blue)"></i>doing <b id="s-doing">0</b></span>
<span class="pill"><i class="d" style="background:var(--red);color:var(--red)"></i>blocked <b id="s-blocked">0</b></span>
<span class="pill"><i class="d" style="background:var(--open);color:var(--open)"></i>open <b id="s-open">0</b></span>
<span class="pill"><i class="d" style="background:var(--green);color:var(--green)"></i>done <b id="s-done">0</b></span>
<span class="pill"><i class="d" style="background:var(--purple);color:var(--purple)"></i>agents <b id="s-agents">0</b></span>
<span class="pill run"><i class="d" style="background:var(--green);color:var(--green)"></i>live <b id="s-live">0</b></span>
<span class="pill run"><i class="d" style="background:var(--green);color:var(--green)"></i>pm2 <b id="s-running">0</b></span>
<span class="pill run"><i class="d" style="background:var(--green);color:var(--green)"></i>sessions <b id="s-sessions">0</b></span>
<span class="pill"><i class="d" style="background:var(--purple);color:var(--purple)"></i>DMs <b id="s-dms">0</b></span>
</div>
<span class="age">SYNC <b id="s-age">–</b></span>
</div>
<div class="legend glass">
<div class="lhd">Workload</div>
<div class="lrow" style="--c:var(--blue)"><span class="lc"></span><span class="ln">doing</span><span class="lv" id="lg-doing">0</span><span class="lbar"><i id="bar-doing"></i></span></div>
<div class="lrow" style="--c:var(--red)"><span class="lc"></span><span class="ln">blocked</span><span class="lv" id="lg-blocked">0</span><span class="lbar"><i id="bar-blocked"></i></span></div>
<div class="lrow" style="--c:var(--open)"><span class="lc"></span><span class="ln">open</span><span class="lv" id="lg-open">0</span><span class="lbar"><i id="bar-open"></i></span></div>
<div class="lrow" style="--c:var(--green)"><span class="lc"></span><span class="ln">done</span><span class="lv" id="lg-done">0</span><span class="lbar"><i id="bar-done"></i></span></div>
</div>
<div class="ctl glass">
<div class="csec">View</div>
<button id="b-orbit" class="on">Orbit</button>
<button id="b-follow">Follow DMs</button>
<div class="csec">Scene</div>
<button id="b-labels" class="on">Labels</button>
<button id="b-run" class="on">Running edges</button>
<button id="b-procs" class="on">Process wall</button>
<button id="b-done" class="on">Done rooms</button>
<button id="b-ping" title="Preview the DM packet animation (visual only — writes nothing)">Ping ✦</button>
<button id="b-reset">Reset camera</button>
</div>
<div id="tip" class="glass"></div>
<div id="err"></div>
<div class="hud" id="ticker"><div class="lbl">Activity stream</div><div id="log"><div class="lgrow muted">connecting to ticket board…</div></div></div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { Reflector } from 'three/addons/objects/Reflector.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
// ───────────────────────── config ─────────────────────────
const LANES = ['doing', 'blocked', 'open', 'done'];
const COLOR = { doing:0x6db3f2, blocked:0xff6b7a, open:0x9aa4b8, done:0x3ee6a0 };
const ROOM=9, PITCH=12, LANE_W=42, DONE_CAP=14, POLL_MS=4000;
const RUN=0x34d9a0; // "running" green — the through-line for live things
const LIVE_MS=8*60*1000; // an agent that acted within 8 min counts as live/running
const OVERDUE_MS=2*60*60*1000; // a DOING room idle this long → red "stalled" numeral
// ───────────────────────── renderer ─────────────────────────
const canvas=document.getElementById('c');
const renderer=new THREE.WebGLRenderer({ canvas, antialias:true, powerPreference:'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.05;
renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
const scene=new THREE.Scene();
scene.fog=new THREE.FogExp2(0x07080d, 0.0038);
const camera=new THREE.PerspectiveCamera(46, innerWidth/innerHeight, 1, 2500);
const HOME={ pos:new THREE.Vector3(52,150,235), tgt:new THREE.Vector3(30,4,95) };
camera.position.set(30,540,540); // fly-in start
const controls=new OrbitControls(camera, canvas);
controls.enableDamping=true; controls.dampingFactor=0.06;
controls.maxPolarAngle=Math.PI*0.47; controls.minDistance=34; controls.maxDistance=620;
controls.target.copy(HOME.tgt);
controls.autoRotate=true; controls.autoRotateSpeed=0.35;
// environment reflections (glossy metals) — guarded for headless
try{ const pmrem=new THREE.PMREMGenerator(renderer);
scene.environment=pmrem.fromScene(new RoomEnvironment(), 0.03).texture; }catch(e){}
// ───────────────────────── atmosphere ─────────────────────────
// gradient dome
const dome=new THREE.Mesh(new THREE.SphereGeometry(1200,32,16),
new THREE.ShaderMaterial({ side:THREE.BackSide, depthWrite:false, uniforms:{},
vertexShader:`varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.); }`,
fragmentShader:`varying vec3 vp; void main(){ float h=normalize(vp).y*0.5+0.5;
vec3 top=vec3(0.03,0.05,0.11), midc=vec3(0.05,0.04,0.10), bot=vec3(0.01,0.01,0.03);
vec3 c=mix(bot, mix(midc,top,smoothstep(0.5,1.0,h)), smoothstep(0.0,0.6,h));
c+=vec3(0.05,0.03,0.09)*pow(max(0.,1.-abs(h-0.46)*6.0),2.0); // horizon glow band
gl_FragColor=vec4(c,1.); }` }));
scene.add(dome);
// drifting dust
const DUST=900, dpos=new Float32Array(DUST*3);
for(let i=0;i<DUST;i++){ dpos[i*3]=(Math.random()-0.5)*640; dpos[i*3+1]=Math.random()*160+2; dpos[i*3+2]=(Math.random()-0.2)*520; }
const dustGeo=new THREE.BufferGeometry(); dustGeo.setAttribute('position',new THREE.BufferAttribute(dpos,3));
const dust=new THREE.Points(dustGeo, new THREE.PointsMaterial({ color:0x8fb4ff, size:0.6, transparent:true, opacity:0.5,
blending:THREE.AdditiveBlending, depthWrite:false }));
scene.add(dust);
// ───────────────────────── floor ─────────────────────────
const reflector=new Reflector(new THREE.PlaneGeometry(2000,2000), {
textureWidth:1024, textureHeight:1024, color:0x0a0d16 });
reflector.rotation.x=-Math.PI/2; reflector.position.y=0; scene.add(reflector);
// tint the mirror down so it's a sheen, not a mirror
const sheen=new THREE.Mesh(new THREE.PlaneGeometry(2000,2000),
new THREE.MeshStandardMaterial({ color:0x07080d, transparent:true, opacity:0.72, roughness:0.6, metalness:0.2 }));
sheen.rotation.x=-Math.PI/2; sheen.position.y=0.01; sheen.receiveShadow=true; scene.add(sheen);
// glowing floor grid
const grid=new THREE.GridHelper(1600,160,0x2a3550,0x141a28);
grid.material.transparent=true; grid.material.opacity=0.35; grid.position.y=0.02; scene.add(grid);
// ───────────────────────── lights ─────────────────────────
scene.add(new THREE.HemisphereLight(0x9fb8ff,0x05060a,0.55));
const key=new THREE.DirectionalLight(0xfff2e0,1.05); key.position.set(70,170,60); // warm key
key.castShadow=true; key.shadow.mapSize.set(2048,2048); key.shadow.bias=-0.0004;
Object.assign(key.shadow.camera,{ left:-300,right:300,top:300,bottom:-300,near:20,far:600 });
scene.add(key);
const fill=new THREE.DirectionalLight(0x4d6bff,0.5); fill.position.set(-90,60,-40); scene.add(fill); // cool fill
// ───────────────────────── postprocessing ─────────────────────────
const composer=new EffectComposer(renderer);
composer.addPass(new RenderPass(scene,camera));
const bloom=new UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight), 0.55, 0.6, 0.22);
composer.addPass(bloom);
composer.addPass(new OutputPass());
// ───────────────────────── label sprites (neon) ─────────────────────────
function makeLabel(lines,{ w=520, scale=1, glow='#6db3f2' }={}){
const pad=18, lh=34, cnv=document.createElement('canvas');
const h=pad*2+lines.length*lh; const dpr=2; cnv.width=w*dpr; cnv.height=h*dpr;
const g=cnv.getContext('2d'); g.scale(dpr,dpr);
g.fillStyle='rgba(9,11,17,0.78)'; roundRect(g,0,0,w,h,15); g.fill();
g.strokeStyle=glow+'88'; g.lineWidth=1.5; roundRect(g,1,1,w-2,h-2,14); g.stroke();
g.textBaseline='top';
lines.forEach((ln,i)=>{ g.shadowColor=ln.sub?'transparent':glow; g.shadowBlur=ln.sub?0:12;
g.fillStyle=ln.sub?'#9aa4b8':'#ffffff';
g.font=`${ln.sub?'400 22px':'700 26px'} -apple-system,Segoe UI,sans-serif`;
g.fillText(ln.t,pad,pad+i*lh); });
const tex=new THREE.CanvasTexture(cnv); tex.anisotropy=4;
const spr=new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false }));
spr.scale.set(w/40*scale, h/40*scale, 1); return spr;
}
function roundRect(g,x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r);
g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
// ── live clock chip (repaintable canvas sprite) — one per DOING room ──────────
// DTD-committed design: big number = time-in-doing (duration); small line = "last
// active Xs ago"; 3 states GREEN(live)/AMBER(idle)/GREY(orphan); RED numeral if overdue.
function fmtDur(ms){ if(ms<0||!isFinite(ms)) ms=0; let s=Math.floor(ms/1000);
const h=Math.floor(s/3600); s-=h*3600; const m=Math.floor(s/60); s-=m*60;
const p=n=>String(n).padStart(2,'0');
return h>0 ? h+':'+p(m)+':'+p(s) : p(m)+':'+p(s); }
function fmtAgo(ms){ if(ms<0||!isFinite(ms)) return '—'; const s=Math.floor(ms/1000);
if(s<60) return s+'s'; const m=Math.floor(s/60); if(m<60) return m+'m';
const h=Math.floor(m/60); return h<24 ? h+'h' : Math.floor(h/24)+'d'; }
const CLK = { live:'#3ee6a0', idle:'#f5b74a', orphan:'#586074' }; // state accents
function makeClockSprite(){ const w=360,h=90,dpr=2,cnv=document.createElement('canvas');
cnv.width=w*dpr; cnv.height=h*dpr; const g=cnv.getContext('2d'); g.scale(dpr,dpr);
const tex=new THREE.CanvasTexture(cnv); tex.anisotropy=4;
const spr=new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false }));
spr.scale.set(w/40, h/40, 1); spr.userData={ cnv, g, tex, w, h, last:'' }; return spr; }
// st: {state:'live'|'idle'|'orphan', dur, ago, worker, blink, overdue}
function drawClock(spr,st){
const key=st.state+'|'+st.dur+'|'+st.ago+'|'+(st.worker||'')+'|'+(st.blink?1:0)+'|'+(st.overdue?1:0);
const u=spr.userData; if(key===u.last) return; u.last=key; const { g,w,h }=u;
const accent=CLK[st.state]||CLK.idle;
g.clearRect(0,0,w,h);
g.fillStyle='rgba(9,11,17,0.84)'; roundRect(g,0,0,w,h,16); g.fill();
g.strokeStyle=accent+(st.state==='live'?'cc':'88'); g.lineWidth=2; roundRect(g,1.5,1.5,w-3,h-3,15); g.stroke();
// status dot (+ halo) — blinks only while live; solid otherwise. Bigger so the
// blink survives the bloom pass at camera distance.
g.strokeStyle=accent+'55'; g.lineWidth=2; g.beginPath(); g.arc(28,h/2,13,0,Math.PI*2); g.stroke();
g.fillStyle = (st.state==='live' && st.blink) ? '#0b0d11' : accent;
g.beginPath(); g.arc(28,h/2,9,0,Math.PI*2); g.fill();
// big duration numeral (RED when stalled/overdue) — the "how long in doing" signal
g.textBaseline='middle'; g.textAlign='left';
g.shadowColor=st.overdue?'#ff6b7a':accent; g.shadowBlur=st.state==='live'?16:9;
g.fillStyle=st.overdue?'#ff8a95':(st.state==='live'?'#8affce':(st.state==='orphan'?'#6a7282':'#e8c070'));
g.font='800 34px ui-monospace,SFMono-Regular,Menlo,monospace'; g.fillText(st.dur, 50, h/2+1);
// right column: worker/state label (large) over "last active" (readable) — the "who/now" signal
const wn=(st.worker||''); const w12=wn.length>12?wn.slice(0,11)+'…':wn;
g.shadowBlur=0; g.textAlign='right';
g.font='700 20px -apple-system,Segoe UI,sans-serif'; g.fillStyle=accent;
const top = st.state==='live' ? ('● '+(w12||'working'))
: st.state==='orphan' ? 'no agent' : (w12?('@'+w12):'unassigned');
g.fillText(top, w-16, h*0.35);
g.font='500 17px -apple-system,Segoe UI,sans-serif'; g.fillStyle='#99a3b6';
const sub = st.state==='orphan' ? 'no actor yet'
: st.state==='live' ? ('last '+st.ago+' ago') : ('idle '+st.ago);
g.fillText(sub, w-16, h*0.67);
u.tex.needsUpdate=true; }
function hashHue(s){ let h=0; for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return h%360; }
function agentColor(n){ const c=new THREE.Color(); c.setHSL(hashHue(n)/360,0.7,0.6); return c; }
const hex=n=>'#'+n.toString(16).padStart(6,'0');
// ───────────────────────── room ─────────────────────────
function buildRoom(t){
const grp=new THREE.Group(); const col=COLOR[t.status]??COLOR.open;
// floor slab
const floor=new THREE.Mesh(new THREE.BoxGeometry(ROOM,0.5,ROOM),
new THREE.MeshStandardMaterial({ color:col, roughness:0.5, metalness:0.4,
emissive:col, emissiveIntensity:t.status==='doing'?0.42:(t.status==='blocked'?0.34:0.12) }));
floor.position.y=0.25; floor.receiveShadow=true; grp.add(floor);
// glowing perimeter rim (additive line loop on the top edge)
const rimPts=[[-ROOM/2,-ROOM/2],[ROOM/2,-ROOM/2],[ROOM/2,ROOM/2],[-ROOM/2,ROOM/2],[-ROOM/2,-ROOM/2]]
.map(([x,z])=>new THREE.Vector3(x,0.56,z));
const rim=new THREE.Line(new THREE.BufferGeometry().setFromPoints(rimPts),
new THREE.LineBasicMaterial({ color:col, transparent:true, opacity:0.95, blending:THREE.AdditiveBlending, depthWrite:false }));
grp.add(rim);
// green "RUNNING" edge — a bright box outline that lights up only for live (doing) tickets
const runEdge=new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(ROOM+0.7,2.4,ROOM+0.7)),
new THREE.LineBasicMaterial({ color:RUN, transparent:true, opacity:0, blending:THREE.AdditiveBlending, depthWrite:false }));
runEdge.position.y=1.2; grp.add(runEdge);
// glass walls
const wallMat=new THREE.MeshPhysicalMaterial({ color:0x0d1119, roughness:0.25, metalness:0.1,
transmission:0.0, transparent:true, opacity:0.32, side:THREE.DoubleSide });
const wallH=2.0, tk=0.18;
for(const [dx,dz,ww,dd] of [[0,-ROOM/2,ROOM,tk],[0,ROOM/2,ROOM,tk],[-ROOM/2,0,tk,ROOM],[ROOM/2,0,tk,ROOM]]){
const w=new THREE.Mesh(new THREE.BoxGeometry(ww,wallH,dd),wallMat);
w.position.set(dx,wallH/2,dz); grp.add(w);
}
// neon top trim on near wall
const trim=new THREE.Mesh(new THREE.BoxGeometry(ROOM,0.16,0.2),
new THREE.MeshBasicMaterial({ color:col }));
trim.position.set(0,wallH+0.05,ROOM/2); grp.add(trim);
// desk
const desk=new THREE.Mesh(new THREE.BoxGeometry(2.6,0.9,1.4),
new THREE.MeshStandardMaterial({ color:0x161b26, roughness:0.7, metalness:0.3 }));
desk.position.set(-ROOM/2+2,0.7,-ROOM/2+1.4); desk.castShadow=true; grp.add(desk);
const mon=new THREE.Mesh(new THREE.BoxGeometry(1.5,0.9,0.08),
new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.7 }));
mon.position.set(-ROOM/2+2,1.55,-ROOM/2+1.0); grp.add(mon);
const label=roomLabel(t);
label.position.set(0,6.6,0); grp.add(label);
// live clock chip — only shown for DOING rooms (see apply() + tick())
const clock=makeClockSprite(); clock.position.set(0,3.6,0); clock.visible=false; grp.add(clock);
grp.userData={ ticket:t, floor, rim, trim, mon, label, clock, runEdge,
baseEmis:floor.material.emissiveIntensity, flash:0, col,
workSince:+new Date(t.created_at||t.updated_at||Date.now()),
lastActionTs:+new Date(t.updated_at||t.created_at||Date.now()),
worker:null, liveWork:false, orphan:false };
return grp;
}
function roomLabel(t){
const st=(t.title||''); const short=st.length>44?st.slice(0,42)+'…':st;
return makeLabel([
{ t:t.id.replace(/^(TK-\d+).*/, '$1')+' · '+(t.status||'').toUpperCase() },
{ t:short, sub:true },
{ t:'@'+(t.assignee||'unassigned')+(t.project?(' ['+t.project+']'):''), sub:true },
], { scale:0.9, glow:hex(COLOR[t.status]??COLOR.open) });
}
// ───────────────────────── avatar ─────────────────────────
function buildAvatar(name){
const grp=new THREE.Group(); const col=agentColor(name);
const body=new THREE.Mesh(new THREE.CapsuleGeometry(0.9,1.7,6,16),
new THREE.MeshStandardMaterial({ color:col, roughness:0.35, metalness:0.5,
emissive:col, emissiveIntensity:0.35 }));
body.position.y=1.7; body.castShadow=true; grp.add(body);
const head=new THREE.Mesh(new THREE.SphereGeometry(0.72,20,20),
new THREE.MeshStandardMaterial({ color:0xffe2c4, roughness:0.6, metalness:0.1 }));
head.position.y=3.3; head.castShadow=true; grp.add(head);
const visor=new THREE.Mesh(new THREE.SphereGeometry(0.74,20,20,0,Math.PI*2,0,Math.PI*0.55),
new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.5, transparent:true, opacity:0.5 }));
visor.position.y=3.35; visor.rotation.x=-0.3; grp.add(visor);
const ring=new THREE.Mesh(new THREE.RingGeometry(1.3,1.75,32),
new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.25, side:THREE.DoubleSide,
blending:THREE.AdditiveBlending, depthWrite:false }));
ring.rotation.x=-Math.PI/2; ring.position.y=0.12; grp.add(ring);
// green "running" halo — a crisp torus outline that appears only for live agents
const runRing=new THREE.Mesh(new THREE.TorusGeometry(1.55,0.07,10,44),
new THREE.MeshBasicMaterial({ color:RUN, transparent:true, opacity:0, blending:THREE.AdditiveBlending, depthWrite:false }));
runRing.rotation.x=-Math.PI/2; runRing.position.y=0.18; grp.add(runRing);
const light=new THREE.PointLight(col.getHex(),0.0,10); light.position.y=2.4; grp.add(light);
const label=makeLabel([{t:name}],{ w:Math.max(180,name.length*15+56), scale:0.5, glow:hex(col.getHex()) });
label.position.set(0,4.8,0); grp.add(label);
grp.userData={ name,body,head,ring,runRing,light,label,col,target:new THREE.Vector3(),phase:Math.random()*6.28,working:0,room:null,live:false };
return grp;
}
// ───────────────────────── DM comet ─────────────────────────
const packets=[];
function spawnPacket(from,to,broadcast){
const c=broadcast?0xffcf6b:0xc9a4f2;
const head=new THREE.Mesh(new THREE.IcosahedronGeometry(0.72,1), new THREE.MeshBasicMaterial({color:c}));
head.add(new THREE.Mesh(new THREE.SphereGeometry(1.7,16,16),
new THREE.MeshBasicMaterial({ color:c, transparent:true, opacity:0.22, blending:THREE.AdditiveBlending, depthWrite:false })));
const light=new THREE.PointLight(c,3.2,34); head.add(light); scene.add(head);
const MAX=46, buf=new Float32Array(MAX*3), tg=new THREE.BufferGeometry();
tg.setAttribute('position',new THREE.BufferAttribute(buf,3));
const trail=new THREE.Line(tg,new THREE.LineBasicMaterial({ color:c, transparent:true, opacity:0.85,
blending:THREE.AdditiveBlending, depthWrite:false })); trail.frustumCulled=false; scene.add(trail);
const mid=from.clone().add(to).multiplyScalar(0.5); mid.y+=from.distanceTo(to)*0.32+9;
packets.push({ head,trail,buf,tg,hist:[],MAX,from,mid,to,t:0,dur:1.25+from.distanceTo(to)/210 });
}
function qbez(a,b,c,t,o){ const u=1-t; o.x=u*u*a.x+2*u*t*b.x+t*t*c.x; o.y=u*u*a.y+2*u*t*b.y+t*t*c.y; o.z=u*u*a.z+2*u*t*b.z+t*t*c.z; return o; }
// ───────────────────────── state + data ─────────────────────────
const roomsGrp=new THREE.Group(); scene.add(roomsGrp);
const avatarsGrp=new THREE.Group(); scene.add(avatarsGrp);
const roomByTicket=new Map(), avatarByName=new Map();
let seenMsgMids=null, lastActionTs=0, lastFetch=0;
let showLabels=true, showDone=true, followDMs=false, showRunning=true, showProcs=true;
let laneSigns=null;
// Where each agent stands. An agent that is WORKING — owns or is acting on a
// live doing/blocked ticket — belongs in THAT room, even if they more-recently
// dropped a stray comment on some done ticket. So we rank active lanes (and being
// the assignee) above pure recency, and only fall back to latest-touch for agents
// with no active work. Returns name → { ticketId, working }.
function agentPlacement(tk){
const ACTIVE={ doing:3, blocked:2, open:1, done:0 };
const best=new Map();
const put=(name,ts,t,assigned)=>{ if(!name) return;
// rank: active lane dominates; assignee of that ticket beats a mere toucher
const rank=(ACTIVE[t.status]??0)*10 + (assigned?2:0);
const cur=best.get(name);
if(!cur || rank>cur.rank || (rank===cur.rank && ts>cur.ts))
best.set(name,{ ts, ticketId:t.id, rank, working:t.status==='doing' });
};
for(const t of tk){
(t.actions ||[]).forEach(a=>put(a.agent,+new Date(a.ts??0),t, t.assignee===a.agent));
(t.comments||[]).forEach(c=>put(c.agent,+new Date(c.ts??0),t, t.assignee===c.agent));
if(t.assignee) put(t.assignee,+new Date(t.updated_at??0),t,true);
}
return best; }
function layout(tk){
const byLane={doing:[],blocked:[],open:[],done:[]};
for(const t of tk) if(byLane[t.status]) byLane[t.status].push(t);
for(const k of LANES) byLane[k].sort((a,b)=>a.updated_at<b.updated_at?1:-1);
byLane.done=byLane.done.slice(0,DONE_CAP);
const want=new Set();
LANES.forEach((lane,li)=>{ if(lane==='done'&&!showDone) return;
const x=li*LANE_W-LANE_W*1.5+30;
byLane[lane].forEach((t,ri)=>{ const z=ri*PITCH; want.add(t.id);
let room=roomByTicket.get(t.id);
if(!room){ room=buildRoom(t); roomByTicket.set(t.id,room); roomsGrp.add(room); } else updateRoom(room,t);
room.position.set(x,0,z); room.visible=true; });
});
for(const [id,room] of roomByTicket) if(!want.has(id)) room.visible=false;
if(!laneSigns){ laneSigns=new THREE.Group(); scene.add(laneSigns);
LANES.forEach((lane,li)=>{ const x=li*LANE_W-LANE_W*1.5+30;
const s=makeLabel([{t:lane.toUpperCase()}],{ w:260, scale:1.5, glow:hex(COLOR[lane]) });
s.position.set(x,12,-PITCH); laneSigns.add(s);
// continuous colored floor stripe = lane identity readable from any angle
const stripe=new THREE.Mesh(new THREE.PlaneGeometry(ROOM+4, PITCH*15),
new THREE.MeshBasicMaterial({ color:COLOR[lane], transparent:true, opacity:0.07,
blending:THREE.AdditiveBlending, depthWrite:false }));
stripe.rotation.x=-Math.PI/2; stripe.position.set(x,0.04,PITCH*6.2); scene.add(stripe);
const edge=new THREE.Mesh(new THREE.PlaneGeometry(0.3, PITCH*15),
new THREE.MeshBasicMaterial({ color:COLOR[lane], transparent:true, opacity:0.5,
blending:THREE.AdditiveBlending, depthWrite:false }));
edge.rotation.x=-Math.PI/2; edge.position.set(x-(ROOM+4)/2,0.05,PITCH*6.2); scene.add(edge);
}); }
}
function updateRoom(room,t){ const u=room.userData, prev=u.ticket; u.ticket=t;
const col=COLOR[t.status]??COLOR.open; u.col=col;
u.floor.material.color.setHex(col); u.floor.material.emissive.setHex(col);
u.rim.material.color.setHex(col); u.trim.material.color.setHex(col); u.mon.material.color.setHex(col); u.mon.material.emissive.setHex(col);
u.baseEmis=t.status==='doing'?0.42:(t.status==='blocked'?0.34:0.12);
if(prev.status!==t.status||prev.assignee!==t.assignee){ room.remove(u.label);
u.label.material.map.dispose(); u.label.material.dispose();
u.label=roomLabel(t); u.label.position.set(0,6.6,0); room.add(u.label); }
}
function slotInRoom(p,i,n){ if(n<=1) return new THREE.Vector3(p.x,0,p.z+1.2);
const r=Math.min(2.6,1.2+n*0.15),a=(i/n)*Math.PI*2; return new THREE.Vector3(p.x+Math.cos(a)*r,0,p.z+Math.sin(a)*r); }
function placeAvatars(tk){ const place=agentPlacement(tk), perRoom=new Map(), assign=[];
for(const av of avatarByName.values()) av.userData.atWork=false; // clear stale glow before re-seating
for(const [name,info] of place){ const room=roomByTicket.get(info.ticketId);
const rid=(room&&room.visible)?info.ticketId:null; assign.push({name,rid,working:info.working&&!!rid});
if(rid) perRoom.set(rid,(perRoom.get(rid)||0)+1); }
const lobby=[], idx=new Map();
for(const a of assign){ let av=avatarByName.get(a.name);
if(!av){ av=buildAvatar(a.name); avatarByName.set(a.name,av); avatarsGrp.add(av);
av.position.set(a.rid?roomByTicket.get(a.rid).position.x:-46, 0, a.rid?roomByTicket.get(a.rid).position.z:30); }
av.userData.atWork=a.working; // persistent "working" glow while in a doing room
if(a.rid){ const room=roomByTicket.get(a.rid),n=perRoom.get(a.rid),i=idx.get(a.rid)||0; idx.set(a.rid,i+1);
av.userData.target.copy(slotInRoom(room.position,i,n)); av.userData.room=a.rid; } else lobby.push(av); }
lobby.forEach((av,i)=>{ av.userData.atWork=false; av.userData.target.set(-48+(i%10)*7,0,26+Math.floor(i/10)*7); av.userData.room=null; });
}
// ───────────────────────── process wall (pm2 online + live sessions) ─────────
// A grid of green-edged panels behind the lanes — one per online pm2 service.
// This is the literal "green edge for what is running" for OS-level processes.
const PROC_Z=188, PROC_YBASE=16, PROC_S=4.6, PROC_BOX=3.2; // elevated status board near the camera
const procWall=new THREE.Group(); procWall.rotation.x=-0.32; scene.add(procWall); // tilt toward the downward camera
let procNodes=[], procHeader=null, procCount=-1, procHeaderTxt='', procYTop=40;
function setProcHeader(n,sessions){ const txt='pm2 · '+n+' online · '+sessions+' live sessions';
if(txt===procHeaderTxt && procHeader) return; procHeaderTxt=txt;
if(procHeader){ procWall.remove(procHeader); procHeader.material.map.dispose(); procHeader.material.dispose(); }
procHeader=makeLabel([{t:'FLEET PROCESSES'},{t:txt,sub:true}],{ w:520, scale:2.0, glow:hex(RUN) });
procHeader.position.set(30, procYTop+6, PROC_Z); procWall.add(procHeader); }
function buildProcWall(list, sessions){ const n=list.length;
if(n!==procCount){ procCount=n;
for(const nd of procNodes){ procWall.remove(nd); nd.traverse(o=>{ o.geometry&&o.geometry.dispose&&o.geometry.dispose(); o.material&&o.material.dispose&&o.material.dispose(); }); }
procNodes=[];
const cols=Math.max(1,Math.ceil(Math.sqrt(n*2.4))), rows=Math.max(1,Math.ceil(n/cols));
const x0=30-(cols-1)*PROC_S/2; procYTop=PROC_YBASE+(rows-1)*PROC_S;
for(let i=0;i<n;i++){ const r=Math.floor(i/cols), c=i%cols; const nd=new THREE.Group();
const box=new THREE.BoxGeometry(PROC_BOX,PROC_BOX,0.5);
const panel=new THREE.Mesh(box,new THREE.MeshStandardMaterial({ color:0x0c2019, emissive:RUN, emissiveIntensity:0.25, roughness:0.5, metalness:0.3 }));
const edge=new THREE.LineSegments(new THREE.EdgesGeometry(box),
new THREE.LineBasicMaterial({ color:RUN, transparent:true, opacity:0.9, blending:THREE.AdditiveBlending, depthWrite:false }));
nd.add(panel,edge); nd.position.set(x0+c*PROC_S, procYTop-r*PROC_S, PROC_Z);
nd.userData={ panel, edge, phase:Math.random()*6.28, proc:list[i] };
procWall.add(nd); procNodes.push(nd); }
} else { for(let i=0;i<n;i++) if(procNodes[i]) procNodes[i].userData.proc=list[i]; }
setProcHeader(n,sessions); procWall.visible=showProcs;
}
function applyRunning(d){
const set=(id,v)=>{ const el=document.getElementById(id); if(el) el.textContent=v; };
set('s-running',(d.pm2||[]).length); set('s-sessions',d.sessions||0);
buildProcWall(d.pm2||[], d.sessions||0);
}
async function poll(){
try{
const api=p=>new URL(p,location.origin).href; // no embedded creds → fetch-legal
const [tr,mr]=await Promise.all([ fetch(api('/api/tickets'),{credentials:'same-origin'}),
fetch(api('/api/messages'),{credentials:'same-origin'}) ]);
if(!tr.ok||!mr.ok) throw new Error('HTTP '+tr.status+'/'+mr.status);
apply(await tr.json(), await mr.json());
document.getElementById('err').style.display='none'; lastFetch=Date.now();
}catch(e){ const el=document.getElementById('err'); el.textContent='⚠ '+e.message+' — retrying'; el.style.display='block'; }
}
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function worldPos(av){ const v=new THREE.Vector3(); av.getWorldPosition(v); v.y=3.3; return v; }
function apply(tk,msgs){
layout(tk); placeAvatars(tk);
// heartbeat: an agent that acted/commented within LIVE_MS is "running" → green ring
const now=Date.now(), lastByAgent=new Map();
for(const t of tk){ for(const a of t.actions||[]){ const ts=+new Date(a.ts); if(ts>(lastByAgent.get(a.agent)||0)) lastByAgent.set(a.agent,ts); }
for(const c of t.comments||[]){ const ts=+new Date(c.ts); if(ts>(lastByAgent.get(c.agent)||0)) lastByAgent.set(c.agent,ts); } }
let liveCount=0;
for(const [nm,av] of avatarByName){ const live=(now-(lastByAgent.get(nm)||0))<LIVE_MS; av.userData.live=live; if(live) liveCount++; }
const liveEl=document.getElementById('s-live'); if(liveEl) liveEl.textContent=liveCount;
// ── per-DOING-room work clock (DTD-committed) ─────────────────────────────
// Liveness is scoped to activity ON THIS TICKET (not the agent's global
// heartbeat) so a room only reads "live" when someone is actually working IT.
// Big number = time in doing (earliest action ≈ doing-entry); sub = last-active.
for(const t of tk){ const room=roomByTicket.get(t.id); if(!room) continue; const u=room.userData;
if(t.status!=='doing'){ u.liveWork=false; continue; }
let lwt=0, ewt=Infinity, worker=null, workerHere=0, asgHere=0;
for(const a of t.actions||[]){ const ts=+new Date(a.ts);
if(ts>lwt){ lwt=ts; worker=a.agent; } // most-recent action here → candidate worker
if(ts<ewt) ewt=ts; // earliest action here → doing-entry proxy
if(a.agent===t.assignee && ts>asgHere) asgHere=ts; }
if(worker) for(const a of t.actions||[]) if(a.agent===worker){ const ts=+new Date(a.ts); if(ts>workerHere) workerHere=ts; }
if(!lwt) lwt=+new Date(t.updated_at||t.created_at||now);
if(!isFinite(ewt)) ewt=+new Date(t.created_at||t.updated_at||now);
const workerLive = worker && (now-workerHere)<LIVE_MS; // worker acted on THIS ticket within 8min
const asgLive = t.assignee && (now-asgHere)<LIVE_MS; // assignee acted on THIS ticket within 8min
u.workSince=ewt; u.lastActionTs=lwt;
u.worker = asgLive ? t.assignee : (workerLive ? worker : (t.assignee||worker));
u.liveWork = !!(workerLive||asgLive);
u.orphan = !worker && !t.assignee; } // nobody owns it and nobody's ever acted
let maxTs=lastActionTs, latest=null;
for(const t of tk) for(const a of t.actions||[]){ const ts=+new Date(a.ts);
if(ts>lastActionTs){ const room=roomByTicket.get(t.id); if(room) room.userData.flash=1;
const av=avatarByName.get(a.agent); if(av) av.userData.working=2.2; }
if(ts>=+new Date((latest?.ts)||0)) latest={agent:a.agent,ts:a.ts,text:a.text,id:t.id};
if(ts>maxTs) maxTs=ts; }
lastActionTs=maxTs;
// activity log — 3 most-recent actions across all tickets, newest first
const evs=[];
for(const t of tk) for(const a of t.actions||[]) evs.push({ ts:+new Date(a.ts), agent:a.agent, id:t.id, text:a.text });
evs.sort((x,y)=>y.ts-x.ts);
const hhmm=ms=>{ const d=new Date(ms); return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'); };
const logEl=document.getElementById('log');
logEl.innerHTML=evs.length ? evs.slice(0,3).map((e,i)=>
`<div class="lgrow${i===0?' fresh':''}"><span class="t">${hhmm(e.ts)}</span><span class="ag">@${esc(e.agent||'—')}</span>`+
`<span class="tk">${esc(e.id.replace(/^(TK-\d+).*/,'$1'))}</span><span class="mx">${esc((e.text||'').slice(0,200))}</span></div>`).join('')
: '<div class="lgrow muted">no activity yet</div>';
if(seenMsgMids===null) seenMsgMids=new Set(msgs.map(m=>m.mid));
else for(const m of msgs){ if(seenMsgMids.has(m.mid)) continue; seenMsgMids.add(m.mid);
const from=avatarByName.get(m.from); if(!from) continue;
if(m.to==='all'||m.to==='*'||m.to==='everyone'){ for(const [nm,av] of avatarByName) if(nm!==m.from) spawnPacket(worldPos(from),worldPos(av),true); }
else { const to=avatarByName.get(m.to); if(to) spawnPacket(worldPos(from),worldPos(to),false); } }
const cnt={doing:0,blocked:0,open:0,done:0}; for(const t of tk) if(cnt[t.status]!=null) cnt[t.status]++;
const totalActive=(cnt.doing+cnt.blocked+cnt.open+cnt.done)||1;
const set=(id,v)=>{ const el=document.getElementById(id); if(el) el.textContent=v; };
set('s-rooms', cnt.doing+cnt.blocked+cnt.open+(showDone?Math.min(cnt.done,DONE_CAP):0));
set('s-agents', avatarByName.size); set('s-doing',cnt.doing); set('s-blocked',cnt.blocked);
set('s-open',cnt.open); set('s-done',cnt.done); set('s-dms',msgs.length);
for(const k of ['doing','blocked','open','done']){ set('lg-'+k,cnt[k]);
const bar=document.getElementById('bar-'+k); if(bar) bar.style.width=Math.round(cnt[k]/totalActive*100)+'%'; }
}
// ───────────────────────── hover tooltip ─────────────────────────
const ray=new THREE.Raycaster(), mouse=new THREE.Vector2(); let hover=null;
addEventListener('pointermove',e=>{ mouse.x=(e.clientX/innerWidth)*2-1; mouse.y=-(e.clientY/innerHeight)*2+1; hover={x:e.clientX,y:e.clientY}; });
function updateTip(){ const tip=document.getElementById('tip'); if(!hover){ tip.style.display='none'; return; }
ray.setFromCamera(mouse,camera); const hits=ray.intersectObjects(roomsGrp.children,true);
let o=hits.length?hits[0].object:null; while(o&&!(o.userData&&o.userData.ticket)) o=o.parent;
if(!o){ // no room hit — try a process-wall node
if(showProcs){ const ph=ray.intersectObjects(procWall.children,true); let p=ph.length?ph[0].object:null;
while(p&&!(p.userData&&p.userData.proc)) p=p.parent;
if(p){ const pr=p.userData.proc;
tip.style.borderLeftColor='#34d9a0';
tip.innerHTML=`<div class="h" style="color:#34d9a0">${esc(pr.name)}</div>
<div class="s">pm2 · online · ${pr.cpu}% cpu · ${pr.mem} MB${pr.restarts?(' · '+pr.restarts+' restarts'):''}</div>`;
tip.style.display='block'; tip.style.left=Math.min(hover.x+14,innerWidth-372)+'px'; tip.style.top=Math.min(hover.y+14,innerHeight-120)+'px'; return; } }
tip.style.display='none'; return; }
tip.style.borderLeftColor='#5aa9f0'; const t=o.userData.ticket;
const last=(t.actions&&t.actions[t.actions.length-1])||(t.comments&&t.comments[t.comments.length-1]);
tip.innerHTML=`<div class="h">${esc(t.id.replace(/^(TK-\d+).*/,'$1'))} · ${esc(t.status.toUpperCase())}</div>
<div>${esc(t.title||'')}</div><div class="s">@${esc(t.assignee||'unassigned')}${t.project?(' · '+esc(t.project)):''} · ${(t.actions||[]).length} actions · ${(t.comments||[]).length} comments</div>
${last?`<div class="t">${esc((last.text||'').slice(0,220))}</div>`:''}`;
tip.style.display='block'; tip.style.left=Math.min(hover.x+14,innerWidth-372)+'px'; tip.style.top=Math.min(hover.y+14,innerHeight-170)+'px'; }
// ───────────────────────── buttons ─────────────────────────
const B=id=>document.getElementById(id);
B('b-orbit').onclick=e=>{ controls.autoRotate=!controls.autoRotate; e.target.classList.toggle('on',controls.autoRotate); };
B('b-labels').onclick=e=>{ showLabels=!showLabels; e.target.classList.toggle('on',showLabels); };
B('b-run').onclick=e=>{ showRunning=!showRunning; e.target.classList.toggle('on',showRunning); };
B('b-procs').onclick=e=>{ showProcs=!showProcs; e.target.classList.toggle('on',showProcs); procWall.visible=showProcs; };
B('b-done').onclick=e=>{ showDone=!showDone; e.target.classList.toggle('on',showDone); poll(); };
B('b-follow').onclick=e=>{ followDMs=!followDMs; e.target.classList.toggle('on',followDMs); };
B('b-reset').onclick=()=>{ boot.t=1; camera.position.copy(HOME.pos); controls.target.copy(HOME.tgt); hideBoot(); };
let bootHidden=false;
function hideBoot(){ if(bootHidden) return; bootHidden=true; const b=document.getElementById('boot');
b.style.opacity=0; setTimeout(()=>b.style.display='none',800); }
setTimeout(hideBoot, 3400); // wall-clock guarantee the curtain always lifts, even on a slow GPU
B('b-ping').onclick=()=>{ const a=[...avatarByName.values()]; if(a.length<2) return;
for(let k=0;k<5;k++){ const x=a[(Math.random()*a.length)|0],y=a[(Math.random()*a.length)|0]; if(x!==y) spawnPacket(worldPos(x),worldPos(y),Math.random()<0.25); } };
window.__ping=()=>B('b-ping').click(); window.__orbit=on=>{ controls.autoRotate=on; };
// ───────────────────────── animate ─────────────────────────
const clock=new THREE.Clock(); const boot={ t:0, from:new THREE.Vector3(30,540,540) };
function tick(){
const dt=Math.min(clock.getDelta(),0.05), el=clock.elapsedTime, nowMs=Date.now();
// cinematic fly-in
if(boot.t<1){ boot.t=Math.min(1,boot.t+dt/2.6); const e=1-Math.pow(1-boot.t,3);
camera.position.lerpVectors(boot.from,HOME.pos,e);
if(boot.t>=1) hideBoot(); }
// avatars
for(const av of avatarByName.values()){ const u=av.userData;
av.position.lerp(u.target,1-Math.pow(0.0015,dt));
const moving=av.position.distanceToSquared(u.target)>0.05;
const bob=moving?Math.abs(Math.sin(el*7+u.phase))*0.28:Math.sin(el*2+u.phase)*0.07;
u.body.position.y=1.7+bob; u.head.position.y=3.3+bob;
if(moving){ const d=u.target.clone().sub(av.position); av.rotation.y=Math.atan2(d.x,d.z); }
if(u.working>0) u.working-=dt;
const w=u.working>0||u.atWork; u.ring.material.opacity=w?(0.5+0.4*Math.sin(el*10)):0.18;
u.ring.scale.setScalar(1+(w?0.18*Math.sin(el*10):0)); u.light.intensity=w?2.0+Math.sin(el*12):0.0;
u.body.material.emissiveIntensity=0.35+(w?0.28+0.22*Math.sin(el*6+u.phase):0); // working breath
if(u.runRing){ const on=showRunning&&u.live; u.runRing.material.opacity=on?0.45+0.4*Math.sin(el*5+u.phase):0;
u.runRing.scale.setScalar(on?1+0.12*Math.sin(el*5+u.phase):1); }
u.label.visible=showLabels; }
// rooms
for(const room of roomByTicket.values()){ if(!room.visible) continue; const u=room.userData;
let em=u.baseEmis;
if(u.ticket.status==='doing') em=0.4+0.16*Math.sin(el*2.4+room.position.z*0.2);
else if(u.ticket.status==='blocked') em=0.32+0.14*Math.sin(el*5);
if(u.flash>0){ em+=u.flash*1.0; u.flash-=dt*1.1; }
u.floor.material.emissiveIntensity=em; u.rim.material.opacity=0.7+0.3*Math.sin(el*2+room.position.x);
if(u.runEdge){ const on=showRunning&&u.ticket.status==='doing';
u.runEdge.material.opacity=on?0.5+0.45*Math.sin(el*4+room.position.z*0.3):0; }
// live clock chip — ticks every frame; state + colour reflect ACTUAL work on this ticket
if(u.clock){ const on=u.ticket.status==='doing'&&showLabels; u.clock.visible=on;
if(on){ const durMs=nowMs-u.workSince, idleMs=nowMs-u.lastActionTs;
const state=u.orphan?'orphan':(u.liveWork?'live':'idle');
// OVERDUE alarm keys off IDLE time (stalled), not total age — a live room never reds
drawClock(u.clock,{ state, dur:fmtDur(durMs), ago:fmtAgo(idleMs),
worker:u.worker, blink:(nowMs%1000)<500, overdue:idleMs>OVERDUE_MS });
// live rooms "breathe" via sprite opacity — a distance-visible motion cue,
// no canvas repaint (keeps drawClock's diff-guard intact)
u.clock.material.opacity = u.liveWork ? 0.86+0.14*Math.sin(el*4) : 1; }
u.clock.position.y=3.6+Math.sin(el*2.2+room.position.z*0.2)*0.12; }
u.label.visible=showLabels; }
if(laneSigns) laneSigns.children.forEach((s,i)=>{ s.visible=showLabels; s.position.y=12+Math.sin(el*1.2+i)*0.5; });
// process wall — each online pm2 node breathes green; busy (cpu>3%) nodes pulse faster
if(procWall.visible){ for(const nd of procNodes){ const u=nd.userData; const busy=u.proc&&u.proc.cpu>3;
const pulse=0.5+0.5*Math.sin(el*(busy?7:2.2)+u.phase);
u.edge.material.opacity=showRunning?0.35+0.6*pulse:0.22;
u.panel.material.emissiveIntensity=0.18+(busy?0.55:0.22)*pulse; } }
// packets + trails
for(let i=packets.length-1;i>=0;i--){ const p=packets[i]; p.t+=dt/p.dur;
if(p.t>=1){ scene.remove(p.head,p.trail); p.head.traverse(o=>{o.material?.dispose?.();o.geometry?.dispose?.();});
p.trail.material.dispose(); p.tg.dispose(); packets.splice(i,1); continue; }
qbez(p.from,p.mid,p.to,p.t,p.head.position);
p.head.scale.setScalar(1+0.4*Math.sin(p.t*Math.PI)); p.head.rotation.y+=dt*6; p.head.rotation.x+=dt*4;
p.hist.push(p.head.position.clone()); if(p.hist.length>p.MAX) p.hist.shift();
for(let j=0;j<p.hist.length;j++){ p.buf[j*3]=p.hist[j].x; p.buf[j*3+1]=p.hist[j].y; p.buf[j*3+2]=p.hist[j].z; }
p.tg.setDrawRange(0,p.hist.length); p.tg.attributes.position.needsUpdate=true;
p.trail.material.opacity=0.85*(1-p.t*0.3); }
if(followDMs&&packets.length) controls.target.lerp(packets[0].head.position,0.05);
// dust drift
const dp=dust.geometry.attributes.position; for(let i=0;i<DUST;i++){ dp.array[i*3+1]+=dt*1.2;
if(dp.array[i*3+1]>170) dp.array[i*3+1]=2; } dp.needsUpdate=true; dust.rotation.y=el*0.01;
if(lastFetch) document.getElementById('s-age').textContent=Math.round((Date.now()-lastFetch)/1000)+'s ago';
updateTip(); controls.update(); composer.render();
requestAnimationFrame(tick);
}
function resize(){ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight); composer.setSize(innerWidth,innerHeight); bloom.setSize(innerWidth,innerHeight); }
addEventListener('resize',resize); resize();
// running signal on its OWN cadence, independent of the ticket poll — fires immediately
function pollRunning(){ fetch(new URL('/api/running',location.origin).href,{credentials:'same-origin'})
.then(r=>r.ok?r.json():null).then(d=>{ if(d) applyRunning(d); }).catch(()=>{}); }
poll(); setInterval(poll,POLL_MS);
pollRunning(); setInterval(pollRunning,4000);
tick();
</script>
</body>
</html>