sendou.ink/app/components/ObjectiveTimeline.tsx
2026-08-07 20:47:19 +03:00

285 lines
8.5 KiB
TypeScript

/**
* 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 { smoothPenalties } 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,
}: {
events: readonly ObjectiveTimelineEvent[];
teamLabels: readonly [string, string];
}) {
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 (
<div className={styles.container}>
<Line
data={{ datasets }}
options={{
animation: false,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
scales: {
x: {
type: "linear",
min: sorted[0]!.t,
max: sorted[sorted.length - 1]!.t,
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: {
color: colors.text,
maxRotation: 0,
maxTicksLimit: 8,
callback: (value) => formatElapsed(Number(value)),
},
},
y: {
min: -CONTROL_LANE_DEPTH,
suggestedMax: 100,
bounds: "data",
grid: {
color: (ctx) => gridColor(ctx.tick?.value ?? 0, colors),
tickColor: (ctx) => gridColor(ctx.tick?.value ?? 0, colors),
},
border: { color: colors.borderHigh },
afterBuildTicks: (axis) => {
axis.ticks = countAxisTicks(axis.max);
},
ticks: { color: colors.text, autoSkip: false },
},
},
plugins: {
legend: {
labels: {
color: colors.text,
boxWidth: 10,
boxHeight: 10,
filter: (item) => (item.datasetIndex ?? 0) < 2,
},
},
tooltip: {
filter: (item) => item.datasetIndex < 2,
callbacks: {
title: (items) => {
if (!items[0]) return "";
const clock = sorted[items[0].dataIndex]?.data.time;
const elapsed = formatElapsed(items[0].parsed.x ?? 0);
return clock != null
? `${elapsed} · ${t("common:objectiveTimeline.timeLeft", {
time: formatClock(clock),
})}`
: elapsed;
},
label: (item) => {
const event = sorted[item.dataIndex];
if (!event) return "";
const side = item.datasetIndex as 0 | 1;
const { score, penalty, control } = event.data;
return [
`${teamLabels[side]}: ${score[side] ?? "?"}`,
penalty[side] !== null
? t("common:objectiveTimeline.penalty", {
value: penalty[side],
})
: null,
control[side]
? t("common:objectiveTimeline.inControl")
: null,
]
.filter(Boolean)
.join(" · ");
},
},
},
},
}}
/>
</div>
);
}
/**
* One tick every 25 up to the top of the data and none below zero, so the
* control gutter stays free of axis furniture.
*/
function countAxisTicks(max: number) {
const ticks = [];
for (let value = 0; value <= max; value += COUNT_TICK_STEP) {
ticks.push({ value });
}
return ticks;
}
/** zero divides counts from the lane, so it is drawn stronger; the gutter has no grid */
function gridColor(
value: number,
colors: { border: string; borderHigh: string },
) {
if (value < 0) return "transparent";
return value === 0 ? colors.borderHigh : colors.border;
}
/** Position on the x-axis: m:ss, growing an hours part only when needed. */
function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
: `${minutes}:${rest}`;
}
/** the match timer's M:SS (215 → "3:35") */
function formatClock(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return `${minutes}:${rest}`;
}