1// Jev's takes on a song, for its view (JevTakes.jsx): every recorded 2// performance of it (worker/src/listening.ts, GET /jev/performances?song=), 3// each replayed through the replay link (performance.mjs), and how Jev 4// moves through the song across them (GET /jev/performances/moves?song=), 5// laid out in the song's written order. 6import { PERFORMANCES } from './performance.mjs'; 7 8export const TAKES_PAGE = 20; 9 10async function read(url, fetchImpl) { 11 const res = await fetchImpl(url); 12 if (!res.ok) throw new Error(`the site answered ${res.status}`); 13 return res.json(); 14} 15 16// { song, total, takes: [{ id, day, at, player, ended, codeHash, segments, fallbacks, path }] } 17export const fetchTakes = (song, offset = 0, fetchImpl = globalThis.fetch) => 18 read(`${PERFORMANCES}?song=${encodeURIComponent(song)}&offset=${offset}&limit=${TAKES_PAGE}`, fetchImpl); 19 20// { song, performances, of, sections: [{ section, plays, asked, fallbacks, ms, next }] } 21export const fetchMoves = (song, fetchImpl = globalThis.fetch) => 22 read(`${PERFORMANCES}/moves?song=${encodeURIComponent(song)}`, fetchImpl); 23 24// When a take was recorded, in UTC to the minute. Takes stored before the 25// site kept the time (worker/migrations/0006) are dated noon UTC of their 26// day, and show the day alone rather than an hour nobody recorded. 27export function takeTime({ day, at }) { 28 if (!Number.isFinite(at) || at === Date.parse(`${day}T12:00:00Z`)) return day; 29 return `${new Date(at).toISOString().slice(0, 16).replace('T', ' ')} UTC`; 30} 31 32// A song's sections in the order it is written, read from its code: its 33// walk() form's sections by `at` (`name: { at: 16, next: [...] }`, or 34// `at: at.name` from a `const at = { name: 16, ... }`). [] when the code 35// does not say (a song without a form, or one that builds its sections in 36// code, like ALL GREEN): the view then uses the booth's form if the song 37// is playing, or the order Jev plays them in. 38export function writtenOrder(code) { 39 const text = String(code ?? ''); 40 const objects = new Map(); 41 for (const m of text.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*\{([^{}]*)\}/g)) { 42 objects.set(m[1], Object.fromEntries([...m[2].matchAll(/([A-Za-z_]\w*)\s*:\s*(\d+)/g)].map((e) => [e[1], Number(e[2])]))); 43 } 44 const sections = []; 45 for (const m of text.matchAll(/(?:^|[\s{,])([A-Za-z_]\w*)\s*:\s*\{\s*at\s*:\s*(\d+|[A-Za-z_$][\w$]*\.[A-Za-z_]\w*)/g)) { 46 const [obj, key] = m[2].split('.'); 47 const at = key === undefined ? Number(obj) : objects.get(obj)?.[key]; 48 if (typeof at !== 'number') return []; 49 if (!sections.some((s) => s.name === m[1])) sections.push({ name: m[1], at }); 50 } 51 return sections.length > 1 ? sections.sort((a, b) => a.at - b.at).map((s) => s.name) : []; 52} 53 54// Each section's mean place in the takes' paths, to order the sections of 55// a song whose written order is unknown the way Jev plays them. 56export function positions(takes) { 57 const sum = new Map(); 58 for (const { path } of takes) { 59 (path ?? []).forEach((name, i) => { 60 const [t, n] = sum.get(name) ?? [0, 0]; 61 sum.set(name, [t + i, n + 1]); 62 }); 63 } 64 return new Map([...sum].map(([name, [t, n]]) => [name, t / n])); 65} 66 67// The form map's rows: every section Jev played, in written order (then 68// any the code no longer has, or, the order unknown, by their place in the 69// takes), a song without a form in section number order; each with the 70// section after it as written (null when unknown), and its next sections 71// marked when one is that. 72export function formMap(moves, written, placed = new Map()) { 73 const played = new Map(moves.sections.map((s) => [s.section, s])); 74 const numbered = !written.length && [...played.keys()].every((n) => /^\d+$/.test(n)); 75 const place = (n) => placed.get(n) ?? Infinity; 76 const order = numbered 77 ? [...played.keys()].sort((a, b) => Number(a) - Number(b)) 78 : [ 79 ...written.filter((n) => played.has(n)), 80 ...[...played.keys()].filter((n) => !written.includes(n)).sort((a, b) => place(a) - place(b) || a.localeCompare(b)), 81 ]; 82 return order.map((name) => { 83 const s = played.get(name); 84 const i = written.indexOf(name); 85 const writtenNext = numbered ? String(Number(name) + 1) : i >= 0 ? (written[i + 1] ?? null) : null; 86 return { 87 ...s, 88 writtenNext, 89 next: s.next.map((n) => ({ ...n, asWritten: n.section === writtenNext })), 90 }; 91 }); 92} 93 94// "3 of 12", or a share as a percentage. 95export const pct = (x) => `${Math.round(x * 100)}%`;