Add timeline start/end markers, cursor hover, and live drag readout (ticket 031)

LogTimeline now always shows the first/last bucket's index and timestamp,
a hover readout that tracks the cursor with the bucket at/before it, and
a live-updating start/end readout while dragging a selection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 16:20:18 +01:00
co-authored by Claude Sonnet 5
parent b8a55b3caa
commit 1d1b0eee01
2 changed files with 51 additions and 43 deletions
+51 -3
View File
@@ -31,6 +31,10 @@ export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
return { startMs: now - preset.ms, endMs: now }
}
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleTimeString()
}
export function LogTimeline({
buckets,
range,
@@ -47,11 +51,14 @@ export function LogTimeline({
const containerRef = useRef<HTMLDivElement>(null)
const [dragStartX, setDragStartX] = useState<number | null>(null)
const [dragCurrentX, setDragCurrentX] = useState<number | null>(null)
const [hoverX, setHoverX] = useState<number | null>(null)
const maxCount = Math.max(1, ...buckets.map((b) => b.count))
const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0
const domainEnd = range?.endMs ?? Date.now()
const domainSpan = Math.max(1, domainEnd - domainStart)
const firstBucket = buckets[0] ?? null
const lastBucket = buckets[buckets.length - 1] ?? null
function xToMs(x: number) {
const rect = containerRef.current?.getBoundingClientRect()
@@ -60,12 +67,25 @@ export function LogTimeline({
return Math.round(domainStart + ratio * domainSpan)
}
function bucketAtOrBefore(ms: number): TimelineBucket | null {
let result: TimelineBucket | null = null
for (const b of buckets) {
if (b.start_ms <= ms) result = b
else break
}
return result ?? firstBucket
}
function handleMouseDown(e: React.MouseEvent) {
setDragStartX(e.clientX)
setDragCurrentX(e.clientX)
}
function handleMouseMove(e: React.MouseEvent) {
if (dragStartX !== null) setDragCurrentX(e.clientX)
if (dragStartX !== null) {
setDragCurrentX(e.clientX)
} else {
setHoverX(e.clientX)
}
}
function handleMouseUp() {
if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) {
@@ -84,6 +104,8 @@ export function LogTimeline({
width: Math.abs(dragCurrentX! - dragStartX!),
}
: null
const hoverBucket = !dragging && hoverX !== null ? bucketAtOrBefore(xToMs(hoverX)) : null
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0
return (
<div className="border-b border-border p-3">
@@ -128,6 +150,7 @@ export function LogTimeline({
onMouseLeave={() => {
setDragStartX(null)
setDragCurrentX(null)
setHoverX(null)
}}
>
<div className="flex h-full w-full items-end gap-px overflow-hidden px-1">
@@ -136,16 +159,41 @@ export function LogTimeline({
key={b.start_ms}
className="min-w-[2px] flex-1 rounded-t bg-accent/70"
style={{ height: `${(b.count / maxCount) * 100}%` }}
title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`}
/>
))}
</div>
{firstBucket && (
<div className="pointer-events-none absolute left-1 top-1 whitespace-nowrap text-[10px] text-text-subtle">
#0 · {formatTimestamp(firstBucket.start_ms)}
</div>
)}
{lastBucket && (
<div className="pointer-events-none absolute right-1 top-1 whitespace-nowrap text-[10px] text-text-subtle">
#{buckets.length - 1} · {formatTimestamp(lastBucket.start_ms)}
</div>
)}
{selRect && (
<div
className="pointer-events-none absolute top-0 h-full border border-accent bg-accent/20"
style={{ left: selRect.left - (containerRef.current?.getBoundingClientRect().left ?? 0), width: selRect.width }}
style={{ left: selRect.left - containerLeft, width: selRect.width }}
/>
)}
{dragging && (
<div
className="pointer-events-none absolute top-1 z-10 -translate-x-1/2 whitespace-nowrap rounded border border-accent bg-surface-2 px-1.5 py-0.5 text-[11px] text-text-secondary shadow"
style={{ left: (selRect?.left ?? 0) - containerLeft + (selRect?.width ?? 0) / 2 }}
>
{formatTimestamp(xToMs(dragStartX!))} → {formatTimestamp(xToMs(dragCurrentX!))}
</div>
)}
{!dragging && hoverBucket && hoverX !== null && (
<div
className="pointer-events-none absolute top-1 z-10 -translate-x-1/2 whitespace-nowrap rounded border border-border-soft bg-surface-2 px-1.5 py-0.5 text-[11px] text-text-muted shadow"
style={{ left: hoverX - containerLeft }}
>
{formatTimestamp(hoverBucket.start_ms)} — {hoverBucket.count}
</div>
)}
</div>
</div>
)