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:
co-authored by
Claude Sonnet 5
parent
b8a55b3caa
commit
1d1b0eee01
@@ -1,40 +0,0 @@
|
|||||||
# 031 — Timeline start/end markers, cursor hover, and live drag readout
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
`LogTimeline` (`webapp/src/components/LogTimeline.tsx`) shows a bar per
|
|
||||||
bucket with only a per-bar `title` tooltip on native hover, and a drag
|
|
||||||
selection that's silent until `handleMouseUp` fires `onSelectRange`. Add
|
|
||||||
always-visible start/end labels, a hover readout that tracks the cursor, and
|
|
||||||
a live-updating readout while dragging.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- **Start/end markers**: render the sequence index and timestamp of the
|
|
||||||
first and last visible bucket at the two ends of the timeline (using
|
|
||||||
`domainStart`/`domainEnd`, already computed at
|
|
||||||
`LogTimeline.tsx:52-53`), always visible, not hover-only.
|
|
||||||
- **Cursor hover readout**: on `onMouseMove` (already wired for drag at
|
|
||||||
`handleMouseMove`, `LogTimeline.tsx:65`), when not dragging, compute the
|
|
||||||
bucket at or immediately before the cursor via `xToMs` and show its
|
|
||||||
timestamp/count in a readout near the cursor — replacing reliance on the
|
|
||||||
native per-bar `title` attribute (`LogTimeline.tsx:135`).
|
|
||||||
- **Live drag readout**: while `dragging` is true (`LogTimeline.tsx:78`), show
|
|
||||||
the resolved start/end (`xToMs(dragStartX)`/`xToMs(dragCurrentX)`) updating
|
|
||||||
on every `handleMouseMove`, not just after `handleMouseUp` resolves the
|
|
||||||
final range — the drag rectangle already updates live (`selRect`,
|
|
||||||
`LogTimeline.tsx:79-84`); this adds the numeric readout next to it.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- The timeline always shows the sequence/timestamp of its first and last
|
|
||||||
bucket, without needing to hover.
|
|
||||||
- Moving the mouse over the timeline (not dragging) shows the timestamp/count
|
|
||||||
of the bucket under (or just before) the cursor, updating as the mouse
|
|
||||||
moves.
|
|
||||||
- While dragging, the displayed start/end timestamps update continuously as
|
|
||||||
the drag extends, matching the already-live selection rectangle.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
None — builds on the existing `LogTimeline` drag/hover plumbing.
|
|
||||||
@@ -31,6 +31,10 @@ export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
|
|||||||
return { startMs: now - preset.ms, endMs: now }
|
return { startMs: now - preset.ms, endMs: now }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
export function LogTimeline({
|
export function LogTimeline({
|
||||||
buckets,
|
buckets,
|
||||||
range,
|
range,
|
||||||
@@ -47,11 +51,14 @@ export function LogTimeline({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [dragStartX, setDragStartX] = useState<number | null>(null)
|
const [dragStartX, setDragStartX] = useState<number | null>(null)
|
||||||
const [dragCurrentX, setDragCurrentX] = 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 maxCount = Math.max(1, ...buckets.map((b) => b.count))
|
||||||
const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0
|
const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0
|
||||||
const domainEnd = range?.endMs ?? Date.now()
|
const domainEnd = range?.endMs ?? Date.now()
|
||||||
const domainSpan = Math.max(1, domainEnd - domainStart)
|
const domainSpan = Math.max(1, domainEnd - domainStart)
|
||||||
|
const firstBucket = buckets[0] ?? null
|
||||||
|
const lastBucket = buckets[buckets.length - 1] ?? null
|
||||||
|
|
||||||
function xToMs(x: number) {
|
function xToMs(x: number) {
|
||||||
const rect = containerRef.current?.getBoundingClientRect()
|
const rect = containerRef.current?.getBoundingClientRect()
|
||||||
@@ -60,12 +67,25 @@ export function LogTimeline({
|
|||||||
return Math.round(domainStart + ratio * domainSpan)
|
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) {
|
function handleMouseDown(e: React.MouseEvent) {
|
||||||
setDragStartX(e.clientX)
|
setDragStartX(e.clientX)
|
||||||
setDragCurrentX(e.clientX)
|
setDragCurrentX(e.clientX)
|
||||||
}
|
}
|
||||||
function handleMouseMove(e: React.MouseEvent) {
|
function handleMouseMove(e: React.MouseEvent) {
|
||||||
if (dragStartX !== null) setDragCurrentX(e.clientX)
|
if (dragStartX !== null) {
|
||||||
|
setDragCurrentX(e.clientX)
|
||||||
|
} else {
|
||||||
|
setHoverX(e.clientX)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function handleMouseUp() {
|
function handleMouseUp() {
|
||||||
if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) {
|
if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) {
|
||||||
@@ -84,6 +104,8 @@ export function LogTimeline({
|
|||||||
width: Math.abs(dragCurrentX! - dragStartX!),
|
width: Math.abs(dragCurrentX! - dragStartX!),
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
const hoverBucket = !dragging && hoverX !== null ? bucketAtOrBefore(xToMs(hoverX)) : null
|
||||||
|
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border p-3">
|
<div className="border-b border-border p-3">
|
||||||
@@ -128,6 +150,7 @@ export function LogTimeline({
|
|||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
setDragStartX(null)
|
setDragStartX(null)
|
||||||
setDragCurrentX(null)
|
setDragCurrentX(null)
|
||||||
|
setHoverX(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-end gap-px overflow-hidden px-1">
|
<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}
|
key={b.start_ms}
|
||||||
className="min-w-[2px] flex-1 rounded-t bg-accent/70"
|
className="min-w-[2px] flex-1 rounded-t bg-accent/70"
|
||||||
style={{ height: `${(b.count / maxCount) * 100}%` }}
|
style={{ height: `${(b.count / maxCount) * 100}%` }}
|
||||||
title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{selRect && (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute top-0 h-full border border-accent bg-accent/20"
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user