223 lines
8.3 KiB
TypeScript
223 lines
8.3 KiB
TypeScript
/**
|
||
* 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<number>();
|
||
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),
|
||
};
|
||
}
|