From ec9963557a7373bd61f7ca641611080459691e26 Mon Sep 17 00:00:00 2001 From: Francisco Alejandro Garcia Cebada Date: Thu, 4 Jun 2026 01:48:30 -0700 Subject: [PATCH 1/2] fix: Add horizontal scrolling. --- .../webview-ui/src/monitor_panel/App.css | 35 +- .../webview-ui/src/monitor_panel/App.tsx | 4 - .../src/monitor_panel/components/Timeline.tsx | 319 +++++++++++------- 3 files changed, 234 insertions(+), 124 deletions(-) diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css index 91162a3..5982d78 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css @@ -133,9 +133,42 @@ overflow: auto; position: relative; background: var(--bg); + display: grid; + grid-template-columns: 180px max-content; + grid-template-rows: 32px max-content; +} + +.timeline-corner { + position: sticky; + top: 0; + left: 0; + z-index: 3; + grid-column: 1; + grid-row: 1; +} + +.timeline-header { + position: sticky; + top: 0; + z-index: 2; + grid-column: 2; + grid-row: 1; +} + +.timeline-labels { + position: sticky; + left: 0; + z-index: 2; + grid-column: 1; + grid-row: 2; +} + +.timeline-body { + grid-column: 2; + grid-row: 2; } -canvas#timeline { +.timeline-container canvas { display: block; } diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx index 12f2034..395e115 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx @@ -19,7 +19,6 @@ function App() { workers: [], }); const [timeScale, setTimeScale] = useState(20); - const [horizontalOffset, setHorizontalOffset] = useState(0); useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -47,7 +46,6 @@ function App() { const handleZoomReset = useCallback(() => { setTimeScale(20); - setHorizontalOffset(0); }, []); const session = snapshot.session; @@ -72,8 +70,6 @@ function App() { diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx index 332e3ce..efe3907 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx @@ -8,12 +8,12 @@ const ROW_HEIGHT = 28; const LABEL_WIDTH = 180; const HEADER_HEIGHT = 32; const PAD = 4; +const MIN_BODY_HEIGHT = 200 - HEADER_HEIGHT; +const RIGHT_PAD = 100; interface TimelineProps { session?: BuildSession; timeScale: number; - horizontalOffset: number; - onHorizontalOffsetChange: (offset: number) => void; onTimeScaleChange: (scale: number) => void; } @@ -131,22 +131,21 @@ function drawJob( job: BuildJob, startTime: number, rowY: number, - viewWidth: number, - timeScale: number, - horizontalOffset: number + contentWidth: number, + timeScale: number ) { const jobStart = (job.startTime - startTime) / 1000; const jobEnd = ((job.endTime || Date.now()) - startTime) / 1000; let jobDuration = jobEnd - jobStart; if (jobDuration < 0.1) jobDuration = 0.1; - const x = LABEL_WIDTH + jobStart * timeScale - horizontalOffset; + const x = jobStart * timeScale; const w = Math.max(jobDuration * timeScale, 2); const y = rowY + PAD; const h = ROW_HEIGHT - PAD * 2; // Clip to visible - if (x + w < LABEL_WIDTH || x > viewWidth) return; + if (x + w < 0 || x > contentWidth) return; const color = STATUS_COLORS[job.status] || '#9E9E9E'; ctx.fillStyle = color; @@ -169,69 +168,119 @@ function drawJob( } } +function setupCanvas( + canvas: HTMLCanvasElement, + width: number, + height: number, + dpr: number +): CanvasRenderingContext2D | undefined { + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + canvas.width = Math.max(1, Math.round(width * dpr)); + canvas.height = Math.max(1, Math.round(height * dpr)); + const ctx = canvas.getContext('2d'); + if (!ctx) return undefined; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return ctx; +} + export default function Timeline({ session, timeScale, - horizontalOffset, - onHorizontalOffsetChange, onTimeScaleChange, }: TimelineProps) { - const canvasRef = useRef(null); const containerRef = useRef(null); + const cornerCanvasRef = useRef(null); + const headerCanvasRef = useRef(null); + const labelsCanvasRef = useRef(null); + const bodyCanvasRef = useRef(null); + + // True while the viewport is scrolled to (or near) the right edge, so new + // events keep coming into view automatically during a live build. + const autoScrollRef = useRef(true); const draw = useCallback(() => { - const canvas = canvasRef.current; const container = containerRef.current; - if (!canvas || !container) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; + const corner = cornerCanvasRef.current; + const header = headerCanvasRef.current; + const labels = labelsCanvasRef.current; + const body = bodyCanvasRef.current; + if (!container || !corner || !header || !labels || !body) return; const dpr = window.devicePixelRatio || 1; const containerWidth = container.clientWidth; + const viewportContentWidth = Math.max(0, containerWidth - LABEL_WIDTH); const coreRows = session ? getCoreRows(session) : []; - const canvasHeight = Math.max(200, HEADER_HEIGHT + coreRows.length * ROW_HEIGHT + PAD * 2); - - canvas.style.width = containerWidth + 'px'; - canvas.style.height = canvasHeight + 'px'; - canvas.width = containerWidth * dpr; - canvas.height = canvasHeight * dpr; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const bodyHeight = Math.max(MIN_BODY_HEIGHT, coreRows.length * ROW_HEIGHT + PAD * 2); - // Background - ctx.fillStyle = '#1E1E1E'; - ctx.fillRect(0, 0, containerWidth, canvasHeight); + const startTime = session ? session.startTime : 0; + const maxTime = session ? getMaxTime(session) : 0; + let totalSeconds = session ? (maxTime - startTime) / 1000 : 1; + if (totalSeconds < 1) totalSeconds = 1; + const contentWidth = Math.max( + viewportContentWidth, + Math.ceil(totalSeconds * timeScale + RIGHT_PAD) + ); + + const cornerCtx = setupCanvas(corner, LABEL_WIDTH, HEADER_HEIGHT, dpr); + const headerCtx = setupCanvas(header, contentWidth, HEADER_HEIGHT, dpr); + const labelsCtx = setupCanvas(labels, LABEL_WIDTH, bodyHeight, dpr); + const bodyCtx = setupCanvas(body, contentWidth, bodyHeight, dpr); + if (!cornerCtx || !headerCtx || !labelsCtx || !bodyCtx) return; + + // ---- Corner (top-left, fixed) ---- + cornerCtx.fillStyle = '#2D2D30'; + cornerCtx.fillRect(0, 0, LABEL_WIDTH, HEADER_HEIGHT); + cornerCtx.strokeStyle = '#444'; + cornerCtx.lineWidth = 1; + cornerCtx.beginPath(); + cornerCtx.moveTo(LABEL_WIDTH - 0.5, 0); + cornerCtx.lineTo(LABEL_WIDTH - 0.5, HEADER_HEIGHT); + cornerCtx.moveTo(0, HEADER_HEIGHT - 0.5); + cornerCtx.lineTo(LABEL_WIDTH, HEADER_HEIGHT - 0.5); + cornerCtx.stroke(); + + // ---- Header (time axis, sticky top) ---- + headerCtx.fillStyle = '#2D2D30'; + headerCtx.fillRect(0, 0, contentWidth, HEADER_HEIGHT); + headerCtx.strokeStyle = '#444'; + headerCtx.lineWidth = 1; + headerCtx.beginPath(); + headerCtx.moveTo(0, HEADER_HEIGHT - 0.5); + headerCtx.lineTo(contentWidth, HEADER_HEIGHT - 0.5); + headerCtx.stroke(); + + // ---- Labels column (sticky left) ---- + labelsCtx.fillStyle = '#1E1E1E'; + labelsCtx.fillRect(0, 0, LABEL_WIDTH, bodyHeight); + + // ---- Body (scrolls both axes) ---- + bodyCtx.fillStyle = '#1E1E1E'; + bodyCtx.fillRect(0, 0, contentWidth, bodyHeight); if (!session || coreRows.length === 0) { - ctx.fillStyle = '#888'; - ctx.font = '16px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('Waiting for FASTBuild data...', containerWidth / 2, canvasHeight / 2); + bodyCtx.fillStyle = '#888'; + bodyCtx.font = '16px sans-serif'; + bodyCtx.textAlign = 'center'; + bodyCtx.textBaseline = 'middle'; + bodyCtx.fillText( + 'Waiting for FASTBuild data...', + Math.min(contentWidth, viewportContentWidth) / 2 + container.scrollLeft, + bodyHeight / 2 + ); + // Labels right border + labelsCtx.strokeStyle = '#444'; + labelsCtx.beginPath(); + labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0); + labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight); + labelsCtx.stroke(); return; } - const startTime = session.startTime; - const maxTime = getMaxTime(session); - let totalSeconds = (maxTime - startTime) / 1000; - if (totalSeconds < 1) totalSeconds = 1; - - // Auto-scroll - const totalWidth = LABEL_WIDTH + totalSeconds * timeScale + 100; - if (totalWidth > containerWidth) { - const newOffset = totalWidth - containerWidth + 100; - if (Math.abs(newOffset - horizontalOffset) > 1) { - onHorizontalOffsetChange(newOffset); - } - } - - // Time axis header - ctx.fillStyle = '#2D2D30'; - ctx.fillRect(0, 0, containerWidth, HEADER_HEIGHT); - - ctx.font = '10px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; + // Time-axis ticks + vertical grid lines + headerCtx.font = '10px sans-serif'; + headerCtx.textAlign = 'center'; + headerCtx.textBaseline = 'middle'; let tickInterval = 1; if (timeScale < 5) tickInterval = 30; @@ -240,77 +289,84 @@ export default function Timeline({ else if (timeScale < 40) tickInterval = 2; for (let t = 0; t <= totalSeconds; t += tickInterval) { - const x = LABEL_WIDTH + t * timeScale - horizontalOffset; - if (x < LABEL_WIDTH || x > containerWidth) continue; + const x = t * timeScale; + if (x > contentWidth) break; - ctx.strokeStyle = '#444'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, HEADER_HEIGHT - 8); - ctx.lineTo(x, HEADER_HEIGHT); - ctx.stroke(); + headerCtx.strokeStyle = '#444'; + headerCtx.lineWidth = 1; + headerCtx.beginPath(); + headerCtx.moveTo(x, HEADER_HEIGHT - 8); + headerCtx.lineTo(x, HEADER_HEIGHT); + headerCtx.stroke(); - ctx.fillStyle = '#999'; + headerCtx.fillStyle = '#999'; const label = t >= 3600 ? formatHMS(t) : formatMS(t); - ctx.fillText(label, x, HEADER_HEIGHT / 2); - - // Vertical grid line - ctx.strokeStyle = '#2A2A2A'; - ctx.beginPath(); - ctx.moveTo(x, HEADER_HEIGHT); - ctx.lineTo(x, canvasHeight); - ctx.stroke(); + headerCtx.fillText(label, x, HEADER_HEIGHT / 2); + + bodyCtx.strokeStyle = '#2A2A2A'; + bodyCtx.lineWidth = 1; + bodyCtx.beginPath(); + bodyCtx.moveTo(x, 0); + bodyCtx.lineTo(x, bodyHeight); + bodyCtx.stroke(); } // Core rows (one row per virtual CPU core per host) for (let i = 0; i < coreRows.length; i++) { const row = coreRows[i]; - const y = HEADER_HEIGHT + i * ROW_HEIGHT; + const y = i * ROW_HEIGHT; + const isAlt = i % 2 === 0; - // Alternate background - if (i % 2 === 0) { - ctx.fillStyle = '#252526'; - ctx.fillRect(0, y, containerWidth, ROW_HEIGHT); + // Body row background + separator + if (isAlt) { + bodyCtx.fillStyle = '#252526'; + bodyCtx.fillRect(0, y, contentWidth, ROW_HEIGHT); } + bodyCtx.strokeStyle = '#333'; + bodyCtx.lineWidth = 1; + bodyCtx.beginPath(); + bodyCtx.moveTo(0, y + ROW_HEIGHT - 0.5); + bodyCtx.lineTo(contentWidth, y + ROW_HEIGHT - 0.5); + bodyCtx.stroke(); - // Row separator - ctx.strokeStyle = '#333'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, y + ROW_HEIGHT); - ctx.lineTo(containerWidth, y + ROW_HEIGHT); - ctx.stroke(); - - // Draw jobs scheduled on this core for (const job of row.jobs) { - drawJob(ctx, job, startTime, y, containerWidth, timeScale, horizontalOffset); - } - - // Alternate background label - if (i % 2 === 0) { - ctx.fillStyle = '#252526'; - } else { - ctx.fillStyle = '#1E1E1E'; + drawJob(bodyCtx, job, startTime, y, contentWidth, timeScale); } - ctx.fillRect(0, y, LABEL_WIDTH, ROW_HEIGHT); - // Core label - ctx.fillStyle = '#CCC'; - ctx.font = '11px sans-serif'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; + // Labels row background + separator + text + labelsCtx.fillStyle = isAlt ? '#252526' : '#1E1E1E'; + labelsCtx.fillRect(0, y, LABEL_WIDTH, ROW_HEIGHT); + labelsCtx.strokeStyle = '#333'; + labelsCtx.lineWidth = 1; + labelsCtx.beginPath(); + labelsCtx.moveTo(0, y + ROW_HEIGHT - 0.5); + labelsCtx.lineTo(LABEL_WIDTH, y + ROW_HEIGHT - 0.5); + labelsCtx.stroke(); + + labelsCtx.fillStyle = '#CCC'; + labelsCtx.font = '11px sans-serif'; + labelsCtx.textAlign = 'left'; + labelsCtx.textBaseline = 'middle'; const label = `${row.hostName} (Core # ${row.coreIndex})`; - ctx.fillText(label, 4, y + ROW_HEIGHT / 2, LABEL_WIDTH - 8); + labelsCtx.fillText(label, 4, y + ROW_HEIGHT / 2, LABEL_WIDTH - 8); } - // Label column separator - ctx.strokeStyle = '#444'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(LABEL_WIDTH, 0); - ctx.lineTo(LABEL_WIDTH, canvasHeight); - ctx.stroke(); - }, [session, timeScale, horizontalOffset, onHorizontalOffsetChange]); + // Labels right border + labelsCtx.strokeStyle = '#444'; + labelsCtx.lineWidth = 1; + labelsCtx.beginPath(); + labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0); + labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight); + labelsCtx.stroke(); + + // Auto-scroll to the right edge while the user is parked at the end + if (autoScrollRef.current) { + const desiredScrollLeft = Math.max(0, contentWidth - viewportContentWidth); + if (Math.abs(container.scrollLeft - desiredScrollLeft) > 1) { + container.scrollLeft = desiredScrollLeft; + } + } + }, [session, timeScale]); useEffect(() => { draw(); @@ -319,13 +375,22 @@ export default function Timeline({ useEffect(() => { const container = containerRef.current; if (!container) return; - - // Resize handler const observer = new ResizeObserver(() => draw()); observer.observe(container); return () => observer.disconnect(); }, [draw]); + // Reset auto-scroll when a new build session starts. + useEffect(() => { + autoScrollRef.current = true; + }, [session?.processId, session?.startTime]); + + const handleScroll = useCallback((e: React.UIEvent) => { + const c = e.currentTarget; + const maxScroll = c.scrollWidth - c.clientWidth; + autoScrollRef.current = maxScroll <= 0 || c.scrollLeft >= maxScroll - 5; + }, []); + // Allow mouse wheel zoom on the timeline const handleWheel = useCallback( (e: React.WheelEvent) => { @@ -341,31 +406,31 @@ export default function Timeline({ [timeScale, onTimeScaleChange] ); - // Tooltip on canvas hover + // Tooltip on body-canvas hover. Coordinates are local to the body canvas; + // the labels column lives in a separate canvas and is handled independently. const handleMouseMove = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; + const canvas = bodyCanvasRef.current; if (!canvas || !session || !session.jobs.length) return; const rect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const mx = (e.clientX - rect.left) * dpr; - const my = (e.clientY - rect.top) * dpr; + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; const coreRows = getCoreRows(session); const startTime = session.startTime; let tooltipJob: BuildJob | undefined = undefined; for (let i = 0; i < coreRows.length; i++) { - const yTop = (HEADER_HEIGHT + i * ROW_HEIGHT + PAD) * dpr; - const yBot = (HEADER_HEIGHT + i * ROW_HEIGHT + ROW_HEIGHT - PAD) * dpr; + const yTop = i * ROW_HEIGHT + PAD; + const yBot = i * ROW_HEIGHT + ROW_HEIGHT - PAD; if (my < yTop || my > yBot) continue; for (const job of coreRows[i].jobs) { const jobStart = (job.startTime - startTime) / 1000; const jobEnd = ((job.endTime || Date.now()) - startTime) / 1000; - const x = (LABEL_WIDTH + jobStart * timeScale - horizontalOffset) * dpr; - const w = Math.max((jobEnd - jobStart) * timeScale * dpr, 2 * dpr); + const x = jobStart * timeScale; + const w = Math.max((jobEnd - jobStart) * timeScale, 2); if (mx >= x && mx <= x + w) { tooltipJob = job; break; @@ -382,12 +447,28 @@ export default function Timeline({ canvas.title = ''; } }, - [session, timeScale, horizontalOffset] + [session, timeScale] ); return ( -
- +
+
+ +
+
+ +
+
+ +
+
+ +
); } From 99bf6c4161e528a2eac56bb05df1004977352015 Mon Sep 17 00:00:00 2001 From: Francisco Alejandro Garcia Cebada Date: Thu, 4 Jun 2026 15:49:47 -0700 Subject: [PATCH 2/2] feat: Adding horizontal scrolling to timeline. --- .../webview-ui/src/monitor_panel/App.css | 14 ++ .../src/monitor_panel/components/Timeline.tsx | 133 +++++++++++++----- 2 files changed, 109 insertions(+), 38 deletions(-) diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css index 5982d78..612b72b 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css @@ -172,6 +172,20 @@ display: block; } +/* + * The header/body wrapper divs are sized (via inline style in Timeline.tsx) to + * the full content width so the browser exposes a horizontal scrollbar that + * spans the entire timeline. The canvases inside them are only as wide as the + * visible viewport and sticky-positioned at the labels column, so their + * backing-store dimensions never exceed the browser's maximum canvas size + * (~32767 px in Chromium) regardless of zoom level. + */ +.timeline-header canvas, +.timeline-body canvas { + position: sticky; + left: 180px; +} + /* Side panel */ .side-panel { width: 280px; diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx index efe3907..777f817 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx @@ -131,7 +131,8 @@ function drawJob( job: BuildJob, startTime: number, rowY: number, - contentWidth: number, + visibleStart: number, + visibleEnd: number, timeScale: number ) { const jobStart = (job.startTime - startTime) / 1000; @@ -144,8 +145,8 @@ function drawJob( const y = rowY + PAD; const h = ROW_HEIGHT - PAD * 2; - // Clip to visible - if (x + w < 0 || x > contentWidth) return; + // Clip to the visible viewport range (content coordinates) + if (x + w < visibleStart || x > visibleEnd) return; const color = STATUS_COLORS[job.status] || '#9E9E9E'; ctx.fillStyle = color; @@ -194,10 +195,19 @@ export default function Timeline({ const headerCanvasRef = useRef(null); const labelsCanvasRef = useRef(null); const bodyCanvasRef = useRef(null); + // Parent wrappers that provide the horizontal scroll extent. Their width is + // set to the full content width while the canvases inside them are only as + // wide as the visible viewport (sticky-positioned). + const headerWrapperRef = useRef(null); + const bodyWrapperRef = useRef(null); // True while the viewport is scrolled to (or near) the right edge, so new // events keep coming into view automatically during a live build. const autoScrollRef = useRef(true); + // Tracks the scrollLeft value the canvases were last drawn for, so we can + // skip redundant redraws when the scroll event was triggered by our own + // auto-scroll inside draw(). + const lastDrawnScrollLeftRef = useRef(null); const draw = useCallback(() => { const container = containerRef.current; @@ -205,11 +215,23 @@ export default function Timeline({ const header = headerCanvasRef.current; const labels = labelsCanvasRef.current; const body = bodyCanvasRef.current; - if (!container || !corner || !header || !labels || !body) return; + const headerWrapper = headerWrapperRef.current; + const bodyWrapper = bodyWrapperRef.current; + if ( + !container || + !corner || + !header || + !labels || + !body || + !headerWrapper || + !bodyWrapper + ) { + return; + } const dpr = window.devicePixelRatio || 1; const containerWidth = container.clientWidth; - const viewportContentWidth = Math.max(0, containerWidth - LABEL_WIDTH); + const viewportContentWidth = Math.max(1, containerWidth - LABEL_WIDTH); const coreRows = session ? getCoreRows(session) : []; const bodyHeight = Math.max(MIN_BODY_HEIGHT, coreRows.length * ROW_HEIGHT + PAD * 2); @@ -222,12 +244,38 @@ export default function Timeline({ Math.ceil(totalSeconds * timeScale + RIGHT_PAD) ); + // The header/body wrappers occupy the full content width so the browser + // exposes a horizontal scrollbar that spans the entire timeline. The + // canvases inside are sticky and only as wide as the visible viewport, + // which keeps their backing-store dimensions well below the browser's + // maximum canvas size (~32767 px in Chromium) regardless of zoom. + headerWrapper.style.width = contentWidth + 'px'; + bodyWrapper.style.width = contentWidth + 'px'; + + // Apply auto-scroll before reading scrollLeft so we draw the + // freshly-scrolled viewport in this same frame. + if (autoScrollRef.current) { + const desiredScrollLeft = Math.max(0, contentWidth - viewportContentWidth); + if (Math.abs(container.scrollLeft - desiredScrollLeft) > 1) { + container.scrollLeft = desiredScrollLeft; + } + } + const scrollLeft = container.scrollLeft; + const visibleStart = scrollLeft; + const visibleEnd = scrollLeft + viewportContentWidth; + lastDrawnScrollLeftRef.current = scrollLeft; + const cornerCtx = setupCanvas(corner, LABEL_WIDTH, HEADER_HEIGHT, dpr); - const headerCtx = setupCanvas(header, contentWidth, HEADER_HEIGHT, dpr); + const headerCtx = setupCanvas(header, viewportContentWidth, HEADER_HEIGHT, dpr); const labelsCtx = setupCanvas(labels, LABEL_WIDTH, bodyHeight, dpr); - const bodyCtx = setupCanvas(body, contentWidth, bodyHeight, dpr); + const bodyCtx = setupCanvas(body, viewportContentWidth, bodyHeight, dpr); if (!cornerCtx || !headerCtx || !labelsCtx || !bodyCtx) return; + // Draw using content coordinates by translating the visible viewport. + // Canvas (x = 0) corresponds to content (x = scrollLeft). + headerCtx.translate(-scrollLeft, 0); + bodyCtx.translate(-scrollLeft, 0); + // ---- Corner (top-left, fixed) ---- cornerCtx.fillStyle = '#2D2D30'; cornerCtx.fillRect(0, 0, LABEL_WIDTH, HEADER_HEIGHT); @@ -242,21 +290,21 @@ export default function Timeline({ // ---- Header (time axis, sticky top) ---- headerCtx.fillStyle = '#2D2D30'; - headerCtx.fillRect(0, 0, contentWidth, HEADER_HEIGHT); + headerCtx.fillRect(visibleStart, 0, viewportContentWidth, HEADER_HEIGHT); headerCtx.strokeStyle = '#444'; headerCtx.lineWidth = 1; headerCtx.beginPath(); - headerCtx.moveTo(0, HEADER_HEIGHT - 0.5); - headerCtx.lineTo(contentWidth, HEADER_HEIGHT - 0.5); + headerCtx.moveTo(visibleStart, HEADER_HEIGHT - 0.5); + headerCtx.lineTo(visibleEnd, HEADER_HEIGHT - 0.5); headerCtx.stroke(); // ---- Labels column (sticky left) ---- labelsCtx.fillStyle = '#1E1E1E'; labelsCtx.fillRect(0, 0, LABEL_WIDTH, bodyHeight); - // ---- Body (scrolls both axes) ---- + // ---- Body (visible viewport only; sticky left) ---- bodyCtx.fillStyle = '#1E1E1E'; - bodyCtx.fillRect(0, 0, contentWidth, bodyHeight); + bodyCtx.fillRect(visibleStart, 0, viewportContentWidth, bodyHeight); if (!session || coreRows.length === 0) { bodyCtx.fillStyle = '#888'; @@ -265,7 +313,7 @@ export default function Timeline({ bodyCtx.textBaseline = 'middle'; bodyCtx.fillText( 'Waiting for FASTBuild data...', - Math.min(contentWidth, viewportContentWidth) / 2 + container.scrollLeft, + visibleStart + viewportContentWidth / 2, bodyHeight / 2 ); // Labels right border @@ -277,7 +325,7 @@ export default function Timeline({ return; } - // Time-axis ticks + vertical grid lines + // Time-axis ticks + vertical grid lines (only within visible range) headerCtx.font = '10px sans-serif'; headerCtx.textAlign = 'center'; headerCtx.textBaseline = 'middle'; @@ -288,9 +336,13 @@ export default function Timeline({ else if (timeScale < 20) tickInterval = 5; else if (timeScale < 40) tickInterval = 2; - for (let t = 0; t <= totalSeconds; t += tickInterval) { + const firstVisibleTick = Math.max( + 0, + Math.floor(visibleStart / timeScale / tickInterval) * tickInterval + ); + for (let t = firstVisibleTick; t <= totalSeconds; t += tickInterval) { const x = t * timeScale; - if (x > contentWidth) break; + if (x > visibleEnd) break; headerCtx.strokeStyle = '#444'; headerCtx.lineWidth = 1; @@ -317,20 +369,20 @@ export default function Timeline({ const y = i * ROW_HEIGHT; const isAlt = i % 2 === 0; - // Body row background + separator + // Body row background + separator (only the visible slice) if (isAlt) { bodyCtx.fillStyle = '#252526'; - bodyCtx.fillRect(0, y, contentWidth, ROW_HEIGHT); + bodyCtx.fillRect(visibleStart, y, viewportContentWidth, ROW_HEIGHT); } bodyCtx.strokeStyle = '#333'; bodyCtx.lineWidth = 1; bodyCtx.beginPath(); - bodyCtx.moveTo(0, y + ROW_HEIGHT - 0.5); - bodyCtx.lineTo(contentWidth, y + ROW_HEIGHT - 0.5); + bodyCtx.moveTo(visibleStart, y + ROW_HEIGHT - 0.5); + bodyCtx.lineTo(visibleEnd, y + ROW_HEIGHT - 0.5); bodyCtx.stroke(); for (const job of row.jobs) { - drawJob(bodyCtx, job, startTime, y, contentWidth, timeScale); + drawJob(bodyCtx, job, startTime, y, visibleStart, visibleEnd, timeScale); } // Labels row background + separator + text @@ -358,14 +410,6 @@ export default function Timeline({ labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0); labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight); labelsCtx.stroke(); - - // Auto-scroll to the right edge while the user is parked at the end - if (autoScrollRef.current) { - const desiredScrollLeft = Math.max(0, contentWidth - viewportContentWidth); - if (Math.abs(container.scrollLeft - desiredScrollLeft) > 1) { - container.scrollLeft = desiredScrollLeft; - } - } }, [session, timeScale]); useEffect(() => { @@ -385,11 +429,19 @@ export default function Timeline({ autoScrollRef.current = true; }, [session?.processId, session?.startTime]); - const handleScroll = useCallback((e: React.UIEvent) => { - const c = e.currentTarget; - const maxScroll = c.scrollWidth - c.clientWidth; - autoScrollRef.current = maxScroll <= 0 || c.scrollLeft >= maxScroll - 5; - }, []); + const handleScroll = useCallback( + (e: React.UIEvent) => { + const c = e.currentTarget; + const maxScroll = c.scrollWidth - c.clientWidth; + autoScrollRef.current = maxScroll <= 0 || c.scrollLeft >= maxScroll - 15; + // Skip the redraw if this scroll event was triggered by our own + // auto-scroll inside draw() (lastDrawnScrollLeftRef matches). + if (lastDrawnScrollLeftRef.current !== c.scrollLeft) { + draw(); + } + }, + [draw] + ); // Allow mouse wheel zoom on the timeline const handleWheel = useCallback( @@ -411,10 +463,15 @@ export default function Timeline({ const handleMouseMove = useCallback( (e: React.MouseEvent) => { const canvas = bodyCanvasRef.current; - if (!canvas || !session || !session.jobs.length) return; + const container = containerRef.current; + if (!canvas || !container || !session || !session.jobs.length) return; const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; + // The body canvas is sticky and only viewport-wide, so its left edge + // is at LABEL_WIDTH from the container. Add scrollLeft to translate + // the local canvas x into content coordinates (the same space jobs + // are drawn in). + const mx = e.clientX - rect.left + container.scrollLeft; const my = e.clientY - rect.top; const coreRows = getCoreRows(session); const startTime = session.startTime; @@ -460,13 +517,13 @@ export default function Timeline({
-
+
-
+