diff --git a/app/components/GameTimeline.module.css b/app/components/GameTimeline.module.css new file mode 100644 index 000000000..30e626b44 --- /dev/null +++ b/app/components/GameTimeline.module.css @@ -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; +} diff --git a/app/components/GameTimeline.tsx b/app/components/GameTimeline.tsx new file mode 100644 index 000000000..152e11217 --- /dev/null +++ b/app/components/GameTimeline.tsx @@ -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(null); + const plotRef = useRef(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 ( +
{ + // touch fires a leave as the finger lifts; keep the readout up instead + if (event.pointerType !== "touch") setScrub(null); + }} + > + +
+ {scrub ? ( + + ) : null} +
+
+ ); +} + +/** 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 ? ( + + ) : null} + {objective.length > 0 ? ( + + ) : 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 ( + <> +
+
+
+ {formatElapsed(time)} + {objectiveNow?.clock != null + ? ` · ${t("common:objectiveTimeline.timeLeft", { + time: formatElapsed(objectiveNow.clock), + })}` + : null} +
+ {([0, 1] as const).map((side) => ( +
+
+ + + {teams[side].label} + + {objectiveNow ? ( + + {objectiveNow.scores[side] ?? "?"} + + ) : null} + {objectiveNow?.penalties[side] != null ? ( + + {t("common:objectiveTimeline.penalty", { + value: objectiveNow.penalties[side], + })} + + ) : null} + {objectiveNow?.control[side] ? ( + + {t("common:objectiveTimeline.inControl")} + + ) : null} +
+ {statusNow ? ( + <> + + + + ) : null} +
+ ))} +
+ + ); +} + +function StatusWeaponsRow({ + label, + kind, + slots, + weapons, +}: { + label: string; + kind: "dead" | "special"; + slots: number[]; + weapons: (MainWeaponId | null)[]; +}) { + if (slots.length === 0) return null; + + return ( +
+ + {label} + + + {slots.map((slot) => + weapons[slot] != null ? ( + + ) : ( + ? + ), + )} + +
+ ); +} + +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")], + }; +} diff --git a/app/components/ObjectiveTimeline.tsx b/app/components/ObjectiveTimeline.tsx index 587d75ddc..4081f5c1c 100644 --- a/app/components/ObjectiveTimeline.tsx +++ b/app/components/ObjectiveTimeline.tsx @@ -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) => { diff --git a/app/components/PlayerStatusTimeline.module.css b/app/components/PlayerStatusTimeline.module.css index 8b9c240ef..789a7ab95 100644 --- a/app/components/PlayerStatusTimeline.module.css +++ b/app/components/PlayerStatusTimeline.module.css @@ -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 { diff --git a/app/components/PlayerStatusTimeline.tsx b/app/components/PlayerStatusTimeline.tsx index 171bf1c81..2ac487bcf 100644 --- a/app/components/PlayerStatusTimeline.tsx +++ b/app/components/PlayerStatusTimeline.tsx @@ -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 ( -
+
@@ -80,7 +90,9 @@ export function PlayerStatusTimeline({
{teams[side].label}
{[0, 1, 2, 3].map((slot) => (
- +
+ +
{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; } diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index 591606fb1..d8e625a68 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -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({ {isExpanded ? (
- {scoreboard.playerStatus && scoreboard.playerStatus.length > 0 ? ( - player.weaponSplId), - }, - { - label: teams.bravo.name, - weapons: scoreboard.bravo.map((player) => player.weaponSplId), - }, - ]} - domain={objectiveEventsDomain(scoreboard.objective)} - /> - ) : null} - {scoreboard.objective && scoreboard.objective.length > 0 ? ( - - ) : null} + player.weaponSplId), + }, + { + label: teams.bravo.name, + weapons: scoreboard.bravo.map((player) => player.weaponSplId), + }, + ]} + />
event.t); - return [Math.min(...ts), Math.max(...ts)]; -} - function ScoreboardTable({ name, players, diff --git a/app/components/objective-timeline-utils.ts b/app/components/objective-timeline-utils.ts index 592cf754a..61c6eea23 100644 --- a/app/components/objective-timeline-utils.ts +++ b/app/components/objective-timeline-utils.ts @@ -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; diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index d4222351e..697aa7fd2 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -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; +} diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx index 3d6f3fc68..07d2c62f9 100644 --- a/app/features/scanner/components/LivePage.tsx +++ b/app/features/scanner/components/LivePage.tsx @@ -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 ? ( - - ) : null} - {objectiveEvents.length > 0 ? ( - - ) : null} + {cardEvents.map((e) => ( - {statusSamples.length > 0 ? ( - - ) : null} - {objectiveEvents.length > 0 ? ( - - ) : null} + {cardEvents.map((e) => { const vodMatch = vodMatchByEvent.get(e); return ( diff --git a/app/features/scanner/components/player-status-view.ts b/app/features/scanner/components/player-status-view.ts index 9f7aacf36..70ecfa390 100644 --- a/app/features/scanner/components/player-status-view.ts +++ b/app/features/scanner/components/player-status-view.ts @@ -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]; -}