example demo: https://chatgpt.com/canvas/shared/68ef9b24406c81918750bdc21d2ebdf6
credit: 2025-10-15__chatgpt-5-extended-thinking
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Thumbnail Loader — Ordered Queue & Viewer-Aware Queue</title> | |
| <style> | |
| :root { --gap: 10px; --bg: #0b1020; --card: #11172a; --ink: #e8ecff; --muted: #a6b0d9; } | |
| html, body { margin:0; padding:0; background: var(--bg); color: var(--ink); font: 15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; } | |
| header { position: sticky; top: 0; z-index: 5; background: linear-gradient(180deg, rgba(11,16,32,.96), rgba(11,16,32,.8)); border-bottom: 1px solid #223; } | |
| header .wrap { max-width: 1100px; margin: 0 auto; padding: 12px 16px; display:grid; gap:10px; } | |
| h1 { margin:0; font-size: 20px; } | |
| .muted { color: var(--muted); } | |
| .row { display:flex; gap:10px; flex-wrap: wrap; align-items:center; } | |
| input, button { background: var(--card); color: var(--ink); border: 1px solid #2a3558; border-radius: 10px; padding: 8px 10px; } | |
| button { cursor: pointer; font-weight: 600; } | |
| main { max-width: 1100px; margin: 0 auto; padding: 16px; display:grid; gap: 16px; } | |
| .card { background: var(--card); border:1px solid #2a3558; border-radius:14px; padding: 12px; display:grid; gap:10px; } | |
| .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: var(--gap); } | |
| .thumb { position:relative; width: 96px; height: 96px; border-radius: 10px; overflow: hidden; background: #0e1326; border: 1px solid #2a3558; display: grid; place-items: center; color: var(--muted); font-size: 12px; } | |
| .thumb img { display:block; width: 100%; height: 100%; object-fit: cover; } | |
| .status { position:absolute; inset:auto 6px 6px 6px; background: rgba(0,0,0,.55); border:1px solid #2a3558; border-radius:8px; padding:2px 6px; font-size:11px; text-align:center; } | |
| .pills { display:flex; gap:8px; } | |
| .pill { font-size:12px; padding:4px 8px; border-radius:999px; border:1px solid #2a3558; color: var(--muted); } | |
| .warn { color:#ff9; } | |
| .sep { height:1px; background:#243; margin:4px 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="wrap"> | |
| <h1>Thumbnail Loader — Ordered Queue & Viewer-Aware Queue</h1> | |
| <div class="muted">All thumbnails start with a <b>“not loaded”</b> indicator. Use <b>Start Loading</b> on either approach. <b>Reset</b> clears everything and server counters.</div> | |
| <div class="row"> | |
| <label>Count <input id="count" type="number" value="300" min="1" max="5000" /></label> | |
| <label>Server concurrency limit <input id="serverLimit" type="number" value="24" min="1" max="256" /></label> | |
| <button id="resetAll">Reset</button> | |
| <div class="pills"> | |
| <span class="pill" id="inflight">in‑flight: 0</span> | |
| <span class="pill" id="rejected">rejected: 0</span> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <section class="card" id="orderedCard"> | |
| <h2>Option 1 — Ordered queue (no burst): start loading and continue as slots open</h2> | |
| <div class="muted">Strict item order with a client‑side concurrency cap. No initial “first X” burst; just steady flow.</div> | |
| <div class="row"> | |
| <button id="startOrdered">Start Loading</button> | |
| <label>Client concurrency <input id="clientLimitA" type="number" value="8" min="1" max="64" /></label> | |
| <span id="orderedInfo" class="muted"></span> | |
| </div> | |
| <div id="gridOrdered" class="grid" aria-live="polite"></div> | |
| </section> | |
| <section class="card" id="viewerCard"> | |
| <h2>Option 2 — Viewer‑aware queue (IntersectionObserver + concurrency cap)</h2> | |
| <div class="muted">Only loads items near/in the viewport; aborts off‑screen loads; caps in‑flight requests.</div> | |
| <div class="row"> | |
| <button id="startViewer">Start Loading</button> | |
| <label>Client concurrency <input id="clientLimitB" type="number" value="8" min="1" max="64" /></label> | |
| <span id="viewerInfo" class="muted"></span> | |
| </div> | |
| <div id="gridViewer" class="grid" aria-live="polite"></div> | |
| </section> | |
| </main> | |
| <script> | |
| // --- Simulated server (no network required) ------------------------------ | |
| let SERVER_MAX_CONCURRENCY = 24; // configurable in UI | |
| let serverInFlight = 0; let serverRejected = 0; | |
| const updateServerPills = () => { | |
| document.getElementById('inflight').textContent = `in‑flight: ${serverInFlight}`; | |
| document.getElementById('rejected').textContent = `rejected: ${serverRejected}`; | |
| }; | |
| updateServerPills(); | |
| function simulateServerFetch(id, signal) { | |
| return new Promise((resolve, reject) => { | |
| if (serverInFlight >= SERVER_MAX_CONCURRENCY) { | |
| serverRejected++; updateServerPills(); | |
| return reject(new Error('429 too many concurrent requests')); | |
| } | |
| serverInFlight++; updateServerPills(); | |
| const t = 30 + Math.random() * 180; // 30-210ms | |
| const timer = setTimeout(() => { | |
| // generate a tiny PNG blob labeled with the id | |
| const c = document.createElement('canvas'); c.width = 96; c.height = 96; | |
| const g = c.getContext('2d'); | |
| g.fillStyle = `hsl(${(id*23)%360} 60% 18%)`; g.fillRect(0,0,96,96); | |
| g.fillStyle = 'white'; g.font = '700 14px ui-monospace, monospace'; | |
| g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(String(id), 48, 50); | |
| c.toBlob((blob) => { serverInFlight--; updateServerPills(); resolve(blob); }, 'image/png'); | |
| }, t); | |
| if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); serverInFlight--; updateServerPills(); reject(new DOMException('Aborted','AbortError')); }, { once:true }); | |
| }); | |
| } | |
| // --- Shared utilities ---------------------------------------------------- | |
| const makeIds = (n) => Array.from({length:n}, (_, i) => i + 1); | |
| const blobUrl = (blob) => URL.createObjectURL(blob); | |
| const el = (tag, props={}) => { const e = document.createElement(tag); Object.assign(e, props); return e; }; | |
| function placeholderThumb(id) { | |
| const wrap = el('div', { className:'thumb', id:`wrap-${id}` }); | |
| const status = el('div', { className:'status', textContent:'not loaded' }); | |
| wrap.appendChild(status); | |
| return wrap; | |
| } | |
| function markLoaded(id, blob) { | |
| const wrap = document.getElementById(`wrap-${id}`); | |
| if (!wrap) return; | |
| wrap.innerHTML = ''; | |
| const img = new Image(); | |
| img.alt = `thumb ${id}`; img.decoding = 'async'; | |
| img.src = blobUrl(blob); | |
| wrap.appendChild(img); | |
| } | |
| function markFailed(id) { | |
| const wrap = document.getElementById(`wrap-${id}`); | |
| if (!wrap) return; | |
| wrap.innerHTML = ''; | |
| const status = el('div', { className:'status', textContent:'failed' }); | |
| wrap.appendChild(status); | |
| } | |
| class Semaphore { | |
| constructor(max) { this.max = max; this.active = 0; this.queue = []; } | |
| run(task) { return new Promise((res, rej) => { const job = () => { this.active++; Promise.resolve(task()).then(res, rej).finally(()=>{ this.active--; this._next(); }); }; (this.active < this.max) ? job() : this.queue.push(job); }); } | |
| _next(){ if(this.queue.length && this.active < this.max) this.queue.shift()(); } | |
| } | |
| // --- Option 1: Ordered queue (no burst) --------------------------------- | |
| function buildOrderedGrid(count) { | |
| const grid = document.getElementById('gridOrdered'); grid.innerHTML = ''; | |
| for (const id of makeIds(count)) grid.appendChild(placeholderThumb(id)); | |
| } | |
| async function runOrdered() { | |
| const count = parseInt(document.getElementById('count').value,10) || 300; | |
| const clientLimit = parseInt(document.getElementById('clientLimitA').value,10) || 8; | |
| const info = document.getElementById('orderedInfo'); info.textContent = ''; | |
| buildOrderedGrid(count); | |
| const ids = makeIds(count); | |
| const sem = new Semaphore(clientLimit); | |
| let loaded = 0; | |
| const startTask = (id) => sem.run(async () => { | |
| try { const blob = await simulateServerFetch(id); markLoaded(id, blob); } | |
| catch(_) { markFailed(id); } | |
| finally { loaded++; info.textContent = `${loaded}/${count} loaded`; } | |
| }); | |
| // Enqueue all items in strict order; they'll start as slots become available | |
| for (const id of ids) startTask(id); | |
| } | |
| // --- Option 2: Viewer-aware queue --------------------------------------- | |
| let viewerIO = null; let viewerControllers = new Map(); let viewerSem = null; | |
| function buildViewerGrid(count) { | |
| const grid = document.getElementById('gridViewer'); grid.innerHTML = ''; | |
| for (const id of makeIds(count)) grid.appendChild(placeholderThumb(id)); | |
| } | |
| function startViewer() { | |
| const count = parseInt(document.getElementById('count').value,10) || 300; | |
| const clientLimit = parseInt(document.getElementById('clientLimitB').value,10) || 8; | |
| const info = document.getElementById('viewerInfo'); info.textContent = ''; | |
| buildViewerGrid(count); | |
| viewerControllers.forEach(c => c.abort()); viewerControllers.clear(); | |
| if (viewerIO) viewerIO.disconnect(); | |
| viewerSem = new Semaphore(clientLimit); | |
| let loaded = 0; | |
| viewerIO = new IntersectionObserver((entries) => { | |
| for (const e of entries) { | |
| const wrap = e.target; // .thumb | |
| const id = parseInt(wrap.id.replace('wrap-',''), 10); | |
| if (Number.isNaN(id)) continue; | |
| if (e.isIntersecting) { | |
| if (viewerControllers.has(id)) continue; | |
| const ctrl = new AbortController(); viewerControllers.set(id, ctrl); | |
| viewerSem.run(async () => { | |
| try { const blob = await simulateServerFetch(id, ctrl.signal); markLoaded(id, blob); } | |
| catch(_) { /* if aborted, ignore; else failed */ } | |
| finally { viewerControllers.delete(id); loaded++; info.textContent = `${loaded}/${count} loaded`; } | |
| }); | |
| } else { | |
| const ctrl = viewerControllers.get(id); if (ctrl) { ctrl.abort(); viewerControllers.delete(id); } | |
| } | |
| } | |
| }, { root: null, rootMargin: '400px 0px', threshold: 0.01 }); | |
| // Observe placeholders (the .thumb wrappers) | |
| document.querySelectorAll('#gridViewer .thumb').forEach(wrap => viewerIO.observe(wrap)); | |
| } | |
| // --- Wire up top controls ------------------------------------------------ | |
| document.getElementById('serverLimit').addEventListener('change', (e) => { | |
| SERVER_MAX_CONCURRENCY = parseInt(e.target.value,10) || 24; updateServerPills(); | |
| }); | |
| document.getElementById('startOrdered').addEventListener('click', runOrdered); | |
| document.getElementById('startViewer').addEventListener('click', startViewer); | |
| document.getElementById('resetAll').addEventListener('click', () => { | |
| // clear grids and info | |
| ['gridOrdered','gridViewer'].forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML=''; }); | |
| ['orderedInfo','viewerInfo'].forEach(id => { const el = document.getElementById(id); if (el) el.textContent=''; }); | |
| // reset server counters | |
| serverInFlight = 0; serverRejected = 0; updateServerPills(); | |
| // disconnect viewer observer and abort any inflight | |
| if (viewerIO) viewerIO.disconnect(); | |
| viewerControllers.forEach(c => c.abort()); viewerControllers.clear(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
example demo: https://chatgpt.com/canvas/shared/68ef9b24406c81918750bdc21d2ebdf6
credit: 2025-10-15__chatgpt-5-extended-thinking