diff --git a/frontend/src/lib/components/EventTimeline.svelte b/frontend/src/lib/components/EventTimeline.svelte index 1137318..a4ad78f 100644 --- a/frontend/src/lib/components/EventTimeline.svelte +++ b/frontend/src/lib/components/EventTimeline.svelte @@ -103,6 +103,9 @@ let use24Hour = true; let initialized = false; let showFilterDropdown = false; + let followCurrentTime = false; + let previousFollowCurrentTime = false; + let isFollowUpdating = false; // tooltip state (reactive for svelte template) let tooltipVisible = false; @@ -266,6 +269,14 @@ if (currentTimeWindow) { currentTimeWindow.attr('x', xWindowStart).attr('width', windowWidth).attr('opacity', 0.5); } + + // if following current time, keep the view centered on now + // recenter on every tick to move in sync with now indicator + if (followCurrentTime && zoom && svg) { + isFollowUpdating = true; + centerOnTime(nowDate); + isFollowUpdating = false; + } } else { currentTimeIndicator.attr('opacity', 0); if (currentTimeWindow) { @@ -274,6 +285,24 @@ } } + function centerOnTime(date) { + if (!xScale || !zoom || !svg) return; + + const currentScale = currentTransform ? currentTransform.k : 1; + + const targetX = xScale(date); + const centerX = width / 2; + + // calculate the translation needed to center the target date + const translateX = centerX - targetX * currentScale; + + // create new transform + const newTransform = d3.zoomIdentity.translate(translateX, 0).scale(currentScale); + + // apply transform instantly + d3.select(svg).call(zoom.transform, newTransform); + } + function updateAxesImmediate(scale) { if (!scale?.domain) return; @@ -456,15 +485,24 @@ function handleZoom(event) { if (!xScale) return; + // skip all processing if this is a follow mode update + if (isFollowUpdating) { + currentTransform = event.transform; + pendingScale = currentTransform.rescaleX(xScale); + return; + } + currentTransform = event.transform; pendingScale = currentTransform.rescaleX(xScale); // schedule RAF update for circle positions scheduleZoomUpdate(); - // hide time indicator during interaction - if (currentTimeIndicator) currentTimeIndicator.attr('opacity', 0); - if (currentTimeWindow) currentTimeWindow.attr('opacity', 0); + // hide time indicator during interaction (only if user-initiated) + if (event.sourceEvent) { + if (currentTimeIndicator) currentTimeIndicator.attr('opacity', 0); + if (currentTimeWindow) currentTimeWindow.attr('opacity', 0); + } // debounce expensive operations (axis, virtualization) updateAxesDebounced(pendingScale); @@ -473,6 +511,13 @@ function handleZoomEnd() { if (!pendingScale) return; + // skip processing if this is a follow mode update to prevent infinite loop + if (isFollowUpdating) { + // but still update circle positions so events move with the timeline + updateCirclePositionsFast(pendingScale); + return; + } + // on zoom end, do full re-render with virtualization renderCircles(pendingScale); updateAxesImmediate(pendingScale); @@ -826,11 +871,13 @@ // reactive: initialize timeline $: if (container && svg && !initialized) { initializeTimeline(); + } + + // reactive: update interval for now indicator (same rate whether following or not) + $: if (initialized && xScale) { if (currentTimeIntervalId) clearInterval(currentTimeIntervalId); currentTimeIntervalId = setInterval(() => { - if (initialized && xScale) { - updateCurrentTimeIndicatorImmediate(); - } + updateCurrentTimeIndicatorImmediate(); }, CURRENT_TIME_UPDATE_MS); } @@ -845,6 +892,25 @@ updateAxesImmediate(scale); } + // reactive: follow mode toggled on + $: { + if ( + followCurrentTime !== previousFollowCurrentTime && + followCurrentTime && + initialized && + xScale && + zoom && + svg + ) { + previousFollowCurrentTime = followCurrentTime; + // when follow mode is enabled, immediately center on current time + const now = new Date(); + centerOnTime(now); + } else if (!followCurrentTime && previousFollowCurrentTime !== followCurrentTime) { + previousFollowCurrentTime = followCurrentTime; + } + } + // reactive: filter dropdown click outside $: if (showFilterDropdown) { window.addEventListener('click', handleClickOutside); @@ -1059,6 +1125,12 @@ {/if} +