Dedupe logic

This commit is contained in:
Kalle
2026-08-05 19:53:54 +03:00
parent 2808979777
commit 273ab85d69
6 changed files with 170 additions and 9 deletions

View File

@@ -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" />;
}

View File

@@ -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}

View File

@@ -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

View 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"])),
};
}

View File

@@ -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);

View 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"],
);
});