"use client"; import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, } from "react"; import styles from "./timeline.module.css"; export interface TimelineTranscript { role: "agent" | "user"; start_ms: number; end_ms: number; transcript: string; } export interface TimelineAnnotation { id: string; decision_ms: number; can_reply_now: "1" | "0" | ""; reason?: string; } export interface TimelineProps { durationMs: number; currentMs: number; decisionMs: number | null; transcripts: TimelineTranscript[]; annotations: TimelineAnnotation[]; selectedId: string | null; waveform: number[] | null; readOnly?: boolean; onSeek: (ms: number) => void; onDecisionChange: (ms: number) => void; onSelectAnnotation: (id: string) => void; } const ZOOM_LEVELS = [1, 2, 4, 8]; const TICK_STEPS = [100, 200, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000]; function finite(value: number, fallback = 0) { return Number.isFinite(value) ? value : fallback; } function timeLabel(ms: number, showMilliseconds = false) { const bounded = Math.max(0, Math.round(ms)); const minutes = Math.floor(bounded / 60_000); const seconds = Math.floor((bounded % 60_000) / 1_000); const fraction = String(bounded % 1_000).padStart(3, "0"); return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${showMilliseconds ? `.${fraction}` : ""}`; } function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: string) { return value === "1" ? "可以接话" : value === "0" ? "继续等待" : reason ? "待仲裁" : "待标注"; } export default function Timeline({ durationMs, currentMs, decisionMs, transcripts, annotations, selectedId, waveform, readOnly = false, onSeek, onDecisionChange, onSelectAnnotation, }: TimelineProps) { const helpId = useId(); const rangeId = useId(); const viewportRef = useRef(null); const canvasRef = useRef(null); const draggingRef = useRef<{ kind: "seek" | "decision"; pointerId: number } | null>(null); const zoomAnchorRef = useRef(null); const [viewportWidth, setViewportWidth] = useState(800); const [zoom, setZoom] = useState(1); const duration = Math.max( 1_000, finite(durationMs), ...transcripts.map((item) => finite(item.end_ms)), ...annotations.map((item) => finite(item.decision_ms)), finite(decisionMs ?? 0), ); const canvasWidth = Math.max(360, viewportWidth) * zoom; const clamp = (value: number) => Math.round(Math.max(0, Math.min(duration, finite(value)))); const current = clamp(currentMs); const decision = decisionMs === null ? null : clamp(decisionMs); const position = (ms: number) => (clamp(ms) / duration) * canvasWidth; const selectedAnnotation = annotations.find((item) => item.id === selectedId); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const updateSize = () => setViewportWidth(viewport.clientWidth); updateSize(); const observer = new ResizeObserver(updateSize); observer.observe(viewport); return () => observer.disconnect(); }, []); useEffect(() => { if (zoomAnchorRef.current === null || !viewportRef.current) return; viewportRef.current.scrollLeft = zoomAnchorRef.current * canvasWidth - viewportWidth / 2; zoomAnchorRef.current = null; }, [canvasWidth, viewportWidth]); const ticks = useMemo(() => { const desiredStep = (duration / canvasWidth) * 90; const step = TICK_STEPS.find((item) => item >= desiredStep) ?? Math.ceil(desiredStep / 600_000) * 600_000; const result = []; for (let time = 0; time <= duration; time += step) result.push({ time, label: timeLabel(time, step < 1_000) }); return result; }, [duration, canvasWidth]); const waveformPath = useMemo(() => { if (!waveform?.length) return ""; // Each visible bar is the maximum of a real sample bucket. No generated peaks. const barCount = Math.min(waveform.length, Math.ceil(canvasWidth / 3), 3_200); const bucketSize = waveform.length / barCount; let path = ""; for (let bar = 0; bar < barCount; bar++) { const start = Math.floor(bar * bucketSize); const end = Math.min(waveform.length, Math.ceil((bar + 1) * bucketSize)); let peak = 0; for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(waveform[index])))); const x = ((bar + 0.5) / barCount) * canvasWidth; const halfHeight = Math.max(0.75, peak * 28); path += `M${x.toFixed(2)},${(38 - halfHeight).toFixed(2)}v${(halfHeight * 2).toFixed(2)}`; } return path; }, [waveform, canvasWidth]); function changeZoom(nextZoom: number) { const viewport = viewportRef.current; if (viewport) zoomAnchorRef.current = (viewport.scrollLeft + viewport.clientWidth / 2) / canvasWidth; setZoom(nextZoom); } function locateDecision() { if (decision === null || !viewportRef.current) return; viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" }); } function pointerTime(event: PointerEvent) { const bounds = canvasRef.current?.getBoundingClientRect(); if (!bounds) return current; return clamp(((event.clientX - bounds.left) / bounds.width) * duration); } function beginDrag(event: PointerEvent, kind: "seek" | "decision") { if (event.button !== 0 || (kind === "decision" && (decision === null || readOnly))) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.focus({ preventScroll: true }); event.currentTarget.setPointerCapture(event.pointerId); draggingRef.current = { kind, pointerId: event.pointerId }; if (kind === "seek") onSeek(pointerTime(event)); } function moveDrag(event: PointerEvent) { const drag = draggingRef.current; if (!drag || drag.pointerId !== event.pointerId) return; event.stopPropagation(); const ms = pointerTime(event); if (drag.kind === "seek") onSeek(ms); else onDecisionChange(ms); // Keep the pointer near the visible edge usable while a zoomed track scrolls. const viewport = viewportRef.current; if (viewport) { const bounds = viewport.getBoundingClientRect(); if (event.clientX > bounds.right - 24) viewport.scrollLeft += 14; else if (event.clientX < bounds.left + 24) viewport.scrollLeft -= 14; } } function endDrag(event: PointerEvent) { if (draggingRef.current?.pointerId !== event.pointerId) return; draggingRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); } function keyboardMove(event: KeyboardEvent, kind: "seek" | "decision") { if (kind === "decision" && readOnly) return; const base = kind === "seek" ? current : decision; if (base === null) return; const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100; let next: number | null = null; if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment; if (["ArrowRight", "ArrowUp", "+", "="].includes(event.key)) next = base + increment; if (event.key === "Home") next = 0; if (event.key === "End") next = duration; if (next === null) return; event.preventDefault(); event.stopPropagation(); if (kind === "seek") onSeek(clamp(next)); else onDecisionChange(clamp(next)); } const trackStyle = { "--timeline-width": `${canvasWidth}px` } as CSSProperties; return (
对话时间轴 {timeLabel(duration)} 总时长
{zoom}×
beginDrag(event, "seek")} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onLostPointerCapture={() => { draggingRef.current = null; }} onKeyDown={(event) => keyboardMove(event, "seek")} >
{waveformPath ? ( ) : (
载入录音后显示波形
)}
{(["agent", "user"] as const).map((role) => (
{transcripts.filter((item) => item.role === role).map((item, index) => { const start = position(item.start_ms); const end = position(item.end_ms); return {item.transcript}; })}
))}
{annotations.filter((item) => item.id !== selectedId).map((item, index) => ( ))} {decision !== null && {selectedAnnotation ? annotationLabel(selectedAnnotation.can_reply_now, selectedAnnotation.reason) : "当前判断"} }
{decision !== null &&
}
播放 {timeLabel(current, true)}
{readOnly ? "点击定位播放 · 选择标记查看详情" : "点击定位播放 · 拖动橙色标记调整判断点"}
onDecisionChange(clamp(Number(event.currentTarget.value)))} />
); }