Add set dividers

This commit is contained in:
Kalle
2026-08-06 07:20:34 +03:00
parent 0404ae1e95
commit 1d4c9a3b89
6 changed files with 304 additions and 64 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import {
listVideoInputs,
openVirtualCamera,
@@ -11,6 +11,7 @@ import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import { TimelineBuilder } from "../core/timeline/index";
import {
clearEvents,
@@ -27,7 +28,7 @@ import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
import { MatchCard } from "./MatchCard";
import { MatchCard, SetDivider } from "./MatchCard";
import {
aggregateSendStatus,
matchContaining,
@@ -208,6 +209,8 @@ export function LivePage({
}, [deviceId, refreshFeed, send]);
const builtMatches = buildScannerMatches(feed);
const setNumbers = assignMatchSets(builtMatches.map((b) => b.match));
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedFeed = feed.filter((e) => !groupedEvents.has(e));
@@ -319,41 +322,47 @@ export function LivePage({
<p className="score">No detections yet.</p>
) : null}
{[...builtMatches].reverse().map((built, reverseIndex) => {
const index = builtMatches.length - 1 - reverseIndex;
const id = built.sources[0]!.id!;
const ingestable = isIngestableMatch(built.match);
return (
<MatchCard
key={id}
match={built.match}
live={
running && reverseIndex === 0 && built.match.winner === null
}
inProgress={reverseIndex === 0 && built.match.winner === null}
ingestable={ingestable}
send={aggregateSendStatus(built.sources)}
onSend={
SENDOU_UPLOAD_ENABLED && sendouUser && ingestable
? () => void send(matchContaining(id), { manual: true })
: undefined
}
>
{withoutRepeatEvents(built.sources).map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
/>
))}
</MatchCard>
<Fragment key={id}>
{showSetDividers &&
setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
<MatchCard
match={built.match}
live={
running && reverseIndex === 0 && built.match.winner === null
}
inProgress={reverseIndex === 0 && built.match.winner === null}
ingestable={ingestable}
send={aggregateSendStatus(built.sources)}
onSend={
SENDOU_UPLOAD_ENABLED && sendouUser && ingestable
? () => void send(matchContaining(id), { manual: true })
: undefined
}
>
{withoutRepeatEvents(built.sources).map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
/>
))}
</MatchCard>
</Fragment>
);
})}
{ungroupedFeed.length > 0 ? (

View File

@@ -145,6 +145,11 @@ export function MatchCard({
);
}
/** Labeled rule above the newest card of each set in the feed. */
export function SetDivider({ number }: { number: number }) {
return <div className="set-divider">Set {number}</div>;
}
function timeRangeLabel(match: ScannerMatch): string {
const start = formatTime(match.startsAt!);
return match.endsAt !== null && match.endsAt !== match.startsAt

View File

@@ -12,12 +12,20 @@
* Completed scans are persisted to IndexedDB keyed by file name
* (src/store/vods.ts); the default view lists them for reinspection.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Link } from "react-router";
import { openVodScan } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import type { DetectedEvent } from "../core/detectors/types";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import { TimelineBuilder } from "../core/timeline/index";
import type { SendStatus } from "../store/events";
import {
@@ -36,7 +44,7 @@ import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
import { formatTime } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchCard, SetDivider } from "./MatchCard";
import {
countIngestableMatches,
type SendouUser,
@@ -131,6 +139,8 @@ export function VodPage({
);
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
const setNumbers = assignMatchSets(builtMatches.map((b) => b.match));
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event));
@@ -578,35 +588,41 @@ export function VodPage({
) : null}
{/* newest match on top; the builder keeps ascending video-time order */}
{[...builtMatches].reverse().map((built, reverseIndex) => {
const index = builtMatches.length - 1 - reverseIndex;
const ingestable = isIngestableMatch(built.match);
return (
<MatchCard
key={vodMatchByEvent.get(built.sources[0]!)!.key}
match={built.match}
inProgress={
status === "scanning" &&
reverseIndex === 0 &&
built.match.winner === null
}
ingestable={ingestable}
send={ingestable ? bulkSend : undefined}
>
{withoutRepeatEvents(built.sources).map((e, i) => {
const vodMatch = vodMatchByEvent.get(e);
return (
<EventCard
key={i}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data}
abilities={abilityMap.get(e)}
thumbnail={vodMatch?.thumbnail}
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
/>
);
})}
</MatchCard>
<Fragment key={vodMatchByEvent.get(built.sources[0]!)!.key}>
{showSetDividers &&
setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
<MatchCard
match={built.match}
inProgress={
status === "scanning" &&
reverseIndex === 0 &&
built.match.winner === null
}
ingestable={ingestable}
send={ingestable ? bulkSend : undefined}
>
{withoutRepeatEvents(built.sources).map((e, i) => {
const vodMatch = vodMatchByEvent.get(e);
return (
<EventCard
key={i}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data}
abilities={abilityMap.get(e)}
thumbnail={vodMatch?.thumbnail}
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
/>
);
})}
</MatchCard>
</Fragment>
);
})}
{ungroupedMatches.length > 0 ? (

View File

@@ -626,6 +626,26 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
/* ── ingested matches feed ─────────────────────────────────────────── */
.scanner-app .set-divider {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.08em;
&::before,
&::after {
content: "";
flex: 1;
height: var(--border-width);
background-color: var(--color-border);
}
}
.scanner-app .match-card {
position: sticky;
top: var(--layout-sticky-top);
@@ -770,7 +790,8 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
padding: var(--s-0-5);
&.pov {
outline: 2px solid var(--color-text-accent);
outline: var(--border-style-high);
background-color: var(--color-bg-high);
outline-offset: 1px;
}
}

View File

@@ -0,0 +1,66 @@
/**
* Divide a chronological run of ScannerMatches into sets — consecutive games
* played by the same eight players (a Bo3/Bo5 between two teams). Rosters
* are compared by in-game names pooled across both teams (scoreboards list
* winners first, so team sides swap between games): names match fuzzily
* because two OCR reads of the same name can differ by a glyph or two, and
* one differing player is tolerated as a sub between games. A match with no
* readable names is inconclusive and never opens a new set.
*/
import type { ScannerMatch } from "./scanner-match";
import { closestBy } from "./text";
/**
* closestBy score two reads of the same name must reach — 0.7 forgives one
* bad glyph on a four-letter name, two on longer ones.
*/
const SAME_NAME_SCORE = 0.7;
/** Players allowed to differ between consecutive games of one set. */
const MAX_SUBS_PER_GAME = 1;
/**
* For each match its 1-based set number, aligned by index with the input;
* matches must be in chronological order. Numbers only ever step up by one:
* a match whose roster disagrees with the current set's opens the next set.
*/
export function assignMatchSets(matches: readonly ScannerMatch[]): number[] {
const setNumbers: number[] = [];
let setNumber = 1;
let roster: string[] | null = null;
for (const match of matches) {
const names = rosterNames(match);
if (names.length > 0) {
if (roster !== null && !sameRoster(roster, names)) setNumber++;
roster = names;
}
setNumbers.push(setNumber);
}
return setNumbers;
}
function rosterNames(match: ScannerMatch): string[] {
return match.teams
.flatMap((team) => team.players)
.map((player) => player.name)
.filter((name): name is string => name !== null);
}
/**
* Whether two rosters read as the same eight players: every name of the
* smaller roster must find a fuzzy partner in the other, short of
* MAX_SUBS_PER_GAME misses. Partial reads compare only what both saw, so a
* minimap-sourced half-roster still chains a set together.
*/
function sameRoster(a: readonly string[], b: readonly string[]): boolean {
const remaining = [...b];
let matched = 0;
for (const name of a) {
const best = closestBy(name, remaining, (entry) => entry);
if (best && best.score >= SAME_NAME_SCORE) {
matched++;
remaining.splice(remaining.indexOf(best.entry), 1);
}
}
return Math.min(a.length, b.length) - matched <= MAX_SUBS_PER_GAME;
}

View File

@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match";
import test from "./node-test-compat";
const TEAM_A = ["Sendou", "Kiver", "Brian", "Zed"];
const TEAM_B = ["Gos", "Noah", "Alice", "Bob"];
const TEAM_C = ["Totoro", "Miso", "Ramen", "Udon"];
const TEAM_D = ["Pearl", "Marina", "Callie", "Marie"];
function player(name: string | null): ScannerMatchPlayer {
return { name, weaponId: null, paint: null, ka: null, d: null, s: null };
}
function match(
alpha: (string | null)[],
bravo: (string | null)[],
): ScannerMatch {
return {
startsAt: null,
endsAt: null,
playedAt: null,
lobby: null,
mode: null,
stage: null,
matchScores: null,
replayCode: null,
cast: false,
teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }],
winner: null,
pov: null,
};
}
test("assignMatchSets", async (t) => {
await t.test("empty input", () => {
assert.deepEqual(assignMatchSets([]), []);
});
await t.test("same eight players stay in one set", () => {
const games = [
match(TEAM_A, TEAM_B),
match(TEAM_A, TEAM_B),
match(TEAM_A, TEAM_B),
];
assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
});
await t.test("team sides swapping keeps the set together", () => {
const games = [match(TEAM_A, TEAM_B), match(TEAM_B, TEAM_A)];
assert.deepEqual(assignMatchSets(games), [1, 1]);
});
await t.test("a fully different lobby opens a new set", () => {
const games = [
match(TEAM_A, TEAM_B),
match(TEAM_A, TEAM_B),
match(TEAM_C, TEAM_D),
match(TEAM_C, TEAM_D),
];
assert.deepEqual(assignMatchSets(games), [1, 1, 2, 2]);
});
await t.test("OCR stutter on a couple of names is tolerated", () => {
const games = [
match(TEAM_A, TEAM_B),
match(
["Send0u", "Kiver", "Brian", "Zed"],
["Gos", "N0ah", "Alice", "Bob"],
),
];
assert.deepEqual(assignMatchSets(games), [1, 1]);
});
await t.test("one sub is tolerated, two are a new set", () => {
const oneSub = [
match(TEAM_A, TEAM_B),
match(["Sendou", "Kiver", "Brian", "NewGuy"], TEAM_B),
];
assert.deepEqual(assignMatchSets(oneSub), [1, 1]);
const twoSubs = [
match(TEAM_A, TEAM_B),
match(["Sendou", "Kiver", "Sub1", "Sub2"], TEAM_B),
];
assert.deepEqual(assignMatchSets(twoSubs), [1, 2]);
});
await t.test("a nameless match never breaks the chain", () => {
const games = [
match(TEAM_A, TEAM_B),
match([null, null, null, null], [null, null, null, null]),
match(TEAM_A, TEAM_B),
];
assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
});
await t.test("a partial roster read chains on what both saw", () => {
const games = [
match(TEAM_A, TEAM_B),
match(["Sendou", "Kiver", null, null], [null, null, null, null]),
match(TEAM_A, TEAM_B),
];
assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
});
await t.test("a partial read of different players opens a new set", () => {
const games = [
match(TEAM_A, TEAM_B),
match(["Totoro", "Miso", "Ramen", null], [null, null, null, null]),
];
assert.deepEqual(assignMatchSets(games), [1, 2]);
});
await t.test("comparison follows the latest roster, not the first", () => {
const games = [
match(TEAM_A, TEAM_B),
match(["Sendou", "Kiver", "Brian", "SubA"], TEAM_B),
match(["Sendou", "Kiver", "Brian", "SubB"], TEAM_B),
];
assert.deepEqual(assignMatchSets(games), [1, 1, 1]);
});
});