Add kills/splatted by to track

This commit is contained in:
Kalle
2026-09-26 11:13:25 +03:00
parent cd99cb6d54
commit 8e340e94ca
27 changed files with 516 additions and 82 deletions

View File

@@ -107,6 +107,10 @@
&.statusLabelSpecial {
color: var(--color-chart-special);
}
&.statusLabelKill {
color: var(--color-chart-kill);
}
}
.readoutWeapons {

View File

@@ -1,7 +1,7 @@
/**
* A game's two scanned-timeline charts (player status bands above the objective-counter chart)
* on one shared time axis. Hovering scrubs both: a cursor line spans the charts and a readout
* shows the moment's state, replacing the chart's own tooltip.
* shows the moment's state and any kills under the cursor, replacing the chart's own tooltip.
*/
import clsx from "clsx";
import { memo, useRef, useState } from "react";
@@ -23,6 +23,8 @@ import {
import {
PLAYER_STATUS_TAIL_SECONDS,
PlayerStatusTimeline,
type PlayerStatusTimelineKill,
type PlayerStatusTimelinePov,
type PlayerStatusTimelineSample,
type PlayerStatusTimelineTeam,
statusSpans,
@@ -31,11 +33,14 @@ import {
/** Cursor position past this fraction of the plot flips the readout to its left side. */
const READOUT_FLIP_RATIO = 0.55;
const READOUT_CURSOR_GAP_PX = 12;
/** Kill ticks this close to the cursor show up in the readout. */
const KILL_READOUT_RADIUS_PX = 4;
interface GameTimelineProps {
objectiveEvents?: readonly ObjectiveTimelineEvent[];
playerStatusSamples?: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
pov?: PlayerStatusTimelinePov;
}
interface ScrubPosition {
@@ -52,6 +57,7 @@ export function GameTimeline({
objectiveEvents,
playerStatusSamples,
teams,
pov,
}: GameTimelineProps) {
const [scrub, setScrub] = useState<ScrubPosition | null>(null);
const plotRef = useRef<HTMLDivElement>(null);
@@ -91,6 +97,7 @@ export function GameTimeline({
objectiveEvents={objectiveEvents}
playerStatusSamples={playerStatusSamples}
teams={teams}
pov={pov}
/>
<div className={styles.plotOverlay} ref={plotRef}>
{scrub ? (
@@ -100,6 +107,7 @@ export function GameTimeline({
objective={objective}
samples={samples}
teams={teams}
pov={samples.length > 0 ? pov : undefined}
/>
) : null}
</div>
@@ -114,6 +122,7 @@ const TimelineCharts = memo(function TimelineCharts({
objectiveEvents,
playerStatusSamples,
teams,
pov,
}: GameTimelineProps) {
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
@@ -123,7 +132,12 @@ const TimelineCharts = memo(function TimelineCharts({
return (
<>
{samples.length > 0 ? (
<PlayerStatusTimeline samples={samples} teams={teams} domain={domain} />
<PlayerStatusTimeline
samples={samples}
teams={teams}
domain={domain}
pov={pov}
/>
) : null}
{objective.length > 0 ? (
<ObjectiveTimeline
@@ -143,18 +157,24 @@ function ScrubReadout({
objective,
samples,
teams,
pov,
}: {
scrub: ScrubPosition;
domain: [number, number];
objective: readonly ObjectiveTimelineEvent[];
samples: readonly PlayerStatusTimelineSample[];
teams: GameTimelineProps["teams"];
pov?: PlayerStatusTimelinePov;
}) {
const { t } = useTranslation(["common"]);
const [min, max] = domain;
const time = min + (scrub.x / scrub.width) * (max - min);
const objectiveNow = objectiveStateAt(objective, time);
const statusNow = playerStatusAt(samples, time);
const killRadius = (KILL_READOUT_RADIUS_PX / scrub.width) * (max - min);
const killsNow = (pov?.kills ?? []).filter(
(kill) => Math.abs(kill.t - time) <= killRadius,
);
const flipped = scrub.x > scrub.width * READOUT_FLIP_RATIO;
return (
@@ -230,6 +250,7 @@ function ScrubReadout({
/>
</>
) : null}
{pov?.side === side ? <KillsRow kills={killsNow} /> : null}
</div>
))}
</div>
@@ -284,6 +305,20 @@ function StatusWeaponsRow({
);
}
function KillsRow({ kills }: { kills: PlayerStatusTimelineKill[] }) {
const { t } = useTranslation(["common"]);
if (kills.length === 0) return null;
return (
<div className={styles.readoutStatusRow}>
<span className={clsx(styles.readoutStatusLabel, styles.statusLabelKill)}>
{t("common:playerStatusTimeline.kill")}
</span>
<span>{kills.map((kill) => kill.name ?? "?").join(" · ")}</span>
</div>
);
}
function timelineDomain(
objective: readonly ObjectiveTimelineEvent[],
samples: readonly PlayerStatusTimelineSample[],

View File

@@ -22,7 +22,9 @@
}
.legendSwatchDead,
.legendSwatchSpecial {
.legendSwatchSpecial,
.legendSwatchKill,
.legendSwatchSplattedByPov {
width: 10px;
height: 10px;
border-radius: 2px;
@@ -36,6 +38,15 @@
background-color: var(--color-chart-special);
}
.legendSwatchKill {
width: 3px;
background-color: var(--color-chart-kill);
}
.legendSwatchSplattedByPov {
background-color: var(--color-chart-kill);
}
.team {
display: flex;
flex-direction: column;
@@ -61,13 +72,25 @@
padding-right: var(--s-2);
}
.track {
.trackArea {
position: relative;
flex: 1;
}
.track {
position: relative;
height: 14px;
overflow: hidden;
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
&.trackPov {
background-color: color-mix(
in oklch,
var(--color-bg-higher) 75%,
var(--color-accent) 25%
);
}
}
.spanDead,
@@ -81,12 +104,27 @@
.spanDead {
background-color: var(--color-chart-splatted);
&.spanSplattedByPov {
background-color: var(--color-chart-kill);
}
}
.spanSpecial {
background-color: var(--color-chart-special);
}
.killTick {
position: absolute;
top: -3px;
bottom: -3px;
width: 3px;
transform: translateX(-50%);
border-radius: 2px;
background-color: var(--color-chart-kill);
box-shadow: 0 0 0 1px var(--color-bg-high);
}
.unknownWeapon {
opacity: 0.5;
}

View File

@@ -2,8 +2,10 @@
* Per-player splatted / special-held bands over a game's scanned icon-strip reads, rendered above
* the ObjectiveTimeline on the same `t` axis (pass `domain` to share the range). Reads re-confirm
* an unchanged state every few seconds; a longer gap means the HUD was not observed, so bands never
* bridge across one.
* bridge across one. A known POV player gets a highlighted row with their kills as ticks, and the
* splatted bands those kills opened stand out on the victims' rows.
*/
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
@@ -20,6 +22,16 @@ const MAX_BRIDGE_SECONDS = 15;
/** Trailing open band drawn this long past its last confirming read. */
export const PLAYER_STATUS_TAIL_SECONDS = 1;
/**
* The kill-feed row and the victim's crossed-out icon show together, but either read can land a
* beat late (an ink burst hiding the feed, the strip's ~1s cadence): the victim's splatted band
* opening within this much of the kill is that kill's.
*/
const KILL_SPLAT_MAX_LEAD_SECONDS = 4;
const KILL_SPLAT_MAX_LAG_SECONDS = 3;
const PLAYER_SLOTS = [0, 1, 2, 3] as const;
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
/** One icon-strip read, sides in `[alpha, bravo]` order. */
@@ -36,15 +48,33 @@ export interface PlayerStatusTimelineTeam {
weapons: (MainWeaponId | null)[];
}
/** The player whose point of view the footage is, with their kills. */
export interface PlayerStatusTimelinePov {
side: 0 | 1;
slot: number;
kills: readonly PlayerStatusTimelineKill[];
}
export interface PlayerStatusTimelineKill {
/** seconds on the same axis as the samples */
t: number;
/** the splatted player's name; null when unknown */
name: string | null;
/** the splatted player's row on the other side; null when unknown */
victimSlot: number | null;
}
export function PlayerStatusTimeline({
samples,
teams,
domain,
pov,
}: {
samples: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
/** x-axis range override, to share the objective chart's axis */
domain?: [number, number];
pov?: PlayerStatusTimelinePov;
}) {
const { t } = useTranslation(["common"]);
const sorted = samples.toSorted((a, b) => a.t - b.t);
@@ -56,11 +86,17 @@ export function PlayerStatusTimeline({
sorted[sorted.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS,
);
const range = Math.max(1, max - min);
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
const leftOf = (at: number) => `${((at - min) / range) * 100}%`;
const widthOf = (span: StatusSpan) =>
`${((span.end - span.start) / range) * 100}%`;
const titleOf = (label: string, span: StatusSpan) =>
`${label} · ${formatElapsed(span.start)}–${formatElapsed(span.end)}`;
const isPovRow = (side: 0 | 1, slot: number) =>
pov?.side === side && pov.slot === slot;
const killsOfVictim = (side: 0 | 1, slot: number) =>
pov && pov.side !== side
? pov.kills.filter((kill) => kill.victimSlot === slot)
: [];
return (
<div
@@ -80,46 +116,92 @@ export function PlayerStatusTimeline({
<span className={styles.legendSwatchSpecial} />
{t("common:playerStatusTimeline.specialReady")}
</span>
{pov && pov.kills.length > 0 ? (
<span className={styles.legendItem}>
<span className={styles.legendSwatchKill} />
{t("common:playerStatusTimeline.kill")}
</span>
) : null}
{pov?.kills.some((kill) => kill.victimSlot !== null) ? (
<span className={styles.legendItem}>
<span className={styles.legendSwatchSplattedByPov} />
{t("common:playerStatusTimeline.splattedByPov")}
</span>
) : null}
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.team}>
<div className={styles.teamLabel}>{teams[side].label}</div>
{[0, 1, 2, 3].map((slot) => (
<div key={slot} className={styles.row}>
<div className={styles.slotLabel}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
</div>
<div className={styles.track}>
{statusSpans(sorted, (sample) => sample.dead[side][slot]!).map(
(span, i) => (
<div
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.splatted"),
span,
)}
/>
),
)}
{statusSpans(
sorted,
(sample) => sample.special[side][slot]!,
).map((span, i) => (
{PLAYER_SLOTS.map((slot) => {
const deadSpans = statusSpans(
sorted,
(sample) => sample.dead[side][slot]!,
);
const splattedByPov = spansOpenedByKills(
deadSpans,
killsOfVictim(side, slot),
);
return (
<div key={slot} className={styles.row}>
<div className={styles.slotLabel}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
</div>
<div className={styles.trackArea}>
<div
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.specialReady"),
span,
)}
/>
))}
className={clsx(styles.track, {
[styles.trackPov]: isPovRow(side, slot),
})}
>
{deadSpans.map((span, i) => (
<div
key={`d${i}`}
className={clsx(styles.spanDead, {
[styles.spanSplattedByPov]: splattedByPov.has(i),
})}
style={{
left: leftOf(span.start),
width: widthOf(span),
}}
title={titleOf(
splattedByPov.has(i)
? t("common:playerStatusTimeline.splattedByPov")
: t("common:playerStatusTimeline.splatted"),
span,
)}
/>
))}
{statusSpans(
sorted,
(sample) => sample.special[side][slot]!,
).map((span, i) => (
<div
key={`s${i}`}
className={styles.spanSpecial}
style={{
left: leftOf(span.start),
width: widthOf(span),
}}
title={titleOf(
t("common:playerStatusTimeline.specialReady"),
span,
)}
/>
))}
</div>
{isPovRow(side, slot)
? pov!.kills.map((kill, i) => (
<div
key={i}
className={styles.killTick}
style={{ left: leftOf(kill.t) }}
title={`${t("common:playerStatusTimeline.kill")} · ${kill.name ?? "?"} · ${formatElapsed(kill.t)}`}
/>
))
: null}
</div>
</div>
</div>
))}
);
})}
</div>
))}
</div>
@@ -145,6 +227,23 @@ interface StatusSpan {
end: number;
}
/** Indexes of the spans each kill opened: the latest one opening inside the kill's window. */
function spansOpenedByKills(
spans: readonly StatusSpan[],
kills: readonly PlayerStatusTimelineKill[],
): Set<number> {
const opened = new Set<number>();
for (const kill of kills) {
const index = spans.findLastIndex(
(span) =>
span.start >= kill.t - KILL_SPLAT_MAX_LEAD_SECONDS &&
span.start <= kill.t + KILL_SPLAT_MAX_LAG_SECONDS,
);
if (index !== -1) opened.add(index);
}
return opened;
}
/**
* Contiguous stretches where the flag held true: opens at the first true read, closes at the read
* showing false, or one second past the last confirmation when the next read is too far away.

View File

@@ -39,8 +39,8 @@ import type {
import { matchResult } from "../core/sessions";
import type { ScannerClip } from "../store/clips";
import type { GetFrame } from "./EventCard";
import { displayOrder, gameTimelineProps } from "./game-timeline-view";
import styles from "./MatchCard.module.css";
import { playerStatusTeams } from "./player-status-view";
import { RawDetections } from "./RawDetections";
import type { ScanEvent, SessionKind } from "./session-data";
import { type UploadState, UploadStatusButton } from "./UploadStatus";
@@ -243,13 +243,7 @@ export function MatchCard({
<Scoreboard match={match} result={result} />
{match.objective || match.playerStatus ? (
<GameTimeline
objectiveEvents={(match.objective?.samples ?? []).map(
(sample) => ({ t: sample.t - matchOrigin, data: sample }),
)}
playerStatusSamples={(match.playerStatus?.samples ?? []).map(
(sample) => ({ ...sample, t: sample.t - matchOrigin }),
)}
teams={playerStatusTeams(match, teamLabels(match))}
{...gameTimelineProps(match, matchOrigin, TEAM_LABELS)}
/>
) : null}
<DeathsAndKills built={built} clips={clips} onPlayClip={onPlayClip} />
@@ -289,23 +283,6 @@ function elapsed(mode: ModeShort | null, timeLeft: number): number {
return Math.max(0, clockStart - timeLeft);
}
/**
* `teams` order is winner-first on a scoreboard-closed match, so it flips
* between games. The card keeps the scan's own side left and the enemy right
* for every match so consecutive games line up; footage with no POV seat
* read (casts) keeps `teams` order.
*/
function displayOrder(match: ScannerMatch): [0 | 1, 0 | 1] {
return match.pov?.team === 1 ? [1, 0] : [0, 1];
}
/** labels by team index: Alpha is whichever team the card shows on the left */
function teamLabels(match: ScannerMatch): readonly [string, string] {
return displayOrder(match)[0] === 0
? TEAM_LABELS
: [TEAM_LABELS[1], TEAM_LABELS[0]];
}
function Score({
match,
result,

View File

@@ -0,0 +1,99 @@
/** Prop derivation for rendering a ScannerMatch with the shared <GameTimeline />. */
import type { ComponentProps } from "react";
import type { GameTimeline } from "~/components/GameTimeline";
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match";
import { rankBy } from "../core/text";
/**
* A kill-feed name pins its kill on the enemy row it reads exactly (after
* matchKey), else on the row it resembles clearly more than any other: OCR of
* the feed's font garbles symbols the scoreboard reads fine, so a loose best
* match stands when the other names are far off.
*/
const VICTIM_NAME_MIN_SIMILARITY = 0.4;
const VICTIM_NAME_MIN_MARGIN = 0.25;
const PLAYER_SLOTS = [0, 1, 2, 3] as const;
/**
* `teams` order is winner-first on a scoreboard-closed match, so it flips
* between games. The card keeps the scan's own side left and the enemy right
* for every match so consecutive games line up; footage with no POV seat
* read (casts) keeps `teams` order.
*/
export function displayOrder(match: ScannerMatch): [0 | 1, 0 | 1] {
return match.pov?.team === 1 ? [1, 0] : [0, 1];
}
/**
* The match's counter and icon-strip reads as <GameTimeline /> props: sides
* in display order (the POV's team first), `t` shifted to `origin`. With the
* POV seat known, its row carries the kill feed's kills, each pinned to its
* victim's row by name. A cast has no seat: its feed follows the camera, so
* the kills belong to no single row.
*/
export function gameTimelineProps(
match: ScannerMatch,
origin: number,
labels: readonly [string, string],
): ComponentProps<typeof GameTimeline> {
const [first, second] = displayOrder(match);
const ordered = <T>(pair: readonly [T, T]): [T, T] => [
pair[first],
pair[second],
];
return {
objectiveEvents: (match.objective?.samples ?? []).map((sample) => ({
t: sample.t - origin,
data: {
time: sample.time,
score: ordered(sample.score),
penalty: ordered(sample.penalty),
control: ordered(sample.control),
},
})),
playerStatusSamples: (match.playerStatus?.samples ?? []).map((sample) => ({
t: sample.t - origin,
special: ordered(sample.special),
dead: ordered(sample.dead),
})),
teams: ordered(match.teams).map(
(team, side): PlayerStatusTimelineTeam => ({
label: labels[side]!,
weapons: PLAYER_SLOTS.map(
(slot) => team.players[slot]?.weaponId ?? null,
),
}),
) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam],
pov: match.pov
? {
side: 0,
slot: match.pov.index,
kills: (match.kills ?? []).map((kill) => ({
t: kill.t - origin,
name: kill.name,
victimSlot: victimSlot(kill.name, match.teams[second].players),
})),
}
: undefined,
};
}
function victimSlot(
name: string | null,
enemies: readonly ScannerMatchPlayer[],
): number | null {
if (name === null) return null;
const named = enemies.flatMap((player, slot) =>
player.name !== null ? [{ slot, name: player.name }] : [],
);
const [best, runnerUp] = rankBy(name, named, (enemy) => enemy.name);
if (!best || best.score < VICTIM_NAME_MIN_SIMILARITY) return null;
if (best.score === 1 && runnerUp?.score !== 1) return best.entry.slot;
if (runnerUp && best.score - runnerUp.score < VICTIM_NAME_MIN_MARGIN) {
return null;
}
return best.entry.slot;
}

View File

@@ -1,16 +0,0 @@
/** Prop derivation for rendering a ScannerMatch's status samples with the shared <PlayerStatusTimeline />. */
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
import type { ScannerMatch } from "../core/scanner-match";
/** Row weapons per team from the match's known players, slots by index. */
export function playerStatusTeams(
match: ScannerMatch,
labels: readonly [string, string],
): [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam] {
return [0, 1].map((side) => ({
label: labels[side]!,
weapons: [0, 1, 2, 3].map(
(slot) => match.teams[side as 0 | 1].players[slot]?.weaponId ?? null,
),
})) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}

View File

@@ -51,6 +51,13 @@ export interface TimelineOptions {
* it for the match builder's smoothing. Confidence never moves a sample.
*/
sampledTypes: readonly string[];
/**
* types whose first read is the event: their content guard only merges
* identical repeats, so a more confident one carries nothing new except a
* later `t` — and a kill-feed row is re-read over its whole ~5s+ life, so
* taking it over would move the kill to when the row was about to leave.
*/
firstReadTypes: readonly string[];
}
const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
@@ -90,6 +97,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
[STRIP_WEAPONS_EVENT_TYPE]: 0,
},
sampledTypes: [PLAYER_STATUS_EVENT_TYPE],
firstReadTypes: [KILL_EVENT_TYPE],
};
export type TimelineAction =
@@ -136,7 +144,10 @@ export class TimelineBuilder {
this.#events.sort((a, b) => a.t - b.t);
return { action: "added", event };
}
if (event.confidence > near.confidence) {
if (
event.confidence > near.confidence &&
!this.#options.firstReadTypes.includes(event.type)
) {
this.#events[this.#events.indexOf(near)] = event;
this.#events.sort((a, b) => a.t - b.t);
return { action: "replaced", event, replaced: near };

View File

@@ -0,0 +1,133 @@
import { describe, expect, test } from "vitest";
import { gameTimelineProps } from "../../components/game-timeline-view";
import type { ScannerMatch, ScannerMatchKill } from "../../core/scanner-match";
const LABELS = ["Alpha", "Bravo"] as const;
const player = (name: string | null) => ({
name,
weaponId: null,
paint: null,
ka: null,
d: null,
s: null,
});
const MATCH: ScannerMatch = {
startsAt: 100,
endsAt: 400,
playedAt: null,
lobby: "PRIVATE",
mode: "TC",
stage: 3,
matchScores: [100, 0],
replayCode: null,
cast: false,
objective: {
mode: "SZ",
samples: [
{
t: 110,
time: 290,
score: [90, 100],
penalty: [null, 5],
control: [true, false],
},
],
},
playerStatus: null,
kills: null,
teams: [
{
players: [
player("Jrod_14"),
player("Elis"),
player("GOLD SHIP"),
player("merocï¬・~"),
],
},
{
players: [
player("Florescent"),
player("くらうに ★~"),
player("Bocchi"),
player("have faith"),
],
},
],
winner: 0,
pov: { team: 1, index: 3 },
};
const withKills = (
kills: ScannerMatchKill[],
teams: ScannerMatch["teams"] = MATCH.teams,
): ScannerMatch => ({ ...MATCH, kills, teams });
describe("gameTimelineProps", () => {
test("puts the POV's team first", () => {
const props = gameTimelineProps(MATCH, 100, LABELS);
expect(props.objectiveEvents![0]).toEqual({
t: 10,
data: {
time: 290,
score: [100, 90],
penalty: [5, null],
control: [false, true],
},
});
expect(props.pov?.side).toBe(0);
expect(props.pov?.slot).toBe(3);
});
test("has no POV row on footage without a seat", () => {
const props = gameTimelineProps(
{ ...withKills([{ t: 150, time: 250, name: "Elis" }]), pov: null },
100,
LABELS,
);
expect(props.pov).toBeUndefined();
});
test.each([
{ why: "exact name", name: "Jrod_14", victimSlot: 0 },
{
why: "garbled symbols, others far off",
name: "merocïつ·˜",
victimSlot: 3,
},
{ why: "unrelated name", name: "Squidward", victimSlot: null },
{ why: "unread name", name: null, victimSlot: null },
])("pins a kill on its victim's row: $why", ({ name, victimSlot }) => {
const props = gameTimelineProps(
withKills([{ t: 150, time: 250, name }]),
100,
LABELS,
);
expect(props.pov?.kills[0]?.victimSlot).toBe(victimSlot);
});
test.each([
{ why: "exact read beats a lookalike", name: "Sendou1", victimSlot: 0 },
{
why: "two lookalikes fit about as well",
name: "Sendou",
victimSlot: null,
},
])("near-twin enemy names: $why", ({ name, victimSlot }) => {
const teams: ScannerMatch["teams"] = [
{ players: [player("Sendou1"), player("Sendou2")] },
MATCH.teams[1],
];
const props = gameTimelineProps(
withKills([{ t: 150, time: 250, name }], teams),
100,
LABELS,
);
expect(props.pov?.kills[0]?.victimSlot).toBe(victimSlot);
});
});

View File

@@ -188,6 +188,26 @@ test("kill stacks merge while unchanged and split when a row enters", () => {
assert.equal(tl.events.length, 2);
});
// Triton cup VoD: "Splatted Jrod_14!" shows from 4:21 but a sharper read at 4:16 took over
test("a kill stack stays at its first read when a repeat reads more confidently", () => {
const tl = new TimelineBuilder();
tl.push({
type: "Kill",
t: 11077.3,
confidence: 0.92,
data: { time: 261, names: ["Jrod_14"] },
});
tl.push({
type: "Kill",
t: 11082.3,
confidence: 0.937,
data: { time: 256, names: ["Jrod_14"] },
});
assert.equal(tl.events.length, 1);
assert.equal(tl.events[0]!.t, 11077.3);
assert.deepEqual(tl.events[0]!.data, { time: 261, names: ["Jrod_14"] });
});
const NO_FLAGS: PlayerStatusData["dead"] = [
[false, false, false, false],
[false, false, false, false],

View File

@@ -164,6 +164,7 @@ html.dark [data-custom-theme],
--color-chart-bravo: oklch(60% 0.17 275);
--color-chart-splatted: oklch(66% 0.03 var(--_base-h));
--color-chart-special: oklch(80% 0.14 84);
--color-chart-kill: oklch(78% 0.19 150);
/* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
@@ -232,6 +233,7 @@ html.light [data-custom-theme],
--color-chart-bravo: oklch(47% 0.19 275);
--color-chart-splatted: oklch(50% 0.03 var(--_base-h));
--color-chart-special: oklch(67% 0.14 72);
--color-chart-kill: oklch(55% 0.17 150);
/* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Lav bane-liste",
"maps.halfSz": "50% DD",
"maps.mapPool": "Banepulje",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Arenen-Liste erstellen",
"maps.halfSz": "50% Herrschaft",
"maps.mapPool": "Arenen-Pool",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "In control",
"playerStatusTimeline.splatted": "Splatted",
"playerStatusTimeline.specialReady": "Special ready",
"playerStatusTimeline.kill": "Kill",
"playerStatusTimeline.splattedByPov": "Splatted by POV",
"maps.createMapList": "Create map list",
"maps.halfSz": "50% SZ",
"maps.mapPool": "Map pool",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Crear lista de mapas",
"maps.halfSz": "50% Pintazonas",
"maps.mapPool": "Rotación de mapas",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Crear lista de escenarios",
"maps.halfSz": "50% Pintazonas",
"maps.mapPool": "Grupo de escenarios",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ",
"maps.mapPool": "Pool de stages",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Créer une liste de stages",
"maps.halfSz": "50% DdZ",
"maps.mapPool": "Pool de stages",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "יצירת רשימת מפות",
"maps.halfSz": "50% SZ",
"maps.mapPool": "מאגר מפות",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Crea lista scenari",
"maps.halfSz": "50% ZS",
"maps.mapPool": "Pool di scenari",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "ステージリストを作る",
"maps.halfSz": "半分エリア",
"maps.mapPool": "選択可能なステージ",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "맵 목록 생성",
"maps.halfSz": "에어리어 50%",
"maps.mapPool": "맵 풀",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Maak levellijst",
"maps.halfSz": "50% SZ",
"maps.mapPool": "Beschikbare levels",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Stwórz liste map",
"maps.halfSz": "50% SZ",
"maps.mapPool": "Pula map",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Criar lista de mapas",
"maps.halfSz": "50% Zones",
"maps.mapPool": "Seleção de mapas",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "Создать список карт",
"maps.halfSz": "50% Зон",
"maps.mapPool": "Пул карт",

View File

@@ -213,6 +213,8 @@
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"playerStatusTimeline.kill": "",
"playerStatusTimeline.splattedByPov": "",
"maps.createMapList": "创建场地列表",
"maps.halfSz": "真格区域占 50%",
"maps.mapPool": "场地池",