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}