masterdanh Claude Opus 5 commited on
Commit
feffb09
·
1 Parent(s): 0c8f330

feat(fe): always-on spin+power hero on shot detail

Browse files

A player reads two things: what spin and how hard. The widget carrying
both waited for a replay, so the page opened on debug numbers instead.
It now leads the page whenever the shot has a shotnet block, with a
plain-English gloss and a POWER_TIERS label beside the m/s figure.
Gate: pytest 160 passed + 7 skipped, 3 runs; demo 14 shots checked by
hand (hero, caveat, downgrade badge, suspect box, mobile quickbar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. app/static/app.js +102 -13
  2. app/static/index.html +37 -21
  3. app/static/style.css +20 -1
app/static/app.js CHANGED
@@ -289,6 +289,25 @@ const AND_RESIM_DEFAULT_ON = false;
289
  // trusted range is [1, 8] m/s, hence a ceiling of 8.0.
290
  const AND_V0_MIN = 0.5, AND_V0_MAX = 8.0;
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  const ANV = {
293
  videoId: null,
294
  timer: null,
@@ -944,6 +963,24 @@ const fmtTs = (s) => (s === null || s === undefined ? "—"
944
 
945
  const CONF_LABEL = { low: "low", medium: "medium", high: "high" };
946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
947
  /** The shot table, filling in GRADUALLY. Numbers are shown exactly as the
948
  server sent them; V0, phi and spin prefer the model column, falling back
949
  to the analytic one labelled "bi" (ball) because they are different
@@ -1049,14 +1086,7 @@ function renderAnvShots(body) {
1049
  } else if (i === 5) { // the confidence badge
1050
  if (sn) {
1051
  const b = document.createElement("span");
1052
- b.className = `anv-badge anv-badge-${sn.confidence}`;
1053
- b.textContent = CONF_LABEL[sn.confidence] || sn.confidence;
1054
- if (sn.confidence_raw && sn.confidence_raw !== sn.confidence) {
1055
- b.title = `downgraded from ${CONF_LABEL[sn.confidence_raw]} -- `
1056
- + `detection density `
1057
- + `${(sn.det_density || 0).toFixed(2)}/frame`;
1058
- b.textContent += " ↓";
1059
- }
1060
  td.appendChild(b);
1061
  } else {
1062
  td.textContent = s.spin_confidence
@@ -1213,8 +1243,8 @@ function renderAnvDetail() {
1213
  const warn = $("#and-warnings");
1214
  const line = $("#and-resim");
1215
  const btn = $("#and-replay");
1216
- $("#and-shotviz").classList.add("hidden"); // shown once replay starts
1217
  if (!res.metrics) {
 
1218
  tbl.innerHTML = "";
1219
  warn.innerHTML = "";
1220
  line.classList.add("hidden");
@@ -1230,7 +1260,10 @@ function renderAnvDetail() {
1230
  syncQbAnalyzer();
1231
  return;
1232
  }
 
1233
  const hasSn = fillAnMetrics(tbl, res, { flags: true });
 
 
1234
  $("#and-caveat").classList.toggle("hidden", !hasSn);
1235
  fillAnWarnings(warn, res);
1236
 
@@ -1310,8 +1343,11 @@ function fillAndShotviz(sn) {
1310
  dot.setAttribute("cx", 50 - sn.a * 46);
1311
  dot.setAttribute("cy", 50 - sn.b * 46);
1312
  }
 
 
 
1313
  $("#and-power-val").textContent =
1314
- `${sn.v0_cue_mps.toFixed(2)} m/s (cue V0)`;
1315
  $("#and-spin-cap").textContent =
1316
  `${sn.spin_vert} + ${sn.spin_side} (a=${sn.a.toFixed(2)}, `
1317
  + `b=${sn.b.toFixed(2)})`;
@@ -1320,6 +1356,60 @@ function fillAndShotviz(sn) {
1320
  return true;
1321
  }
1322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1323
  /** The Analyzer's quickbar row -- the mobile version of the replay widget:
1324
  shown while a shot is open, hidden on the list screen.
1325
 
@@ -1366,9 +1456,8 @@ async function anvReplay() {
1366
  closeSheet();
1367
  const k = S.table.w / res.metrics.table_w_m;
1368
  const pts = smoothTrack(res.track).map((p) => [p.x * k, p.y * k]);
1369
- if (fillAndShotviz(res.shotnet)) {
1370
- $("#and-shotviz").classList.remove("hidden");
1371
- }
1372
  // disable it BY HAND rather than through syncQbAnalyzer: `S.animating`
1373
  // only becomes true INSIDE animateShot, so a sync call here would read
1374
  // false and leave the button live
 
289
  // trusted range is [1, 8] m/s, hence a ceiling of 8.0.
290
  const AND_V0_MIN = 0.5, AND_V0_MAX = 8.0;
291
 
292
+ // How hard the shot was, in words: soft < 2.5 <= medium <= 4.5 < hard, on
293
+ // the model's CUE V0 in m/s. These are DISPLAY LABELS ONLY -- the m/s
294
+ // number is always printed beside the tier, so nothing is lost by rounding
295
+ // a shot into a word. The thresholds sit inside the trusted band
296
+ // AND_V0_MIN..AND_V0_MAX above and are meant to be re-tuned once there is a
297
+ // feel for real footage; this table is the single place to do it.
298
+ const POWER_TIERS = [
299
+ { max: 2.5, inclusive: false, label: "soft" },
300
+ { max: 4.5, inclusive: true, label: "medium" },
301
+ { max: Infinity, inclusive: true, label: "hard" },
302
+ ];
303
+
304
+ /** The power tier of a cue V0 in m/s. */
305
+ function powerTier(v0Cue) {
306
+ const t = POWER_TIERS.find(
307
+ (x) => (x.inclusive ? v0Cue <= x.max : v0Cue < x.max));
308
+ return (t || POWER_TIERS[POWER_TIERS.length - 1]).label;
309
+ }
310
+
311
  const ANV = {
312
  videoId: null,
313
  timer: null,
 
963
 
964
  const CONF_LABEL = { low: "low", medium: "medium", high: "high" };
965
 
966
+ /** Write the confidence badge into `el` -- the shot list cell and the
967
+ detail hero share this one builder, so the two places cannot drift.
968
+
969
+ The rank shown is the DISPLAYED one, which has already passed the
970
+ detection-density valve. When the valve downgraded it, the badge keeps
971
+ the ↓ marker and says what by: a rank that drops without explanation
972
+ reads as "the model is bad". */
973
+ function fillConfBadge(el, sn) {
974
+ el.className = `anv-badge anv-badge-${sn.confidence}`;
975
+ el.textContent = CONF_LABEL[sn.confidence] || sn.confidence;
976
+ el.removeAttribute("title");
977
+ if (sn.confidence_raw && sn.confidence_raw !== sn.confidence) {
978
+ el.title = `downgraded from ${CONF_LABEL[sn.confidence_raw]} -- `
979
+ + `detection density ${(sn.det_density || 0).toFixed(2)}/frame`;
980
+ el.textContent += " ↓";
981
+ }
982
+ }
983
+
984
  /** The shot table, filling in GRADUALLY. Numbers are shown exactly as the
985
  server sent them; V0, phi and spin prefer the model column, falling back
986
  to the analytic one labelled "bi" (ball) because they are different
 
1086
  } else if (i === 5) { // the confidence badge
1087
  if (sn) {
1088
  const b = document.createElement("span");
1089
+ fillConfBadge(b, sn);
 
 
 
 
 
 
 
1090
  td.appendChild(b);
1091
  } else {
1092
  td.textContent = s.spin_confidence
 
1243
  const warn = $("#and-warnings");
1244
  const line = $("#and-resim");
1245
  const btn = $("#and-replay");
 
1246
  if (!res.metrics) {
1247
+ hideAndHero();
1248
  tbl.innerHTML = "";
1249
  warn.innerHTML = "";
1250
  line.classList.add("hidden");
 
1260
  syncQbAnalyzer();
1261
  return;
1262
  }
1263
+ fillAndHero(res);
1264
  const hasSn = fillAnMetrics(tbl, res, { flags: true });
1265
+ // ruled 1 of the schema docstring: model numbers on screen ALWAYS carry
1266
+ // the sim-only caveat, which now sits directly under the hero
1267
  $("#and-caveat").classList.toggle("hidden", !hasSn);
1268
  fillAnWarnings(warn, res);
1269
 
 
1343
  dot.setAttribute("cx", 50 - sn.a * 46);
1344
  dot.setAttribute("cy", 50 - sn.b * 46);
1345
  }
1346
+ // the tier first, because it is the part a player reads; the m/s
1347
+ // number rides along so the word never hides a measurement, and "(cue)"
1348
+ // keeps it apart from the analytic BALL speed
1349
  $("#and-power-val").textContent =
1350
+ `${powerTier(sn.v0_cue_mps)} -- ${sn.v0_cue_mps.toFixed(2)} m/s (cue)`;
1351
  $("#and-spin-cap").textContent =
1352
  `${sn.spin_vert} + ${sn.spin_side} (a=${sn.a.toFixed(2)}, `
1353
  + `b=${sn.b.toFixed(2)})`;
 
1356
  return true;
1357
  }
1358
 
1359
+ // The plain-English gloss under the big spin label. It is derived from the
1360
+ // SAME two class fields the label uses, never from raw a/b, so the sentence
1361
+ // can never contradict the label above it.
1362
+ const SPIN_VERT_WORDS = {
1363
+ follow: "hit above centre",
1364
+ stun: "hit at centre height",
1365
+ draw: "hit below centre",
1366
+ };
1367
+ const SPIN_SIDE_WORDS = { "side-L": "left side", "side-R": "right side" };
1368
+
1369
+ /** The big spin label, in the SYSTEM vocabulary (follow/stun/draw +
1370
+ side-L/side-R) so the headline and the numbers table say the same
1371
+ words. */
1372
+ const spinLabel = (sn) => sn.spin_vert.charAt(0).toUpperCase()
1373
+ + `${sn.spin_vert.slice(1)} + ${sn.spin_side}`;
1374
+
1375
+ const spinExplain = (sn) =>
1376
+ `${SPIN_VERT_WORDS[sn.spin_vert] || sn.spin_vert}, `
1377
+ + `${SPIN_SIDE_WORDS[sn.spin_side] || sn.spin_side}`;
1378
+
1379
+ /** The hero at the top of the detail page: WHAT SPIN and HOW HARD, the two
1380
+ things the player came for. It is on as soon as the page opens whenever
1381
+ the shot has a `shotnet` block -- the widget used to wait for a replay,
1382
+ which buried the headline.
1383
+
1384
+ Without a model block it says so in one line and falls back to the
1385
+ coarse analytic spin baseline, rather than inventing numbers. */
1386
+ function fillAndHero(res) {
1387
+ const sn = (res || {}).shotnet || null;
1388
+ const none = $("#and-noshotnet");
1389
+ $("#and-shotviz").classList.toggle("hidden", !sn);
1390
+ none.classList.toggle("hidden", Boolean(sn));
1391
+ if (sn) {
1392
+ fillAndShotviz(sn);
1393
+ $("#and-spin-label").textContent = spinLabel(sn);
1394
+ $("#and-spin-explain").textContent = spinExplain(sn);
1395
+ fillConfBadge($("#and-conf"), sn);
1396
+ return;
1397
+ }
1398
+ none.textContent = "No model prediction for this shot."
1399
+ + (res.spin_class
1400
+ ? ` Analytic spin baseline: ${res.spin_class}`
1401
+ + (res.spin_confidence ? ` (confidence: ${res.spin_confidence})` : "")
1402
+ + "."
1403
+ : "");
1404
+ }
1405
+
1406
+ /** A shot whose JSON carries no numbers at all (an error result): there is
1407
+ no headline to show, and the reason is printed with the warnings. */
1408
+ function hideAndHero() {
1409
+ $("#and-shotviz").classList.add("hidden");
1410
+ $("#and-noshotnet").classList.add("hidden");
1411
+ }
1412
+
1413
  /** The Analyzer's quickbar row -- the mobile version of the replay widget:
1414
  shown while a shot is open, hidden on the list screen.
1415
 
 
1456
  closeSheet();
1457
  const k = S.table.w / res.metrics.table_w_m;
1458
  const pts = smoothTrack(res.track).map((p) => [p.x * k, p.y * k]);
1459
+ // the spin/power widget is the page hero now and is already on screen --
1460
+ // the replay no longer has to reveal it
 
1461
  // disable it BY HAND rather than through syncQbAnalyzer: `S.animating`
1462
  // only becomes true INSIDE animateShot, so a sync call here would read
1463
  // false and leave the button live
app/static/index.html CHANGED
@@ -89,24 +89,18 @@
89
  <a id="and-download" class="btn-mini and-link hidden">⬇ Download mp4</a>
90
  </div>
91
  <h3 id="and-title"></h3>
92
- <video id="and-video" class="and-video hidden" controls playsinline></video>
93
- <div id="and-video-missing" class="an-note hidden">This shot has
94
- no overlay.mp4 yet -- the result comes from a run before A2 (the
95
- clip is deleted, so it cannot be rendered retroactively), or the
96
- render failed (see the warnings).</div>
97
- <!-- The replay animates the OBSERVED trajectory, so the real
98
- shot comes back to life and is right regardless of the
99
- model. -->
100
- <button id="and-replay" class="an-cta" disabled>▶ Replay the shot (observed path)</button>
101
- <!-- The shot-info widget shown during a replay: the cue ball with
102
- its contact dot (a/b) plus the V0 power bar. It reuses the
103
- STYLE-ONLY markup and CSS (.viz*) of the older #shotviz, but
104
- its interactive ids and classes are its own
105
- (.and-spin-dot / .and-power-bar) because .spin-dot/.power-bar
106
- were swept globally by the earlier initialisers. The numbers
107
- come from the SHOTNET set (the analytic path has no a/b) and
108
- the set is named in the label. -->
109
- <div id="and-shotviz" class="viz hidden">
110
  <div class="viz-spin">
111
  <svg id="and-spin-ball" viewBox="0 0 100 100" aria-label="cue-ball contact point (model)">
112
  <defs>
@@ -123,12 +117,37 @@
123
  <div class="viz-cap" id="and-spin-cap"></div>
124
  </div>
125
  <div class="viz-power">
 
 
 
 
 
 
 
 
 
 
126
  <div class="viz-lbl">Power <b id="and-power-val"></b></div>
127
  <div id="and-power-bar" class="and-power-bar"></div>
128
  <div class="viz-scale"><span>0.5</span><span>m/s</span><span>8.0</span></div>
129
  <div class="viz-cap" id="and-viz-label"></div>
130
  </div>
131
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  <div id="and-suspect" class="an-warn hidden"></div>
133
  <!-- The re-simulation layer is a toggle, DEFAULTING OFF (the
134
  observed path and the re-simulation currently disagree on the
@@ -141,9 +160,6 @@
141
  <div id="and-resim" class="an-note hidden"></div>
142
  <div id="and-warnings"></div>
143
  <table id="and-metrics" class="an-metrics"></table>
144
- <div id="and-caveat" class="an-caveat hidden">The model is
145
- trained purely on simulation (P2) and has not been tuned on real
146
- footage.</div>
147
  <div class="an-note">The table on the left: a <b>solid line
148
  coloured by time</b> is the observed track; balls carry the
149
  number and colour from BallID when the result has them (older
 
89
  <a id="and-download" class="btn-mini and-link hidden">⬇ Download mp4</a>
90
  </div>
91
  <h3 id="and-title"></h3>
92
+ <!-- The HERO of the page: what spin and how hard, the two numbers
93
+ a player actually reads. It is on from the moment the page
94
+ opens whenever the shot carries a `shotnet` block -- it used
95
+ to appear only during a replay, which buried the headline
96
+ under the debug numbers. It keeps the STYLE-ONLY markup and
97
+ CSS (.viz*) of the older #shotviz, but its interactive ids
98
+ and classes are its own (.and-spin-dot / .and-power-bar)
99
+ because .spin-dot/.power-bar were swept globally by the
100
+ earlier initialisers. The numbers come from the SHOTNET set
101
+ (the analytic path has no a/b) and the set is named in the
102
+ label. -->
103
+ <div id="and-shotviz" class="viz and-hero hidden">
 
 
 
 
 
 
104
  <div class="viz-spin">
105
  <svg id="and-spin-ball" viewBox="0 0 100 100" aria-label="cue-ball contact point (model)">
106
  <defs>
 
117
  <div class="viz-cap" id="and-spin-cap"></div>
118
  </div>
119
  <div class="viz-power">
120
+ <!-- The confidence badge borrows the STYLE-ONLY .anv-badge
121
+ classes of the shot list (nothing binds events to them).
122
+ It shows the DISPLAYED rank, which has already been
123
+ through the detection-density valve, and keeps the same
124
+ downgrade marker the list row uses. -->
125
+ <div class="and-hero-head">
126
+ <div id="and-spin-label" class="and-hero-spin"></div>
127
+ <span id="and-conf" class="anv-badge"></span>
128
+ </div>
129
+ <div id="and-spin-explain" class="and-hero-sub"></div>
130
  <div class="viz-lbl">Power <b id="and-power-val"></b></div>
131
  <div id="and-power-bar" class="and-power-bar"></div>
132
  <div class="viz-scale"><span>0.5</span><span>m/s</span><span>8.0</span></div>
133
  <div class="viz-cap" id="and-viz-label"></div>
134
  </div>
135
  </div>
136
+ <!-- No model block: say so in one line and fall back to the coarse
137
+ analytic spin baseline, rather than inventing a number. -->
138
+ <div id="and-noshotnet" class="an-note hidden"></div>
139
+ <div id="and-caveat" class="an-caveat hidden">The model is
140
+ trained purely on simulation (P2) and has not been tuned on real
141
+ footage.</div>
142
+ <video id="and-video" class="and-video hidden" controls playsinline></video>
143
+ <div id="and-video-missing" class="an-note hidden">This shot has
144
+ no overlay.mp4 yet -- the result comes from a run before A2 (the
145
+ clip is deleted, so it cannot be rendered retroactively), or the
146
+ render failed (see the warnings).</div>
147
+ <!-- The replay animates the OBSERVED trajectory, so the real
148
+ shot comes back to life and is right regardless of the
149
+ model. -->
150
+ <button id="and-replay" class="an-cta" disabled>▶ Replay the shot (observed path)</button>
151
  <div id="and-suspect" class="an-warn hidden"></div>
152
  <!-- The re-simulation layer is a toggle, DEFAULTING OFF (the
153
  observed path and the re-simulation currently disagree on the
 
160
  <div id="and-resim" class="an-note hidden"></div>
161
  <div id="and-warnings"></div>
162
  <table id="and-metrics" class="an-metrics"></table>
 
 
 
163
  <div class="an-note">The table on the left: a <b>solid line
164
  coloured by time</b> is the observed track; balls carry the
165
  number and colour from BallID when the result has them (older
app/static/style.css CHANGED
@@ -154,7 +154,7 @@ main {
154
  /* The replay widget on the detail page: it looks EXACTLY like the older
155
  #shotviz but carries its OWN interactive classes, because .power-bar and
156
  .spin-dot were swept globally by the suggestion tab's initialisers. */
157
- #and-spin-ball { width: 88px; height: 88px; display: block; margin: 0 auto; }
158
  .and-spin-dot { transition: cx .25s ease, cy .25s ease; }
159
  .and-power-bar { display: flex; gap: 4px; }
160
  .and-power-bar .seg {
@@ -165,6 +165,25 @@ main {
165
  transition: background .2s;
166
  }
167
  #and-shotviz { margin-top: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  .and-toggle {
169
  display: flex;
170
  align-items: center;
 
154
  /* The replay widget on the detail page: it looks EXACTLY like the older
155
  #shotviz but carries its OWN interactive classes, because .power-bar and
156
  .spin-dot were swept globally by the suggestion tab's initialisers. */
157
+ #and-spin-ball { width: 104px; height: 104px; display: block; margin: 0 auto; }
158
  .and-spin-dot { transition: cx .25s ease, cy .25s ease; }
159
  .and-power-bar { display: flex; gap: 4px; }
160
  .and-power-bar .seg {
 
165
  transition: background .2s;
166
  }
167
  #and-shotviz { margin-top: 10px; }
168
+ /* The hero at the top of the detail page: the spin label is the headline of
169
+ the whole page, so it reads at heading size with the plain sentence and
170
+ the power line stepping down from it. */
171
+ .and-hero-head {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 9px;
175
+ flex-wrap: wrap;
176
+ }
177
+ .and-hero-spin {
178
+ font-size: 19px;
179
+ font-weight: 700;
180
+ letter-spacing: .2px;
181
+ }
182
+ .and-hero-sub {
183
+ color: var(--muted);
184
+ font-size: 12.5px;
185
+ margin: 3px 0 10px;
186
+ }
187
  .and-toggle {
188
  display: flex;
189
  align-items: center;