jevstrudel.git / website / src / jev / takes.mjs

Jev's takes on a song, for its view (JevTakes.jsx): every recorded performance of it (worker/src/listening.ts, GET /jev/performances?song=), each replayed through the replay link (performance.mjs), and how Jev moves through the song across them (GET /jev/performances/moves?song=), laid out in the song's written order.

6import { PERFORMANCES } from './performance.mjs';
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}

{ 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);

{ 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);

When a take was recorded, in UTC to the minute. Takes stored before the site kept the time (worker/migrations/0006) are dated noon UTC of their 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}

A song's sections in the order it is written, read from its code: its walk() form's sections by at (name: { at: 16, next: [...] }, or at: at.name from a const at = { name: 16, ... }). [] when the code does not say (a song without a form, or one that builds its sections in code, like ALL GREEN): the view then uses the booth's form if the song 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}

Each section's mean place in the takes' paths, to order the sections of 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}

The form map's rows: every section Jev played, in written order (then any the code no longer has, or, the order unknown, by their place in the takes), a song without a form in section number order; each with the section after it as written (null when unknown), and its next sections 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}

"3 of 12", or a share as a percentage.

95export const pct = (x) => `${Math.round(x * 100)}%`;