diff --git a/app/features/scanner/components/EventTypeIcon.tsx b/app/features/scanner/components/EventTypeIcon.tsx
index 27bf04a3b..4ed4845b6 100644
--- a/app/features/scanner/components/EventTypeIcon.tsx
+++ b/app/features/scanner/components/EventTypeIcon.tsx
@@ -37,5 +37,5 @@ export function EventTypeIcon({
size?: number;
}) {
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
- return ;
+ return ;
}
diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx
index eb07d2367..077245aeb 100644
--- a/app/features/scanner/components/LivePage.tsx
+++ b/app/features/scanner/components/LivePage.tsx
@@ -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) => (
- {built.sources.map((e, i) => {
+ {withoutRepeatEvents(built.sources).map((e, i) => {
const vodMatch = vodMatchByEvent.get(e);
return (
(
+ 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"])),
+ };
+}
diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css
index 6c6cc33fd..d6ed275fc 100644
--- a/app/features/scanner/components/styles.css
+++ b/app/features/scanner/components/styles.css
@@ -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);
diff --git a/app/features/scanner/tests/dedupe-events.test.ts b/app/features/scanner/tests/dedupe-events.test.ts
new file mode 100644
index 000000000..4f82faa37
--- /dev/null
+++ b/app/features/scanner/tests/dedupe-events.test.ts
@@ -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"],
+ );
+});