This commit is contained in:
Kalle
2026-08-09 17:21:24 +03:00
parent a588ffda5b
commit 71a1f586eb
11 changed files with 617 additions and 96 deletions

View File

@@ -0,0 +1,120 @@
.root {
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-3);
/* horizontal touch drags scrub, vertical ones keep scrolling the page */
touch-action: pan-y;
}
/* pinned to the shared plot area: both cards pad horizontally by --s-3 and
keep their y-labels inside the same fixed gutter */
.plotOverlay {
position: absolute;
inset: 0 var(--s-3) 0 calc(var(--s-3) + var(--plot-gutter));
pointer-events: none;
}
.scrubLine {
position: absolute;
top: 0;
bottom: 0;
border-left: 2px dotted var(--color-text-high);
}
.readout {
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
max-width: 16rem;
padding: var(--s-2) var(--s-2-5);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
font-size: var(--font-xs);
transform: translateY(-50%);
white-space: nowrap;
}
.readoutPinned {
top: 0;
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - var(--s-2));
white-space: normal;
}
.readoutTitle {
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.readoutTeam {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.readoutTeamHeader {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1-5);
}
.readoutTeamName {
font-weight: var(--weight-semi);
}
.readoutScore {
font-variant-numeric: tabular-nums;
}
.readoutPenalty,
.readoutControl {
color: var(--color-text-high);
}
.swatch {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 2px;
&.swatchAlpha {
background-color: var(--color-chart-alpha);
}
&.swatchBravo {
background-color: var(--color-chart-bravo);
}
}
.readoutStatusRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
padding-left: calc(8px + var(--s-1-5));
}
.readoutStatusLabel {
&.statusLabelDead {
color: var(--color-error);
}
&.statusLabelSpecial {
color: var(--color-info);
}
}
.readoutWeapons {
display: flex;
align-items: center;
gap: var(--s-1);
}
.unknownWeapon {
opacity: 0.5;
}

View File

@@ -0,0 +1,377 @@
/**
* A game's two scanned-timeline charts stacked on one shared time axis and
* plot width: per-player status bands above the objective-counter chart.
* Hovering scrubs over both — a dotted cursor line spans the charts and a
* readout next to the cursor shows the moment's elapsed time, match clock,
* scores, penalties, who was in control, who was splatted and who had their
* special ready. The chart's own tooltip is turned off in favor of the
* readout.
*/
import clsx from "clsx";
import { memo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { clamp } from "remeda";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import styles from "./GameTimeline.module.css";
import { Image, WeaponImage } from "./Image";
import {
ObjectiveTimeline,
type ObjectiveTimelineEvent,
} from "./ObjectiveTimeline";
import {
formatElapsed,
smoothPenalties,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import {
PLAYER_STATUS_TAIL_SECONDS,
PlayerStatusTimeline,
type PlayerStatusTimelineSample,
type PlayerStatusTimelineTeam,
statusSpans,
} from "./PlayerStatusTimeline";
/** 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;
interface GameTimelineProps {
objectiveEvents?: readonly ObjectiveTimelineEvent[];
playerStatusSamples?: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}
interface ScrubPosition {
/** px from the plot area's left edge */
x: number;
/** px from the plot area's top edge */
y: number;
width: number;
/** touch scrubs pin the readout to the top so the finger doesn't hide it */
pinned: boolean;
}
export function GameTimeline({
objectiveEvents,
playerStatusSamples,
teams,
}: GameTimelineProps) {
const [scrub, setScrub] = useState<ScrubPosition | null>(null);
const plotRef = useRef<HTMLDivElement>(null);
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
const domain = timelineDomain(objective, samples);
if (!domain) return null;
const handlePointer = (event: React.PointerEvent) => {
const rect = plotRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
setScrub({
x: clamp(event.clientX - rect.left, { min: 0, max: rect.width }),
y: clamp(event.clientY - rect.top, { min: 0, max: rect.height }),
width: rect.width,
pinned: event.pointerType === "touch",
});
};
return (
<div
className={styles.root}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
onPointerDown={handlePointer}
onPointerMove={handlePointer}
onPointerLeave={(event) => {
// touch fires a leave as the finger lifts; keep the readout up instead
if (event.pointerType !== "touch") setScrub(null);
}}
>
<TimelineCharts
objectiveEvents={objectiveEvents}
playerStatusSamples={playerStatusSamples}
teams={teams}
/>
<div className={styles.plotOverlay} ref={plotRef}>
{scrub ? (
<ScrubReadout
scrub={scrub}
domain={domain}
objective={objective}
samples={samples}
teams={teams}
/>
) : null}
</div>
</div>
);
}
/** Memoized so scrubbing re-renders only the overlay, not the chart canvas. */
const TimelineCharts = memo(function TimelineCharts({
objectiveEvents,
playerStatusSamples,
teams,
}: GameTimelineProps) {
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
const domain = timelineDomain(objective, samples);
if (!domain) return null;
return (
<>
{samples.length > 0 ? (
<PlayerStatusTimeline samples={samples} teams={teams} domain={domain} />
) : null}
{objective.length > 0 ? (
<ObjectiveTimeline
events={objective}
teamLabels={[teams[0].label, teams[1].label]}
domain={domain}
showTooltip={false}
/>
) : null}
</>
);
});
function ScrubReadout({
scrub,
domain,
objective,
samples,
teams,
}: {
scrub: ScrubPosition;
domain: [number, number];
objective: readonly ObjectiveTimelineEvent[];
samples: readonly PlayerStatusTimelineSample[];
teams: GameTimelineProps["teams"];
}) {
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 flipped = scrub.x > scrub.width * READOUT_FLIP_RATIO;
return (
<>
<div className={styles.scrubLine} style={{ left: scrub.x }} />
<div
className={clsx(styles.readout, {
[styles.readoutPinned]: scrub.pinned,
})}
style={
scrub.pinned
? undefined
: {
top: scrub.y,
left: flipped ? undefined : scrub.x + READOUT_CURSOR_GAP_PX,
right: flipped
? scrub.width - scrub.x + READOUT_CURSOR_GAP_PX
: undefined,
}
}
>
<div className={styles.readoutTitle}>
{formatElapsed(time)}
{objectiveNow?.clock != null
? ` · ${t("common:objectiveTimeline.timeLeft", {
time: formatElapsed(objectiveNow.clock),
})}`
: null}
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.readoutTeam}>
<div className={styles.readoutTeamHeader}>
<span
className={clsx(
styles.swatch,
side === 0 ? styles.swatchAlpha : styles.swatchBravo,
)}
/>
<span className={styles.readoutTeamName}>
{teams[side].label}
</span>
{objectiveNow ? (
<span className={styles.readoutScore}>
{objectiveNow.scores[side] ?? "?"}
</span>
) : null}
{objectiveNow?.penalties[side] != null ? (
<span className={styles.readoutPenalty}>
{t("common:objectiveTimeline.penalty", {
value: objectiveNow.penalties[side],
})}
</span>
) : null}
{objectiveNow?.control[side] ? (
<span className={styles.readoutControl}>
{t("common:objectiveTimeline.inControl")}
</span>
) : null}
</div>
{statusNow ? (
<>
<StatusWeaponsRow
label={t("common:playerStatusTimeline.splatted")}
kind="dead"
slots={statusNow.dead[side]}
weapons={teams[side].weapons}
/>
<StatusWeaponsRow
label={t("common:playerStatusTimeline.specialReady")}
kind="special"
slots={statusNow.special[side]}
weapons={teams[side].weapons}
/>
</>
) : null}
</div>
))}
</div>
</>
);
}
function StatusWeaponsRow({
label,
kind,
slots,
weapons,
}: {
label: string;
kind: "dead" | "special";
slots: number[];
weapons: (MainWeaponId | null)[];
}) {
if (slots.length === 0) return null;
return (
<div className={styles.readoutStatusRow}>
<span
className={clsx(
styles.readoutStatusLabel,
kind === "dead" ? styles.statusLabelDead : styles.statusLabelSpecial,
)}
>
{label}
</span>
<span className={styles.readoutWeapons}>
{slots.map((slot) =>
weapons[slot] != null ? (
<WeaponImage
key={slot}
weaponSplId={weapons[slot]}
variant="badge"
size={20}
/>
) : (
<Image
key={slot}
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={20}
className={styles.unknownWeapon}
/>
),
)}
</span>
</div>
);
}
function timelineDomain(
objective: readonly ObjectiveTimelineEvent[],
samples: readonly PlayerStatusTimelineSample[],
): [number, number] | null {
const start = Math.min(
objective[0]?.t ?? Number.POSITIVE_INFINITY,
samples[0]?.t ?? Number.POSITIVE_INFINITY,
);
const end = Math.max(
objective[objective.length - 1]?.t ?? Number.NEGATIVE_INFINITY,
samples.length > 0
? samples[samples.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS
: Number.NEGATIVE_INFINITY,
);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
return [start, Math.max(end, start + 1)];
}
interface ObjectiveStateAtTime {
/** seconds shown on the match timer at the latest read; null = unreadable */
clock: number | null;
/** last readable count per team at the scrubbed moment */
scores: [number | null, number | null];
penalties: [number | null, number | null];
control: [boolean, boolean];
}
/** State implied by the last objective read at or before `time`, scores carried across unreadable reads. */
function objectiveStateAt(
sorted: readonly ObjectiveTimelineEvent[],
time: number,
): ObjectiveStateAtTime | null {
let index = -1;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i]!.t > time) break;
index = i;
}
if (index === -1) return null;
const scores: [number | null, number | null] = [null, null];
for (let i = 0; i <= index; i++) {
scores[0] = sorted[i]!.data.score[0] ?? scores[0];
scores[1] = sorted[i]!.data.score[1] ?? scores[1];
}
const penalties = ([0, 1] as const).map(
(side) =>
smoothPenalties(
sorted.map((event) => ({
t: event.t,
penalty: event.data.penalty[side],
})),
)[index] ?? null,
) as [number | null, number | null];
const latest = sorted[index]!;
return {
clock: latest.data.time,
scores,
penalties,
control: [latest.data.control[0], latest.data.control[1]],
};
}
interface PlayerStatusAtTime {
/** slot indexes inside a splatted band at the scrubbed moment, per side */
dead: [number[], number[]];
/** slot indexes inside a special-ready band at the scrubbed moment, per side */
special: [number[], number[]];
}
/** Matches the rendered bands: a player counts only while inside a drawn span. */
function playerStatusAt(
sorted: readonly PlayerStatusTimelineSample[],
time: number,
): PlayerStatusAtTime | null {
if (sorted.length === 0) return null;
const activeSlots = (side: 0 | 1, kind: "dead" | "special") =>
[0, 1, 2, 3].filter((slot) =>
statusSpans(sorted, (sample) => sample[kind][side][slot]!).some(
(span) => span.start <= time && time <= span.end,
),
);
return {
dead: [activeSlots(0, "dead"), activeSlots(1, "dead")],
special: [activeSlots(0, "special"), activeSlots(1, "special")],
};
}

View File

@@ -30,7 +30,11 @@ import { Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./ObjectiveTimeline.module.css";
import { formatElapsed, smoothPenalties } from "./objective-timeline-utils";
import {
formatElapsed,
smoothPenalties,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
ChartJS.register(
LinearScale,
@@ -68,9 +72,15 @@ export interface ObjectiveTimelineEvent {
export function ObjectiveTimeline({
events,
teamLabels,
domain,
showTooltip = true,
}: {
events: readonly ObjectiveTimelineEvent[];
teamLabels: readonly [string, string];
/** x-axis range override, to share the player-status timeline's axis */
domain?: [number, number];
/** off when a parent renders its own scrub readout over the chart */
showTooltip?: boolean;
}) {
const { t } = useTranslation(["common"]);
const colors = useThemeColors({
@@ -166,17 +176,19 @@ export function ObjectiveTimeline({
animation: false,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
layout: { autoPadding: false },
scales: {
x: {
type: "linear",
min: sorted[0]!.t,
max: sorted[sorted.length - 1]!.t,
min: domain?.[0] ?? sorted[0]!.t,
max: domain?.[1] ?? sorted[sorted.length - 1]!.t,
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: {
color: colors.text,
maxRotation: 0,
maxTicksLimit: 8,
align: "inner",
callback: (value) => formatElapsed(Number(value)),
},
},
@@ -192,6 +204,9 @@ export function ObjectiveTimeline({
afterBuildTicks: (axis) => {
axis.ticks = countAxisTicks(axis.max);
},
afterFit: (axis) => {
axis.width = TIMELINE_PLOT_GUTTER_PX;
},
ticks: { color: colors.text, autoSkip: false },
},
},
@@ -205,6 +220,7 @@ export function ObjectiveTimeline({
},
},
tooltip: {
enabled: showTooltip,
filter: (item) => item.datasetIndex < 2,
callbacks: {
title: (items) => {

View File

@@ -51,7 +51,14 @@
.row {
display: flex;
align-items: center;
gap: var(--s-2);
}
.slotLabel {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
width: var(--plot-gutter, 36px);
padding-right: var(--s-2);
}
.track {

View File

@@ -12,14 +12,17 @@ import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { Image, WeaponImage } from "./Image";
import { formatElapsed } from "./objective-timeline-utils";
import {
formatElapsed,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import styles from "./PlayerStatusTimeline.module.css";
/** Consecutive reads further apart than this leave an unknown gap. */
const MAX_BRIDGE_SECONDS = 15;
/** Trailing open band drawn this long past its last confirming read. */
const TAIL_SECONDS = 1;
export const PLAYER_STATUS_TAIL_SECONDS = 1;
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
@@ -54,7 +57,7 @@ export function PlayerStatusTimeline({
const min = Math.min(domain?.[0] ?? Number.POSITIVE_INFINITY, sorted[0]!.t);
const max = Math.max(
domain?.[1] ?? 0,
sorted[sorted.length - 1]!.t + TAIL_SECONDS,
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}%`;
@@ -64,7 +67,14 @@ export function PlayerStatusTimeline({
`${label} · ${formatElapsed(span.start)}${formatElapsed(span.end)}`;
return (
<div className={styles.container}>
<div
className={styles.container}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={styles.legendSwatchDead} />
@@ -80,7 +90,9 @@ export function PlayerStatusTimeline({
<div className={styles.teamLabel}>{teams[side].label}</div>
{[0, 1, 2, 3].map((slot) => (
<div key={slot} className={styles.row}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
<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) => (
@@ -143,7 +155,7 @@ interface StatusSpan {
* its last confirmation when the next read is too far away (or the series
* ends) to know what happened in between.
*/
function statusSpans(
export function statusSpans(
sorted: readonly PlayerStatusTimelineSample[],
flagOf: (sample: PlayerStatusTimelineSample) => boolean,
): StatusSpan[] {
@@ -153,7 +165,7 @@ function statusSpans(
for (const sample of sorted) {
const flag = flagOf(sample);
if (start !== null && sample.t - lastTrueT > MAX_BRIDGE_SECONDS) {
spans.push({ start, end: lastTrueT + TAIL_SECONDS });
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
start = null;
}
if (flag) {
@@ -164,6 +176,7 @@ function statusSpans(
start = null;
}
}
if (start !== null) spans.push({ start, end: lastTrueT + TAIL_SECONDS });
if (start !== null)
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
return spans;
}

View File

@@ -30,16 +30,11 @@ import { Ability } from "../Ability";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { GameTimeline } from "../GameTimeline";
import { Image, ModeImage, StageImage, WeaponImage } from "../Image";
import {
ObjectiveTimeline,
type ObjectiveTimelineEvent,
} from "../ObjectiveTimeline";
import type { ObjectiveTimelineEvent } from "../ObjectiveTimeline";
import { matchScoresFromObjective } from "../objective-timeline-utils";
import {
PlayerStatusTimeline,
type PlayerStatusTimelineSample,
} from "../PlayerStatusTimeline";
import type { PlayerStatusTimelineSample } from "../PlayerStatusTimeline";
import styles from "./MatchTimeline.module.css";
import { type InferredSubstitution, inferSubstitutions } from "./utils";
import type { WeaponPoolWeapon } from "./WeaponPool";
@@ -448,28 +443,20 @@ function TimelineScoreboardSection({
</button>
{isExpanded ? (
<div className={styles.scoreboardPanel}>
{scoreboard.playerStatus && scoreboard.playerStatus.length > 0 ? (
<PlayerStatusTimeline
samples={scoreboard.playerStatus}
teams={[
{
label: teams.alpha.name,
weapons: scoreboard.alpha.map((player) => player.weaponSplId),
},
{
label: teams.bravo.name,
weapons: scoreboard.bravo.map((player) => player.weaponSplId),
},
]}
domain={objectiveEventsDomain(scoreboard.objective)}
/>
) : null}
{scoreboard.objective && scoreboard.objective.length > 0 ? (
<ObjectiveTimeline
events={scoreboard.objective}
teamLabels={[teams.alpha.name, teams.bravo.name]}
/>
) : null}
<GameTimeline
objectiveEvents={scoreboard.objective}
playerStatusSamples={scoreboard.playerStatus}
teams={[
{
label: teams.alpha.name,
weapons: scoreboard.alpha.map((player) => player.weaponSplId),
},
{
label: teams.bravo.name,
weapons: scoreboard.bravo.map((player) => player.weaponSplId),
},
]}
/>
<div className={styles.scoreboardTables}>
<ScoreboardTable
name={teams.alpha.name}
@@ -486,14 +473,6 @@ function TimelineScoreboardSection({
);
}
function objectiveEventsDomain(
events: ObjectiveTimelineEvent[] | undefined,
): [number, number] | undefined {
if (!events || events.length === 0) return undefined;
const ts = events.map((event) => event.t);
return [Math.min(...ts), Math.max(...ts)];
}
function ScoreboardTable({
name,
players,

View File

@@ -1,5 +1,12 @@
const PENALTY_BRIDGE_SECONDS = 6;
/**
* Width of the label gutter left of the plot area, shared by the objective
* chart (its y-axis is forced to this width) and the player-status rows (their
* weapon-icon column), so both plots span exactly the same x-range.
*/
export const TIMELINE_PLOT_GUTTER_PX = 36;
/** The count a knockout wins at: the counter runs out and the team takes all of it. */
const FULL_COUNT = 100;

View File

@@ -23,12 +23,16 @@ import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs } from "~/components/match-page/MatchTabs";
import type { ObjectiveTimelineEvent } from "~/components/ObjectiveTimeline";
import type { PlayerStatusTimelineSample } from "~/components/PlayerStatusTimeline";
import { logger } from "~/utils/logger";
import type { SendouRouteHandle } from "~/utils/remix.server";
/** Counter reads of a made-up zones game, for previewing the timeline chart. */
const MOCK_OBJECTIVE_EVENTS = mockObjectiveEvents();
/** Icon-strip reads of the same made-up game, for previewing the status bands. */
const MOCK_PLAYER_STATUS_SAMPLES = mockPlayerStatusSamples();
type ActionVariant =
| "winner"
| "counterpick-stage"
@@ -699,6 +703,7 @@ export default function MatchPageTestRoute() {
},
scoreboard: {
objective: MOCK_OBJECTIVE_EVENTS,
playerStatus: MOCK_PLAYER_STATUS_SAMPLES,
scores: [100, 0],
alpha: [
{
@@ -912,3 +917,32 @@ function mockObjectiveEvents(): ObjectiveTimelineEvent[] {
return events;
}
/**
* Staggered respawn and special cycles per player over the same game as
* `mockObjectiveEvents`, sampled at the same cadence.
*/
function mockPlayerStatusSamples(): PlayerStatusTimelineSample[] {
const DURATION_SECONDS = 190;
const SAMPLE_EVERY_SECONDS = 2;
const samples: PlayerStatusTimelineSample[] = [];
for (
let t = SAMPLE_EVERY_SECONDS;
t <= DURATION_SECONDS;
t += SAMPLE_EVERY_SECONDS
) {
const flags = (kind: "dead" | "special", side: number) =>
[0, 1, 2, 3].map((slot) => {
const phase = t + slot * 17 + side * 31;
return kind === "dead" ? phase % 61 < 8 : phase % 47 < 12;
}) as [boolean, boolean, boolean, boolean];
samples.push({
t,
dead: [flags("dead", 0), flags("dead", 1)],
special: [flags("special", 0), flags("special", 1)],
});
}
return samples;
}

View File

@@ -3,8 +3,7 @@ import { Camera, Ellipsis, FileText, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { PlayerStatusTimeline } from "~/components/PlayerStatusTimeline";
import { GameTimeline } from "~/components/GameTimeline";
import {
listVideoInputs,
openVirtualCamera,
@@ -47,7 +46,7 @@ import { type FixtureData, saveFixture } from "./fixture-export";
import styles from "./LivePage.module.css";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
import { playerStatusTeams } from "./player-status-view";
import {
aggregateSendStatus,
matchContaining,
@@ -448,22 +447,11 @@ export function LivePage({
: undefined
}
>
{statusSamples.length > 0 ? (
<PlayerStatusTimeline
samples={statusSamples}
teams={playerStatusTeams(
built.match,
SCANNER_TEAM_LABELS,
)}
domain={objectiveDomain(objectiveEvents)}
/>
) : null}
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}
teamLabels={SCANNER_TEAM_LABELS}
/>
) : null}
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => (
<EventCard
key={e.id}

View File

@@ -21,8 +21,7 @@ import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { PlayerStatusTimeline } from "~/components/PlayerStatusTimeline";
import { GameTimeline } from "~/components/GameTimeline";
import { useSearchParam } from "~/modules/search-params/hooks";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
@@ -65,7 +64,7 @@ import type { FixtureData } from "./fixture-export";
import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
import { playerStatusTeams } from "./player-status-view";
import {
countIngestableMatches,
type SendouUser,
@@ -767,22 +766,11 @@ export function VodPage({
send?.state === "sent" && link ? { ...send, link } : send
}
>
{statusSamples.length > 0 ? (
<PlayerStatusTimeline
samples={statusSamples}
teams={playerStatusTeams(
built.match,
SCANNER_TEAM_LABELS,
)}
domain={objectiveDomain(objectiveEvents)}
/>
) : null}
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}
teamLabels={SCANNER_TEAM_LABELS}
/>
) : null}
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => {
const vodMatch = vodMatchByEvent.get(e);
return (

View File

@@ -17,11 +17,3 @@ export function playerStatusTeams(
),
})) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}
/** The objective chart's x-range, so both timelines share one axis. */
export function objectiveDomain(
events: readonly { t: number }[],
): [number, number] | undefined {
if (events.length === 0) return undefined;
return [events[0]!.t, events[events.length - 1]!.t];
}