import type { EventResultGroup, EventResultRow } from '@/shared' import type { LiveLayout } from './fitFont' /** * 实时赛况页的纯逻辑:谁上屏、怎么分栏、栏内排哪些行、什么时候该发下一次请求。 * * 页面与测试共用这一份判定。全部是纯函数,不依赖 Vue;「当前是哪个项目」由服务端判定 * (GET /api/display/live,见 server/internal/httpapi/live_events.go),大屏只负责渲染。 */ /** 一屏最多并排几个项目;与服务端的 MaxLiveEvents 一致。 */ export const MAX_LIVE_COLUMNS = 2 /** * 一栏最多列几个名次。 * * 行高由 flex 均分、字号按行数缩,名次一多字就小到看不清,索性截断; * 实时赛况看的是领先的几行,完整名次该由分项成绩页承担。 */ export const MAX_LIVE_ROWS = 10 /** * 单项目一栏时,行数到几行就把名次拆成左右两块并排。 * * 阈值取 7 是因为它正好是「拆开之后字更大」的临界点:字号按 BOARD_AVAILABLE_PX 与高度比例 * 0.62 算(见 fitFont.ts),一张表铺满时 7 行只剩 49px(低于半屏版式的 56px 上限),而 6 行 * 还有 57px。7 行起拆成两块(每块 ≤5 行)换来的字号永远更大,再往下(8/9/10 行)差距越拉越大; * fitFont.test.ts 里有一条测试盯着这个临界点。 */ export const LIVE_SPLIT_MIN_ROWS = 7 /** * 定时兜底刷新间隔。 * * 成绩发布有 WS 快照推送,这条定时只负责「靠时间自己过期」的那部分——项目切换的依据是 * scheduled_at 落在 ±30 分钟窗口内、以及最近一次发布,快照不会为时间流逝推送。 * 30s 把切换延迟压在人的感知以内,同时每块屏只有 2 次/分钟的读请求; * 比 30 分钟窗口小两个数量级,再密只是白打服务端。 */ export const LIVE_REFRESH_INTERVAL_MS = 30_000 /** * 两次请求之间的最小间隔。 * * 三种触发(挂载 / WS 快照 / 定时)撞在一起时靠它合并:落在间隔内的触发只登记一次尾随刷新, * 既不会同一秒打两次,也不会漏掉这段时间里发布的新成绩。 */ export const LIVE_REFRESH_MIN_GAP_MS = 4_000 /** 一个「当前项目」的原始素材:小项信息 + 它已发布的全部组别。 */ export interface LiveEventSource { eventId: number eventName: string /** 已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。 */ groups: readonly EventResultGroup[] } export interface LiveColumn { eventId: number /** 小项名;拿不到名字时退回「项目 #id」,不留空白。 */ title: string /** 主栏那一组的组别名;没有成绩时为空串。 */ category: string /** 同一项目还有几组别没进这一栏(只取最近发布的一组当主栏)。 */ hiddenGroups: number /** 栏内的名次行:名次升序、无名次排最后、已截断到 MAX_LIVE_ROWS。 */ rows: EventResultRow[] } /** * 接口判定出的「当前项目」→ 屏幕上要画的栏。 * * 给了几个就画几栏(最多 MAX_LIVE_COLUMNS),顺序照服务端的来;没有项目时返回空数组, * 页面显示空态。项目还没发布成绩时仍然保留这一栏(rows 为空),由页面显示占位—— * 少一栏会让剩下的一栏铺满全屏,现场看不出另一个项目其实没数据。 */ export function pickLiveColumns(events: readonly LiveEventSource[]): LiveColumn[] { const columns: LiveColumn[] = [] for (const item of events) { if (!Number.isInteger(item.eventId) || item.eventId <= 0) continue if (columns.some((column) => column.eventId === item.eventId)) continue const group = pickLatestGroup(item.groups) columns.push({ eventId: item.eventId, title: item.eventName.trim() === '' ? `项目 #${item.eventId}` : item.eventName, category: group?.categoryName ?? '', hiddenGroups: item.groups.length > 1 ? item.groups.length - 1 : 0, rows: liveRows(group), }) if (columns.length >= MAX_LIVE_COLUMNS) break } return columns } /** * 接口还没成功过时的兜底素材:人工指定的 id + 快照 latestResults 里能拿到的组。 * * 只在老服务端(没有 /api/display/live)、断网首屏、示例数据这三种情况下走这条路; * 快照的 latestResults 窗口是**每组保底前 10 行、最多 8 组**(`realtime/snapshot.go` 的 * `latestResultsGroupLimit` / `latestResultsRowsPerGroup`),所以它只能算「有总比没有强」。 * 快照里没有该项目时保留这一栏(groups 为空),由页面显示占位而不是静默少一栏。 */ export function fallbackLiveSources( ids: readonly number[], available: readonly EventResultGroup[], eventNames: ReadonlyMap = new Map(), ): LiveEventSource[] { return ids.map((eventId) => ({ eventId, eventName: eventNames.get(eventId) ?? '', groups: available.filter((group) => group.eventId === eventId), })) } /** * 距离下一次可以发请求还要等多久(毫秒);0 表示现在就能发。 * * 纯算术,抽出来是为了让「合并去重」这条规则能被测试覆盖;lastAttemptAt 为 0(还没发过) * 时一定返回 0,页面挂载后立刻就能拿到结论。 */ export function liveRefreshDelay( lastAttemptAt: number, now: number, minGapMs: number = LIVE_REFRESH_MIN_GAP_MS, ): number { if (lastAttemptAt <= 0) return 0 return Math.max(0, minGapMs - (now - lastAttemptAt)) } /** * 单栏拆几块:只有「一个项目 + 行数够多」才拆,两个项目时本来就是一栏一块。 */ export function liveBoardLayout(columns: number, rowCount: number): LiveLayout { if (columns > 1) return 'duo' return rowCount >= LIVE_SPLIT_MIN_ROWS ? 'split' : 'single' } /** * 字号要按「一块里有几行」算,而不是整栏几行——拆两块的意义就在这里。 */ export function liveBlockRowCount(rowCount: number, layout: LiveLayout): number { return layout === 'split' ? Math.ceil(rowCount / 2) : rowCount } /** * 把一栏的名次切成左右两块:上半(名次靠前)在左,下半在右。 * * 前一半多一行(7 行 → 4 + 3),让更重要的名次留在左块;两块的行数最多差 1 行, * 行高才不会一块紧一块松。非 split 版式原样返回单块。 */ export function liveRowBlocks(rows: readonly T[], layout: LiveLayout): T[][] { if (layout !== 'split' || rows.length < LIVE_SPLIT_MIN_ROWS) return [rows as T[]] const half = Math.ceil(rows.length / 2) return [rows.slice(0, half), rows.slice(half)] } /** 同一小项分了多个组别时取最近发布的一组;发布时间缺失按最旧算。 */ function pickLatestGroup(groups: readonly EventResultGroup[]): EventResultGroup | null { let latest: EventResultGroup | null = null for (const group of groups) { if (latest === null || publishedAtMs(group) > publishedAtMs(latest)) latest = group } return latest } function publishedAtMs(group: EventResultGroup): number { if (group.publishedAt === null) return 0 const parsed = Date.parse(group.publishedAt) return Number.isFinite(parsed) ? parsed : 0 } /** * 栏内的名次行:按名次升序,无名次的排最后,同名次按录入顺序。 * * 服务端下发前已经排过一次,但本地缓存 / 示例数据不保证顺序,所以这里统一收口; * 顺带截断到 MAX_LIVE_ROWS。 */ export function liveRows(group: EventResultGroup | null): EventResultRow[] { if (group === null) return [] return [...group.rows] .sort((a, b) => { if (a.rank === null && b.rank === null) return a.entryId - b.entryId if (a.rank === null) return 1 if (b.rank === null) return -1 return a.rank - b.rank || a.entryId - b.entryId }) .slice(0, MAX_LIVE_ROWS) }