feat(rectification): show the credible range as a persistent timeline
The range is the only quantity that expresses convergence, and it existed only as a sentence in the transcript. The bar makes it permanent. It is the chat grid's first row, outside the scroll container, so scrollHeight is untouched and use-conversation-scroll-anchor needs no change. Its height is fixed because nothing observes it: a growing bar would alter clientHeight and let an anchored reader lose the tail. The axis is the current search window, not the opening one, since widening overruns the opening window. Marks are binary — in range or excluded — because relative support between candidate minutes carries no demonstrated minute-level discrimination (BUG-560, blocked). Band and marks move by transform alone; globals.css bans width transitions, and transform keeps the motion off the layout path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
This commit is contained in:
co-authored by
Claude Fable 5
parent
109b1d7f0c
commit
ce91a0d23c
@@ -2673,7 +2673,127 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
/* Timeline row, scroll row, composer row. The timeline is deliberately its
|
||||
own row rather than a sticky child of .conversation: outside the scroll
|
||||
container it leaves scrollHeight untouched, so the stick-to-bottom distance
|
||||
in use-conversation-scroll-anchor stays exact and its ResizeObserver (which
|
||||
watches only the container's children) never sees it. */
|
||||
grid-template-rows: var(--rectification-timeline-height) minmax(0, 1fr) auto;
|
||||
}
|
||||
/* Fixed height is correctness, not taste: the bar sits outside the scroll
|
||||
container, so a height change alters clientHeight with nothing observing it,
|
||||
and an anchored reader would have content slide out of view unrecovered.
|
||||
The pending state keeps the same box for the same reason. */
|
||||
.rectification-workspace__chat {
|
||||
--rectification-timeline-height: 64px;
|
||||
}
|
||||
.rectification-timeline {
|
||||
height: var(--rectification-timeline-height);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--color-canvas);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rectification-timeline__readout {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.rectification-timeline__range {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.rectification-timeline__width {
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
.rectification-timeline__axis {
|
||||
position: relative;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
}
|
||||
.rectification-timeline__rule {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 7px;
|
||||
height: 1px;
|
||||
background: var(--color-border-strong);
|
||||
}
|
||||
/* Positioned and resized by transform alone: transitioning left/width would run
|
||||
layout on every frame, and this stylesheet bans width transitions outright
|
||||
(the sidebar shell animates none of its geometry either). The band is full
|
||||
width and scaled down from its left edge, so one transform carries both the
|
||||
start and the span. No side borders — scaleX would distort them, and the
|
||||
fill edge already reads as the boundary. */
|
||||
.rectification-timeline__band {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-action-soft);
|
||||
transform-origin: left center;
|
||||
will-change: transform;
|
||||
transition: transform 420ms cubic-bezier(.32, .72, .24, 1);
|
||||
}
|
||||
.rectification-timeline__mark-at {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
transition: transform 420ms cubic-bezier(.32, .72, .24, 1);
|
||||
}
|
||||
/* Every mark is the same size. Relative support between candidate minutes is
|
||||
7–9 out of 100 with no demonstrated minute-level discrimination (BUG-560,
|
||||
blocked), so grading them by size or weight would show a difference the
|
||||
engine cannot back. In-range versus excluded is the only honest encoding. */
|
||||
.rectification-timeline__mark {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: -4.5px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-action);
|
||||
background: var(--color-action);
|
||||
transition: background 260ms ease, border-color 260ms ease, opacity 260ms ease;
|
||||
}
|
||||
.rectification-timeline__mark[data-state="out"] {
|
||||
background: var(--color-canvas);
|
||||
border-color: var(--color-border-strong);
|
||||
opacity: .55;
|
||||
}
|
||||
.rectification-timeline__tick {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 56px is the existing --rectification-jump-clearance (44px touch target plus
|
||||
space-3), reused rather than inventing a second constant. Past ~64px the bar
|
||||
costs a whole message bubble on a 667px viewport. */
|
||||
@media (max-width: 767px) {
|
||||
.rectification-workspace__chat {
|
||||
--rectification-timeline-height: 56px;
|
||||
}
|
||||
.rectification-timeline {
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
}
|
||||
.rectification-workspace__chat .conversation {
|
||||
--rectification-jump-clearance: calc(44px + var(--space-3));
|
||||
|
||||
@@ -60,8 +60,15 @@ import {
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionGapState,
|
||||
searchWindowFromSnapshot,
|
||||
caseStageFromSnapshot,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { RectificationTimeline } from "@/components/rectification-timeline";
|
||||
import {
|
||||
buildRectificationTimeline,
|
||||
type RectificationTimelineStage,
|
||||
} from "@/lib/rectification-timeline-scale";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
import {
|
||||
isPublicRectificationActivity,
|
||||
@@ -392,6 +399,8 @@ type CaseSnapshotState = Readonly<{
|
||||
savedTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
nextUserActionId: string | null;
|
||||
searchWindow: readonly [string, string] | null;
|
||||
stage: RectificationTimelineStage | null;
|
||||
}>;
|
||||
|
||||
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
|
||||
@@ -413,6 +422,8 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
|
||||
savedTime: confirmedTime ?? acceptedTime,
|
||||
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
|
||||
nextUserActionId: nextUserActionIdFromSnapshot(payload),
|
||||
searchWindow: searchWindowFromSnapshot(payload.case?.candidate_range),
|
||||
stage: caseStageFromSnapshot(payload.case?.stage),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,6 +464,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
|
||||
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
||||
const [searchWindow, setSearchWindow] = useState<readonly [string, string] | null>(() => caseSnapshotState(initialSnapshot)?.searchWindow ?? null);
|
||||
const [caseStage, setCaseStage] = useState<RectificationTimelineStage | null>(() => caseSnapshotState(initialSnapshot)?.stage ?? null);
|
||||
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
@@ -590,6 +603,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
candidate_range?: unknown;
|
||||
stage?: unknown;
|
||||
};
|
||||
} | null) => {
|
||||
if (!payload) return;
|
||||
@@ -604,6 +619,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const nextActionId = typeof payload.next_user_action?.id === "string"
|
||||
? payload.next_user_action.id.trim()
|
||||
: "";
|
||||
// Only overwrite when the payload actually carries them: a snapshot without
|
||||
// a window should leave the timeline showing the last known one rather than
|
||||
// dropping to the skeleton mid-session.
|
||||
const nextWindow = searchWindowFromSnapshot(payload.case?.candidate_range);
|
||||
if (nextWindow) setSearchWindow(nextWindow);
|
||||
const nextStage = caseStageFromSnapshot(payload.case?.stage);
|
||||
if (nextStage) setCaseStage(nextStage);
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||
@@ -1393,6 +1415,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
const canOfferCards = canShowRectificationSelectionCards(candidateResult);
|
||||
// Cheap arithmetic over at most a dozen candidate minutes; recomputed with
|
||||
// the render rather than memoised, matching the rest of this component.
|
||||
const timelineView = buildRectificationTimeline({
|
||||
searchWindow,
|
||||
credibleRange: candidateResult?.credibleRange ?? null,
|
||||
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
|
||||
stage: caseStage,
|
||||
});
|
||||
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
|
||||
const selectionCardMessageKey = persistedOfferKey
|
||||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||||
@@ -1520,6 +1550,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
>
|
||||
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
|
||||
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
|
||||
{/* Its own grid row, above and outside the scroll container, so
|
||||
scrollHeight is untouched and the scroll anchor needs no changes. */}
|
||||
<RectificationTimeline view={timelineView} />
|
||||
<section
|
||||
ref={conversation}
|
||||
className="conversation is-rectification"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The always-present range bar above the rectification conversation.
|
||||
*
|
||||
* Read-only by design: it answers "how wide is the range now", never "which
|
||||
* minute do I take". Adoption stays on the delivery card, so this component
|
||||
* takes no callbacks and renders no controls.
|
||||
*
|
||||
* It carries exactly two things — the axis and the range readout. Counts, a
|
||||
* legend, the stage name, the range-change sentence and an explanatory footnote
|
||||
* were all considered and cut: that sentence is already written into the
|
||||
* persisted assistant message (BUG-588 requires it there, so repeating it here
|
||||
* would put one sentence on screen twice), and the rest explain what a single
|
||||
* glance already shows.
|
||||
*
|
||||
* The bar lives outside the scroll container as its own grid row, so its height
|
||||
* must never change — see `.rectification-timeline` in globals.css. An unready
|
||||
* case renders the same-height skeleton rather than nothing.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import type { RectificationTimelineView } from "@/lib/rectification-timeline-scale";
|
||||
|
||||
export function RectificationTimeline({ view }: { view: RectificationTimelineView | null }) {
|
||||
if (!view) {
|
||||
return <div className="rectification-timeline" data-state="pending" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rectification-timeline" data-state="ready">
|
||||
<p className="rectification-timeline__readout">
|
||||
<span className="rectification-timeline__range">{view.rangeLabel}</span>
|
||||
<span className="rectification-timeline__width">{view.widthLabel}</span>
|
||||
</p>
|
||||
<div
|
||||
className="rectification-timeline__axis"
|
||||
role="img"
|
||||
aria-label={`出生时间搜索范围 ${view.axisStartLabel} 到 ${view.axisEndLabel},当前区间 ${view.rangeLabel},${view.widthLabel}`}
|
||||
>
|
||||
<span className="rectification-timeline__rule" />
|
||||
{/* Placed and resized with transform only. Animating left/width would
|
||||
run layout every frame, and globals.css bans transitions on width
|
||||
outright (the sidebar shell animates none of its geometry). */}
|
||||
<span
|
||||
className="rectification-timeline__band"
|
||||
style={{ transform: `translateX(${view.bandStartPercent}%) scaleX(${view.bandWidthPercent / 100})` }}
|
||||
/>
|
||||
{view.marks.map((mark) => (
|
||||
<span
|
||||
key={mark.key}
|
||||
className="rectification-timeline__mark-at"
|
||||
style={{ transform: `translateX(${mark.percent}%)` }}
|
||||
>
|
||||
<span className="rectification-timeline__mark" data-state={mark.state} />
|
||||
</span>
|
||||
))}
|
||||
{view.ticks.map((tick) => (
|
||||
<span
|
||||
key={tick.key}
|
||||
className="rectification-timeline__tick"
|
||||
style={{ left: `${tick.percent}%` }}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,10 +65,30 @@ export type RectificationCaseSnapshotPayload = Readonly<{
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
/**
|
||||
* Both are already on the wire from the case route; declaring them here
|
||||
* lets the timeline read the current search window and stage. No server
|
||||
* change was needed.
|
||||
*/
|
||||
candidate_range?: unknown;
|
||||
stage?: unknown;
|
||||
}>;
|
||||
turns?: unknown;
|
||||
}>;
|
||||
|
||||
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
|
||||
export function searchWindowFromSnapshot(value: unknown): readonly [string, string] | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as { start_time?: unknown; end_time?: unknown };
|
||||
const start = typeof row.start_time === "string" ? row.start_time.trim() : "";
|
||||
const end = typeof row.end_time === "string" ? row.end_time.trim() : "";
|
||||
return start && end ? [start, end] : null;
|
||||
}
|
||||
|
||||
export function caseStageFromSnapshot(value: unknown): "minute" | "block_scan" | null {
|
||||
return value === "minute" || value === "block_scan" ? value : null;
|
||||
}
|
||||
|
||||
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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;
|
||||
/** `2 分钟` */
|
||||
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 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[];
|
||||
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 seen = new Set<number>();
|
||||
for (const raw of input.candidateTimes) {
|
||||
const parsed = parseClockMinutes(raw);
|
||||
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),
|
||||
// Closed interval: a candidate sitting on a boundary is still in range.
|
||||
state: at >= bandStart && at <= bandEnd ? "in" : "out",
|
||||
});
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user