mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 14:46:10 -05:00
Add set dividers
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
66
app/features/scanner/core/match-sets.ts
Normal file
66
app/features/scanner/core/match-sets.ts
Normal 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;
|
||||
}
|
||||
123
app/features/scanner/tests/match-sets.test.ts
Normal file
123
app/features/scanner/tests/match-sets.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user