/** * Line chart of a game's objective-counter reads: one line per team * (remaining count over match time, so lines fall toward 0). Control is a * state rather than a count, so it gets its own lane in a gutter below the * zero gridline instead of sharing the count axis — a strip in the * controlling team's color, absent while neither team controls. The * zero gridline is drawn in the stronger border color to read as the * divider between the counts above and the lane below. Penalty is a * translucent band filled between score and score + penalty — its thickness * is the extra count the team must burn through before its score moves * again, so it grows when a penalty lands and shrinks as it counts down. * Control state and exact values stay in the shared hover tooltip. * * Series colors are the chart tokens from vars.css — the theme's text-tier * colors are too pastel to tell apart as marks; these are the same two hues * re-stepped per theme and validated for CVD separation and surface * contrast. */ import { Chart as ChartJS, Filler, Legend, LinearScale, LineElement, PointElement, Tooltip, } from "chart.js"; 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, TIMELINE_PLOT_GUTTER_PX, } from "./objective-timeline-utils"; ChartJS.register( LinearScale, PointElement, LineElement, Filler, Tooltip, Legend, ); /** count-axis units of gutter kept below zero for the control lane */ const CONTROL_LANE_DEPTH = 13; const CONTROL_LANE_Y = -6; const CONTROL_LANE_WIDTH = 6; const COUNT_TICK_STEP = 25; /** One objective-counter read, values in `[alpha, bravo]` order. */ export interface ObjectiveTimelineSample { /** seconds shown on the match timer at the read ("3:35" = 215); null = unreadable */ time: number | null; /** displayed count per team; null = unreadable */ score: [number | null, number | null]; /** penalty pill value per team; null = no pill (or unreadable) */ penalty: [number | null, number | null]; /** which team held the objective at the read */ control: [boolean, boolean]; } export interface ObjectiveTimelineEvent { /** whole seconds into the source (video, stream or game) the read was made at */ t: number; data: ObjectiveTimelineSample; } 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({ alpha: "--color-chart-alpha", bravo: "--color-chart-bravo", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", }); const sorted = events.toSorted((a, b) => a.t - b.t); if (sorted.length === 0) return null; const teamColors = [colors.alpha, colors.bravo]; const scoreDatasets = ([0, 1] as const).map((side) => ({ label: teamLabels[side], data: sorted.map((event) => ({ x: event.t, y: event.data.score[side], })), borderColor: teamColors[side], backgroundColor: teamColors[side], pointBackgroundColor: teamColors[side], pointBorderColor: teamColors[side], borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, hitRadius: 20, spanGaps: true, cubicInterpolationMode: "monotone" as const, })); // strip along the lane while the team is in control; the losing edge is // kept in the lane too so the strip extends exactly to where control ended const controlDatasets = ([0, 1] as const).map((side) => ({ label: `${teamLabels[side]} control`, data: sorted.map((event, i) => ({ x: event.t, y: event.data.control[side] || sorted[i - 1]?.data.control[side] ? CONTROL_LANE_Y : null, })), borderColor: teamColors[side], borderWidth: CONTROL_LANE_WIDTH, borderCapStyle: "round" as const, pointRadius: 0, pointHoverRadius: 0, hitRadius: 0, spanGaps: false, stepped: "after" as const, })); // band between score and score + penalty; its thickness is the penalty const penaltyDatasets = ([0, 1] as const).map((side) => { const penalties = smoothPenalties( sorted.map((event) => ({ t: event.t, penalty: event.data.penalty[side], })), ); let lastScore: number | null = null; return { label: `${teamLabels[side]} penalty`, data: sorted.map((event, i) => { lastScore = event.data.score[side] ?? lastScore; return { x: event.t, y: lastScore === null ? null : lastScore + (penalties[i] ?? 0), }; }), borderColor: `${teamColors[side]}8c`, backgroundColor: `${teamColors[side]}38`, borderWidth: 1, pointRadius: 0, pointHoverRadius: 0, cubicInterpolationMode: "monotone" as const, fill: { target: side }, // edge only where a penalty exists so zero-height bands stay invisible segment: { borderColor: (ctx: { p0DataIndex: number; p1DataIndex: number }) => (penalties[ctx.p0DataIndex] ?? 0) > 0 || (penalties[ctx.p1DataIndex] ?? 0) > 0 ? undefined : "transparent", }, }; }); const datasets = [...scoreDatasets, ...penaltyDatasets, ...controlDatasets]; return (