diff --git a/schemas/visual-config.schema.json b/schemas/visual-config.schema.json index dfd3aab51..1fd98b5ee 100644 --- a/schemas/visual-config.schema.json +++ b/schemas/visual-config.schema.json @@ -226,6 +226,9 @@ "KEYLOG_GLOW_BLUR": { "type": "number" }, + "KEYLOG_INTERVAL_LENGTH": { + "type": "number" + }, "KEYLOG_MAXIMUM_FOR_NORMALIZATION": { "type": "number" }, diff --git a/src/frontend/overlay/src/components/molecules/Clock.tsx b/src/frontend/overlay/src/components/molecules/Clock.tsx index 2a4d8decb..2ce7d0328 100644 --- a/src/frontend/overlay/src/components/molecules/Clock.tsx +++ b/src/frontend/overlay/src/components/molecules/Clock.tsx @@ -21,6 +21,23 @@ const formatLongTime = (ms: number, showSeconds: boolean): string => { } }; +export const getStartTime = (contestInfo: ContestInfo): number | undefined => { + const contestType = contestInfo?.status?.type; + if (contestType === undefined) { + return undefined; + } + switch (contestType) { + case ContestStatus.Type.before: + return contestInfo.status.scheduledStartAtUnixMs; + case ContestStatus.Type.running: + return contestInfo.status.startedAtUnixMs; + case ContestStatus.Type.finalized: + case ContestStatus.Type.over: + return contestInfo.status.startedAtUnixMs; + } + return undefined; +}; + export const calculateContestTime = (contestInfo: ContestInfo): number => { if (contestInfo === undefined) { return undefined; diff --git a/src/frontend/overlay/src/components/organisms/holder/TimeLine.tsx b/src/frontend/overlay/src/components/organisms/holder/TimeLine.tsx index f0fa34ca4..519a005e2 100644 --- a/src/frontend/overlay/src/components/organisms/holder/TimeLine.tsx +++ b/src/frontend/overlay/src/components/organisms/holder/TimeLine.tsx @@ -3,8 +3,17 @@ import styled from "styled-components"; import { useAppSelector } from "@/redux/hooks"; import c from "@/config"; import { getIOIColor } from "@/utils/statusInfo"; -import { ContestInfo, TeamId, TeamInfo, TimeLineRunInfo } from "@shared/api"; -import { calculateContestTime } from "@/components/molecules/Clock"; +import { + ContestInfo, + ContestStatus, + TeamId, + TeamInfo, + TimeLineRunInfo, +} from "@shared/api"; +import { + calculateContestTime, + getStartTime, +} from "@/components/molecules/Clock"; import { isShouldUseDarkColor } from "@/utils/colors"; import { KeylogGraph } from "./KeylogGraph"; @@ -425,6 +434,34 @@ export function TimeLineBackground({ ); } +interface KeyboardEvent { + timestamp: string; // ISO8601 + keys: Record; +} + +interface KeyStats { + raw?: number; + bare?: number; + shift?: number; + ctrl?: number; + alt?: number; + meta?: number; + + "ctrl+shift"?: number; + "ctrl+alt"?: number; + "shift+alt"?: number; + "ctrl+meta"?: number; + "shift+meta"?: number; + "alt+meta"?: number; + + "ctrl+shift+alt"?: number; + "ctrl+shift+meta"?: number; + "ctrl+alt+meta"?: number; + "shift+alt+meta"?: number; + + "ctrl+shift+alt+meta"?: number; +} + export function TimeLine({ teamId, className = null, @@ -509,20 +546,60 @@ export function TimeLine({ }, [contestInfo, isPvp]); useEffect(() => { - if (!keylogUrl) return; + const startTime = getStartTime(contestInfo); + if (!keylogUrl || !startTime) return; - async function fetchKeylogData() { + // TODO: Move all this code to KeylogGraph + async function fetchNDJSON(): Promise { try { const response = await fetch(keylogUrl); - const data: number[] = await response.json(); - setKeylog(data); - } catch (error) { - console.error("Error fetching keylog data:", error); + if (!response.ok) throw new Error("Failed to fetch keylog"); + + const text = await response.text(); + return text + .trim() + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as KeyboardEvent); + } catch (e) { + console.error(e); + return []; } } + async function fetchKeylogData() { + const events = await fetchNDJSON(); + if (events.length === 0) return; + + const contestStart = new Date(startTime!).getTime(); + const AGGREGATION_MS = c.KEYLOG_INTERVAL_LENGTH; + + const totalIntervals = Math.ceil( + contestInfo!.contestLengthMs / c.KEYLOG_INTERVAL_LENGTH, + ); + const interval_minutes = c.KEYLOG_INTERVAL_LENGTH / 60000; + const pressesPerMinuteFactor = 1 / interval_minutes; + const newKeylog = new Array(totalIntervals).fill(0); + + events.forEach((event) => { + const eventTime = new Date(event.timestamp).getTime(); + + if (eventTime < contestStart) return; + + const timeDiff = eventTime - contestStart; + const index = Math.floor(timeDiff / AGGREGATION_MS); + + if (index >= 0 && index < totalIntervals) { + const pressesCount = Object.values(event.keys).reduce((sum, k) => sum + (k.bare ?? 0) + (k.shift ?? 0), 0); + newKeylog[index] += pressesCount; + } + }); + + setKeylog(newKeylog.map(v => v * pressesPerMinuteFactor)); + } + fetchKeylogData(); - }, [keylogUrl]); + }, [keylogUrl, contestInfo]); if (!contestInfo) return null; diff --git a/src/frontend/overlay/src/config.interface.ts b/src/frontend/overlay/src/config.interface.ts index 40aadf0d5..78d74a4d9 100644 --- a/src/frontend/overlay/src/config.interface.ts +++ b/src/frontend/overlay/src/config.interface.ts @@ -289,6 +289,7 @@ export interface OverlayConfig { // Keylog KEYLOG_MAXIMUM_FOR_NORMALIZATION: number; // max value for normalization + KEYLOG_INTERVAL_LENGTH: number; // ms KEYLOG_TOP_PADDING: number; // px KEYLOG_BOTTOM_PADDING: number; // px KEYLOG_Z_INDEX: number; diff --git a/src/frontend/overlay/src/config.ts b/src/frontend/overlay/src/config.ts index 0487b72c9..3da56ace4 100644 --- a/src/frontend/overlay/src/config.ts +++ b/src/frontend/overlay/src/config.ts @@ -355,6 +355,7 @@ function getDefaultConfig(): EvaluatableTo { TIMELINE_TIME_BORDER_WIDTH: 2, KEYLOG_MAXIMUM_FOR_NORMALIZATION: 500, // max value for normalization + KEYLOG_INTERVAL_LENGTH: 1000 * 60, // ms KEYLOG_TOP_PADDING: 6, // px KEYLOG_BOTTOM_PADDING: 2, // px KEYLOG_Z_INDEX: 0,