generated from mathias/template-go-web
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the latest run's per-job created_at/updated_at (last job in pipeline order = deploy/stage 07, everything before it = CI/stage 06). Frontend: weightedSpineDist() redistributes the pixel-time-budget the 06/07 segments already had, splitting it by real CI:CD duration ratio instead of raw pixel width. Falls back to the exact prior constant-speed sweep when no run data is available (weights default to segment pixel length) or when a job is skipped (0 duration) — no behavior change for stages 00-05/08, which still have no live timing source (same gap as #5's ledger item). Verified: real duration values flow through /api/atlas.json (ci_duration_s: 18 observed against a real run), full page screenshot confirms no visual regression.
426 lines
21 KiB
HTML
426 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>CAD Atlas · From Signal to Pod</title>
|
|
<style>
|
|
:root{
|
|
--bg:#080b12; --panel:#0e1420; --panel2:#121a2b; --line:#1e2b44;
|
|
--ink:#cdd9ef; --dim:#7f92b5; --mono:#8fb4ff;
|
|
--blue:#4aa8ff; --amber:#f5b942; --coral:#ff7a5c; --green:#4ad07a; --violet:#9b8cff; --gold:#e6c15a;
|
|
}
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;height:100%}
|
|
body{
|
|
background:
|
|
radial-gradient(circle at 1px 1px,#16213a 1px,transparent 0) 0 0/26px 26px,
|
|
var(--bg);
|
|
color:var(--ink);
|
|
font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif;
|
|
-webkit-font-smoothing:antialiased;
|
|
}
|
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
|
|
header{
|
|
position:sticky;top:0;z-index:30;backdrop-filter:blur(8px);
|
|
background:linear-gradient(180deg,rgba(8,11,18,.94),rgba(8,11,18,.72));
|
|
border-bottom:1px solid var(--line);
|
|
display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;padding:14px 22px;
|
|
}
|
|
header h1{font-size:19px;margin:0}
|
|
header h1 b{color:var(--blue)}
|
|
header .sub{color:var(--dim);font-size:12px}
|
|
header .sub em{color:var(--amber);font-style:normal}
|
|
.controls{margin-left:auto;display:flex;gap:8px}
|
|
button{
|
|
font:inherit;font-size:12px;color:var(--ink);cursor:pointer;
|
|
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
|
padding:7px 13px;display:inline-flex;align-items:center;gap:7px;
|
|
}
|
|
button:hover{border-color:var(--blue)}
|
|
button.on{border-color:var(--amber);color:var(--amber)}
|
|
button .dot{width:7px;height:7px;border-radius:50%;background:var(--blue)}
|
|
button.on .dot{background:var(--amber)}
|
|
|
|
.substrate{display:flex;gap:10px;flex-wrap:wrap;align-items:center;
|
|
padding:10px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
.substrate .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px}
|
|
.host{border:1px solid var(--line);border-radius:8px;padding:6px 11px;
|
|
background:var(--panel2);font-size:12px;display:flex;gap:8px;align-items:center}
|
|
.host b{color:var(--blue)}
|
|
.host .k{color:var(--dim);font-size:11px}
|
|
.host.mesh{border-style:dashed;color:var(--dim)}
|
|
|
|
.tl{display:none;gap:5px;align-items:center;flex-wrap:wrap;
|
|
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
.tl .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px}
|
|
.rundot{width:22px;height:16px;border-radius:4px;border:1px solid rgba(0,0,0,.35);
|
|
display:inline-flex;align-items:center;justify-content:center;
|
|
font-size:9px;color:#08121f;font-weight:600}
|
|
|
|
/* progressive disclosure */
|
|
.tech-sub{color:var(--dim);font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;margin:1px 0 5px}
|
|
.plain-what{color:var(--ink);font-size:12.5px;line-height:1.45;margin-bottom:2px;opacity:.92}
|
|
.translabel{fill:var(--mono);font-size:10px;font-family:ui-monospace,SFMono-Regular,monospace}
|
|
.translabel-gate{fill:var(--gold);font-weight:700}
|
|
.stagehead{min-height:64px}
|
|
@media(min-width:821px){
|
|
body.plain .stagehead{min-height:188px}
|
|
.stage .node:first-of-type{margin-top:24px}
|
|
}
|
|
.trans-row{display:none}
|
|
@media(max-width:820px){
|
|
.trans-row{display:block;margin:0 0 10px 6px;padding:7px 12px;border-left:3px solid var(--mono);
|
|
background:var(--panel);border-radius:0 8px 8px 0;font-size:12px;color:var(--dim);line-height:1.45}
|
|
.trans-row .tr-lbl{display:block;color:var(--mono);font-weight:600;margin-bottom:2px}
|
|
.trans-row-gate{border-left-color:var(--gold)}
|
|
.trans-row-gate .tr-lbl{color:var(--gold)}
|
|
}
|
|
/* Plain view hides the jargon-dense infra ribbon + technical footer */
|
|
body.plain .substrate{display:none}
|
|
body.plain footer{display:none}
|
|
.legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;
|
|
padding:7px 22px;border-bottom:1px solid var(--line);background:var(--bg);font-size:11px;color:var(--dim)}
|
|
.legend .lbl{letter-spacing:1.5px;margin-right:2px}
|
|
.legend .lg{display:inline-flex;align-items:center;gap:6px}
|
|
.legend .lg i{width:11px;height:11px;border-radius:3px;display:inline-block;border:1px solid rgba(0,0,0,.35)}
|
|
.legend .dash{width:18px;border-top:2px dashed var(--violet);display:inline-block}
|
|
/* overview rail — see the whole 9-stage shape + jump to any stage */
|
|
.rail{display:flex;gap:4px;flex-wrap:wrap;align-items:center;
|
|
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
.rail .lbl{color:var(--dim);letter-spacing:1.5px;margin-right:4px;font-size:11px}
|
|
.railchip{font:inherit;font-size:11px;cursor:pointer;color:var(--ink);
|
|
background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:4px 9px}
|
|
.railchip:hover{border-color:var(--blue)}
|
|
.railchip b{color:var(--dim);font-weight:600;margin-right:3px}
|
|
.railchip.gate{border-color:rgba(245,185,66,.5)} .railchip.telos{border-color:rgba(155,140,255,.5)}
|
|
.railchip.exec{border-color:rgba(255,122,92,.5)} .railchip.cd{border-color:rgba(74,208,122,.5)}
|
|
.railarr{color:var(--dim);font-size:10px}
|
|
/* Plain view: drop decorative node pills so colour is reserved for live status (keyed in the legend) */
|
|
body.plain .node .pill{display:none}
|
|
|
|
.scroll{overflow-x:auto;padding:24px 22px 20px}
|
|
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content}
|
|
svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible}
|
|
.pulse{position:absolute;top:0;left:0;width:13px;height:13px;border-radius:50%;
|
|
background:var(--amber);box-shadow:0 0 15px 4px rgba(245,185,66,.75);
|
|
transform:translate(-6.5px,-6.5px);z-index:6}
|
|
|
|
.stage{width:300px;flex:0 0 300px;padding:0 14px;position:relative;z-index:2}
|
|
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
|
|
.stage h2{font-size:17px;margin:6px 0 2px}
|
|
.stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px}
|
|
.node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
|
|
padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
|
|
transition:border-color .25s,box-shadow .25s,transform .25s}
|
|
.node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
|
|
.node .d{color:var(--dim);font-size:12px}
|
|
.node .tag{display:inline-block;font-size:10.5px;padding:2px 7px;border-radius:20px;
|
|
border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0}
|
|
.pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
|
|
|
|
.stage.telos h2{color:var(--violet)}
|
|
.stage.gate h2{color:var(--amber)}
|
|
.stage.exec h2{color:var(--coral)}
|
|
.stage.cd h2{color:var(--green)}
|
|
.node.win{border-color:var(--coral);box-shadow:0 0 0 1px rgba(255,122,92,.25)}
|
|
.node.gateway{border-color:var(--amber);background:linear-gradient(180deg,rgba(245,185,66,.06),transparent)}
|
|
.node.council{border-color:var(--violet)}
|
|
.node.bridge{border-color:var(--blue);border-style:dashed}
|
|
.node.oath{border-color:var(--gold);border-style:dashed;background:linear-gradient(180deg,rgba(230,193,90,.05),transparent)}
|
|
|
|
.stage.active h2{text-shadow:0 0 18px currentColor}
|
|
.stage.active .node{border-color:var(--amber);
|
|
box-shadow:0 0 0 1px rgba(245,185,66,.35),0 10px 30px -12px rgba(245,185,66,.4);transform:translateY(-2px)}
|
|
|
|
.risk{display:flex;gap:6px;margin-top:8px;flex-wrap:wrap}
|
|
.risk span{font-size:10.5px;padding:2px 7px;border-radius:6px;border:1px solid var(--line)}
|
|
.risk .lo{color:var(--green);border-color:rgba(74,208,122,.4)}
|
|
.risk .md{color:var(--amber);border-color:rgba(245,185,66,.4)}
|
|
.risk .hi{color:var(--coral);border-color:rgba(255,122,92,.4)}
|
|
.gatebtns{display:flex;gap:8px;margin-top:10px}
|
|
.gatebtns .g{flex:1;text-align:center;padding:8px;border-radius:8px;font-size:12px;border:1px solid}
|
|
.gatebtns .ok{border-color:rgba(74,208,122,.5);color:var(--green)}
|
|
.gatebtns .no{border-color:rgba(255,122,92,.5);color:var(--coral)}
|
|
|
|
footer{padding:14px 22px 30px;color:var(--dim);font-size:11px;border-top:1px solid var(--line)}
|
|
footer code{color:var(--mono)}
|
|
|
|
/* ---- responsive: stack vertically, drop the SVG spine ---- */
|
|
@media(max-width:820px){
|
|
.scroll{overflow-x:visible;padding:16px}
|
|
.track{flex-direction:column;min-width:0}
|
|
svg.spine,.pulse{display:none}
|
|
.stage{width:100%;flex:auto;padding:0;border-left:2px solid var(--line);margin-left:6px;
|
|
padding-left:16px;padding-bottom:8px}
|
|
.stage.telos{border-color:var(--violet)} .stage.gate{border-color:var(--amber)}
|
|
.stage.exec{border-color:var(--coral)} .stage.cd{border-color:var(--green)}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
|
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
|
|
<div class="controls">
|
|
<button id="mode" class="on">View · <span id="modeState">Plain</span></button>
|
|
<button id="replay"><span class="dot"></span> Replay</button>
|
|
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div>
|
|
<div class="tl mono" id="timeline"></div>
|
|
<div class="legend mono">
|
|
<span class="lbl">KEY</span>
|
|
<span class="lg"><i style="background:var(--green)"></i>passed / ready</span>
|
|
<span class="lg"><i style="background:var(--amber)"></i>running / waiting</span>
|
|
<span class="lg"><i style="background:var(--coral)"></i>failed / blocked</span>
|
|
<span class="lg"><i style="background:var(--gold)"></i>governance gate — must pass to advance</span>
|
|
<span class="lg"><span class="dash"></span> feedback loop · partly manual</span>
|
|
</div>
|
|
<div class="rail mono" id="rail"></div>
|
|
|
|
<div class="scroll">
|
|
<div class="track" id="track">
|
|
<svg class="spine" id="spine">
|
|
<defs>
|
|
<marker id="arw" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto">
|
|
<path d="M0,0 L6,3 L0,6 Z" fill="#33507f"></path>
|
|
</marker>
|
|
</defs>
|
|
<path id="spinePath" fill="none" stroke="#33507f" stroke-width="2"
|
|
marker-mid="url(#arw)" marker-end="url(#arw)"></path>
|
|
<path id="loopPath" fill="none" stroke="#9b8cff" stroke-width="1.6"
|
|
stroke-dasharray="5 5" opacity=".7"></path>
|
|
<text id="loopLbl" fill="#9b8cff" font-size="11"
|
|
font-family="ui-monospace,monospace" opacity=".85"></text>
|
|
<g id="translabels"></g>
|
|
</svg>
|
|
<div class="pulse" id="pulse"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="mono">
|
|
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
|
|
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
|
|
Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
|
|
Phase C: <code>Gitea run API</code> live (latest run on the CI stage). Pending: <code>assessor-loop</code> ledger · <code>session_log</code> · per-job status · Flux events.
|
|
</footer>
|
|
|
|
<script>
|
|
// Single source of truth: /api/atlas.json (Go app — authored atlas.json + CI
|
|
// stage from the live cd.yml + substrate from the live cluster). These start
|
|
// empty and are filled by init()'s fetch; on failure the page shows an error
|
|
// banner rather than stale inline data.
|
|
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[], CI_DUR=0, CD_DUR=0;
|
|
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
|
|
|
|
const track=document.getElementById('track');
|
|
let stageEls=[];
|
|
function renderAtlas(){
|
|
const sub=document.getElementById('substrate');
|
|
sub.querySelectorAll('.host').forEach(el=>el.remove());
|
|
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
|
|
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
|
|
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
|
|
stageEls=[];
|
|
track.querySelectorAll('.stage,.trans-row').forEach(el=>el.remove());
|
|
const plain = MODE==='plain';
|
|
STAGES.forEach((s,idx)=>{
|
|
const st=document.createElement('div');st.className='stage '+(s.cls||'');
|
|
const head = plain
|
|
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
|
|
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
|
|
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
|
|
let h = `<div class="stagehead">${head}</div>`;
|
|
(s.nodes||[]).forEach(n=>{
|
|
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
|
|
const body = plain ? (n.plain||n.d||'') : (n.d||'');
|
|
const ntitle = (plain && n.plain_t) ? n.plain_t : n.t;
|
|
const nsub = (plain && n.plain_t && n.plain_t!==n.t) ? `<div class="tech-sub">${n.t}</div>` : '';
|
|
let inner=`<div class="t">${pill}${ntitle}</div>${nsub}`+(body?`<div class="d">${body}</div>`:'');
|
|
if(!plain){
|
|
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join('');
|
|
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
|
|
}
|
|
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
|
|
const tag = n.url ? 'a' : 'div';
|
|
const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : '';
|
|
h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}</${tag}>`;
|
|
});
|
|
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
|
|
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
|
|
if(s.trans_label){
|
|
const tr=document.createElement('div');
|
|
tr.className='trans-row'+((idx===4||idx===6)?' trans-row-gate':'');
|
|
tr.innerHTML=`<span class="tr-lbl">${(idx===4||idx===6)?'🔒 ':''}${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
|
|
track.appendChild(tr);
|
|
}
|
|
});
|
|
renderRail();
|
|
}
|
|
|
|
function renderRail(){
|
|
const rail=document.getElementById('rail');
|
|
rail.innerHTML='<span class="lbl">PIPELINE</span>';
|
|
STAGES.forEach((s,i)=>{
|
|
const c=document.createElement('button');
|
|
c.className='railchip'+(s.cls?' '+s.cls:'');
|
|
c.innerHTML=`<b>${(s.no||'').replace('STAGE ','')}</b>${s.short||s.plain_title||s.title}`;
|
|
c.onclick=()=>{ if(stageEls[i]) stageEls[i].scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'}); };
|
|
rail.appendChild(c);
|
|
if(i<STAGES.length-1){const a=document.createElement('span');a.className='railarr';a.textContent='→';rail.appendChild(a);}
|
|
});
|
|
}
|
|
|
|
function renderTimeline(){
|
|
const tl=document.getElementById('timeline');
|
|
if(!TIMELINE.length){tl.style.display='none';return;}
|
|
tl.innerHTML='<span class="lbl">RECENT RUNS</span>';
|
|
TIMELINE.forEach(d=>{
|
|
const c=d.state==='success'?'var(--green)':(d.state==='failure'?'var(--coral)':'var(--amber)');
|
|
const el=document.createElement('span');
|
|
el.className='rundot';el.style.background=c;
|
|
el.title='run #'+d.number+' · '+d.state;el.textContent=d.number;
|
|
tl.appendChild(el);
|
|
});
|
|
tl.style.display='flex';
|
|
}
|
|
|
|
/* ---- geometry ---- */
|
|
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
|
|
loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'),
|
|
pulse=document.getElementById('pulse');
|
|
let RAILY=70;
|
|
let cs=[],loopY=0,spineLen=0,loopLen=0,slow=false,raf=null,t0=null,mobile=false;
|
|
|
|
function build(){
|
|
mobile=window.matchMedia('(max-width:820px)').matches;
|
|
if(mobile)return;
|
|
cs=stageEls.map(s=>s.offsetLeft+s.offsetWidth/2);
|
|
// spine sits in the band between the (uniform) stage headers and the first node
|
|
const firstTops=stageEls.map(s=>{const n=s.querySelector('.node');return n?s.offsetTop+n.offsetTop:120;});
|
|
RAILY=Math.max(66, Math.min(...firstTops)-16);
|
|
const maxBottom=Math.max(...stageEls.map(s=>s.offsetTop+s.offsetHeight));
|
|
loopY=maxBottom+40;
|
|
spine.setAttribute('width',track.scrollWidth);
|
|
spine.setAttribute('height',loopY+70);
|
|
track.style.minHeight=(loopY+70)+'px';
|
|
spinePath.setAttribute('d','M '+cs[0]+' '+RAILY+cs.slice(1).map(x=>' L '+x+' '+RAILY).join(''));
|
|
const lastX=cs[cs.length-1], telosX=cs[1];
|
|
loopPath.setAttribute('d',`M ${lastX} ${RAILY} L ${lastX} ${loopY} L ${telosX} ${loopY} L ${telosX} ${RAILY}`);
|
|
loopLbl.setAttribute('x',(telosX+lastX)/2-90);loopLbl.setAttribute('y',loopY-8);
|
|
loopLbl.textContent=(STAGES[8]&&STAGES[8].trans_label?STAGES[8].trans_label+' · ':'')+'feedback bus · partly manual today';
|
|
// transition labels on the spine — the "what must be true to advance" story.
|
|
// Always visible (default layer). Gate hops (04→05 human, 06→07 CI) in gold.
|
|
const tg=document.getElementById('translabels'); tg.innerHTML='';
|
|
for(let i=0;i<cs.length-1;i++){
|
|
const s=STAGES[i]; if(!s||!s.trans_label) continue;
|
|
const gate = (i===4||i===6);
|
|
const t=document.createElementNS('http://www.w3.org/2000/svg','text');
|
|
t.setAttribute('x',(cs[i]+cs[i+1])/2);t.setAttribute('y',RAILY-9);
|
|
t.setAttribute('text-anchor','middle');
|
|
t.setAttribute('class',gate?'translabel translabel-gate':'translabel');
|
|
t.textContent=(gate?'🔒 ':'')+s.trans_label;
|
|
const ttl=document.createElementNS('http://www.w3.org/2000/svg','title');
|
|
ttl.textContent=s.trans||''; t.appendChild(ttl);
|
|
tg.appendChild(t);
|
|
}
|
|
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
|
|
}
|
|
// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length
|
|
// distance. Default weight per stage-to-stage segment is its own pixel length —
|
|
// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations
|
|
// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is
|
|
// re-split by their real relative duration instead of by raw pixel width — so the
|
|
// reel visits stage 06 vs 07 at speeds proportional to how long they actually took.
|
|
function weightedSpineDist(f){
|
|
const n=cs.length-1;
|
|
if(n<=0)return 0;
|
|
const segLens=[];for(let i=0;i<n;i++)segLens.push(cs[i+1]-cs[i]);
|
|
const weights=segLens.slice();
|
|
if(CI_DUR>0&&CD_DUR>0&&n>=7){
|
|
const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR;
|
|
weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot;
|
|
}
|
|
const totalW=weights.reduce((a,b)=>a+b,0);
|
|
const target=f*totalW;
|
|
let acc=0;
|
|
for(let i=0;i<n;i++){
|
|
if(target<=acc+weights[i]||i===n-1){
|
|
const local=weights[i]>0?(target-acc)/weights[i]:0;
|
|
const segStart=cs[i]-cs[0];
|
|
return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local))));
|
|
}
|
|
acc+=weights[i];
|
|
}
|
|
return spineLen;
|
|
}
|
|
function run(ts){
|
|
if(mobile)return;
|
|
if(!t0)t0=ts;
|
|
const dur=slow?16000:7000;
|
|
const p=Math.min((ts-t0)/dur,1);
|
|
const spineFrac=spineLen/(spineLen+loopLen);
|
|
let dist,onLoop;
|
|
if(p<spineFrac){
|
|
dist=weightedSpineDist(p/spineFrac); onLoop=false;
|
|
}else{
|
|
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
|
|
}
|
|
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
|
|
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
|
|
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
|
|
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
|
|
let act=onLoop?1:0; // during loop, highlight TELOS
|
|
if(!onLoop)cs.forEach((c,i)=>{if(pt.x>=c-150)act=i;});
|
|
stageEls.forEach((s,i)=>s.classList.toggle('active',i===act));
|
|
if(p<1)raf=requestAnimationFrame(run);
|
|
}
|
|
function replay(){cancelAnimationFrame(raf);t0=null;build();if(!mobile)raf=requestAnimationFrame(run);}
|
|
document.getElementById('replay').onclick=replay;
|
|
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
|
|
document.getElementById('slowState').textContent=slow?'on':'off';replay();};
|
|
function applyMode(){
|
|
document.getElementById('modeState').textContent = MODE==='plain'?'Plain':'Technical';
|
|
document.getElementById('mode').classList.toggle('on', MODE==='plain');
|
|
document.body.classList.toggle('plain', MODE==='plain');
|
|
}
|
|
document.getElementById('mode').onclick=()=>{
|
|
MODE = MODE==='plain' ? 'technical' : 'plain';
|
|
localStorage.setItem('atlas-mode',MODE);
|
|
applyMode(); renderAtlas(); replay();
|
|
};
|
|
window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);});
|
|
async function init(){
|
|
try{
|
|
const r=await fetch('/api/atlas.json');
|
|
if(!r.ok) throw new Error('atlas.json '+r.status);
|
|
const data=await r.json();
|
|
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
|
|
if(typeof data.ns==='string') NS=data.ns;
|
|
if(Array.isArray(data.stages)) STAGES=data.stages;
|
|
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
|
|
if(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
|
|
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
|
|
if(data.version) document.getElementById('ver').textContent=data.version;
|
|
}catch(e){
|
|
console.error('atlas: failed to load /api/atlas.json —',e);
|
|
document.getElementById('track').insertAdjacentHTML('beforeend',
|
|
'<div class="stage"><div class="no mono">ERROR</div>'+
|
|
'<h2 style="color:var(--coral)">Data unavailable</h2>'+
|
|
'<div class="path mono">/api/atlas.json failed to load</div></div>');
|
|
return;
|
|
}
|
|
applyMode();
|
|
renderAtlas();
|
|
renderTimeline();
|
|
replay();
|
|
}
|
|
window.addEventListener('load',init);
|
|
</script>
|
|
</body>
|
|
</html>
|