jevstrudel.git / website / src / jev / JevTakes.jsx
JevTakes.jsx206 lines · 8.0 KB · raw
1// A song's recorded performances on its view (takes.mjs), a site song's
2// or a listener's (whose code is `foreignCode`, and whose takes replay in
3// the sandbox): how Jev
4// moves through the song across them (a form map in written order: each
5// section's next sections with their shares, fallbacks and answer times),
6// and every take, newest first, with its path through the form and a
7// who played it and when, and ▶ replay, its replay link (performance.mjs). Shows
8// nothing where the Worker is not (a static host) or nothing is recorded.
9import { useEffect, useState, useSyncExternalStore } from 'react';
10import { boothStore } from './boothStore.mjs';
11import { NameLink } from './NameLink.jsx';
12import { nowPlaying } from './client.mjs';
13import { codeHash, performancePath } from './performance.mjs';
14import { fetchMoves, fetchTakes, formMap, pct, positions, TAKES_PAGE, takeTime, writtenOrder } from './takes.mjs';
15import { themeColor as ACCENT } from './site.mjs';
16
17// The map as a picture: the sections in written order along a line, the
18// written way on as the line itself, and every other move Jev made as an
19// arc, above for a jump ahead and below for a way back, thicker the more
20// often it was taken.
21function MapPicture({ rows }) {
22  const W = 400;
23  const step = rows.length > 1 ? (W - 40) / (rows.length - 1) : 0;
24  const x = new Map(rows.map((r, i) => [r.section, 20 + i * step]));
25  const y = 60;
26  const arcs = rows.flatMap((r) =>
27    r.next
28      .filter((n) => !n.asWritten && x.has(n.section))
29      .map((n) => {
30        const x1 = x.get(r.section);
31        const x2 = x.get(n.section);
32        const lift = Math.min(50, 10 + Math.abs(x2 - x1) / 4) * (x2 > x1 ? -1 : 1);
33        return (
34          <path
35            key={`${r.section}>${n.section}`}
36            d={x1 === x2 ? `M ${x1 - 3} ${y} a 6 8 0 1 1 6 0` : `M ${x1} ${y} Q ${(x1 + x2) / 2} ${y + lift * 2} ${x2} ${y}`}
37            fill="none"
38            stroke={ACCENT}
39            strokeOpacity={0.35 + 0.55 * n.share}
40            strokeWidth={1 + 4 * n.share}
41          >
42            <title>{`${r.section} → ${n.section}: ${pct(n.share)} (${n.n})`}</title>
43          </path>
44        );
45      }),
46  );
47  return (
48    <svg viewBox={`0 0 ${W} 120`} className="w-full max-w-md" role="img" aria-label="how Jev moves through the song; the list below says the same">
49      <line x1={20} x2={W - 20} y1={y} y2={y} stroke="currentColor" strokeOpacity={0.25} />
50      {arcs}
51      {rows.map((r) => (
52        <g key={r.section}>
53          <circle cx={x.get(r.section)} cy={y} r={4} fill={r.fallbacks ? '#f87171' : 'currentColor'}>
54            <title>{`${r.section}: played ${r.plays}`}</title>
55          </circle>
56          <text
57            x={x.get(r.section)}
58            y={y + 14}
59            fontSize={8}
60            fill="currentColor"
61            fillOpacity={0.7}
62            transform={`rotate(35 ${x.get(r.section)} ${y + 14})`}
63          >
64            {r.section.slice(0, 10)}
65          </text>
66        </g>
67      ))}
68    </svg>
69  );
70}
71
72// The written order: the booth's form while this song plays here (exact,
73// even for a form built in code), else read from the code, else unknown
74// and the sections placed where Jev plays them (takes.mjs).
75export function FormMap({ song, moves, takes }) {
76  // the booth (JevBooth.jsx's useBooth, read here without its editor imports)
77  const { booth } = useSyncExternalStore(boothStore.subscribe, boothStore.getSnapshot, boothStore.getSnapshot);
78  const live = nowPlaying.get() === song.id ? booth?.form?.sections : null;
79  const rows = formMap(moves, live?.length ? live : writtenOrder(codeOf(song)), positions(takes));
80  if (!rows.length) return null;
81  return (
82    <div className="mb-3" data-form-map>
83      <p className="mb-1">
84        How Jev moves through it, over {moves.performances === moves.of ? '' : `the latest ${moves.performances} of `}
85        {moves.of} {moves.of === 1 ? 'take' : 'takes'}: the line is the song as written, each arc a way Jev took
86        instead.
87      </p>
88      <MapPicture rows={rows} />
89      <ul className="text-xs font-mono space-y-0.5 mt-1">
90        {rows.map((r) => (
91          <li key={r.section}>
92            <b>{r.section}</b> <span className="opacity-60">played {r.plays}×</span>
93            {r.fallbacks > 0 && (
94              <span className="text-red-400">
95                {' '}
96                · Jev unreachable {r.fallbacks} of {r.asked}
97              </span>
98            )}
99            {r.ms && (
100              <span className="opacity-60">
101                {' '}
102                · answered in {r.ms.median} ms (p90 {r.ms.p90})
103              </span>
104            )}
105            {r.next.length > 0 && (
106              <>
107                {' '}
108                →{' '}
109                {r.next.map((n, i) => (
110                  <span key={n.section}>
111                    {i > 0 && ', '}
112                    <span style={n.asWritten ? undefined : { color: ACCENT }}>{n.section}</span> {pct(n.share)}
113                    {n.asWritten && <span className="opacity-60"> (as written)</span>}
114                  </span>
115                ))}
116              </>
117            )}
118          </li>
119        ))}
120      </ul>
121    </div>
122  );
123}
124
125// the code a take is checked against: a site song's, or a listener's
126const codeOf = (song) => song.code ?? song.foreignCode ?? '';
127
128export function Takes({ song, first }) {
129  const [takes, setTakes] = useState(first.takes);
130  const [total, setTotal] = useState(first.total);
131  const [busy, setBusy] = useState(false);
132  const [hash, setHash] = useState(null);
133  useEffect(() => {
134    codeHash(codeOf(song)).then(setHash, () => setHash(null));
135  }, [codeOf(song)]);
136  const more = async () => {
137    setBusy(true);
138    try {
139      const page = await fetchTakes(song.id, takes.length);
140      setTakes((t) => [...t, ...page.takes]);
141      setTotal(page.total);
142    } finally {
143      setBusy(false);
144    }
145  };
146  return (
147    <details className="mb-3" data-takes>
148      <summary className="cursor-pointer">
149        Jev's takes · {total}: every recorded performance, newest first
150      </summary>
151      <ol className="text-xs font-mono space-y-1 mt-1">
152        {takes.map((t) => (
153          <li key={t.id} className="flex gap-2 items-baseline">
154            <a
155              className="shrink-0 underline hover:opacity-70"
156              href={performancePath(song.id, t.id)}
157              title="replay this take: Jev's decisions as they were, with no Jev calls"
158            >
159              ▶ replay
160            </a>
161            <span className="min-w-0">
162              <span className="opacity-60">
163                {takeTime(t)} ·{' '}
164              </span>
165              {t.player ? <NameLink id={t.player.id} name={t.player.name} /> : <span className="opacity-60">a listener</span>}
166              <span className="opacity-60">
167                {' '}
168                · {t.ended} · {t.segments} {t.segments === 1 ? 'section' : 'sections'}
169              </span>
170              {t.fallbacks > 0 && <span className="text-red-400"> · {t.fallbacks} unanswered</span>}
171              {hash && t.codeHash !== hash && <span className="opacity-60"> · the song has changed since</span>}
172              {t.path && <span className="block break-words">{t.path.join(' → ')}</span>}
173            </span>
174          </li>
175        ))}
176      </ol>
177      {takes.length < total && (
178        <button className="text-xs underline hover:opacity-70 mt-1" disabled={busy} onClick={more}>
179          {busy ? 'loading…' : `${Math.min(TAKES_PAGE, total - takes.length)} more`}
180        </button>
181      )}
182    </details>
183  );
184}
185
186export function JevTakes({ song }) {
187  const [data, setData] = useState(null);
188  useEffect(() => {
189    let live = true;
190    setData(null);
191    Promise.all([fetchTakes(song.id), fetchMoves(song.id)]).then(
192      ([takes, moves]) => live && setData({ takes, moves }),
193      () => live && setData(null),
194    );
195    return () => {
196      live = false;
197    };
198  }, [song.id]);
199  if (!data || !data.takes.total) return null;
200  return (
201    <div className="not-prose text-sm" data-jev-takes>
202      <FormMap song={song} moves={data.moves} takes={data.takes.takes} />
203      <Takes song={song} first={data.takes} />
204    </div>
205  );
206}