Will it run? A GPU shop for local models, built by MiniMax M3.1 Flash
Hostedvia OpenCodeOpenRouter
223.2Ktokens
Show the code the model wroteHide the code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GPU Store — build a local AI rig</title>
<style>
/* =========================================================
GPU Store — one screen board for home AI rigs
========================================================= */
:root{
--bg:#080b11;
--bg2:#0d121b;
--col:#141b28;
--col2:#1a2333;
--line:#26314a;
--line2:#1e2637;
--txt:#e9eff9;
--mut:#93a3ba;
--dim:#67768d;
--acc:#4cc2ff;
--acc2:#1d8fbf;
--weights:#6d7bff;
--ctx:#1fc8b4;
--ovh:#f2a33c;
--ram:#a97bff;
--bad:#ff5d73;
--good:#3ddc97;
--radius:10px;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
--sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family:var(--sans);
color:var(--txt);
background:
radial-gradient(1200px 700px at 15% -10%, #16233a 0%, transparent 60%),
radial-gradient(1000px 600px at 95% 0%, #1a1630 0%, transparent 55%),
var(--bg);
display:flex;flex-direction:column;
overflow:hidden;
font-variant-numeric:tabular-nums;
-webkit-font-smoothing:antialiased;
}
button,select,input{font-family:inherit;font-size:inherit;color:inherit}
kbd{
font-family:var(--mono);font-size:10px;line-height:1;
padding:2px 4px;border-radius:4px;
background:#0d1420;border:1px solid var(--line);
box-shadow:0 1px 0 #000a inset;color:var(--mut);
}
/* ---------------- top bar ---------------- */
.topbar{
flex:0 0 auto;
display:flex;align-items:center;gap:22px;
padding:10px 18px;
border-bottom:1px solid var(--line2);
background:linear-gradient(180deg,#101827 0%, #0b111b 100%);
}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{
width:36px;height:36px;flex:0 0 36px;border-radius:9px;
display:grid;place-items:center;font-size:19px;
background:linear-gradient(145deg,var(--acc),#6d7bff 70%);
color:#06101c;box-shadow:0 4px 16px #4cc2ff33;
}
.brandtext h1{margin:0;font-size:17px;letter-spacing:.2px;line-height:1.1}
.brandtext p{margin:2px 0 0;font-size:11.5px;color:var(--dim);line-height:1.25}
.legend{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
.lg{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--mut);white-space:nowrap}
.sw{width:11px;height:11px;border-radius:3px;display:inline-block}
.sw-weights{background:var(--weights)}
.sw-ctx{background:var(--ctx)}
.sw-ovh{background:var(--ovh)}
.sw-ram{background:var(--ram)}
.sw-bad{background:repeating-linear-gradient(45deg,#ff5d73 0 3px,#7d1f2c 3px 6px)}
.stats{display:flex;gap:18px;margin-left:auto}
.stat{display:flex;flex-direction:column;align-items:flex-end;line-height:1.1}
.stat .k{font-size:10.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.7px}
.stat .v{font-size:18px;font-weight:700;font-family:var(--mono)}
.topright{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
.keyhint{display:flex;gap:10px;font-size:10.5px;color:var(--dim);flex-wrap:wrap;justify-content:flex-end;max-width:330px}
.keyhint span{display:flex;align-items:center;gap:3px}
.btn{
border:1px solid var(--line);background:var(--col2);color:var(--txt);
border-radius:7px;padding:5px 10px;cursor:pointer;font-size:12px;
transition:background .12s,border-color .12s,transform .06s;
}
.btn:hover{background:#223047;border-color:#3a4a68}
.btn:active{transform:translateY(1px)}
.btn-ghost{background:transparent}
.btn.on{background:linear-gradient(180deg,#1c5f85,#124963);border-color:#2e8ec4;color:#e8f7ff;box-shadow:0 0 0 1px #4cc2ff33 inset}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* ---------------- board ---------------- */
.board{
flex:1 1 auto;min-height:0;
display:flex;gap:12px;padding:12px 14px 6px;
overflow:hidden;
}
.col{
background:linear-gradient(180deg,#111825 0%,#0e141f 100%);
border:1px solid var(--line2);
border-radius:12px;
display:flex;flex-direction:column;min-height:0;
overflow:hidden;
}
.colhead{
flex:0 0 auto;display:flex;align-items:baseline;gap:8px;
padding:9px 12px;border-bottom:1px solid var(--line2);
background:#131b29;
}
.colhead h2{margin:0;font-size:13px;letter-spacing:.4px;text-transform:uppercase;color:#cfdcef}
.colhead .sub{font-size:11px;color:var(--dim)}
.colbody{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;padding:8px;display:flex;flex-direction:column;gap:6px}
.modelscol{flex:0 0 296px}
.mcol{flex:1 1 0;min-width:238px}
.addcol{flex:0 0 128px;background:transparent;border-style:dashed;border-color:#24304a}
.addcol .colbody{padding:8px}
.addbtn{
width:100%;height:100%;min-height:120px;border-radius:10px;
border:1px dashed #33425f;background:#0e1522;color:var(--mut);
cursor:pointer;font-size:13px;display:grid;place-items:center;gap:6px;align-content:center;
transition:.15s;
}
.addbtn:hover{border-color:var(--acc);color:var(--acc);background:#101a29}
.addbtn .plus{font-size:26px;line-height:1}
/* scrollbars */
.colbody::-webkit-scrollbar,.ptextwrap::-webkit-scrollbar,.mlist::-webkit-scrollbar{width:9px}
.colbody::-webkit-scrollbar-thumb,.ptextwrap::-webkit-scrollbar-thumb,.mlist::-webkit-scrollbar-thumb{background:#233049;border-radius:8px;border:2px solid transparent;background-clip:padding-box}
/* ---------------- catalog model card ---------------- */
.mcat{
background:var(--col);border:1px solid var(--line2);border-radius:9px;
padding:5px 8px;cursor:grab;user-select:none;
transition:border-color .12s,background .12s,transform .08s;
flex:0 0 auto;
}
.mcat:hover{border-color:#3d5a80;background:#17202f}
.mcat.dragging{opacity:.35}
.catrow1{display:flex;align-items:center;gap:6px}
.catrow1 .nm{font-weight:650;font-size:12.5px;letter-spacing:.1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}
.catrow2{font-size:10px;color:var(--dim);margin-top:0;font-family:var(--mono);line-height:1.3}
.catrow3{display:flex;gap:12px;margin-top:2px;font-size:10.5px;font-family:var(--mono);color:var(--mut);line-height:1.3}
.catrow3 i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px}
.catrow3 .q4 i{background:var(--weights)}
.catrow3 .q8 i{background:#8a93ff}
.tag{
font-size:9.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;
padding:1.5px 5px;border-radius:4px;border:1px solid transparent;white-space:nowrap;
}
.tag.moe{background:#3a2a55;color:#c9aaff;border-color:#553c7d}
.tag.dense{background:#1d2a3d;color:#8fb2d8;border-color:#2c3d57}
/* ---------------- machine column ---------------- */
.mhead{flex:0 0 auto;padding:9px 10px 8px;border-bottom:1px solid var(--line2);background:#131b29}
.mrow{display:flex;align-items:center;gap:6px;margin-bottom:6px;flex-wrap:nowrap}
.mrow:last-child{margin-bottom:0}
.sel{
background:#0e1522;border:1px solid var(--line);border-radius:6px;
padding:4px 6px;font-size:12px;cursor:pointer;max-width:100%;
}
.sel:focus{outline:1px solid var(--acc)}
.cardsel{flex:1 1 80px;min-width:0}
.ramsel{flex:0 1 auto;min-width:62px}
.countsel{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;flex:0 0 auto}
.countsel button{
background:#0e1522;border:0;padding:4px 9px;font-size:12px;cursor:pointer;color:var(--mut);
font-family:var(--mono);
}
.countsel button.on{background:linear-gradient(180deg,#1c5f85,#124963);color:#e8f7ff}
.countsel button:hover:not(.on){background:#1a2434}
.price{margin-left:auto;font-family:var(--mono);font-size:15px;font-weight:700;color:#9fe8c4;flex:0 0 auto}
.iconbtn{
background:transparent;border:1px solid var(--line);border-radius:6px;color:var(--dim);
width:24px;height:24px;cursor:pointer;line-height:1;font-size:12px;flex:0 0 auto;
}
.iconbtn:hover{color:var(--bad);border-color:#6b2734;background:#1d1116}
.spillwrap{display:flex;align-items:center;gap:6px}
.spilllabel{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
.spilllabel input{accent-color:var(--acc);width:15px;height:15px;cursor:pointer}
.mstatus{
font-size:11px;color:var(--acc);font-family:var(--mono);margin-top:2px;
min-height:0;line-height:1.35;
}
.mstatus:empty{display:none}
.mstatus.warn{color:var(--ovh)}
/* bars */
.bars{flex:0 0 auto;padding:9px 10px 4px;display:flex;flex-direction:column;gap:7px}
.barrow{display:grid;grid-template-columns:1fr;gap:3px}
.barlab{display:flex;justify-content:space-between;font-size:11px;color:var(--dim);font-family:var(--mono)}
.barlab b{color:var(--mut);font-weight:600}
.track{
position:relative;height:19px;border-radius:5px;
background:#0b1017;border:1px solid #1d2636;overflow:hidden;
display:flex;
}
.seg{height:100%;min-width:0;transition:width .12s linear}
.seg-weights{background:linear-gradient(180deg,#7d89ff,#5a68f0)}
.seg-ctx{background:linear-gradient(180deg,#2fdcc6,#16a894)}
.seg-ovh{background:linear-gradient(180deg,#f7b45c,#d98a22)}
.seg-ram{background:linear-gradient(180deg,#bb90ff,#8a5de8)}
.overmark{
position:absolute;top:0;bottom:0;pointer-events:none;
background:repeating-linear-gradient(45deg,rgba(255,93,115,.9) 0 4px,rgba(255,93,115,.18) 4px 8px);
border-left:2px solid #ff5d73;
}
.limitmark{position:absolute;top:-1px;bottom:-1px;width:2px;background:#00000066;box-shadow:1px 0 0 #ffffff22}
.barnote{font-size:10.5px;color:var(--dim);font-family:var(--mono);text-align:right;margin-top:-2px}
.fitline{
flex:0 0 auto;margin:0 10px 8px;padding:7px 9px;border-radius:7px;
font-family:var(--mono);font-size:13.5px;font-weight:600;
background:#0e1a17;color:var(--good);border:1px solid #1d4a3b;
text-align:center;
}
.fitline.over{background:#1e0f14;color:var(--bad);border-color:#5c2330}
.fitline.ram{background:#171029;color:#c9aaff;border-color:#3f2c63}
/* machine model cards */
.mlist{flex:1 1 auto;min-height:0;overflow-y:auto;padding:0 10px 10px;display:flex;flex-direction:column;gap:8px}
.mcard{
background:var(--col);border:1px solid var(--line2);border-radius:9px;padding:7px 8px;
cursor:grab;user-select:none;flex:0 0 auto;
transition:border-color .12s,background .12s;
}
.mcard:hover{border-color:#3d5a80}
.mcard.picked{border-color:var(--acc);box-shadow:0 0 0 1px #4cc2ff55, 0 4px 18px #4cc2ff1f;background:#152030}
.mcard.dragging{opacity:.35}
.mcard .mrow{display:flex;align-items:center;gap:6px}
.mcname{font-weight:650;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mcstatus{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--good);text-align:right;white-space:nowrap}
.mcstatus.bad{color:var(--bad)}
.mcstatus.ram{color:#c9aaff}
.ctrls{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap}
.seg2{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.seg2 button{
background:#0e1522;border:0;padding:3px 7px;font-size:10.5px;cursor:pointer;color:var(--dim);
font-family:var(--mono);letter-spacing:.2px;
}
.seg2 button.on{background:#243352;color:#dce9ff}
.seg2 button:hover:not(.on){background:#1a2434;color:var(--mut)}
.ctxrow{display:flex;align-items:center;gap:7px;margin-top:6px}
.ctxrow input[type=range]{
flex:1 1 auto;min-width:0;-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;
background:#23304a;outline:none;cursor:pointer;
}
.ctxrow input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:13px;height:13px;border-radius:50%;
background:var(--acc);border:2px solid #0b111b;cursor:pointer;box-shadow:0 0 0 1px #4cc2ff66;
}
.ctxrow input[type=range]::-moz-range-thumb{
width:13px;height:13px;border-radius:50%;background:var(--acc);border:2px solid #0b111b;cursor:pointer;
}
.ctxval{font-family:var(--mono);font-size:10.5px;color:var(--mut);min-width:52px;text-align:right}
.mcmeta{font-size:10.5px;color:var(--dim);font-family:var(--mono);margin-top:4px;line-height:1.4}
/* drag preview banner */
.dragbanner{
margin:0;padding:0 10px;max-height:0;overflow:hidden;transition:max-height .12s,padding .12s;
font-family:var(--mono);font-size:12px;font-weight:700;
}
.dragbanner.show{max-height:44px;padding:7px 10px}
.dragbanner.ok{background:#0e1f1a;color:var(--good)}
.dragbanner.ram{background:#171029;color:#c9aaff}
.dragbanner.bad{background:#230f15;color:var(--bad)}
.dragbanner .dbtag{font-size:10px;font-weight:600;opacity:.75;margin-right:6px;text-transform:uppercase;letter-spacing:.6px}
.mcol.droptarget{box-shadow:0 0 0 2px #4cc2ff88}
.modelscol.droptarget{box-shadow:0 0 0 2px #ff5d7399}
.dropend{outline:2px dashed #4cc2ff88;outline-offset:-4px;border-radius:8px}
/* drag ghost */
.ghost{
position:fixed;z-index:99;pointer-events:none;
background:#1b2536;border:1px solid var(--acc);border-radius:8px;
padding:6px 9px;font-size:12px;font-weight:600;box-shadow:0 12px 32px #000a;
transform:translate(-50%,-50%);opacity:.95;
}
.ghost small{display:block;font-weight:400;font-size:10px;color:var(--mut);font-family:var(--mono)}
/* ---------------- panel ---------------- */
.panel{
flex:0 0 auto;height:clamp(186px, 24vh, 268px);
margin:6px 14px 12px;
background:linear-gradient(180deg,#111a28 0%,#0d1420 100%);
border:1px solid var(--line2);border-radius:12px;
display:flex;flex-direction:column;overflow:hidden;
}
.phead{
flex:0 0 auto;display:flex;align-items:center;gap:16px;
padding:8px 12px;border-bottom:1px solid var(--line2);background:#131c2b;
}
.pwho{display:flex;align-items:center;gap:7px;min-width:0}
.pwho .pn{font-size:14px;font-weight:700}
.pwho .pm{font-size:11.5px;color:var(--dim);font-family:var(--mono)}
.pstats{display:flex;gap:18px;align-items:center}
.ps{display:flex;flex-direction:column;line-height:1.15}
.ps .k{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
.ps .v{font-size:15px;font-weight:700;font-family:var(--mono);color:var(--good)}
.ps .v.bad{color:var(--bad)}
.ps .v.ram{color:#c9aaff}
.pbtns{margin-left:auto;display:flex;gap:7px;align-items:center}
.pbody{flex:1 1 auto;min-height:0;display:flex;gap:12px;padding:9px 12px 4px}
.pfix{flex:0 0 300px;overflow-y:auto;display:flex;flex-direction:column;gap:6px}
.pfix:empty{display:none}
.fixcard{
background:#151d2c;border:1px solid #2b3a55;border-radius:8px;padding:6px 8px;cursor:pointer;
transition:.12s;
}
.fixcard:hover{background:#1b2740;border-color:var(--acc);transform:translateX(2px)}
.fixcard .ft{font-size:12px;font-weight:650;color:#cfe6ff;display:flex;align-items:center;gap:6px}
.fixcard .ft::before{content:"▸";color:var(--acc);font-size:10px}
.fixcard .fd{font-size:10.5px;color:var(--mut);font-family:var(--mono);margin-top:2px;line-height:1.4}
.pmain{flex:1 1 auto;min-width:0;display:flex;flex-direction:column}
.ptextwrap{
flex:1 1 auto;min-height:0;overflow-y:auto;padding:9px 11px;
background:#0a0f18;border:1px solid var(--line2);border-radius:9px;
font-size:13.5px;line-height:1.55;color:#cfdcec;
}
.ptextwrap .cur{color:var(--acc);font-weight:600}
.ptextwrap .cur::after{content:"▌";opacity:.75;animation:blink 1s steps(2,start) infinite}
.ptextwrap .phint{color:var(--mut);font-size:13px}
.ptextwrap .phint b{color:var(--acc);font-weight:600}
.ptextwrap .phint.bad{color:#ff8b9c}
@keyframes blink{0%,50%{opacity:.75}50.01%,100%{opacity:0}}
.pmath{
flex:1 1 auto;min-height:0;overflow-y:auto;padding:9px 11px;
background:#0a0f18;border:1px solid var(--line2);border-radius:9px;
font-family:var(--mono);font-size:11.5px;line-height:1.6;color:#b9c8dd;
}
.pmath .ml{white-space:pre-wrap;word-break:break-word}
.pmath .ml b{color:#e8f1ff}
.pmath .hl{color:var(--acc)}
.pmath .hi{color:var(--good)}
.pmath .bad{color:var(--bad)}
.pfoot{
flex:0 0 auto;display:flex;align-items:center;gap:12px;
padding:5px 12px 7px;font-family:var(--mono);font-size:11.5px;color:var(--mut);
}
.pbar{flex:0 0 190px;height:5px;border-radius:4px;background:#1b2434;overflow:hidden}
.pbar i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--acc),#8a7bff);transition:width .08s linear}
.pfinish{margin-left:auto;color:var(--good);font-weight:600}
.pfinish.bad{color:var(--bad)}
/* responsive */
@media (max-width:1500px){
.modelscol{flex:0 0 262px}
.mcol{min-width:214px}
.legend{gap:10px}
.keyhint{max-width:240px}
.mhead{padding:8px 8px 7px}
.mhead .mrow{gap:4px}
.mhead .btn{padding:4px 7px;font-size:11px}
.price{font-size:13.5px}
.sel{font-size:11.5px;padding:3px 4px}
.countsel button{padding:3px 7px;font-size:11.5px}
.iconbtn{width:22px;height:22px}
.spilllabel{font-size:11.5px}
.bars{padding:8px 8px 3px}
.fitline{margin:0 8px 8px;font-size:12.5px}
.mlist{padding:0 8px 8px}
}
@media (max-width:1280px){
.brandtext p{display:none}
.legend .lg:nth-child(n+4){display:none}
.stat .v{font-size:16px}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="logo">▚</div>
<div class="brandtext">
<h1>GPU Store</h1>
<p>Drag a model onto a machine. Does it fit, how fast does it run, what does the rig cost?</p>
</div>
</div>
<div class="legend">
<span class="lg"><i class="sw sw-weights"></i>Weights</span>
<span class="lg"><i class="sw sw-ctx"></i>Context</span>
<span class="lg"><i class="sw sw-ovh"></i>Overhead</span>
<span class="lg"><i class="sw sw-ram"></i>RAM</span>
<span class="lg"><i class="sw sw-bad"></i>Over memory</span>
</div>
<div class="stats">
<div class="stat"><span class="k">Rig price</span><span class="v" id="statCost">$0</span></div>
<div class="stat"><span class="k">Models</span><span class="v" id="statModels">0</span></div>
<div class="stat"><span class="k">Fastest</span><span class="v" id="statFast">—</span></div>
</div>
<div class="topright">
<button id="mathBtn" class="btn btn-ghost" title="Show the math (M)">Math <kbd>M</kbd></button>
<div class="keyhint">
<span><kbd>Enter</kbd> pick</span>
<span><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> to machine</span>
<span><kbd>Alt</kbd>+<kbd>↑↓</kbd> move</span>
<span><kbd>Del</kbd> remove</span>
</div>
</div>
</header>
<main id="board" class="board"></main>
<footer id="panel" class="panel"></footer>
<script>
/* =====================================================================
GPU Store — a one-screen board for building home AI rigs.
Everything lives in memory. No storage, no cookies, no network.
===================================================================== */
'use strict';
/* ------------------------------ constants ------------------------------ */
const GIB = 1073741824; // 1 GiB = 2^30 bytes
const KIB = 1024; // 1 KiB = 1024 bytes
const RAM_BW = 80e9; // system RAM reads at 80 GB/s = 80*10^9 B/s
const STEP = 1024; // context slider step
const DEFAULT_CTX = 32768; // context a freshly dropped model starts with
const TOTAL_WORDS = 300;
const CARDS = [
{ id:'rtx3060', name:'RTX 3060 12GB', mem:12, bw:360, price:280 },
{ id:'rtx3090', name:'RTX 3090 24GB', mem:24, bw:936, price:700, used:true },
{ id:'rtx4090', name:'RTX 4090 24GB', mem:24, bw:1008, price:1999 },
{ id:'rtx5090', name:'RTX 5090 32GB', mem:32, bw:1792, price:2599 },
{ id:'rxta6000', name:'RTX A6000 48GB', mem:48, bw:768, price:3300, used:true },
];
const MODELS = [
{ id:'gemma4-12b', name:'Gemma 4 12B', total:11.95e9, active:11.95e9, ctxKiB:16, maxCtx:262144 },
{ id:'gpt-oss-20b', name:'gpt-oss-20b', total:20.9e9, active:3.6e9, ctxKiB:24, maxCtx:131072 },
{ id:'gemma4-26b-a4b', name:'Gemma 4 26B A4B', total:25.2e9, active:3.8e9, ctxKiB:20, maxCtx:262144 },
{ id:'qwen3.8-27b', name:'Qwen3.8 27B', total:27.8e9, active:27.8e9, ctxKiB:64, maxCtx:262144 },
{ id:'gemma4-31b', name:'Gemma 4 31B', total:30.7e9, active:30.7e9, ctxKiB:80, maxCtx:262144 },
{ id:'qwen3.6-35b-a3b',name:'Qwen3.6 35B-A3B', total:35e9, active:3e9, ctxKiB:20, maxCtx:262144 },
{ id:'gpt-oss-120b', name:'gpt-oss-120b', total:116.8e9, active:5.1e9, ctxKiB:36, maxCtx:131072 },
{ id:'glm-5.3-flash', name:'GLM-5.3-Flash', total:320e9, active:18e9, ctxKiB:11, maxCtx:1048576 },
{ id:'glm-5.3', name:'GLM-5.3', total:753e9, active:40e9, ctxKiB:87.75, maxCtx:1048576 },
{ id:'kimi-k2.6', name:'Kimi K2.6', total:1000e9, active:32e9, ctxKiB:68.625, maxCtx:262144 },
];
const CARD_BY_ID = Object.fromEntries(CARDS.map(c => [c.id, c]));
const MODEL_BY_ID = Object.fromEntries(MODELS.map(m => [m.id, m]));
const ANSWER = `Running a language model at home is mostly a memory problem, not a math problem. Every token your model produces asks it to read its weights from somewhere, and the only place that read happens fast enough is the memory bolted to the graphics card. That memory has a fixed speed called bandwidth, and bandwidth sets the ceiling on how fast a local model can think. Here is the arithmetic. A thirty billion parameter model stored at four and a half bits per weight occupies about sixteen gigabytes of memory. To emit one token, the hardware must touch that memory at least once. A card with a terabyte per second of bandwidth can therefore deliver roughly sixty tokens per second, and no amount of extra compute changes that number, because the bytes are simply not arriving fast enough. Two tricks help. Quantization shrinks the weights, so fewer bytes cross the bus on every single token. Keeping the attention cache in eight bits frees room for a longer context without touching the weights at all. Both attack the same bottleneck from different sides. Arithmetic intensity stays low here, because every weight pulled across the bus serves a single multiply, so the processor mostly waits for memory instead. What does not help is a second identical card, because the token still passes through both of them in turn, one after the other, and the wall clock does not care that they are identical. When a model stops fitting, spilling some of its weights to ordinary system memory buys capacity at a real cost, because system memory reads many times slower than the card does. The lesson is short: speed comes from bandwidth, fit comes from capacity, and the fastest local model is simply the one whose weights already live on the card.`
.trim().split(/\s+/);
/* ------------------------------ utilities ------------------------------ */
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
function fmt(n, d) {
d = d === undefined ? 2 : d;
if (!isFinite(n)) return '—';
return n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
}
function fmtInt(n) { return Math.round(n).toLocaleString('en-US'); }
function gib(bytes) { return bytes / GIB; }
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function trimZero(v) {
const r = Math.round(v * 100) / 100;
if (Number.isInteger(r)) return String(r);
return r.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function params(n) { return (n >= 1e12 ? trimZero(n / 1e12) + 'T' : trimZero(n / 1e9) + 'B'); }
function money(n) { return '$' + Math.round(n).toLocaleString('en-US'); }
function isMoE(m) { return m.active < m.total; }
/* -------------------------------- state -------------------------------- */
let uidSeq = 10;
const nextUid = () => 'u' + (uidSeq++);
const state = {
machines: [
{ id:'m1', cardId:'rtx4090', count:1, spill:false, ram:64,
models:[{ uid:nextUid(), modelId:'qwen3.8-27b', quant:'q4', cache:16, ctx:DEFAULT_CTX }] },
{ id:'m2', cardId:'rtx3060', count:1, spill:false, ram:64, models:[] },
],
picked: null, // uid of the picked card
focusUid: null, // uid of the last focused card
playing: false,
math: false,
msg: {}, // per-machine status line
drag: null, // active drag state
stream: { words:0, tokens:0, elapsed:0, lastTs:0, uid:null },
};
/* =======================================================================
COMPUTATION
======================================================================= */
/* Per-model byte math (not per card). */
function modelBytes(inst) {
const def = MODEL_BY_ID[inst.modelId];
const bits = inst.quant === 'q8' ? 8.5 : 4.5;
const perTok = def.ctxKiB * KIB;
return {
bits,
weights: def.total * bits / 8, // bytes
ctx: inst.ctx * perTok * (inst.cache === 8 ? 0.5 : 1),
activeBytes: def.active * bits / 8, // bytes read per token
perTok,
};
}
/* Everything about one machine: memory bars, fit, per-model speed. */
function computeMachine(machine) {
const card = CARD_BY_ID[machine.cardId];
const n = machine.count;
const memB = card.mem * GIB;
const rows = machine.models.map(inst => {
const b = modelBytes(inst);
return {
inst, def: MODEL_BY_ID[inst.modelId],
bits: b.bits, weights: b.weights, ctx: b.ctx, activeBytes: b.activeBytes, perTok: b.perTok,
weightsPerCard: b.weights / n,
ctxPerCard: b.ctx / n,
overheadPerCard: GIB,
weightsOnCard: 0, weightsOnCards: 0, ramWeights: 0, fracOnCards: 1,
};
});
const ctxPerCardTotal = rows.reduce((s, r) => s + r.ctxPerCard, 0);
const overheadPerCard = rows.length * GIB; // 1 GiB per model, per card
const reservedPerCard = ctxPerCardTotal + overheadPerCard; // context + overhead live on the cards
const res = {
card, n, rows, memB, ctxPerCardTotal, overheadPerCard, reservedPerCard,
spill: machine.spill, ramSizeB: machine.ram * GIB,
cardsOver: 0, ramOver: 0, ramUsed: 0, usedPerCard: 0, spare: 0, fits: true, line: '',
};
if (!machine.spill) {
const weightsPerCard = rows.reduce((s, r) => s + r.weightsPerCard, 0);
res.usedPerCard = reservedPerCard + weightsPerCard;
res.spare = memB - res.usedPerCard;
res.fits = res.usedPerCard <= memB;
res.over = res.fits ? 0 : res.usedPerCard - memB;
for (const r of rows) {
r.weightsOnCard = r.weightsPerCard;
r.weightsOnCards = r.weights;
r.ramWeights = 0;
r.fracOnCards = 1;
}
} else {
// context + overhead stay on the cards; weights fill what is left,
// top model first; the rest of the weights spill to RAM.
let free = memB - reservedPerCard;
res.cardsOver = free < 0 ? -free : 0;
if (free < 0) free = 0;
for (const r of rows) {
const take = Math.min(r.weightsPerCard, free);
free -= take;
r.weightsOnCard = take;
r.weightsOnCards = take * n;
r.ramWeights = r.weights - take * n;
r.fracOnCards = r.weights > 0 ? (take * n) / r.weights : 0;
}
res.ramUsed = rows.reduce((s, r) => s + r.ramWeights, 0);
res.ramOver = res.ramUsed > res.ramSizeB ? res.ramUsed - res.ramSizeB : 0;
res.fits = res.cardsOver === 0 && res.ramOver === 0;
res.usedPerCard = reservedPerCard + rows.reduce((s, r) => s + r.weightsOnCard, 0);
res.spare = memB - res.usedPerCard;
}
// speed: active bytes on the cards travel at one card's bandwidth,
// active bytes in RAM travel at RAM bandwidth.
for (const r of rows) {
r.activeOnCards = r.activeBytes * r.fracOnCards;
r.activeInRam = r.activeBytes - r.activeOnCards;
r.time = r.activeOnCards / (card.bw * 1e9) + r.activeInRam / RAM_BW;
r.speed = r.time > 0 ? 1 / r.time : 0;
}
if (!machine.spill) {
res.line = res.fits
? `Fits, ${fmt(gib(res.spare))} GiB to spare`
: `Over by ${fmt(gib(res.over))} GiB`;
} else if (res.cardsOver > 0) {
res.line = `Over by ${fmt(gib(res.cardsOver))} GiB`;
} else if (res.ramOver > 0) {
res.line = `Over by ${fmt(gib(res.ramOver))} GiB of RAM`;
} else {
res.line = `Fits, ${fmt(gib(res.ramUsed))} GiB in RAM`;
}
return res;
}
/* The largest context step this card's machine can still hold with
everything else on the machine unchanged. */
function mostContextFor(machine, uid) {
const inst = machine.models.find(m => m.uid === uid);
if (!inst) return 0;
const def = MODEL_BY_ID[inst.modelId];
const maxStep = Math.floor(def.maxCtx / STEP);
const test = (step) => {
const m2 = { ...machine, models: machine.models.map(x => x.uid === uid ? { ...x, ctx: step * STEP } : x) };
return computeMachine(m2).fits;
};
if (!test(1)) return 0;
let lo = 1, hi = maxStep;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (test(mid)) lo = mid; else hi = mid - 1;
}
return lo * STEP;
}
/* Every single change that would make this machine fit on its own. */
function fitChanges(machine, uid) {
const out = [];
const inst = machine.models.find(m => m.uid === uid);
if (!inst) return out;
const base = computeMachine(machine);
if (base.fits) return out;
const withInst = patch => ({ ...machine, models: machine.models.map(x => x.uid === uid ? { ...x, ...patch } : x) });
const perCardTxt = r => `card ${fmt(gib(r.usedPerCard))} of ${fmt(r.card.mem)} GiB`;
// 1 — shrink the context to the most that fits
const maxCtx = mostContextFor(machine, uid);
if (maxCtx >= STEP) {
const r2 = computeMachine(withInst({ ctx: maxCtx }));
if (r2.fits) {
out.push({
title: `Shrink context to ${fmtInt(maxCtx)} tokens`,
detail: `context ${fmt(gib(inst.ctx * MODEL_BY_ID[inst.modelId].ctxKiB * KIB * (inst.cache === 8 ? .5 : 1)))} → ${fmt(gib(maxCtx * MODEL_BY_ID[inst.modelId].ctxKiB * KIB * (inst.cache === 8 ? .5 : 1)))} GiB · ${perCardTxt(r2)} · ${r2.line}`,
apply: () => setCtx(uid, maxCtx),
});
}
}
// 2 — 8-bit cache
if (inst.cache !== 8) {
const r2 = computeMachine(withInst({ cache: 8 }));
if (r2.fits) {
out.push({
title: 'Use the 8-bit cache',
detail: `context ${fmt(gib(base.ctxPerCardTotal * base.n))} → ${fmt(gib(base.ctxPerCardTotal * base.n / 2))} GiB · ${perCardTxt(r2)} · ${r2.line}`,
apply: () => setCache(uid, 8),
});
}
}
// 3 — Q4_0
if (inst.quant !== 'q4') {
const r2 = computeMachine(withInst({ quant: 'q4' }));
if (r2.fits) {
const w8 = modelBytes(inst).weights, w4 = modelBytes({ ...inst, quant: 'q4' }).weights;
out.push({
title: 'Quantize to Q4_0',
detail: `weights ${fmt(gib(w8))} → ${fmt(gib(w4))} GiB · ${perCardTxt(r2)} · ${r2.line}`,
apply: () => setQuant(uid, 'q4'),
});
}
}
// 4 — a second card
if (machine.count !== 2) {
const r2 = computeMachine({ ...machine, count: 2 });
if (r2.fits) {
out.push({
title: 'Add a second card',
detail: `2 × ${r2.card.name} · ${perCardTxt(r2)} · ${r2.line}`,
apply: () => setCount(machine.id, 2),
});
}
}
// 5 — spill to the chosen RAM
if (!machine.spill) {
const r2 = computeMachine({ ...machine, spill: true });
if (r2.fits) {
out.push({
title: `Spill to ${machine.ram} GiB of RAM`,
detail: `${fmt(gib(r2.ramUsed))} GiB of weights in RAM · ${perCardTxt(r2)} · ${r2.line}`,
apply: () => setSpill(machine.id, true),
});
}
}
return out;
}
/* Cheapest card build that holds everything currently on the machine. */
function cheapestFit(machine) {
const cands = [];
for (const card of CARDS) {
for (const count of [1, 2]) {
const m = { ...machine, cardId: card.id, count };
const r = computeMachine(m);
if (r.fits) {
const slowest = r.rows.length ? Math.min(...r.rows.map(x => x.speed)) : Infinity;
cands.push({ card, count, price: card.price * count, res: r, slowest });
}
}
}
if (!cands.length) {
let need = 0;
for (const inst of machine.models) {
const b = modelBytes(inst);
need += b.weights + b.ctx + GIB;
}
return { ok: false, text: `Nothing fits — these models need ${fmt(gib(need))} GiB in total (weights + context + 1 GiB per model).` };
}
cands.sort((a, b) => (a.price - b.price) || (b.slowest - a.slowest));
const best = cands[0];
return {
ok: true, best,
text: `${best.card.name}${best.count > 1 ? ' ×' + best.count : ''} — ${money(best.price)}` +
(machine.models.length ? ` · slowest ${best.slowest.toFixed(1)} tok/s` : ''),
};
}
/* =======================================================================
RENDER
======================================================================= */
function renderTop() {
let cost = 0, modelCount = 0, fastest = 0, fastestName = '—';
for (const m of state.machines) {
cost += CARD_BY_ID[m.cardId].price * m.count;
const r = computeMachine(m);
for (const row of r.rows) {
modelCount++;
if (r.fits && row.speed > fastest) { fastest = row.speed; fastestName = row.def.name; }
}
}
$('#statCost').textContent = money(cost);
$('#statModels').textContent = String(modelCount);
$('#statFast').textContent = fastest ? `${fastest.toFixed(1)} tok/s` : '—';
$('#statFast').title = fastestName;
$('#mathBtn').classList.toggle('on', state.math);
}
function catalogCardHTML(def) {
const b4 = def.total * 4.5 / 8, b8 = def.total * 8.5 / 8;
const moe = isMoE(def);
const dragging = state.drag && state.drag.kind === 'catalog' && state.drag.modelId === def.id;
return `<article class="mcat${dragging ? ' dragging' : ''}" data-model="${def.id}" title="Drag onto a machine">
<div class="catrow1">
<span class="nm">${esc(def.name)}</span>
<span class="tag ${moe ? 'moe' : 'dense'}">${moe ? 'MoE ' + params(def.active) + ' act' : 'dense'}</span>
</div>
<div class="catrow2">${params(def.total)} params · ${trimZero(def.ctxKiB)} KiB/tok · max ${fmtInt(def.maxCtx)}</div>
<div class="catrow3">
<span class="q4"><i></i>Q4_0 ${fmt(gib(b4))} GiB</span>
<span class="q8"><i></i>Q8_0 ${fmt(gib(b8))} GiB</span>
</div>
</article>`;
}
function modelsColHTML() {
return `<section class="col modelscol" data-drop="models">
<div class="colhead"><h2>Models</h2><span class="sub">drag one onto a machine</span></div>
<div class="colbody">${MODELS.map(catalogCardHTML).join('')}</div>
</section>`;
}
/* the machine a column shows: virtual view used while dragging */
function viewFor(machine) {
const d = state.drag;
if (!d) return { machine, list: machine.models, hover: false };
const hovering = d.hover && d.hover.type === 'machine' && d.hover.id === machine.id;
if (hovering) {
const vm = virtualMachine(machine, d);
return { machine: vm, list: machine.models.filter(x => x.uid !== (d.kind === 'card' ? d.uid : null)), hover: true };
}
return { machine, list: machine.models, hover: false };
}
/* machine + the dragged card hypothetically in it */
function virtualMachine(machine, d) {
const models = machine.models.filter(x => !(d.kind === 'card' && x.uid === d.uid));
let inst;
if (d.kind === 'catalog') {
inst = { uid: '__drag__', modelId: d.modelId, quant: 'q4', cache: 16, ctx: DEFAULT_CTX };
} else {
inst = { ...d.inst };
}
const idx = Math.max(0, Math.min(d.insertIdx, models.length));
models.splice(idx, 0, inst);
return { ...machine, models };
}
function barRowHTML(label, used, cap, segs) {
const track = Math.max(cap, used, 1);
const segHTML = segs.map(s =>
`<div class="seg seg-${s.color}" style="width:${(s.size / track * 100).toFixed(3)}%" title="${esc(s.title)}"></div>`).join('');
const over = used > cap;
const limitPct = Math.min(100, (cap / track) * 100);
return `<div class="barrow">
<div class="barlab"><span>${label}</span><b>${fmt(gib(used))} / ${fmt(gib(cap))} GiB</b></div>
<div class="track">${segHTML}${over ? `<div class="overmark" style="left:${limitPct.toFixed(2)}%"></div>` : ''}</div>
</div>`;
}
function barsHTML(machine, res) {
const parts = [];
for (let i = 0; i < res.n; i++) {
const segs = [];
for (const r of res.rows) {
const nm = r.def.name;
if (r.weightsOnCard > 0) segs.push({ color:'weights', size:r.weightsOnCard, title:`${nm} weights ${fmt(gib(r.weightsOnCard))} GiB` });
if (r.ctxPerCard > 0) segs.push({ color:'ctx', size:r.ctxPerCard, title:`${nm} context ${fmt(gib(r.ctxPerCard))} GiB` });
segs.push({ color:'ovh', size:r.overheadPerCard, title:`${nm} overhead 1.00 GiB` });
}
parts.push(barRowHTML(`Card ${i + 1}`, res.usedPerCard, res.memB, segs));
}
if (machine.spill) {
const segs = res.rows.filter(r => r.ramWeights > 0).map(r =>
({ color:'ram', size:r.ramWeights, title:`${r.def.name} weights in RAM ${fmt(gib(r.ramWeights))} GiB` }));
parts.push(barRowHTML(`RAM ${machine.ram} GiB`, res.ramUsed, res.ramSizeB, segs));
}
return parts.join('');
}
function machineCardHTML(machine, res, inst) {
const def = MODEL_BY_ID[inst.modelId];
const moe = isMoE(def);
const row = res.rows.find(r => r.inst.uid === inst.uid);
const runs = res.fits;
const maxCtx = mostContextFor(machine, inst.uid);
let status, statusCls = '';
if (!runs) {
status = `Won't run · ${res.line}`;
statusCls = 'bad';
} else if (row.ramWeights > 0) {
status = `${row.speed.toFixed(1)} tok/s · ${fmt(gib(row.ramWeights))} GiB in RAM`;
statusCls = 'ram';
} else {
status = `${row.speed.toFixed(1)} tok/s`;
}
const picked = state.picked === inst.uid;
const dragging = state.drag && state.drag.kind === 'card' && state.drag.uid === inst.uid;
const wG = gib(row.weights), cG = gib(row.ctx);
const meta = [
`max ${maxCtx ? fmtInt(maxCtx) : '—'} tok`,
`${fmt(wG)} GiB weights`,
`${fmt(cG)} GiB ctx`,
].join(' · ');
return `<article class="mcard${picked ? ' picked' : ''}${dragging ? ' dragging' : ''}" data-uid="${inst.uid}" data-mid="${machine.id}">
<div class="mrow">
<span class="mcname">${esc(def.name)}</span>
<span class="tag ${moe ? 'moe' : 'dense'}">${moe ? 'MoE' : 'dense'}</span>
<span class="mcstatus ${statusCls}" data-status="${inst.uid}">${status}</span>
</div>
<div class="ctrls">
<div class="seg2" data-role="quant" data-uid="${inst.uid}">
<button data-q="q4" class="${inst.quant === 'q4' ? 'on' : ''}">Q4_0</button>
<button data-q="q8" class="${inst.quant === 'q8' ? 'on' : ''}">Q8_0</button>
</div>
<div class="seg2" data-role="cache" data-uid="${inst.uid}">
<button data-c="16" class="${inst.cache === 16 ? 'on' : ''}">16-bit</button>
<button data-c="8" class="${inst.cache === 8 ? 'on' : ''}">8-bit</button>
</div>
</div>
<div class="ctxrow">
<input type="range" min="${STEP}" max="${def.maxCtx}" step="${STEP}" value="${inst.ctx}" data-uid="${inst.uid}" data-mid="${machine.id}">
<span class="ctxval" data-ctxval="${inst.uid}">${fmtInt(inst.ctx)}</span>
</div>
<div class="mcmeta" data-maxctx="${inst.uid}">${meta}</div>
</article>`;
}
function machineColHTML(machine) {
const view = viewFor(machine);
const res = computeMachine(view.machine);
const card = CARD_BY_ID[machine.cardId];
const price = card.price * machine.count;
const cardOptions = CARDS.map(c =>
`<option value="${c.id}" ${c.id === machine.cardId ? 'selected' : ''}>${c.name}${c.used ? ' (used)' : ''}</option>`).join('');
const ramOptions = [32, 64, 128, 256, 512].map(r =>
`<option value="${r}" ${r === machine.ram ? 'selected' : ''}>${r} GiB</option>`).join('');
const msg = state.msg[machine.id];
const status = msg ? `<div class="mstatus${msg.warn ? ' warn' : ''}">${esc(msg.text)}</div>` : '';
// drag verdict banner
let banner = '';
if (view.hover && state.drag) {
const row = res.rows.find(r => r.inst.uid === (state.drag.kind === 'card' ? state.drag.uid : '__drag__'));
let cls = 'ok', text;
if (!res.fits) { cls = 'bad'; text = res.line; }
else if (row && row.ramWeights > 0) { cls = 'ram'; text = `${row.speed.toFixed(1)} tok/s · ${fmt(gib(row.ramWeights))} GiB in RAM`; }
else if (row) { text = `Fits · ${row.speed.toFixed(1)} tok/s`; }
banner = `<div class="dragbanner show ${cls}"><span class="dbtag">drop</span>${esc(text || '')}</div>`;
} else {
banner = `<div class="dragbanner"></div>`;
}
const fitCls = !res.fits ? 'over' : (machine.spill ? 'ram' : '');
const list = view.list.map(inst => machineCardHTML(view.machine, res, inst)).join('');
return `<section class="col mcol" data-drop="machine" data-mid="${machine.id}">
<div class="mhead">
<div class="mrow">
<select class="sel cardsel" data-mid="${machine.id}">${cardOptions}</select>
<div class="countsel" data-mid="${machine.id}">
<button data-count="1" class="${machine.count === 1 ? 'on' : ''}">1</button>
<button data-count="2" class="${machine.count === 2 ? 'on' : ''}">2</button>
</div>
<div class="price">${money(price)}</div>
<button class="iconbtn rm" data-mid="${machine.id}" title="Remove machine">✕</button>
</div>
<div class="mrow">
<label class="spilllabel"><input type="checkbox" class="spillchk" data-mid="${machine.id}" ${machine.spill ? 'checked' : ''}> Spill to</label>
<select class="sel ramsel" data-mid="${machine.id}">${ramOptions}</select>
<button class="btn cf" data-mid="${machine.id}" style="margin-left:auto">Cheapest fit</button>
</div>
${status}
</div>
${banner}
<div class="bars">${barsHTML(view.machine, res)}</div>
<div class="fitline ${fitCls}">${esc(res.line)}</div>
<div class="mlist">${list || `<div style="color:var(--dim);font-size:11.5px;padding:6px 2px">Empty — drag a model here</div>`}</div>
</section>`;
}
function addColHTML() {
return `<section class="col addcol">
<div class="colhead"><h2>Machines</h2></div>
<div class="colbody">
<button class="addbtn" id="addMachine"><span class="plus">+</span><span>Add machine</span></button>
</div>
</section>`;
}
function renderBoard() {
const parts = [modelsColHTML()];
for (const m of state.machines) parts.push(machineColHTML(m));
if (state.machines.length < 3) parts.push(addColHTML());
$('#board').innerHTML = parts.join('');
}
/* ------------------------------- panel -------------------------------- */
function getPicked() {
if (!state.picked) return null;
for (const m of state.machines) {
const inst = m.models.find(x => x.uid === state.picked);
if (inst) {
const res = computeMachine(m);
const row = res.rows.find(r => r.inst.uid === inst.uid);
return { machine: m, inst, res, row };
}
}
return null;
}
function pickedVerdict(p) {
if (!p) return { text: '—', cls: '' };
if (!p.res.fits) return { text: `Won't run · ${p.res.line}`, cls: 'bad' };
if (p.row.ramWeights > 0) return { text: `Fits, ${fmt(gib(p.row.ramWeights))} GiB in RAM`, cls: 'ram' };
return { text: `Fits, ${fmt(gib(p.res.spare))} GiB to spare`, cls: '' };
}
function mathHTML(p) {
if (!p) return '';
const m = p.machine, res = p.res, row = p.row, inst = p.inst;
const def = row.def;
const b = modelBytes(inst);
const L = [];
const perTokCtx = b.perTok * (inst.cache === 8 ? 0.5 : 1);
const bwB = res.card.bw * 1e9;
L.push(['Weights', `${params(def.total)} params × ${b.bits} bits ÷ 8 = ${fmtInt(b.weights)} B = ${fmt(gib(b.weights))} GiB`]);
L.push(['Context', `${fmtInt(inst.ctx)} tok × ${fmtInt(perTokCtx)} B (${inst.cache}-bit cache) = ${fmtInt(b.ctx)} B = ${fmt(gib(b.ctx))} GiB`]);
L.push(['Overhead', `${res.rows.length} model${res.rows.length === 1 ? '' : 's'} × 1 GiB = ${fmt(gib(res.overheadPerCard))} GiB per card`]);
for (let i = 0; i < res.n; i++) {
const w = res.rows.reduce((s, r) => s + r.weightsOnCard, 0);
const c = res.rows.reduce((s, r) => s + r.ctxPerCard, 0);
L.push([`Card ${i + 1} total`, `weights ${fmt(gib(w))} + context ${fmt(gib(c))} + overhead ${fmt(gib(res.overheadPerCard))} = ${fmt(gib(res.usedPerCard))} GiB of ${fmt(gib(res.memB))} GiB`]);
}
L.push(['Fit', res.line]);
if (res.spill) {
L.push(['RAM', `${fmt(gib(res.ramUsed))} GiB of ${m.ram} GiB used` + (row.ramWeights > 0
? ` · this model ${fmt(gib(row.ramWeights))} GiB (${fmt(row.ramWeights / row.weights * 100, 1)}% of its weights)`
: ' · this model fully on the cards')]);
}
L.push(['Bytes/token', `${fmtInt(row.activeBytes)} B active (${params(def.active)} × ${b.bits} ÷ 8)`]);
if (row.activeInRam > 0) {
L.push([' from cards', `${fmtInt(row.activeOnCards)} B ÷ ${fmtInt(bwB)} B/s`]);
L.push([' from RAM', `${fmtInt(row.activeInRam)} B ÷ ${fmtInt(RAM_BW)} B/s`]);
L.push(['Speed ceiling', `1 ÷ (${fmtInt(row.activeOnCards)} ÷ ${fmtInt(bwB)} + ${fmtInt(row.activeInRam)} ÷ ${fmtInt(RAM_BW)}) = ${row.speed.toFixed(1)} tok/s`]);
} else {
L.push(['Speed ceiling', `${fmtInt(row.activeBytes)} ÷ ${fmtInt(bwB)} = ${row.speed.toFixed(1)} tok/s`]);
}
L.push(['Most context', `${fmtInt(mostContextFor(m, inst.uid))} tokens (largest ${fmtInt(STEP)} step that still fits)`]);
L.push(['Rule', res.n === 1
? '1 card: weights and context stay whole; 1 GiB overhead per model per card'
: '2 cards: weights and context are split in half per card; 1 GiB overhead per model per card']);
return L.map(([k, v]) => `<div class="ml"><b>${esc(k.padEnd(14, ' '))}</b> ${esc(v)}</div>`).join('');
}
function renderPanel() {
const p = getPicked();
const el = $('#panel');
if (!p) {
el.innerHTML = `<div class="pbody"><div class="pmain"><div class="ptextwrap">Pick a card on a machine to stream an answer at its speed.</div></div></div>`;
return;
}
const def = p.row.def;
const moe = isMoE(def);
const v = pickedVerdict(p);
const changes = fitChanges(p.machine, p.inst.uid);
const running = state.playing && state.stream.uid === p.inst.uid;
const canRun = p.res.fits;
const fixHTML = changes.map((c, i) =>
`<div class="fixcard" data-fix="${i}"><div class="ft">${esc(c.title)}</div><div class="fd">${esc(c.detail)}</div></div>`).join('');
const words = state.stream.uid === p.inst.uid ? state.stream.words : 0;
const elapsed = state.stream.uid === p.inst.uid ? state.stream.elapsed : 0;
const started = words > 0 || (running && canRun);
const textInner = started
? '<span id="pDone"></span><span class="cur" id="pCur"></span>'
: (canRun
? `<span class="phint">Press <b>Play</b> — ${esc(def.name)} answers at <b>${p.row.speed.toFixed(1)} tok/s</b>, so ${TOTAL_WORDS} words take ${fmt(TOTAL_WORDS / Math.max(p.row.speed, 0.0001))} s.</span>`
: `<span class="phint bad">This card will not run on this machine yet. Fix the fit below and the answer starts streaming.</span>`);
el.innerHTML = `
<div class="phead">
<div class="pwho">
<span class="pn">${esc(def.name)}</span>
<span class="tag ${moe ? 'moe' : 'dense'}">${moe ? 'MoE ' + params(def.active) + ' act' : 'dense'}</span>
<span class="pm">on ${esc(p.machine.cardId ? CARD_BY_ID[p.machine.cardId].name : '')} ×${p.machine.count}${p.machine.spill ? ' · spilling' : ''}</span>
</div>
<div class="pstats">
<div class="ps"><span class="k">Speed ceiling</span><span class="v ${canRun ? '' : 'bad'}">${p.row.speed.toFixed(1)} tok/s</span></div>
<div class="ps"><span class="k">Verdict</span><span class="v ${v.cls}">${esc(v.text)}</span></div>
</div>
<div class="pbtns">
<button class="btn" id="playBtn" ${canRun ? '' : 'disabled'}>${running ? 'Restart' : 'Play'}</button>
<button class="btn btn-ghost" id="panelMathBtn">Math <kbd>M</kbd></button>
</div>
</div>
<div class="pbody">
<aside class="pfix" id="pFix">${fixHTML}</aside>
<div class="pmain">
<div class="ptextwrap" id="pTextWrap" ${state.math ? 'hidden' : ''}>${textInner}</div>
<div class="pmath" id="pMath" ${state.math ? '' : 'hidden'}>${mathHTML(p)}</div>
</div>
</div>
<div class="pfoot">
<div class="pbar"><i id="pFill"></i></div>
<span id="pWords">${words} / ${TOTAL_WORDS} words · ${fmt(elapsed)} s</span>
<span class="pfinish ${canRun ? '' : 'bad'}" id="pFinish">${words >= TOTAL_WORDS ? `${TOTAL_WORDS} words in ${fmt(elapsed)} s · ${fmt(TOTAL_WORDS / Math.max(elapsed, 0.0001), 1)} tok/s average` : ''}</span>
</div>`;
updateStreamDOM();
}
function updateStreamDOM() {
const done = $('#pDone'), cur = $('#pCur');
if (!done) return;
const n = state.stream.words;
done.textContent = n ? ANSWER.slice(0, n).join(' ') + ' ' : '';
cur.textContent = ANSWER[n] || '';
const wrap = $('#pTextWrap');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
function updateProgress() {
const w = $('#pWords'), f = $('#pFill'), fin = $('#pFinish');
if (!w) return;
const s = state.stream;
w.textContent = `${s.words} / ${TOTAL_WORDS} words · ${fmt(s.elapsed)} s`;
if (f) f.style.width = (s.words / TOTAL_WORDS * 100).toFixed(2) + '%';
if (fin) {
if (s.words >= TOTAL_WORDS) {
fin.textContent = `${TOTAL_WORDS} words in ${fmt(s.elapsed)} s · ${fmt(TOTAL_WORDS / Math.max(s.elapsed, 0.0001), 1)} tok/s average`;
fin.className = 'pfinish';
} else {
fin.textContent = '';
}
}
}
/* keep the keyboard where it was when the board rebuilds itself */
function focusKey(el) {
if (!el || !el.dataset) return null;
if (el.classList && el.classList.contains('cardsel')) return 'cardsel:' + el.dataset.mid;
if (el.classList && el.classList.contains('ramsel')) return 'ramsel:' + el.dataset.mid;
if (el.classList && el.classList.contains('spillchk')) return 'spillchk:' + el.dataset.mid;
if (el.dataset.count) return 'count:' + el.parentElement.dataset.mid + ':' + el.dataset.count;
if (el.dataset.q) return 'quant:' + el.parentElement.dataset.uid + ':' + el.dataset.q;
if (el.dataset.c) return 'cache:' + el.parentElement.dataset.uid + ':' + el.dataset.c;
if (el.id) return 'id:' + el.id;
return null;
}
function restoreFocus(key) {
if (!key) return;
const p = key.split(':');
const sel = {
cardsel: () => `.cardsel[data-mid="${p[1]}"]`,
ramsel: () => `.ramsel[data-mid="${p[1]}"]`,
spillchk: () => `.spillchk[data-mid="${p[1]}"]`,
count: () => `.countsel[data-mid="${p[1]}"] button[data-count="${p[2]}"]`,
quant: () => `.seg2[data-role="quant"][data-uid="${p[1]}"] button[data-q="${p[2]}"]`,
cache: () => `.seg2[data-role="cache"][data-uid="${p[1]}"] button[data-c="${p[2]}"]`,
id: () => `#${p[1]}`,
}[p[0]];
if (!sel) return;
const el = document.querySelector(sel());
if (el && el.focus) el.focus();
}
function renderAll() {
const key = focusKey(document.activeElement);
renderTop();
renderBoard();
renderPanel();
restoreFocus(key);
}
/* live update while a slider is being dragged (must not rebuild DOM nodes
the pointer is currently holding) */
function updateLive(machineId, uid) {
const m = state.machines.find(x => x.id === machineId);
if (!m) return;
const col = $(`.mcol[data-mid="${machineId}"]`);
if (!col) return;
const res = computeMachine(m);
const barsBox = $('.bars', col);
if (barsBox) barsBox.innerHTML = barsHTML(m, res);
const fitEl = $('.fitline', col);
if (fitEl) {
fitEl.textContent = res.line;
fitEl.className = 'fitline ' + (!res.fits ? 'over' : (m.spill ? 'ram' : ''));
}
for (const inst of m.models) {
const row = res.rows.find(r => r.inst.uid === inst.uid);
const st = $(`[data-status="${inst.uid}"]`, col);
if (st && row) {
if (!res.fits) { st.textContent = `Won't run · ${res.line}`; st.className = 'mcstatus bad'; }
else if (row.ramWeights > 0) { st.textContent = `${row.speed.toFixed(1)} tok/s · ${fmt(gib(row.ramWeights))} GiB in RAM`; st.className = 'mcstatus ram'; }
else { st.textContent = `${row.speed.toFixed(1)} tok/s`; st.className = 'mcstatus'; }
}
const mx = $(`[data-maxctx="${inst.uid}"]`, col);
if (mx) {
const maxCtx = mostContextFor(m, inst.uid);
mx.textContent = `max ${maxCtx ? fmtInt(maxCtx) : '—'} tok · ${fmt(gib(row.weights))} GiB weights · ${fmt(gib(row.ctx))} GiB ctx`;
}
}
renderTop();
renderPanel();
}
/* =======================================================================
MUTATIONS
======================================================================= */
function findInst(uid) {
for (const m of state.machines) {
const inst = m.models.find(x => x.uid === uid);
if (inst) return { machine: m, inst };
}
return null;
}
function setCtx(uid, ctx) {
const f = findInst(uid); if (!f) return;
const def = MODEL_BY_ID[f.inst.modelId];
f.inst.ctx = Math.max(STEP, Math.min(def.maxCtx, Math.round(ctx / STEP) * STEP));
renderAll();
}
function setCache(uid, bits) {
const f = findInst(uid); if (!f) return;
f.inst.cache = bits; renderAll();
}
function setQuant(uid, q) {
const f = findInst(uid); if (!f) return;
f.inst.quant = q; renderAll();
}
function setCount(mid, n) {
const m = state.machines.find(x => x.id === mid); if (!m) return;
m.count = n; delete state.msg[mid]; renderAll();
}
function setSpill(mid, on) {
const m = state.machines.find(x => x.id === mid); if (!m) return;
m.spill = on; delete state.msg[mid]; renderAll();
}
function setCard(mid, cardId) {
const m = state.machines.find(x => x.id === mid); if (!m) return;
m.cardId = cardId; delete state.msg[mid]; renderAll();
}
function setRam(mid, ram) {
const m = state.machines.find(x => x.id === mid); if (!m) return;
m.ram = ram; delete state.msg[mid]; renderAll();
}
function removeInstance(uid) {
for (const m of state.machines) {
const i = m.models.findIndex(x => x.uid === uid);
if (i >= 0) {
m.models.splice(i, 1);
delete state.msg[m.id];
if (state.picked === uid) { state.picked = null; stopStream(); }
renderAll();
return true;
}
}
return false;
}
function addMachine() {
if (state.machines.length >= 3) return;
const id = 'm' + (state.machines.length + 1) + '_' + Date.now().toString(36).slice(-3);
state.machines.push({ id, cardId:'rtx3060', count:1, spill:false, ram:64, models:[] });
renderAll();
}
function removeMachine(mid) {
const i = state.machines.findIndex(x => x.id === mid);
if (i < 0) return;
const gone = state.machines[i].models.map(x => x.uid);
state.machines.splice(i, 1);
if (gone.includes(state.picked)) { state.picked = null; stopStream(); }
delete state.msg[mid];
renderAll();
}
/* =======================================================================
PICKING + STREAM
======================================================================= */
function pick(uid, startStream) {
if (!uid) return;
const f = findInst(uid);
if (!f) return;
state.picked = uid;
state.focusUid = uid;
const res = computeMachine(f.machine);
if (startStream !== false && res.fits) startStreamFor(uid);
else renderAll();
}
function startStreamFor(uid) {
state.stream = { words:0, tokens:0, elapsed:0, lastTs:0, uid };
state.playing = true;
renderAll();
}
/* follow a card that is already picked: the stream keeps its place */
function followCard(uid) {
state.picked = uid;
state.focusUid = uid;
state.stream.uid = uid;
if (state.stream.words < TOTAL_WORDS) state.playing = true;
renderAll();
}
function stopStream() {
state.playing = false;
state.stream = { words:0, tokens:0, elapsed:0, lastTs:0, uid:null };
}
function tick(ts) {
requestAnimationFrame(tick);
if (!state.playing) { state.stream.lastTs = ts; return; }
const p = getPicked();
if (!p || state.stream.uid !== p.inst.uid) { state.stream.lastTs = ts; return; }
if (!p.res.fits) { state.stream.lastTs = ts; updateProgress(); return; } // paused, clock stopped
const dt = state.stream.lastTs ? (ts - state.stream.lastTs) / 1000 : 0;
state.stream.lastTs = ts;
state.stream.elapsed += dt;
state.stream.tokens += p.row.speed * dt;
const words = Math.min(TOTAL_WORDS, Math.floor(state.stream.tokens));
if (words >= TOTAL_WORDS) {
// the last word lands between frames — pay the clock back exactly
const over = state.stream.tokens - TOTAL_WORDS;
state.stream.elapsed = Math.max(0, state.stream.elapsed - over / p.row.speed);
state.stream.tokens = TOTAL_WORDS;
}
if (words !== state.stream.words) {
state.stream.words = words;
updateStreamDOM();
}
updateProgress();
if (words >= TOTAL_WORDS) { state.playing = false; updateProgress(); }
}
/* =======================================================================
DRAG & DROP (pointer based, so previews and alt-copy work)
======================================================================= */
let ghostEl = null;
function beginDrag(src, ev) {
const d = {
kind: src.kind, modelId: src.modelId, uid: src.uid, inst: src.inst,
sourceMid: src.mid, hover: null, insertIdx: 0, copy: ev.altKey,
};
if (d.kind === 'card') {
const f = findInst(d.uid);
if (f) d.inst = { ...f.inst };
}
state.drag = d;
const ghostLabel = d.kind === 'catalog'
? MODEL_BY_ID[d.modelId].name
: MODEL_BY_ID[d.inst.modelId].name;
const ghostSub = d.kind === 'catalog'
? 'Q4_0 · 32,768 tok · 16-bit cache'
: (d.copy ? 'copy' : 'move');
ghostEl = document.createElement('div');
ghostEl.className = 'ghost';
ghostEl.innerHTML = `${esc(ghostLabel)}<small>${ghostSub}</small>`;
document.body.appendChild(ghostEl);
moveGhost(ev.clientX, ev.clientY);
const el = d.kind === 'catalog' ? $(`.mcat[data-model="${d.modelId}"]`) : $(`.mcard[data-uid="${d.uid}"]`);
if (el) el.classList.add('dragging');
document.body.style.cursor = 'grabbing';
}
function moveGhost(x, y) {
if (ghostEl) { ghostEl.style.left = x + 'px'; ghostEl.style.top = (y - 18) + 'px'; }
}
function updateDragTarget(x, y) {
const d = state.drag;
if (!d) return;
const under = document.elementFromPoint(x, y);
const zone = under ? under.closest('[data-drop]') : null;
let hover = null;
if (zone) {
if (zone.dataset.drop === 'models') hover = { type: 'models' };
else if (zone.dataset.drop === 'machine') hover = { type: 'machine', id: zone.dataset.mid };
}
const changed = JSON.stringify(hover) !== JSON.stringify(d.hover);
d.hover = hover;
if (changed) {
$$('.mcol').forEach(c => c.classList.remove('droptarget'));
$$('.modelscol').forEach(c => c.classList.remove('droptarget'));
if (hover) {
const el = hover.type === 'models'
? $('.modelscol')
: $(`.mcol[data-mid="${hover.id}"]`);
if (el) el.classList.add('droptarget');
}
renderBoard();
}
if (hover && hover.type === 'machine') {
// index is measured against the preview list, which no longer shows the
// card being dragged
d.insertIdx = insertIndexFor(hover.id, y);
}
}
function insertIndexFor(mid, y) {
const col = $(`.mcol[data-mid="${mid}"]`);
if (!col) return 0;
const list = $('.mlist', col);
if (!list) return 0;
const cards = $$('.mcard', list);
let idx = cards.length;
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
if (y < r.top + r.height / 2) { idx = i; break; }
}
return idx;
}
function endDrag(ev) {
const d = state.drag;
if (!d) return;
const hover = d.hover;
state.drag = null;
if (ghostEl) { ghostEl.remove(); ghostEl = null; }
document.body.style.cursor = '';
$$('.mcat.dragging,.mcard.dragging').forEach(e => e.classList.remove('dragging'));
$$('.mcol,.modelscol').forEach(e => e.classList.remove('droptarget'));
if (!hover) { renderBoard(); return; }
if (hover.type === 'models') {
// dropping a card on the Models column removes it
if (d.kind === 'card') {
removeInstance(d.uid);
return;
}
renderBoard();
return;
}
const target = state.machines.find(m => m.id === hover.id);
if (!target) { renderBoard(); return; }
if (d.kind === 'catalog') {
const inst = { uid: nextUid(), modelId: d.modelId, quant: 'q4', cache: 16, ctx: DEFAULT_CTX };
const idx = Math.max(0, Math.min(d.insertIdx, target.models.length));
target.models.splice(idx, 0, inst);
delete state.msg[target.id];
const res = computeMachine(target);
state.picked = inst.uid;
state.focusUid = inst.uid;
if (res.fits) startStreamFor(inst.uid);
else { state.stream = { words:0, tokens:0, elapsed:0, lastTs:0, uid:inst.uid }; renderAll(); }
return;
}
// moving / copying a card
const src = state.machines.find(m => m.id === d.sourceMid);
const from = src ? src.models.findIndex(x => x.uid === d.uid) : -1;
if (from < 0) { renderBoard(); return; }
let inst = src.models[from];
if (d.copy) {
inst = { ...inst, uid: nextUid() };
} else {
src.models.splice(from, 1);
}
// d.insertIdx is measured against the list without the dragged card,
// which is exactly the list we now hold — unless we are copying inside the
// same machine, where the source card is still in place
let idx = d.insertIdx;
if (d.copy && src === target && from < idx) idx++;
idx = Math.max(0, Math.min(idx, target.models.length));
target.models.splice(idx, 0, inst);
delete state.msg[target.id];
const wasPicked = state.picked === inst.uid;
const res = computeMachine(target);
if (wasPicked) {
// the stream follows the card to its new machine
followCard(inst.uid);
} else if (res.fits) {
state.picked = inst.uid;
state.focusUid = inst.uid;
startStreamFor(inst.uid);
} else {
state.picked = inst.uid;
state.focusUid = inst.uid;
state.stream = { words:0, tokens:0, elapsed:0, lastTs:0, uid:inst.uid };
renderAll();
}
}
/* =======================================================================
EVENTS
======================================================================= */
document.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
const t = e.target;
if (!t || !t.closest) return;
if (t.closest('button,select,input,a')) return;
const cat = t.closest('.mcat');
const mcard = t.closest('.mcard');
if (!cat && !mcard) return;
const src = cat
? { kind: 'catalog', modelId: cat.dataset.model }
: { kind: 'card', uid: mcard.dataset.uid, mid: mcard.dataset.mid, inst: null };
if (src.kind === 'card') {
const f = findInst(src.uid);
if (f) src.inst = { ...f.inst };
}
const start = { x: e.clientX, y: e.clientY };
let started = false;
const onMove = (ev) => {
if (!started) {
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < 5) return;
started = true;
beginDrag(src, ev);
}
moveGhost(ev.clientX, ev.clientY);
updateDragTarget(ev.clientX, ev.clientY);
};
const onUp = (ev) => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
if (started) { endDrag(ev); }
else if (src.kind === 'card') { pick(src.uid, true); }
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
/* clicks on the board (delegated) */
document.addEventListener('click', (e) => {
const addBtn = e.target.closest('#addMachine');
if (addBtn) { addMachine(); return; }
const rm = e.target.closest('.rm');
if (rm) { removeMachine(rm.dataset.mid); return; }
const cf = e.target.closest('.cf');
if (cf) {
const m = state.machines.find(x => x.id === cf.dataset.mid);
if (!m) return;
const r = cheapestFit(m);
state.msg[m.id] = { text: (r.ok ? 'Cheapest fit: ' : '') + r.text, warn: !r.ok };
if (r.ok) {
m.cardId = r.best.card.id;
m.count = r.best.count;
}
renderAll();
return;
}
const q = e.target.closest('.seg2[data-role="quant"] button');
if (q) { setQuant(q.parentElement.dataset.uid, q.dataset.q); return; }
const c = e.target.closest('.seg2[data-role="cache"] button');
if (c) { setCache(c.parentElement.dataset.uid, Number(c.dataset.c)); return; }
const cnt = e.target.closest('.countsel button');
if (cnt) { setCount(cnt.parentElement.dataset.mid, Number(cnt.dataset.count)); return; }
const fix = e.target.closest('.fixcard');
if (fix) {
const p = getPicked();
if (!p) return;
const changes = fitChanges(p.machine, p.inst.uid);
const c2 = changes[Number(fix.dataset.fix)];
if (c2) { c2.apply(); }
return;
}
const pb = e.target.closest('#playBtn');
if (pb) {
const p = getPicked();
if (p) startStreamFor(p.inst.uid);
return;
}
const mb = e.target.closest('#mathBtn, #panelMathBtn');
if (mb) { state.math = !state.math; renderAll(); return; }
});
/* selects + checkboxes */
document.addEventListener('change', (e) => {
const t = e.target;
if (t.classList.contains('cardsel')) { setCard(t.dataset.mid, t.value); return; }
if (t.classList.contains('ramsel')) { setRam(t.dataset.mid, Number(t.value)); return; }
if (t.classList.contains('spillchk')) { setSpill(t.dataset.mid, t.checked); return; }
});
/* context sliders */
document.addEventListener('input', (e) => {
const t = e.target;
if (t.type !== 'range') return;
const uid = t.dataset.uid, mid = t.dataset.mid;
const f = findInst(uid);
if (!f) return;
f.inst.ctx = Number(t.value);
const val = $(`[data-ctxval="${uid}"]`);
if (val) val.textContent = fmtInt(f.inst.ctx);
updateLive(mid, uid);
});
document.addEventListener('change', (e) => {
const t = e.target;
if (t.type !== 'range') return;
const uid = t.dataset.uid, mid = t.dataset.mid;
const f = findInst(uid);
if (!f) return;
f.inst.ctx = Number(t.value);
updateLive(mid, uid);
});
/* keyboard */
document.addEventListener('keydown', (e) => {
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'select' || tag === 'textarea' || (tag === 'input' && e.target.type !== 'range')) return;
if (e.key === 'Enter') {
e.preventDefault();
const focused = document.activeElement && document.activeElement.closest
? document.activeElement.closest('.mcard') : null;
const uid = focused ? focused.dataset.uid : (state.picked || state.focusUid);
if (uid) pick(uid, true);
return;
}
if (e.key >= '1' && e.key <= '3') {
const n = Number(e.key);
const p = getPicked();
if (!p) return;
const target = state.machines[n - 1];
if (!target) return;
e.preventDefault();
const f = findInst(p.inst.uid);
if (!f) return;
if (f.machine === target) return;
f.machine.models.splice(f.machine.models.indexOf(f.inst), 1);
target.models.push(f.inst);
followCard(f.inst.uid);
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
const p = getPicked();
if (p) removeInstance(p.inst.uid);
return;
}
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && e.altKey) {
e.preventDefault();
const p = getPicked();
if (!p) return;
const arr = p.machine.models;
const i = arr.indexOf(p.inst);
const j = e.key === 'ArrowUp' ? i - 1 : i + 1;
if (j < 0 || j >= arr.length) return;
arr.splice(i, 1);
arr.splice(j, 0, p.inst);
renderAll();
return;
}
if (e.key === 'm' || e.key === 'M') {
state.math = !state.math;
renderAll();
}
});
/* =======================================================================
BOOT
======================================================================= */
state.picked = state.machines[0].models[0].uid;
state.focusUid = state.picked;
state.stream.uid = state.picked;
renderAll();
requestAnimationFrame(tick);
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.