mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Timeline
This commit is contained in:
120
app/components/GameTimeline.module.css
Normal file
120
app/components/GameTimeline.module.css
Normal 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;
|
||||
}
|
||||
377
app/components/GameTimeline.tsx
Normal file
377
app/components/GameTimeline.tsx
Normal 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")],
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user