1/* ================= Astral Babes ================= */ 2 3// Set while a nav-link smooth scroll is in flight so the gallery's 4// infinite scroll doesn't grow the page and move the target. 5let anchorScrolling = false; 6 7/* ---------- starfield ---------- */ 8(function starfield() { 9 const canvas = document.getElementById('stars'); 10 const ctx = canvas.getContext('2d'); 11 let stars = []; 12 13 function resize() { 14 canvas.width = window.innerWidth; 15 canvas.height = window.innerHeight; 16 const count = Math.min(260, Math.floor(canvas.width * canvas.height / 6000)); 17 stars = Array.from({ length: count }, () => ({ 18 x: Math.random() * canvas.width, 19 y: Math.random() * canvas.height, 20 r: Math.random() * 1.3 + .2, 21 phase: Math.random() * Math.PI * 2, 22 speed: Math.random() * .9 + .3, 23 hue: Math.random() < .12 ? 285 : (Math.random() < .5 ? 195 : 0), 24 })); 25 } 26 27 function draw(t) { 28 ctx.clearRect(0, 0, canvas.width, canvas.height); 29 for (const s of stars) { 30 const tw = .35 + .65 * (0.5 + 0.5 * Math.sin(s.phase + t * .001 * s.speed)); 31 ctx.beginPath(); 32 ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); 33 ctx.fillStyle = s.hue 34 ? `hsla(${s.hue}, 90%, 78%, ${tw})` 35 : `rgba(236, 230, 247, ${tw})`; 36 ctx.fill(); 37 } 38 requestAnimationFrame(draw); 39 } 40 41 window.addEventListener('resize', resize); 42 resize(); 43 requestAnimationFrame(draw); 44})(); 45 46/* ---------- mobile nav ---------- */ 47(function mobileNav() { 48 const nav = document.querySelector('.nav'); 49 const toggle = document.getElementById('nav-toggle'); 50 function setOpen(open) { 51 nav.classList.toggle('open', open); 52 toggle.setAttribute('aria-expanded', open); 53 } 54 toggle.addEventListener('click', () => setOpen(!nav.classList.contains('open'))); 55 document.querySelectorAll('.nav-links a').forEach((a) => 56 a.addEventListener('click', () => setOpen(false))); 57})(); 58 59/* ---------- anchor scrolling ---------- */ 60(function anchorNav() { 61 let timer; 62 document.querySelectorAll('a[href^="#"]').forEach((a) => { 63 a.addEventListener('click', (e) => { 64 const target = document.querySelector(a.getAttribute('href')); 65 if (!target) return; 66 e.preventDefault(); 67 history.pushState(null, '', a.getAttribute('href')); 68 anchorScrolling = true; 69 target.scrollIntoView({ behavior: 'smooth' }); 70 const done = () => { 71 clearTimeout(timer); 72 document.removeEventListener('scrollend', done); 73 // if the page grew mid-scroll and we landed short, snap to place 74 if (Math.abs(target.getBoundingClientRect().top - 84) > 60) { 75 target.scrollIntoView({ behavior: 'instant' }); 76 } 77 anchorScrolling = false; 78 }; 79 document.addEventListener('scrollend', done); 80 clearTimeout(timer); 81 timer = setTimeout(done, 2000); 82 }); 83 }); 84})(); 85 86/* ---------- name marquees ---------- */ 87(function marquees() { 88 const shuffled = BABE_NAMES.slice().sort(() => Math.random() - .5); 89 const half = 70; 90 [['marquee-a', shuffled.slice(0, half)], ['marquee-b', shuffled.slice(half, half * 2)]] 91 .forEach(([id, names]) => { 92 const track = document.querySelector('#' + id + ' .marquee-track'); 93 const seq = names.map(n => `${n}<span class="star">â¦</span>`).join(''); 94 track.innerHTML = seq + seq; // duplicate for seamless loop 95 }); 96})(); 97 98/* ---------- hero fan rotation ---------- */ 99(function fanRotation() { 100 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; 101 const POOL = [421, 500, 600, 700, 777, 850, 950, 1000, 1100, 1200, 1300, 1400, 102 1500, 1600, 1700, 1800, 1950, 2000, 2100, 2300, 2400, 2500, 2600, 2700, 2800, 2900]; 103 const cards = [...document.querySelectorAll('.hero-fan .fan-card')]; 104 if (!cards.length) return; 105 const current = cards.map((c) => +c.querySelector('.layer-base').src.match(/(\d+)\.webp$/)[1]); 106 let queue = []; 107 let slot = 0; 108 109 function nextBabe() { 110 if (!queue.length) { 111 queue = POOL.filter((n) => !current.includes(n)); 112 for (let i = queue.length - 1; i > 0; i--) { 113 const j = Math.floor(Math.random() * (i + 1)); 114 [queue[i], queue[j]] = [queue[j], queue[i]]; 115 } 116 } 117 return queue.pop(); 118 } 119 120 setInterval(() => { 121 const card = cards[slot]; 122 const idx = slot; 123 slot = (slot + 1) % cards.length; 124 const base = card.querySelector('.layer-base'); 125 const top = card.querySelector('.layer-top'); 126 const n = nextBabe(); 127 const preload = new Image(); 128 preload.onload = () => { 129 // put the new babe on the (invisible) top layer, then dissolve it in 130 top.classList.add('snap'); 131 top.src = preload.src; 132 void top.offsetWidth; 133 top.classList.remove('snap'); 134 top.classList.add('showing'); 135 setTimeout(() => { 136 // dissolve done: bake into base layer and silently reset the top 137 base.src = preload.src; 138 current[idx] = n; 139 top.classList.add('snap'); 140 top.classList.remove('showing'); 141 void top.offsetWidth; 142 top.classList.remove('snap'); 143 }, 1050); 144 }; 145 preload.src = `images/babes/featured/${n}.webp`; 146 }, 3200); 147})(); 148 149/* ---------- gallery ---------- */ 150(function gallery() { 151 const grid = document.getElementById('gallery-grid'); 152 const sentinel = document.getElementById('gallery-sentinel'); 153 const countEl = document.getElementById('shown-count'); 154 const BATCH = window.matchMedia('(max-width: 640px)').matches ? 30 : 70; 155 const AUTO_BATCHES = 10; // pause infinite scroll every N batches so the footer stays reachable 156 const moreBtn = document.getElementById('more-btn'); 157 let order = []; 158 let cursor = 0; 159 let batchesSincePause = 0; 160 161 function shuffle() { 162 order = BABE_NUMS.slice(); 163 for (let i = order.length - 1; i > 0; i--) { 164 const j = Math.floor(Math.random() * (i + 1)); 165 [order[i], order[j]] = [order[j], order[i]]; 166 } 167 } 168 169 function loadBatch() { 170 const frag = document.createDocumentFragment(); 171 const end = Math.min(cursor + BATCH, order.length); 172 for (; cursor < end; cursor++) { 173 const n = order[cursor]; 174 const tile = document.createElement('div'); 175 tile.className = 'tile'; 176 tile.dataset.index = cursor; 177 const img = document.createElement('img'); 178 img.loading = 'lazy'; 179 img.decoding = 'async'; 180 img.src = `images/babes/thumbs/${n}.webp`; 181 const meta = BABE_MAP[n]; 182 img.alt = meta ? meta[0] : 'Astral Babe'; 183 tile.appendChild(img); 184 const label = document.createElement('span'); 185 label.className = 'tile-name'; 186 label.textContent = meta ? meta[0] : 'â¦'; 187 tile.appendChild(label); 188 frag.appendChild(tile); 189 } 190 grid.appendChild(frag);
191 countEl.textContent = cursor.toLocaleString(); 192 } 193 194 const io = new IntersectionObserver((entries) => { 195 if (!entries[0].isIntersecting || cursor >= order.length) return; 196 if (anchorScrolling) return; 197 // only load when approaching from above; if the sentinel is already 198 // above the viewport (user is reading a lower section), don't push 199 // new rows in above them 200 if (entries[0].boundingClientRect.top < 0) return; 201 if (batchesSincePause >= AUTO_BATCHES) { 202 moreBtn.hidden = false; 203 return; 204 } 205 loadBatch(); 206 batchesSincePause++; 207 }, { rootMargin: '1200px' }); 208 209 moreBtn.addEventListener('click', () => { 210 moreBtn.hidden = true; 211 batchesSincePause = 0; 212 loadBatch(); 213 }); 214 215 /* ---------- search ---------- */ 216 const searchInput = document.getElementById('babe-search'); 217 const searchClear = document.getElementById('search-clear'); 218 const noResults = document.getElementById('no-results'); 219 const totalEl = document.getElementById('total-count'); 220 const norm = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); 221 const index = BABE_NUMS.map((n) => { 222 const [name, id, num] = BABE_MAP[n]; 223 return { n, name: norm(name), id, num: String(num) }; 224 }); 225 let baseOrder = []; 226 227 function applySearch(raw) { 228 const q = norm(raw.trim()); 229 grid.innerHTML = ''; 230 cursor = 0; 231 batchesSincePause = 0; 232 moreBtn.hidden = true; 233 if (!q) { 234 order = baseOrder; 235 noResults.hidden = true; 236 totalEl.textContent = '2,912'; 237 loadBatch(); 238 return; 239 } 240 const digits = q.replace(/[#,\s]/g, ''); 241 let matches; 242 if (/^\d+$/.test(digits)) { 243 matches = index.filter((e) => e.num.includes(digits)); 244 } else { 245 const hexish = /^[0-9a-f]{10,}(i\d+)?$/.test(q); 246 matches = index.filter((e) => e.name.includes(q) || (hexish && e.id.includes(q))); 247 } 248 order = matches.map((e) => e.n); 249 totalEl.textContent = order.length.toLocaleString(); 250 noResults.hidden = order.length > 0; 251 loadBatch(); 252 } 253 254 let debounce; 255 searchInput.addEventListener('input', () => { 256 searchClear.hidden = !searchInput.value; 257 clearTimeout(debounce); 258 debounce = setTimeout(() => applySearch(searchInput.value), 180); 259 }); 260 searchClear.addEventListener('click', () => { 261 searchInput.value = ''; 262 searchClear.hidden = true; 263 applySearch(''); 264 searchInput.focus(); 265 }); 266 267 shuffle(); 268 baseOrder = order; 269 loadBatch(); 270 io.observe(sentinel); 271 272 /* ---------- lightbox ---------- */ 273 const lb = document.getElementById('lightbox'); 274 const lbImg = document.getElementById('lb-img'); 275 let current = -1; 276 277 const lbName = document.getElementById('lb-name'); 278 const lbNum = document.getElementById('lb-num'); 279 const lbInfo = document.getElementById('lb-info'); 280 const lbSatflow = document.getElementById('lb-satflow'); 281 const lbOrd = document.getElementById('lb-ord'); 282 const lbSpinner = document.getElementById('lb-spinner'); 283 let loadToken = 0; 284 285 function show(i) { 286 if (i < 0 || i >= cursor) return; 287 lbMode = 'gallery'; 288 current = i; 289 const n = order[i]; 290 const meta = BABE_MAP[n]; 291 const token = ++loadToken; 292 lbInfo.textContent = ''; 293 lbImg.src = `images/babes/thumbs/${n}.webp`; 294 lbImg.alt = meta ? meta[0] : 'Astral Babe'; 295 lbSpinner.classList.toggle('spinning', !!meta); 296 if (meta) { 297 const full = new Image(); 298 full.onload = () => { 299 if (token !== loadToken) return; 300 lbImg.src = full.src; 301 lbSpinner.classList.remove('spinning'); 302 }; 303 full.onerror = () => { 304 if (token === loadToken) lbSpinner.classList.remove('spinning'); 305 }; 306 full.src = 'https://ordinals-api.deezy.io/content/' + meta[1]; 307 } 308 lbName.textContent = meta ? meta[0] : ''; 309 lbNum.textContent = meta ? 'Inscription #' + meta[2].toLocaleString() : ''; 310 lbSatflow.href = meta 311 ? 'https://www.satflow.com/inscription/' + meta[1] 312 : 'https://www.satflow.com/ordinals/astral-babes'; 313 lbOrd.href = meta 314 ? 'https://ordinals.com/inscription/' + meta[1] 315 : 'https://ordinals.com/inscription/ad5438c1bbf593dee182eb1703388753c6c04287c266428861cdad3284189702i0'; 316 lb.hidden = false;
317 document.body.style.overflow = 'hidden'; 318 } 319 // market-mode lightbox: same viewer fed by For Sale / Sales cards 320 let lbMode = 'gallery'; 321 let mList = []; 322 let mIdx = -1; 323 const ID2NUM = {}; 324 for (const n of BABE_NUMS) ID2NUM[BABE_MAP[n][1]] = n; 325 326 function showMarket(list, i) { 327 if (i < 0 || i >= list.length) return; 328 lbMode = 'market'; 329 mList = list; 330 mIdx = i; 331 const item = list[i]; 332 const token = ++loadToken; 333 const fileN = ID2NUM[item.id]; 334 lbImg.src = fileN != null 335 ? `images/babes/thumbs/${fileN}.webp` 336 : 'https://ord.satflow.com/content/' + item.id; 337 lbImg.alt = item.name || 'Astral Babe'; 338 lbName.textContent = item.name || 'Astral Babe'; 339 lbNum.textContent = item.num != null ? 'Inscription #' + item.num.toLocaleString() : ''; 340 lbInfo.textContent = item.__info || ''; 341 lbSatflow.href = 'https://www.satflow.com/inscription/' + item.id; 342 lbOrd.href = 'https://ordinals.com/inscription/' + item.id; 343 lbSpinner.classList.add('spinning'); 344 const full = new Image(); 345 full.onload = () => { 346 if (token !== loadToken) return; 347 lbImg.src = full.src; 348 lbSpinner.classList.remove('spinning'); 349 }; 350 full.onerror = () => { 351 if (token === loadToken) lbSpinner.classList.remove('spinning'); 352 }; 353 full.src = 'https://ordinals-api.deezy.io/content/' + item.id; 354 lb.hidden = false; 355 document.body.style.overflow = 'hidden'; 356 } 357 window.__openMarketItem = showMarket; 358 359 const lbNav = (d) => lbMode === 'market' ? showMarket(mList, mIdx + d) : show(current + d); 360 361 function hide() { 362 lb.hidden = true; 363 document.body.style.overflow = ''; 364 } 365 366 grid.addEventListener('click', (e) => { 367 const tile = e.target.closest('.tile'); 368 if (tile) show(+tile.dataset.index); 369 }); 370 document.getElementById('lb-close').addEventListener('click', hide); 371 document.getElementById('lb-prev').addEventListener('click', () => lbNav(-1)); 372 document.getElementById('lb-next').addEventListener('click', () => lbNav(1)); 373 lb.addEventListener('click', (e) => { if (e.target === lb) hide(); }); 374 document.addEventListener('keydown', (e) => { 375 if (lb.hidden) return; 376 if (e.key === 'Escape') hide(); 377 if (e.key === 'ArrowLeft') lbNav(-1); 378 if (e.key === 'ArrowRight') lbNav(1); 379 }); 380})(); 381 382/* ---------- market (live via /api/market, snapshot fallback) ---------- */ 383(async function market() { 384 let data; 385 for (const src of ['api/market', 'data/market.json']) { 386 try { 387 const res = await fetch(src); 388 if (!res.ok) continue; 389 data = await res.json(); 390 break; 391 } catch { /* try next source */ } 392 } 393 if (!data) return; 394 395 const fmtBtc = (s) => '\u20bf\u00a0' + (s / 100000000).toLocaleString(undefined, { maximumFractionDigits: 5 }); 396 const fmtUsdVal = (v) => '$' + v.toLocaleString(undefined, { maximumFractionDigits: v >= 100 ? 0 : 2 }); 397 const usdOf = (item) => item.priceUsd != null 398 ? item.priceUsd 399 : (data.btcUsd ? item.price / 100000000 * data.btcUsd : null); 400 const timeAgo = (iso) => { 401 const days = Math.floor((Date.now() - new Date(iso)) / 86400000); 402 if (days < 1) return 'today'; 403 if (days === 1) return 'yesterday'; 404 if (days < 30) return days + ' days ago'; 405 const months = Math.floor(days / 30); 406 return months === 1 ? '1 month ago' : months + ' months ago'; 407 }; 408 const fullDate = (iso) => new Date(iso).toLocaleDateString(undefined, 409 { month: 'short', day: 'numeric', year: 'numeric' }); 410 const priceLine = (item) => { 411 const v = usdOf(item); 412 return fmtBtc(item.price) + (v != null ? ' (' + fmtUsdVal(v) + ')' : ''); 413 }; 414 function openMarket(list, i, kind) { 415 if (!window.__openMarketItem) return; 416 window.__openMarketItem(list.map((it) => ({ 417 ...it, 418 __info: kind === 'listing' 419 ? 'Listed for ' + priceLine(it) + ' \u00b7 ' + timeAgo(it.listedAt) 420 : 'Sold for ' + priceLine(it) + (it.soldAt ? ' \u00b7 ' + fullDate(it.soldAt) : ''), 421 })), i); 422 } 423 424 // primary 'usd' renders "$1,000 (\u20bf 0.01)"; default is BTC-primary 425 function priceInto(el, item, primary) { 426 const btcText = fmtBtc(item.price); 427 const v = usdOf(item); 428 const usdText = v != null ? fmtUsdVal(v) : ''; 429 const usdPrimary = primary === 'usd' && usdText; 430 el.textContent = usdPrimary ? usdText : btcText; 431 const paren = usdPrimary ? btcText : usdText; 432 if (paren) { 433 el.appendChild(document.createTextNode(' ')); 434 const u = document.createElement('span'); 435 u.className = 'price-usd'; 436 u.textContent = '(' + paren + ')'; 437 el.appendChild(u); 438 } 439 } 440 441 function card(item, sub, rank, primary, onOpen) { 442 const a = document.createElement('div'); 443 a.className = 'market-card'; 444 if (onOpen) a.addEventListener('click', onOpen); 445 const img = document.createElement('img'); 446 img.loading = 'lazy'; 447 img.decoding = 'async'; 448 img.src = 'https://ord.satflow.com/content/' + item.id; 449 img.alt = item.name || 'Astral Babe'; 450 a.appendChild(img); 451 if (rank) { 452 const badge = document.createElement('span'); 453 badge.className = 'mc-rank'; 454 badge.textContent = '#' + rank; 455 a.appendChild(badge); 456 } 457 const info = document.createElement('div'); 458 info.className = 'mc-info'; 459 info.innerHTML = 460 '<span class="mc-name"></span><span class="mc-num"></span><span class="mc-price"></span><span class="mc-sub"></span>'; 461 info.querySelector('.mc-name').textContent = item.name || 'Astral Babe'; 462 if (item.num != null) { 463 const numEl = info.querySelector('.mc-num'); 464 const lbl = document.createElement('span'); 465 lbl.className = 'mc-num-label'; 466 lbl.textContent = 'Insc '; 467 numEl.append(lbl, document.createTextNode('#' + item.num.toLocaleStr
467ing())); 468 } 469 priceInto(info.querySelector('.mc-price'), item, primary); 470 info.querySelector('.mc-sub').textContent = sub; 471 a.appendChild(info); 472 return a; 473 } 474 475 if (data.listings?.length) { 476 const grid = document.getElementById('listings-grid'); 477 data.listings.forEach((l, i) => grid.appendChild(card(l, 'listed ' + timeAgo(l.listedAt), null, undefined, 478 () => openMarket(data.listings, i, 'listing')))); 479 priceInto(document.getElementById('mkt-floor'), { price: data.stats.floor }); 480 document.getElementById('mkt-count').textContent = data.stats.listedCount; 481 const age = Date.now() - new Date(data.updatedAt).getTime(); 482 document.getElementById('mkt-updated').textContent = age < 600000 483 ? 'just now' 484 : new Date(data.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 485 document.getElementById('market').hidden = false; 486 } 487 488 if (data.sales?.length) { 489 const grid = document.getElementById('sales-grid'); 490 data.sales.forEach((s, i) => grid.appendChild(card(s, 'sold ' + timeAgo(s.soldAt), null, undefined, 491 () => openMarket(data.sales, i, 'sale')))); 492 document.getElementById('sales').hidden = false; 493 } 494 495 if (data.topSales?.length) { 496 const grid = document.getElementById('top-sales-grid'); 497 const soldSub = (s) => s.soldAt 498 ? 'sold ' + new Date(s.soldAt).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) 499 : ''; 500 function renderTop(mode) { 501 grid.innerHTML = ''; 502 const sorted = data.topSales 503 .slice() 504 .sort((a, b) => mode === 'usd' ? (usdOf(b) ?? 0) - (usdOf(a) ?? 0) : b.price - a.price) 505 .slice(0, 30); 506 sorted.forEach((s, i) => grid.appendChild(card(s, soldSub(s), i + 1, mode, 507 () => openMarket(sorted, i, 'sale')))); 508 } 509 document.querySelectorAll('#top-sort .sort-opt').forEach((btn) => { 510 btn.addEventListener('click', () => { 511 document.querySelectorAll('#top-sort .sort-opt').forEach((b) => 512 b.classList.toggle('active', b === btn)); 513 renderTop(btn.dataset.sort); 514 }); 515 }); 516 renderTop('usd'); 517 document.getElementById('top-sales').hidden = false; 518 } 519})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.