/** * Geometry for the rectification timeline: clock strings in, percentages out. * * Kept apart from the component so the scale is testable without a DOM. The * component renders what this returns and decides nothing itself. * * Two rules this module exists to hold: * * - The axis is the **current** search window (`candidate_range`), never the * opening one. The window widens (BUG-572: ±15 → ±30 → ±60 → ±120) and * narrows (a chosen block), so an axis pinned to the opening window would be * overrun by its own band. * - Candidate marks carry no confidence. BUG-560 is blocked: relative support * between candidate minutes is 7–9 out of 100 and calibration found no * minute-level discriminating power, so a graded mark would render a * difference the engine cannot support. The only encoding is in-range or * excluded. */ const MINUTES_PER_DAY = 1440; export type RectificationTimelineStage = "minute" | "block_scan"; export type TimelineMark = Readonly<{ /** Stable across re-renders so CSS transitions animate rather than restart. */ key: string; percent: number; /** Binary by design; see the module note on BUG-560. */ state: "in" | "out"; }>; export type TimelineTick = Readonly<{ key: string; percent: number; label: string; }>; export type RectificationTimelineView = Readonly<{ /** Inclusive clock labels for the axis ends, for assistive text. */ axisStartLabel: string; axisEndLabel: string; bandStartPercent: number; bandWidthPercent: number; marks: readonly TimelineMark[]; ticks: readonly TimelineTick[]; /** `05:07–05:09` */ rangeLabel: string; /** `3 分钟` */ widthLabel: string; }>; /** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */ export function parseClockMinutes(value: unknown): number | null { if (typeof value !== "string") return null; const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(value.trim()); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null; if (hours > 23 || minutes > 59) return null; return hours * 60 + minutes; } export function formatClockMinutes(value: number): string { const wrapped = ((Math.round(value) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY; const hours = Math.floor(wrapped / 60); const minutes = wrapped % 60; return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; } /** * Declared periods may cross midnight (`late_night` is 23:00–03:59), which * arrives as an end earlier than its start. Unrolling onto a second day keeps * the axis monotonic; every other calculation then works on plain numbers. */ export function unrollWindowEnd(start: number, end: number): number { return end >= start ? end : end + MINUTES_PER_DAY; } /** Place a clock reading on an axis that may run past midnight. */ export function alignToAxis(minute: number, axisStart: number, axisEnd: number): number { if (minute >= axisStart) return minute; const shifted = minute + MINUTES_PER_DAY; return shifted <= axisEnd ? shifted : minute; } /** Coarser ticks as the window grows; the labels stay on round clock values. */ export function timelineTickStepMinutes(span: number): number { if (span <= 40) return 5; if (span <= 90) return 10; if (span <= 180) return 20; if (span <= 400) return 60; if (span <= 900) return 120; return 240; } export function timelineDurationLabel(minutes: number): string { const total = Math.max(0, Math.round(minutes)); if (total < 60) return `${total} 分钟`; const hours = Math.floor(total / 60); const rest = total % 60; return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分`; } function clampPercent(value: number): number { if (!Number.isFinite(value)) return 0; if (value < 0) return 0; if (value > 100) return 100; return value; } /** Position on the axis as a percentage; callers clamp-safe by construction. */ export function timelinePercent(minute: number, axisStart: number, axisEnd: number): number { const span = axisEnd - axisStart; if (span <= 0) return 0; return clampPercent(((minute - axisStart) / span) * 100); } function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] { const span = axisEnd - axisStart; const step = timelineTickStepMinutes(span); const ticks: TimelineTick[] = []; const first = Math.ceil(axisStart / step) * step; for (let at = first; at <= axisEnd; at += step) { ticks.push({ key: `t${at}`, percent: timelinePercent(at, axisStart, axisEnd), label: formatClockMinutes(at), }); } return ticks; } export type TimelineInferenceMark = Readonly<{ time: string; eliminated: boolean; }>; export type RectificationTimelineInput = Readonly<{ /** `candidate_range`: the case's current search window, and the axis. */ searchWindow: readonly [string, string] | null; /** `credible_range`; absent during block scan, where the band is the window. */ credibleRange: readonly [string, string] | null; candidateTimes: readonly string[]; /** * Inference-layer minutes with elimination status. When present and non-empty, * these are the marks; `eliminated` is the only in/out judge. When absent, * engine `candidateTimes` are drawn solid. */ inferenceMarks?: readonly TimelineInferenceMark[]; stage: RectificationTimelineStage | null; }>; /** * Returns null when the bar has nothing truthful to draw — the caller renders * an equal-height skeleton rather than shrinking, because the bar sits outside * the scroll container and a height change would silently break stick-to-bottom. */ export function buildRectificationTimeline( input: RectificationTimelineInput, ): RectificationTimelineView | null { const windowStart = parseClockMinutes(input.searchWindow?.[0]); const windowEndRaw = parseClockMinutes(input.searchWindow?.[1]); if (windowStart === null || windowEndRaw === null) return null; const axisStart = windowStart; const axisEnd = unrollWindowEnd(windowStart, windowEndRaw); if (axisEnd <= axisStart) return null; // Block scan has no scored candidates yet, so the credible range is the whole // window. Saying "24 小时" there is accurate, not a placeholder. const bandStartRaw = parseClockMinutes(input.credibleRange?.[0]); const bandEndRaw = parseClockMinutes(input.credibleRange?.[1]); const hasBand = bandStartRaw !== null && bandEndRaw !== null; const bandStart = hasBand ? Math.max(axisStart, alignToAxis(bandStartRaw, axisStart, axisEnd)) : axisStart; const bandEnd = hasBand ? Math.min(axisEnd, Math.max(bandStart, alignToAxis(unrollWindowEnd(bandStartRaw, bandEndRaw), axisStart, axisEnd))) : axisEnd; const bandStartPercent = timelinePercent(bandStart, axisStart, axisEnd); const bandWidthPercent = clampPercent( timelinePercent(bandEnd, axisStart, axisEnd) - bandStartPercent, ); // Minute stage only: during block scan the marks would be blocks, and their // boundaries do not reach the client as structured data (see PROGRESS). const marks: TimelineMark[] = []; if (input.stage === "minute") { const sourced = input.inferenceMarks && input.inferenceMarks.length > 0 ? input.inferenceMarks.map((mark) => ({ time: mark.time, eliminated: mark.eliminated })) : input.candidateTimes.map((time) => ({ time, eliminated: false })); const seen = new Set(); for (const item of sourced) { const parsed = parseClockMinutes(item.time); if (parsed === null) continue; const at = alignToAxis(parsed, axisStart, axisEnd); if (at < axisStart || at > axisEnd) continue; if (seen.has(at)) continue; seen.add(at); marks.push({ key: `m${at}`, percent: timelinePercent(at, axisStart, axisEnd), // Status from the inference layer, never band position (BUG-603). state: item.eliminated ? "out" : "in", }); } marks.sort((left, right) => left.percent - right.percent); } return { axisStartLabel: formatClockMinutes(axisStart), axisEndLabel: formatClockMinutes(axisEnd), bandStartPercent, bandWidthPercent, marks, ticks: buildTicks(axisStart, axisEnd), rangeLabel: `${formatClockMinutes(bandStart)}–${formatClockMinutes(bandEnd)}`, widthLabel: timelineDurationLabel(bandEnd - bandStart + 1), }; }