Fritter-Demo / cloud.js
danielrosehill's picture
Deploy from ASR-Training
18e0955 verified
Raw History Blame Contribute Delete
8.86 kB
// Vocabulary cloud (2026-11-08), shared by danielrosehill/Fritter-Demo and Hebrish/Sabich-Demo (same file in both folders; keep them identical).
// training takes drift left and right, sized by how many takes contain them and coloured by group. Hover or tap a
// word to see a sentence it was recorded in. Data: terms.json, built by ASR-Training scripts/fritter_terms.py from
// danielrosehill/Tech-Sentences meta/*.jsonl. The same file is copied to ai.danielrosehill.com (danielrosehilldotcom).
//
// import { mountCloud } from './cloud.js';
// mountCloud(document.getElementById('cloud'), await (await fetch('terms.json')).json());
//
// Needs in the host page: an element to mount into. Styles are injected once (class prefix "fc-").
const GROUP_COLOURS = {
'AI & models': '#7c5cff',
'Speech & audio': '#ff7a45',
'Dev & data': '#1aa57a',
'Linux & self-hosting': '#2f8fe6',
'Smart home': '#e0a100',
'Hardware': '#e2457a',
};
const CSS = `
.fc { position: relative; --fc-ink: currentColor; }
.fc-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 14px; }
.fc-chip { font: inherit; font-size: .82rem; font-weight: 600; border-radius: 999px; padding: 4px 12px; cursor: pointer;
border: 1.5px solid var(--c); background: transparent; color: inherit; transition: background .2s, color .2s; }
.fc-chip[aria-pressed="true"] { background: var(--c); color: #fff; }
.fc-chip .fc-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--c); margin-right: 6px; vertical-align: 1px; }
.fc-chip[aria-pressed="true"] .fc-dot { background: #fff; }
.fc-stage { position: relative; overflow: hidden; border-radius: 18px; padding: 10px 0;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
.fc-lane { display: flex; width: max-content; align-items: center; white-space: nowrap; padding: 3px 0;
animation: fc-drift var(--dur) linear infinite; animation-direction: var(--dir); }
.fc-stage:hover .fc-lane.fc-hold, .fc-lane.fc-hold { animation-play-state: paused; }
@keyframes fc-drift { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.fc-w { font-weight: 650; letter-spacing: -.01em; margin: 0 .55em; cursor: pointer; color: var(--c);
transition: opacity .3s, transform .2s, filter .3s; line-height: 1.15; border: 0; background: none; padding: 0; font-family: inherit; }
.fc-w:hover, .fc-w:focus-visible, .fc-w.fc-on { transform: scale(1.18); outline: none; text-shadow: 0 0 18px color-mix(in srgb, var(--c) 55%, transparent); }
.fc.fc-filtered .fc-w:not(.fc-in) { opacity: .12; filter: grayscale(1); }
.fc-card { margin-top: 14px; border-radius: 14px; padding: 14px 16px; min-height: 92px;
background: color-mix(in srgb, var(--c, #888) 10%, transparent); border: 1px solid color-mix(in srgb, var(--c, #888) 35%, transparent);
transition: background .4s, border-color .4s; }
.fc-card .fc-term { font-weight: 700; color: var(--c); font-size: 1.05rem; }
.fc-card .fc-meta { font-size: .8rem; opacity: .7; margin-left: 8px; }
.fc-card .fc-said { margin: 6px 0 0; font-size: 1.05rem; }
.fc-card mark { background: color-mix(in srgb, var(--c) 25%, transparent); color: inherit; border-radius: 4px; padding: 0 3px; }
.fc-foot { font-size: .8rem; opacity: .65; margin-top: 8px; }
@media (prefers-reduced-motion: reduce) {
.fc-lane { animation: none; flex-wrap: wrap; width: auto; white-space: normal; justify-content: center; }
.fc-lane .fc-dup { display: none; }
.fc-stage { -webkit-mask-image: none; mask-image: none; }
}`;
function injectCss() {
if (document.getElementById('fc-css')) return;
const s = document.createElement('style');
s.id = 'fc-css';
s.textContent = CSS;
document.head.appendChild(s);
}
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function shuffle(a, seed = 7) {
let x = seed;
const r = () => ((x = (x * 9301 + 49297) % 233280) / 233280);
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
}
export function mountCloud(root, data, opts = {}) {
injectCss();
const lanes = opts.lanes || (matchMedia('(max-width: 600px)').matches ? 6 : 8);
const colours = { ...GROUP_COLOURS, ...(opts.colours || {}) };
const terms = data.terms.filter((t) => colours[t.g]);
const max = Math.max(...terms.map((t) => t.n)), min = Math.min(...terms.map((t) => t.n));
const size = (n) => 0.95 + 1.45 * (Math.log(n) - Math.log(min)) / (Math.log(max) - Math.log(min) || 1); // rem
root.classList.add('fc');
const groups = [...new Set(terms.map((t) => t.g))].sort((a, b) => Object.keys(colours).indexOf(a) - Object.keys(colours).indexOf(b));
const counts = Object.fromEntries(groups.map((g) => [g, terms.filter((t) => t.g === g).length]));
root.innerHTML = `
<div class="fc-chips" role="group" aria-label="Filter by group">
${groups.map((g) => `<button type="button" class="fc-chip" style="--c:${colours[g]}" data-g="${esc(g)}" aria-pressed="false"><span class="fc-dot"></span>${esc(g)} <span style="opacity:.7">${counts[g]}</span></button>`).join('')}
</div>
<div class="fc-stage" aria-label="Tech terms Fritter was trained on"></div>
<div class="fc-card" aria-live="polite"></div>
<p class="fc-foot">${terms.length} of the ${data.distinct_terms.toLocaleString()} terms in ${data.rows.toLocaleString()} ${opts.noun || "recorded tech sentences"}, each with 3 or more takes. Bigger means more takes. Hover or tap a word.</p>`;
const stage = root.querySelector('.fc-stage'), card = root.querySelector('.fc-card');
// Deal terms into lanes so each lane mixes big and small words.
const order = shuffle([...terms]);
const rows = Array.from({ length: lanes }, () => []);
order.forEach((t, i) => rows[i % lanes].push(t));
const wordHtml = (t, dup) => `<button type="button" class="fc-w${dup ? ' fc-dup' : ''}" style="--c:${colours[t.g]};font-size:${size(t.n).toFixed(2)}rem" data-i="${terms.indexOf(t)}"${dup ? ' tabindex="-1" aria-hidden="true"' : ''}>${esc(t.t)}</button>`;
stage.innerHTML = rows.map((r, i) => `<div class="fc-lane" style="--dur:${(r.length * (5.5 + (i % 3) * 1.6)).toFixed(0)}s;--dir:${i % 2 ? 'reverse' : 'normal'}">${r.map((t) => wordHtml(t, false)).join('')}${r.map((t) => wordHtml(t, true)).join('')}</div>`).join('');
let active = null, auto = null, filter = null;
function show(t, el) {
card.style.setProperty('--c', colours[t.g]);
const said = t.s ? esc(t.s).replace(new RegExp(`\\b${reEsc(esc(t.t))}\\b`), (m) => `<mark>${m}</mark>`) : '';
card.innerHTML = `<span class="fc-term">${esc(t.t)}</span><span class="fc-meta">${esc(t.g)} · ${t.n} takes</span>` +
(said ? `<p class="fc-said">“${said}”</p>` : '');
root.querySelectorAll('.fc-w.fc-on').forEach((w) => w.classList.remove('fc-on'));
if (el) root.querySelectorAll(`.fc-w[data-i="${el.dataset.i}"]`).forEach((w) => w.classList.add('fc-on'));
}
const pool = () => terms.filter((t) => t.s && (!filter || t.g === filter));
function tick() { const p = pool(); if (!p.length) return; const t = p[Math.floor(Math.random() * p.length)]; show(t, root.querySelector(`.fc-w[data-i="${terms.indexOf(t)}"]`)); }
const startAuto = () => { clearInterval(auto); auto = setInterval(tick, opts.interval || 4200); };
stage.addEventListener('pointerover', (e) => {
const w = e.target.closest('.fc-w'); if (!w) return;
active = w; w.closest('.fc-lane').classList.add('fc-hold'); clearInterval(auto); show(terms[w.dataset.i], w);
});
stage.addEventListener('pointerout', (e) => {
const w = e.target.closest('.fc-w'); if (!w) return;
w.closest('.fc-lane').classList.remove('fc-hold'); active = null; startAuto();
});
stage.addEventListener('click', (e) => { const w = e.target.closest('.fc-w'); if (w) { clearInterval(auto); show(terms[w.dataset.i], w); setTimeout(startAuto, 6000); } });
stage.addEventListener('focusin', (e) => { const w = e.target.closest('.fc-w'); if (w) { clearInterval(auto); show(terms[w.dataset.i], w); } });
root.querySelector('.fc-chips').addEventListener('click', (e) => {
const c = e.target.closest('.fc-chip'); if (!c) return;
filter = filter === c.dataset.g ? null : c.dataset.g;
root.querySelectorAll('.fc-chip').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.g === filter)));
root.classList.toggle('fc-filtered', !!filter);
root.querySelectorAll('.fc-w').forEach((w) => w.classList.toggle('fc-in', !filter || terms[w.dataset.i].g === filter));
tick(); startAuto();
});
tick(); startAuto();
return { destroy() { clearInterval(auto); root.innerHTML = ''; } };
}