mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-26 21:26:01 -05:00
Dedupe logic
This commit is contained in:
@@ -37,5 +37,5 @@ export function EventTypeIcon({
|
||||
size?: number;
|
||||
}) {
|
||||
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
|
||||
return <Icon size={size} aria-hidden />;
|
||||
return <Icon size={size} aria-hidden className="event-type-icon" />;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
updateEventsSend,
|
||||
} from "../store/events";
|
||||
import { AnalyzerClient } from "../worker/client";
|
||||
import { withoutRepeatEvents } from "./dedupe-events";
|
||||
import { EventCard } from "./EventCard";
|
||||
import { EventsSummary } from "./EventsSummary";
|
||||
import { downloadEventsCsv } from "./events-csv";
|
||||
@@ -334,7 +335,7 @@ export function LivePage({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{built.sources.map((e) => (
|
||||
{withoutRepeatEvents(built.sources).map((e) => (
|
||||
<EventCard
|
||||
key={e.id}
|
||||
type={e.type}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type VodSummary,
|
||||
} from "../store/vods";
|
||||
import { AnalyzerPool, defaultPoolSize } from "../worker/pool";
|
||||
import { withoutRepeatEvents } from "./dedupe-events";
|
||||
import { EventCard, type GetFrame } from "./EventCard";
|
||||
import { EventsSummary } from "./EventsSummary";
|
||||
import { downloadEventsCsv } from "./events-csv";
|
||||
@@ -588,7 +589,7 @@ export function VodPage({
|
||||
ingestable={ingestable}
|
||||
send={ingestable ? bulkSend : undefined}
|
||||
>
|
||||
{built.sources.map((e, i) => {
|
||||
{withoutRepeatEvents(built.sources).map((e, i) => {
|
||||
const vodMatch = vodMatchByEvent.get(e);
|
||||
return (
|
||||
<EventCard
|
||||
|
||||
42
app/features/scanner/components/dedupe-events.ts
Normal file
42
app/features/scanner/components/dedupe-events.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Display-side cleanup for a match card's source event list: a long map-open
|
||||
* can fragment into several minimap events (the timeline's merge window
|
||||
* tracks a drifting `t`, and VoD results arrive out of order), so a minimap
|
||||
* that shows nothing new versus the previous one is pure noise. Names are
|
||||
* ignored in the comparison — OCR wobbles on them while the actual content
|
||||
* (stage, weapons, ability reads) is unchanged. Stored events are untouched;
|
||||
* this only filters what gets rendered.
|
||||
*/
|
||||
|
||||
import { isDeepEqual, omit } from "remeda";
|
||||
import {
|
||||
MINIMAP_EVENT_TYPE,
|
||||
type MinimapData,
|
||||
} from "../core/detectors/minimap/index";
|
||||
|
||||
export function withoutRepeatEvents<T extends { type: string; data: unknown }>(
|
||||
events: readonly T[],
|
||||
): T[] {
|
||||
const result: T[] = [];
|
||||
let previousMinimap: MinimapData | null = null;
|
||||
for (const event of events) {
|
||||
if (event.type === MINIMAP_EVENT_TYPE) {
|
||||
const data = event.data as MinimapData;
|
||||
const repeat =
|
||||
previousMinimap !== null &&
|
||||
isDeepEqual(comparable(previousMinimap), comparable(data));
|
||||
previousMinimap = data;
|
||||
if (repeat) continue;
|
||||
}
|
||||
result.push(event);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function comparable(data: MinimapData) {
|
||||
return {
|
||||
...data,
|
||||
teammates: data.teammates.map((player) => omit(player, ["name"])),
|
||||
enemies: data.enemies.map((player) => omit(player, ["name"])),
|
||||
};
|
||||
}
|
||||
@@ -179,6 +179,25 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
|
||||
/* timestamp leads the row */
|
||||
& > span:first-child {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
/* event-type chip: neutral like the match chips, the icon carries the accent */
|
||||
& .status.detected {
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-bg);
|
||||
|
||||
& .event-type-icon {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scanner-app .card img.thumb {
|
||||
@@ -248,10 +267,13 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
font-weight: var(--weight-body);
|
||||
}
|
||||
|
||||
/* lighter than a solid slab: translucent fill + hairline so the boxes read
|
||||
as grouping, not chrome */
|
||||
.scanner-app .team {
|
||||
border-radius: var(--radius-field);
|
||||
padding: 8px;
|
||||
background: var(--color-bg);
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -301,7 +323,8 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.scanner-app .team.win h3 {
|
||||
@@ -332,12 +355,14 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* round black pucks, same language as the match header weapon row */
|
||||
.scanner-app img.weapon-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5);
|
||||
}
|
||||
|
||||
.scanner-app .minimap-player img.weapon-icon {
|
||||
@@ -647,8 +672,10 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
}
|
||||
|
||||
& img.weapon-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 100%;
|
||||
padding: var(--s-0-5);
|
||||
|
||||
&.pov {
|
||||
outline: 2px solid var(--color-text-accent);
|
||||
|
||||
90
app/features/scanner/tests/dedupe-events.test.ts
Normal file
90
app/features/scanner/tests/dedupe-events.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import { withoutRepeatEvents } from "../components/dedupe-events";
|
||||
import type { DeathData } from "../core/detectors/death/index";
|
||||
import type {
|
||||
MinimapData,
|
||||
MinimapEnemy,
|
||||
MinimapTeammate,
|
||||
} from "../core/detectors/minimap/index";
|
||||
import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois";
|
||||
import type { DetectedEvent } from "../core/detectors/types";
|
||||
import type { ScannerAbility } from "../scanner-types";
|
||||
import test from "./node-test-compat";
|
||||
|
||||
const ALPHA: MainWeaponId[] = [40, 1001, 2010, 3030];
|
||||
const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000];
|
||||
|
||||
function teammate(
|
||||
weaponId: MainWeaponId | null,
|
||||
i: number,
|
||||
{
|
||||
name = null as string | null,
|
||||
abilities = [] as (ScannerAbility | null)[],
|
||||
} = {},
|
||||
): MinimapTeammate {
|
||||
return { slot: SPECTATOR_SLOTS[i]!, name, weaponId, abilities };
|
||||
}
|
||||
|
||||
function enemy(
|
||||
weaponId: MainWeaponId | null,
|
||||
{ name = null as string | null } = {},
|
||||
): MinimapEnemy {
|
||||
return { name, weaponId, abilities: [] };
|
||||
}
|
||||
|
||||
function minimap(
|
||||
t: number,
|
||||
{
|
||||
stage = 0 as StageId | null,
|
||||
teammates = ALPHA.map((id, i) => teammate(id, i)),
|
||||
enemies = BRAVO.map((id) => enemy(id)),
|
||||
} = {},
|
||||
): DetectedEvent {
|
||||
const data: MinimapData = { stage, spectator: true, teammates, enemies };
|
||||
return { type: "Minimap", t, confidence: 0.8, data };
|
||||
}
|
||||
|
||||
function death(t: number): DetectedEvent {
|
||||
const data: DeathData = {
|
||||
weaponId: null,
|
||||
weaponType: "MAIN",
|
||||
abilities: [],
|
||||
name: null,
|
||||
};
|
||||
return { type: "Death", t, confidence: 0.9, data };
|
||||
}
|
||||
|
||||
test("collapses a repeated minimap into the first occurrence", () => {
|
||||
const kept = withoutRepeatEvents([minimap(70), minimap(73), minimap(76)]);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.equal(kept[0]!.t, 70);
|
||||
});
|
||||
|
||||
test("a name-only difference is still a repeat (OCR wobble)", () => {
|
||||
const kept = withoutRepeatEvents([
|
||||
minimap(70, { enemies: BRAVO.map((id) => enemy(id, { name: "クレー" })) }),
|
||||
minimap(73, { enemies: BRAVO.map((id) => enemy(id, { name: "グレー" })) }),
|
||||
]);
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
|
||||
test("a changed ability read keeps both minimaps", () => {
|
||||
const kept = withoutRepeatEvents([
|
||||
minimap(70),
|
||||
minimap(73, {
|
||||
teammates: ALPHA.map((id, i) =>
|
||||
teammate(id, i, { abilities: i === 0 ? ["ISM"] : [] }),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
assert.equal(kept.length, 2);
|
||||
});
|
||||
|
||||
test("dedupes across interleaved events of other types, dropping none of them", () => {
|
||||
const kept = withoutRepeatEvents([minimap(70), death(72), minimap(74)]);
|
||||
assert.deepEqual(
|
||||
kept.map((e) => e.type),
|
||||
["Minimap", "Death"],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user