|
|
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=Syne+Mono&family=Space+Grotesk:wght@300;500;700&display=swap'); |
| *{box-sizing:border-box;margin:0;padding:0} |
| :root{ |
| --ink:#0d0d0d;--paper:#f7f5f0;--accent:#e84c1e;--accent2:#2563eb;--accent3:#16a34a; |
| --mid:#888;--card:rgba(255,255,255,0.7);--border:rgba(0,0,0,0.08); |
| --mono:'Syne Mono',monospace;--title:'Syne',sans-serif;--body:'Space Grotesk',sans-serif; |
| } |
| body{background:var(--paper);color:var(--ink);font-family:var(--body);overflow-x:hidden} |
| @media(prefers-color-scheme:dark){ |
| :root{--ink:#f0ede8;--paper:#111010;--accent:#ff6b3d;--accent2:#60a5fa;--accent3:#4ade80; |
| --mid:#777;--card:rgba(30,28,26,0.8);--border:rgba(255,255,255,0.08);} |
| } |
|
|
| #app{min-height:100vh;display:grid;grid-template-rows:auto 1fr auto;gap:0} |
|
|
| .topbar{ |
| display:flex;align-items:center;justify-content:space-between; |
| padding:1rem 1.5rem;border-bottom:1px solid var(--border); |
| background:var(--card);backdrop-filter:blur(12px); |
| position:sticky;top:0;z-index:100; |
| } |
| .logo{font-family:var(--title);font-weight:800;font-size:1.15rem;letter-spacing:-0.03em} |
| .logo span{color:var(--accent)} |
| .badge{ |
| font-family:var(--mono);font-size:.7rem;padding:.25rem .6rem;border-radius:4px; |
| background:rgba(232,76,30,0.1);color:var(--accent);border:1px solid rgba(232,76,30,0.2); |
| } |
|
|
| .main{display:grid;grid-template-columns:1fr 320px;min-height:0;overflow:hidden} |
| @media(max-width:700px){.main{grid-template-columns:1fr;grid-template-rows:1fr auto}} |
|
|
| /* CANVAS STAGE */ |
| .stage{position:relative;overflow:hidden;background:var(--paper);cursor:crosshair;min-height:480px} |
| #canvas{display:block;width:100%;height:100%} |
| .stage-hint{ |
| position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); |
| text-align:center;pointer-events:none;transition:opacity .5s; |
| } |
| .stage-hint h2{font-family:var(--title);font-size:clamp(1.4rem,4vw,2.5rem);font-weight:800;letter-spacing:-0.04em;line-height:1.1;color:var(--ink);opacity:0.15} |
| .stage-hint p{font-size:.8rem;color:var(--mid);margin-top:.5rem;opacity:0.5} |
|
|
| /* PANEL */ |
| .panel{ |
| border-left:1px solid var(--border);display:flex;flex-direction:column; |
| overflow-y:auto;background:var(--card);backdrop-filter:blur(8px); |
| } |
| .panel-section{padding:1.2rem;border-bottom:1px solid var(--border)} |
| .panel-label{font-family:var(--mono);font-size:.65rem;letter-spacing:.1em;color:var(--mid);text-transform:uppercase;margin-bottom:.75rem} |
| .panel-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem} |
| .panel-val{font-family:var(--mono);font-size:.78rem;color:var(--accent);font-weight:600} |
|
|
| input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer} |
| input[type=text]{ |
| width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px; |
| background:var(--paper);color:var(--ink);font-family:var(--body);font-size:.85rem; |
| outline:none;transition:border-color .2s; |
| } |
| input[type=text]:focus{border-color:var(--accent)} |
|
|
| .btn{ |
| display:block;width:100%;padding:.6rem 1rem;border:1.5px solid var(--border); |
| border-radius:6px;background:transparent;color:var(--ink);font-family:var(--body); |
| font-size:.82rem;font-weight:500;cursor:pointer;text-align:center; |
| transition:all .15s;margin-bottom:.5rem; |
| } |
| .btn:hover{background:var(--ink);color:var(--paper);border-color:var(--ink)} |
| .btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)} |
| .btn.primary:hover{background:#c43d17;border-color:#c43d17} |
|
|
| /* MODE PILLS */ |
| .modes{display:flex;gap:.4rem;flex-wrap:wrap} |
| .mode-pill{ |
| font-family:var(--mono);font-size:.68rem;padding:.3rem .65rem;border-radius:20px; |
| border:1px solid var(--border);cursor:pointer;transition:all .15s;color:var(--mid); |
| } |
| .mode-pill.active{background:var(--ink);color:var(--paper);border-color:var(--ink)} |
|
|
| /* METRICS TICKER */ |
| .metrics{display:grid;grid-template-columns:1fr 1fr;gap:.5rem} |
| .metric-card{ |
| background:var(--paper);border:1px solid var(--border);border-radius:6px; |
| padding:.6rem .75rem; |
| } |
| .metric-card .num{font-family:var(--mono);font-size:1.1rem;font-weight:700;color:var(--ink)} |
| .metric-card .lbl{font-family:var(--mono);font-size:.6rem;color:var(--mid);text-transform:uppercase;letter-spacing:.08em} |
|
|
| /* FONT SWATCHES */ |
| .font-swatches{display:flex;flex-direction:column;gap:.35rem} |
| .font-swatch{ |
| padding:.45rem .75rem;border:1px solid var(--border);border-radius:6px;cursor:pointer; |
| font-size:.88rem;transition:all .15s;display:flex;justify-content:space-between;align-items:center; |
| } |
| .font-swatch.active{border-color:var(--accent);color:var(--accent);background:rgba(232,76,30,0.05)} |
| .font-swatch span{font-size:.65rem;color:var(--mid);font-family:var(--mono)} |
|
|
| /* COLOR SWATCHES */ |
| .color-swatches{display:flex;gap:.5rem;flex-wrap:wrap} |
| .color-dot{width:28px;height:28px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .15s} |
| .color-dot:hover{transform:scale(1.15)} |
| .color-dot.active{border-color:var(--ink);transform:scale(1.1)} |
|
|
| /* STATUS BAR */ |
| .statusbar{ |
| padding:.5rem 1.5rem;border-top:1px solid var(--border); |
| display:flex;align-items:center;gap:1.5rem;font-family:var(--mono);font-size:.68rem;color:var(--mid); |
| background:var(--card);flex-wrap:wrap; |
| } |
| .status-dot{width:7px;height:7px;border-radius:50%;background:var(--accent3);display:inline-block;margin-right:.35rem;animation:pulse 2s infinite} |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}} |
|
|
| /* ANIMATE IN */ |
| @keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}} |
| .panel-section{animation:slideUp .4s ease both} |
| .panel-section:nth-child(2){animation-delay:.05s} |
| .panel-section:nth-child(3){animation-delay:.1s} |
| .panel-section:nth-child(4){animation-delay:.15s} |
| .panel-section:nth-child(5){animation-delay:.2s} |
| </style> |
|
|
| <div id="app"> |
| |
| <header class="topbar"> |
| <div class="logo">Type<span>flow</span> Arena</div> |
| <div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap"> |
| <span class="badge">pretext.js concept</span> |
| <span style="font-family:var(--mono);font-size:.72rem;color:var(--mid)" id="fps-display">60 fps</span> |
| </div> |
| </header> |
|
|
| |
| <div class="main"> |
| |
| <div class="stage" id="stage"> |
| <canvas id="canvas"></canvas> |
| <div class="stage-hint" id="hint"> |
| <h2>Measure without touching the DOM</h2> |
| <p>Click anywhere to spawn text · Pure arithmetic layout</p> |
| </div> |
| </div> |
|
|
| |
| <aside class="panel"> |
|
|
| <div class="panel-section"> |
| <div class="panel-label">Live text</div> |
| <input type="text" id="text-input" placeholder="Type your message..." value="Hello, Pretext.js!" maxlength="80"/> |
| <div style="margin-top:.75rem"> |
| <div class="panel-label" style="margin-bottom:.4rem">Quick phrases</div> |
| <div style="display:flex;flex-direction:column;gap:.3rem"> |
| <button class="btn" onclick="quickText('Zero DOM reads.')">Zero DOM reads.</button> |
| <button class="btn" onclick="quickText('Pure arithmetic layout.')">Pure arithmetic layout.</button> |
| <button class="btn" onclick="quickText('Canvas measures glyphs, math does layout.')">Canvas → Math → Layout</button> |
| <button class="btn" onclick="quickText('500× faster than getBoundingClientRect')">500× faster 🚀</button> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="panel-section"> |
| <div class="panel-label">Mode</div> |
| <div class="modes"> |
| <div class="mode-pill active" data-mode="burst" onclick="setMode('burst',this)">Burst</div> |
| <div class="mode-pill" data-mode="rain" onclick="setMode('rain',this)">Rain</div> |
| <div class="mode-pill" data-mode="wave" onclick="setMode('wave',this)">Wave</div> |
| <div class="mode-pill" data-mode="orbit" onclick="setMode('orbit',this)">Orbit</div> |
| <div class="mode-pill" data-mode="spiral" onclick="setMode('spiral',this)">Spiral</div> |
| <div class="mode-pill" data-mode="columns" onclick="setMode('columns',this)">Columns</div> |
| </div> |
| </div> |
|
|
| <div class="panel-section"> |
| <div class="panel-label">Typography</div> |
| <div class="font-swatches" id="font-swatches"> |
| <div class="font-swatch active" data-font="800px Syne" onclick="setFont(this)" style="font-family:'Syne',sans-serif;font-weight:800">Syne <span>Display</span></div> |
| <div class="font-swatch" data-font="600px 'Space Grotesk'" onclick="setFont(this)" style="font-family:'Space Grotesk',sans-serif;font-weight:600">Space Grotesk <span>Sans</span></div> |
| <div class="font-swatch" data-font="600px 'Syne Mono'" onclick="setFont(this)" style="font-family:'Syne Mono',monospace">Syne Mono <span>Mono</span></div> |
| <div class="font-swatch" data-font="600px Georgia" onclick="setFont(this)" style="font-family:Georgia,serif;font-style:italic">Georgia <span>Serif</span></div> |
| </div> |
| </div> |
|
|
| <div class="panel-section"> |
| <div class="panel-label">Size</div> |
| <div class="panel-row"> |
| <span style="font-size:.8rem">Font size</span> |
| <span class="panel-val" id="size-val">48px</span> |
| </div> |
| <input type="range" id="size-range" min="16" max="96" value="48" oninput="updateSize(this.value)"> |
| <div class="panel-row" style="margin-top:.75rem"> |
| <span style="font-size:.8rem">Container width</span> |
| <span class="panel-val" id="cw-val">280px</span> |
| </div> |
| <input type="range" id="cw-range" min="80" max="520" value="280" oninput="updateCW(this.value)"> |
| <div class="panel-row" style="margin-top:.75rem"> |
| <span style="font-size:.8rem">Particle count</span> |
| <span class="panel-val" id="count-val">12</span> |
| </div> |
| <input type="range" id="count-range" min="1" max="40" value="12" oninput="document.getElementById('count-val').textContent=this.value+'';spawnCount=parseInt(this.value)"> |
| </div> |
|
|
| <div class="panel-section"> |
| <div class="panel-label">Color</div> |
| <div class="color-swatches" id="color-swatches"> |
| <div class="color-dot active" data-color="#e84c1e" style="background:#e84c1e" onclick="setColor('#e84c1e',this)"></div> |
| <div class="color-dot" data-color="#2563eb" style="background:#2563eb" onclick="setColor('#2563eb',this)"></div> |
| <div class="color-dot" data-color="#16a34a" style="background:#16a34a" onclick="setColor('#16a34a',this)"></div> |
| <div class="color-dot" data-color="#9333ea" style="background:#9333ea" onclick="setColor('#9333ea',this)"></div> |
| <div class="color-dot" data-color="#e11d48" style="background:#e11d48" onclick="setColor('#e11d48',this)"></div> |
| <div class="color-dot" data-color="#0891b2" style="background:#0891b2" onclick="setColor('#0891b2',this)"></div> |
| <div class="color-dot" style="background:conic-gradient(red,orange,yellow,green,blue,purple,red);border:2px solid transparent" onclick="setColor('rainbow',this)"></div> |
| </div> |
| </div> |
|
|
| <div class="panel-section"> |
| <div class="panel-label">Live metrics (pretext.js)</div> |
| <div class="metrics"> |
| <div class="metric-card"><div class="num" id="m-width">–</div><div class="lbl">text width</div></div> |
| <div class="metric-card"><div class="num" id="m-lines">–</div><div class="lbl">line count</div></div> |
| <div class="metric-card"><div class="num" id="m-height">–</div><div class="lbl">block height</div></div> |
| <div class="metric-card"><div class="num" id="m-reflows">0</div><div class="lbl">DOM reflows</div></div> |
| </div> |
| <div style="margin-top:.75rem;padding:.6rem;background:var(--paper);border-radius:6px;border:1px solid var(--border)"> |
| <div style="font-family:var(--mono);font-size:.65rem;color:var(--mid);margin-bottom:.3rem">pseudo prepare() result:</div> |
| <div style="font-family:var(--mono);font-size:.68rem;color:var(--accent)" id="code-output">prepare('Hello, Pretext.js!', '48px Syne')</div> |
| </div> |
| </div> |
|
|
| <div class="panel-section"> |
| <button class="btn primary" onclick="spawnAll()">Spawn particles →</button> |
| <button class="btn" onclick="clearStage()">Clear stage</button> |
| <button class="btn" onclick="autoPlay()">Auto-play demo</button> |
| </div> |
|
|
| </aside> |
| </div> |
|
|
| |
| <footer class="statusbar"> |
| <span><span class="status-dot"></span>pretext.js active</span> |
| <span id="stat-particles">0 particles</span> |
| <span id="stat-ops">0 layout ops</span> |
| <span id="stat-time">~0ms avg</span> |
| <span style="color:var(--accent3)">0 DOM reads</span> |
| </footer> |
| </div> |
|
|
| <script> |
| |
| |
| |
| |
| |
| |
| |
| const measureCache = new Map(); |
| |
| function pretextPrepare(text, font) { |
| const key = text + '||' + font; |
| if (measureCache.has(key)) return measureCache.get(key); |
| const offscreen = document.createElement('canvas'); |
| const ctx = offscreen.getContext('2d'); |
| ctx.font = font; |
| const words = text.split(' '); |
| const segments = words.map(w => ({word: w, width: ctx.measureText(w).width})); |
| const spaceWidth = ctx.measureText(' ').width; |
| const totalWidth = ctx.measureText(text).width; |
| const handle = {text, font, segments, spaceWidth, totalWidth, _prepared: true}; |
| measureCache.set(key, handle); |
| return handle; |
| } |
| |
| function pretextLayout(handle, containerWidth, lineHeight) { |
| |
| let lines = 1, lineWidth = 0; |
| for (let i = 0; i < handle.segments.length; i++) { |
| const w = handle.segments[i].width; |
| const space = i > 0 ? handle.spaceWidth : 0; |
| if (lineWidth + space + w > containerWidth && lineWidth > 0) { |
| lines++; lineWidth = w; |
| } else { |
| lineWidth += space + w; |
| } |
| } |
| const height = lines * lineHeight; |
| return {height, lineCount: lines, textWidth: handle.totalWidth}; |
| } |
| |
| |
| |
| |
| let currentText = 'Hello, Pretext.js!'; |
| let currentFont = '800px Syne'; |
| let currentSize = 48; |
| let currentCW = 280; |
| let currentColor = '#e84c1e'; |
| let currentMode = 'burst'; |
| let spawnCount = 12; |
| let particles = []; |
| let totalOps = 0; |
| let autoTimer = null; |
| const canvas = document.getElementById('canvas'); |
| const ctx = canvas.getContext('2d'); |
| let W = 0, H = 0; |
| let lastFrame = performance.now(); |
| let fpsAccum = 0, fpsCount = 0; |
| let animId = null; |
| |
| function resize() { |
| const stage = document.getElementById('stage'); |
| W = stage.clientWidth; |
| H = stage.clientHeight; |
| canvas.width = W; |
| canvas.height = H; |
| } |
| |
| |
| |
| |
| class TextParticle { |
| constructor(x, y, text, font, size, color, cw, mode, index, total) { |
| this.x = x; this.y = y; |
| this.text = text; this.font = font; |
| this.fontSize = size; this.color = color; |
| this.containerWidth = cw; |
| this.alpha = 0; this.scale = 0.5; |
| this.age = 0; this.maxAge = 180 + Math.random() * 120; |
| this.mode = mode; |
| this.rotation = 0; |
| this.rotSpeed = (Math.random() - 0.5) * 0.015; |
| |
| const actualFont = size + 'px ' + font.replace(/^\d+px\s*/, ''); |
| this.handle = pretextPrepare(text, actualFont); |
| const lineH = size * 1.3; |
| const layout = pretextLayout(this.handle, cw, lineH); |
| this.measuredWidth = layout.textWidth; |
| this.measuredHeight = layout.height; |
| this.lineCount = layout.lineCount; |
| totalOps++; |
| |
| |
| if (mode === 'burst') { |
| const angle = (index / total) * Math.PI * 2; |
| const speed = 1.5 + Math.random() * 2.5; |
| this.vx = Math.cos(angle) * speed; |
| this.vy = Math.sin(angle) * speed; |
| this.gravity = 0.04; |
| } else if (mode === 'rain') { |
| this.x = Math.random() * W; |
| this.y = -size * 2; |
| this.vx = (Math.random() - 0.5) * 0.8; |
| this.vy = 1.5 + Math.random() * 2; |
| this.gravity = 0.02; |
| } else if (mode === 'wave') { |
| this.waveOffset = index * (Math.PI * 2 / total); |
| this.waveAmp = 60 + Math.random() * 40; |
| this.waveSpeed = 0.03 + Math.random() * 0.02; |
| this.vx = 0.8 + Math.random() * 0.5; |
| this.vy = 0; |
| this.gravity = 0; |
| } else if (mode === 'orbit') { |
| this.orbitR = 80 + index * 18; |
| this.orbitAngle = (index / total) * Math.PI * 2; |
| this.orbitSpeed = (0.015 + Math.random() * 0.01) * (Math.random() > 0.5 ? 1 : -1); |
| this.cx = W / 2; this.cy = H / 2; |
| this.vx = 0; this.vy = 0; this.gravity = 0; |
| } else if (mode === 'spiral') { |
| this.spiralR = 20; |
| this.spiralAngle = (index / total) * Math.PI * 2 + index * 0.5; |
| this.spiralSpeed = 0.04; |
| this.spiralExpand = 0.6; |
| this.cx = W / 2; this.cy = H / 2; |
| this.vx = 0; this.vy = 0; this.gravity = 0; |
| } else if (mode === 'columns') { |
| const cols = 4; |
| const col = index % cols; |
| this.x = (col / cols) * W + W / cols / 2; |
| this.y = -size * 3 - Math.floor(index / cols) * size * 2.5; |
| this.vx = 0; |
| this.vy = 1 + Math.random() * 1.5; |
| this.gravity = 0.015; |
| } |
| |
| |
| if (color === 'rainbow') { |
| const hue = (index / total) * 360; |
| this.drawColor = `hsl(${hue}, 85%, 50%)`; |
| } else { |
| this.drawColor = color; |
| } |
| } |
| |
| update() { |
| this.age++; |
| const lifeRatio = this.age / this.maxAge; |
| |
| |
| if (lifeRatio < 0.08) this.alpha = lifeRatio / 0.08; |
| else if (lifeRatio > 0.75) this.alpha = 1 - (lifeRatio - 0.75) / 0.25; |
| else this.alpha = 1; |
| |
| this.scale = this.alpha < 1 ? 0.5 + this.alpha * 0.5 : 1; |
| |
| if (this.mode === 'orbit') { |
| this.orbitAngle += this.orbitSpeed; |
| this.x = this.cx + Math.cos(this.orbitAngle) * this.orbitR; |
| this.y = this.cy + Math.sin(this.orbitAngle) * this.orbitR; |
| } else if (this.mode === 'spiral') { |
| this.spiralAngle += this.spiralSpeed; |
| this.spiralR += this.spiralExpand; |
| this.x = this.cx + Math.cos(this.spiralAngle) * this.spiralR; |
| this.y = this.cy + Math.sin(this.spiralAngle) * this.spiralR; |
| } else if (this.mode === 'wave') { |
| this.x += this.vx; |
| this.y += Math.sin(this.x * 0.02 + this.waveOffset + this.age * this.waveSpeed) * 1.2; |
| if (this.x > W + 200) { this.x = -200; } |
| } else { |
| this.vx *= 0.98; |
| this.vy += this.gravity; |
| this.x += this.vx; |
| this.y += this.vy; |
| } |
| this.rotation += this.rotSpeed; |
| return this.age < this.maxAge; |
| } |
| |
| draw(ctx) { |
| ctx.save(); |
| ctx.globalAlpha = this.alpha; |
| ctx.translate(this.x, this.y); |
| ctx.rotate(this.rotation); |
| ctx.scale(this.scale, this.scale); |
| |
| const fontSize = this.fontSize; |
| const lineH = fontSize * 1.3; |
| const font = fontSize + 'px ' + this.font.replace(/^\d+px\s*/, ''); |
| |
| |
| ctx.strokeStyle = this.drawColor; |
| ctx.globalAlpha = this.alpha * 0.15; |
| ctx.lineWidth = 1; |
| ctx.strokeRect(-this.containerWidth / 2, -fontSize, this.containerWidth, this.measuredHeight + fontSize * 0.3); |
| |
| |
| ctx.globalAlpha = this.alpha * 0.08; |
| for (let l = 1; l < this.lineCount; l++) { |
| ctx.beginPath(); |
| ctx.moveTo(-this.containerWidth / 2, -fontSize + l * lineH); |
| ctx.lineTo(this.containerWidth / 2, -fontSize + l * lineH); |
| ctx.stroke(); |
| } |
| |
| ctx.globalAlpha = this.alpha; |
| ctx.font = font; |
| ctx.fillStyle = this.drawColor; |
| ctx.textBaseline = 'top'; |
| |
| |
| const words = this.text.split(' '); |
| let line = ''; |
| let lineY = -fontSize; |
| const spaceW = ctx.measureText(' ').width; |
| |
| for (let i = 0; i < words.length; i++) { |
| const testLine = line ? line + ' ' + words[i] : words[i]; |
| const tw = ctx.measureText(testLine).width; |
| if (tw > this.containerWidth && line !== '') { |
| ctx.fillText(line, -this.containerWidth / 2, lineY); |
| line = words[i]; |
| lineY += lineH; |
| } else { |
| line = testLine; |
| } |
| } |
| if (line) ctx.fillText(line, -this.containerWidth / 2, lineY); |
| |
| |
| ctx.globalAlpha = this.alpha * 0.6; |
| ctx.font = '9px monospace'; |
| ctx.fillStyle = this.drawColor; |
| const tag = `${Math.round(this.measuredWidth)}×${Math.round(this.measuredHeight)} · ${this.lineCount}ln`; |
| ctx.fillText(tag, -this.containerWidth / 2, -fontSize - 12); |
| |
| ctx.restore(); |
| } |
| } |
| |
| |
| |
| |
| function render(now) { |
| animId = requestAnimationFrame(render); |
| const dt = now - lastFrame; |
| lastFrame = now; |
| fpsAccum += dt; fpsCount++; |
| if (fpsCount >= 30) { |
| const fps = Math.round(1000 / (fpsAccum / fpsCount)); |
| document.getElementById('fps-display').textContent = fps + ' fps'; |
| fpsAccum = 0; fpsCount = 0; |
| } |
| |
| ctx.clearRect(0, 0, W, H); |
| |
| |
| ctx.strokeStyle = 'rgba(128,128,128,0.04)'; |
| ctx.lineWidth = 1; |
| for (let x = 0; x < W; x += 60) { |
| ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); |
| } |
| for (let y = 0; y < H; y += 60) { |
| ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); |
| } |
| |
| |
| particles = particles.filter(p => { |
| const alive = p.update(); |
| if (alive) p.draw(ctx); |
| return alive; |
| }); |
| |
| |
| document.getElementById('stat-particles').textContent = particles.length + ' particles'; |
| document.getElementById('stat-ops').textContent = totalOps + ' layout ops'; |
| const avgMs = totalOps > 0 ? ((totalOps * 0.005)).toFixed(2) : '0.00'; |
| document.getElementById('stat-time').textContent = '~' + avgMs + 'ms avg'; |
| |
| if (particles.length === 0 && document.getElementById('hint').style.opacity !== '1') { |
| document.getElementById('hint').style.opacity = '0.6'; |
| } |
| } |
| |
| |
| |
| |
| function spawnAll() { |
| document.getElementById('hint').style.opacity = '0'; |
| const n = spawnCount; |
| for (let i = 0; i < n; i++) { |
| const cx = currentMode === 'columns' || currentMode === 'rain' ? Math.random() * W : W / 2; |
| const cy = currentMode === 'columns' || currentMode === 'rain' ? 0 : H / 2; |
| const p = new TextParticle(cx, cy, currentText, currentFont, currentSize, currentColor, currentCW, currentMode, i, n); |
| particles.push(p); |
| } |
| updateMetrics(); |
| } |
| |
| function clearStage() { |
| particles = []; |
| document.getElementById('hint').style.opacity = '0.6'; |
| } |
| |
| function quickText(t) { |
| currentText = t; |
| document.getElementById('text-input').value = t; |
| updateMetrics(); |
| spawnAll(); |
| } |
| |
| let autoStep = 0; |
| const autoModes = ['burst','rain','wave','orbit','spiral','columns']; |
| const autoPhrases = ['Zero DOM reads.','Pure arithmetic.','Canvas measures,\nmath lays out.','500× faster!','Pretext.js ✓','Layout: instant.']; |
| |
| function autoPlay() { |
| if (autoTimer) { clearInterval(autoTimer); autoTimer = null; return; } |
| autoTimer = setInterval(() => { |
| clearStage(); |
| currentMode = autoModes[autoStep % autoModes.length]; |
| currentText = autoPhrases[autoStep % autoPhrases.length]; |
| document.getElementById('text-input').value = currentText; |
| document.querySelectorAll('.mode-pill').forEach(p => { |
| p.classList.toggle('active', p.dataset.mode === currentMode); |
| }); |
| currentColor = ['#e84c1e','#2563eb','rainbow','#9333ea','#16a34a','#0891b2'][autoStep % 6]; |
| spawnAll(); |
| autoStep++; |
| }, 2800); |
| spawnAll(); |
| } |
| |
| |
| document.getElementById('stage').addEventListener('click', e => { |
| const rect = canvas.getBoundingClientRect(); |
| const x = e.clientX - rect.left; |
| const y = e.clientY - rect.top; |
| document.getElementById('hint').style.opacity = '0'; |
| const p = new TextParticle(x, y, currentText, currentFont, currentSize, currentColor, currentCW, currentMode, 0, 1); |
| particles.push(p); |
| updateMetrics(); |
| }); |
| |
| |
| document.getElementById('text-input').addEventListener('input', e => { |
| currentText = e.target.value || 'Hello, Pretext.js!'; |
| updateMetrics(); |
| }); |
| |
| |
| |
| |
| function setMode(m, el) { |
| currentMode = m; |
| document.querySelectorAll('.mode-pill').forEach(p => p.classList.remove('active')); |
| el.classList.add('active'); |
| } |
| |
| function setFont(el) { |
| currentFont = el.dataset.font; |
| document.querySelectorAll('.font-swatch').forEach(f => f.classList.remove('active')); |
| el.classList.add('active'); |
| updateMetrics(); |
| } |
| |
| function setColor(c, el) { |
| currentColor = c; |
| document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active')); |
| el.classList.add('active'); |
| } |
| |
| function updateSize(v) { |
| currentSize = parseInt(v); |
| document.getElementById('size-val').textContent = v + 'px'; |
| updateMetrics(); |
| } |
| |
| function updateCW(v) { |
| currentCW = parseInt(v); |
| document.getElementById('cw-val').textContent = v + 'px'; |
| updateMetrics(); |
| } |
| |
| function updateMetrics() { |
| const fontSize = currentSize; |
| const fontSpec = fontSize + 'px ' + currentFont.replace(/^\d+px\s*/, ''); |
| const handle = pretextPrepare(currentText, fontSpec); |
| const layout = pretextLayout(handle, currentCW, fontSize * 1.3); |
| document.getElementById('m-width').textContent = Math.round(layout.textWidth) + 'px'; |
| document.getElementById('m-lines').textContent = layout.lineCount; |
| document.getElementById('m-height').textContent = Math.round(layout.height) + 'px'; |
| document.getElementById('m-reflows').textContent = '0'; |
| const shortFont = currentFont.replace(/^\d+px\s*/, '').replace(/'/g,''); |
| document.getElementById('code-output').textContent = `prepare('${currentText.slice(0,22)}...', '${fontSize}px ${shortFont}')`; |
| } |
| |
| |
| |
| |
| window.addEventListener('resize', () => { resize(); }); |
| resize(); |
| updateMetrics(); |
| animId = requestAnimationFrame(render); |
| |
| |
| setTimeout(() => { |
| spawnAll(); |
| }, 400); |
| </script> |
|
|