From 7a8e17dda58e7ab52999d4b959221a900265fd87 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:54:43 -0400 Subject: [PATCH] fix(galaxy): expand timeline for readable history --- .../galaxy/tests/browser/replay-timeline.cjs | 92 ++++++++++++++----- .../galaxy/tests/test_replay_timeline.mjs | 12 +-- openpilot/starpilot/galaxy/web/css/galaxy.css | 9 +- prebuilt.json | 2 +- 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs b/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs index 165d88d34d..008779c3e7 100644 --- a/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs +++ b/openpilot/starpilot/galaxy/tests/browser/replay-timeline.cjs @@ -24,7 +24,9 @@ const assert = require('node:assert/strict') history.append({ schemaVersion: 1, ownerId, sessionId, rowOffset: 0, totalRows: 7, nextCursor: null, source: 'qlog', legend, rows: [[0, 20, 'chill'], [20, 40, 'aol'], [40, 70, 'experimental'], [55, 56, 'error'], [55.5, 55.5, 'bookmark'], [80, 120, 'chill'], [100, 100, 'bookmark']] }, status) - document.body.innerHTML = '
' + document.body.innerHTML = `
+
+
` window.replayControls = [] window.fixtureVM = createApp({ ...ReplayViewer, data: () => ({ ...ReplayViewer.data(), replay: status, replayTimeline: markRaw(history), replayVideoReady: true, @@ -38,22 +40,58 @@ const assert = require('node:assert/strict') }, beforeUnmount() {}, }, { clientId: 'c'.repeat(32), routeId: 'fixture', unauthorized() {} }).mount('#fixture') + window.loadLongReplay = () => { + const vm = window.fixtureVM, status = { ...vm.replay, end: 1968, position: 5, seekRanges: [[0, 1968]] } + const rows = Array.from({ length: 328 }, (_, i) => [i * 6, (i + 1) * 6, i % 3 ? 'chill' : 'aol']) + const history = new ReplayHistory(status.ownerId, status.sessionId) + for (let offset = 0; offset < rows.length; offset += 256) history.append({ schemaVersion: 1, ownerId: status.ownerId, + sessionId: status.sessionId, rowOffset: offset, totalRows: rows.length, source: 'qlog', legend, + rows: rows.slice(offset, offset + 256), nextCursor: offset + 256 < rows.length ? offset + 256 : null }, status) + vm.replay = status; vm.replayTimeline = markRaw(history) + } }) - for (const width of [1280, 390]) { - await page.setViewportSize({ width, height: 900 }) + const geometry = async () => page.locator('.gx-replay__media-controls').evaluate(media => { + const bounds = element => { + const { x, y, width, height } = element.getBoundingClientRect() + return { x, y, width, height } + } + const track = media.querySelector('.gx-replay__track'), canvas = track.querySelector('canvas'), style = getComputedStyle(media) + return { media: bounds(media), caption: bounds(media.querySelector('.gx-replay__timeline > span')), + track: bounds(track), canvas: bounds(canvas), input: bounds(track.querySelector('input')), + controls: bounds(media.querySelector('.gx-replay__controls')), pixels: canvas.width, + available: media.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), + overflow: document.documentElement.scrollWidth > innerWidth } + }) + const assertLayout = async width => { await page.waitForFunction(width => { - const canvas = document.querySelector('.gx-replay__track canvas') - return canvas?.height === (width <= 600 ? 44 : 54) + const canvas = document.querySelector('.gx-replay__rail canvas') + return canvas?.height === (width <= 600 ? 52 : 62) && Math.abs(canvas.width - canvas.getBoundingClientRect().width * 2) <= 1 }, width) - const geometry = await page.locator('.gx-replay__track').evaluate(track => { - const canvas = track.querySelector('canvas'), input = track.querySelector('input') - const bounds = element => ({ width: element.getBoundingClientRect().width, height: element.getBoundingClientRect().height }) - return { canvas: bounds(canvas), input: bounds(input), pixels: canvas.width, overflow: document.documentElement.scrollWidth > innerWidth } - }) - assert.equal(geometry.canvas.height, width <= 600 ? 22 : 27) - assert.ok(geometry.input.height >= 44, 'the rail keeps its generous seek hit area') - assert.ok(Math.abs(geometry.pixels - geometry.canvas.width * 2) <= 1) - assert.equal(geometry.overflow, false) + const g = await geometry() + assert.equal(g.canvas.height, width <= 600 ? 26 : 31) + assert.equal(g.input.height, 48) + assert.ok(Math.abs(g.track.width - g.available) <= 1, 'timeline uses the full control area') + assert.ok(Math.abs(g.canvas.width - (g.track.width - 16)) <= 1) + assert.ok(g.track.y >= g.caption.y + g.caption.height - 1, 'caption is above the track') + assert.ok(g.controls.y >= g.track.y + g.track.height - 1, 'buttons are below the track') + assert.ok(Math.abs(g.pixels - g.canvas.width * 2) <= 1) + assert.equal(g.overflow, false) + } + for (const [embedded, width] of [[false, 1280], [true, 1280], [true, 1000], [true, 390]]) { + await page.evaluate(embedded => { + const workspace = document.getElementById('workspace'), body = workspace.firstElementChild, viewer = body.firstElementChild + workspace.className = embedded ? 'gx-route-workspace' : '' + body.className = embedded ? 'gx-route-workspace__body' : '' + viewer.className = embedded ? 'gx-card gx-route-workspace__viewer' : '' + body.lastElementChild.hidden = !embedded + }, embedded) + await page.setViewportSize({ width, height: 900 }) + await assertLayout(width) + if (embedded && width === 1280) { + const viewer = await page.locator('.gx-route-workspace__viewer').boundingBox() + const files = await page.locator('.gx-route-workspace__files').boundingBox() + assert.ok(files.x >= viewer.x + viewer.width, 'desktop fixture includes the real files sidebar') + } const slider = page.getByRole('slider', { name: 'Replay route position' }) const box = await slider.boundingBox() const x = box.x + box.width * .5, y = box.y + box.height / 2 @@ -77,7 +115,7 @@ const assert = require('node:assert/strict') assert.ok(bounds.y + bounds.height < box.y - 20, 'preview stays above the touch target') assert.equal(await page.locator('.gx-replay__rail canvas').evaluate(canvas => canvas.toDataURL()), bitmap, 'scrubbing reuses the canvas') assert.equal(await page.evaluate(() => window.replayPaints), 0, 'scrubbing never repaints the history') - if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-scrub-${width}.png` }) + if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-scrub-${embedded ? 'workspace' : 'standalone'}-${width}.png` }) await page.mouse.up() assert.equal(await preview.count(), 0) assert.equal(await page.evaluate(() => window.replayControls.length), 1, 'release commits exactly once') @@ -117,16 +155,16 @@ const assert = require('node:assert/strict') assert.match(await slider.getAttribute('aria-valuetext'), /Experimental/) await page.locator('summary', { hasText: 'Timeline colors' }).click() assert.equal(await page.locator('.gx-replay__legend span').count(), 5) - if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-${width}.png` }) + if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-${embedded ? 'workspace' : 'standalone'}-${width}.png` }) await page.locator('summary', { hasText: 'Timeline colors' }).click() await page.getByRole('button', { name: 'Fullscreen video' }).click() - await page.waitForFunction(() => document.fullscreenElement && - document.querySelector('.gx-replay__track canvas').height === (innerWidth <= 600 ? 44 : 54)) + await page.waitForFunction(() => document.fullscreenElement) + await assertLayout(width) const fullscreenHeight = await page.locator('.gx-replay__track canvas').evaluate(canvas => ({ - actual: canvas.getBoundingClientRect().height, expected: innerWidth <= 600 ? 22 : 27, + actual: canvas.getBoundingClientRect().height, expected: innerWidth <= 600 ? 26 : 31, })) assert.equal(fullscreenHeight.actual, fullscreenHeight.expected, 'fullscreen preserves restrained rail sizing') - if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-fullscreen-${width}.png` }) + if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-web-fullscreen-${embedded ? 'workspace' : 'standalone'}-${width}.png` }) await page.evaluate(() => document.exitFullscreen()) } await page.setViewportSize({ width: 390, height: 900 }) @@ -142,7 +180,19 @@ const assert = require('node:assert/strict') assert.equal(await page.locator('.gx-replay__preview').count(), 0) assert.equal(await page.evaluate(() => window.replayControls.length), cancel ? 0 : 1, 'touch end commits once; cancellation never commits') } + await page.evaluate(() => window.loadLongReplay()) + for (const width of [1280, 390, 320]) { + await page.setViewportSize({ width, height: 900 }) + await assertLayout(width) + assert.match(await page.locator('.gx-replay__timeline').textContent(), /32:48/) + if (process.env.REPLAY_SCREENSHOT_DIR) await page.screenshot({ path: `${process.env.REPLAY_SCREENSHOT_DIR}/replay-long-route-${width}.png` }) + } + await page.evaluate(() => { + window.fixtureVM.replayTimeline = null + window.fixtureVM.replayHistoryNotice = 'History unavailable; the recorded route can still be replayed.' + }) + await assertLayout(320) assert.deepEqual(errors, []) await browser.close() - console.log('Replay timeline browser: sizing, cached drag preview, edges, touch, cancellation, authority changes, fullscreen and keyboard passed') + console.log('Replay timeline browser: embedded/standalone layout, dense long routes, cached dragging, touch, cancellation, fullscreen and keyboard passed') })().catch(error => { console.error(error); process.exit(1) }) diff --git a/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs b/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs index 042a070d0b..d804203066 100644 --- a/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs +++ b/openpilot/starpilot/galaxy/tests/test_replay_timeline.mjs @@ -29,25 +29,25 @@ assert.throws(() => { }, /History unavailable/, 'a late page cannot change the declared total') const painted = [] -let height = 27, tick = 5 +let height = 31, tick = 5 globalThis.getComputedStyle = () => ({getPropertyValue: key => key === '--replay-tick' ? String(tick) : '2'}) const canvas = {getBoundingClientRect: () => ({width: 100, height}), getContext: () => ({ setTransform() {}, set fillStyle(value) { this.color = value }, fillRect(x, y, w, h) { painted.push({x, y, w, h, color: this.color}) }, })} drawReplayTimeline(canvas, status, history, 2) assert.equal(canvas.width, 200) -assert.equal(canvas.height, 54) +assert.equal(canvas.height, 62) const red = painted.findIndex(row => row.color === 'rgb(13 20 30)') const mark = painted.findIndex(row => row.color === 'rgb(14 20 30)') assert.ok(red > painted.findIndex(row => row.color === 'rgb(12 20 30)'), 'red is painted after all mode blocks') assert.ok(mark > red && painted[mark].y < painted[red].y, 'bookmark occupies its own lane') assert.ok(painted[red].w >= .5, 'a brief error occupies at least one physical pixel at DPR2') assert.equal(painted[mark].w, 3, 'bookmark remains three CSS pixels wide at DPR2') -assert.equal(painted[red].h, 20) -height = 22; tick = 4 +assert.equal(painted[red].h, 24) +height = 26; tick = 4 drawReplayTimeline(canvas, status, history, 1) -assert.equal(canvas.height, 22) -assert.equal(painted.at(-2).h, 16) +assert.equal(canvas.height, 26) +assert.equal(painted.at(-2).h, 20) console.log('Replay timeline: atomic pages, validation, independent alerts, gaps, pixel precedence and compact sizing passed') // A late page must not restore history after ownership or offroad authority disappears. diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index e72adf5641..61f59ad7b9 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1318,9 +1318,8 @@ button.gx-chip:hover { .gx-route-workspace__job progress { flex: 1 1 8rem; max-width: 16rem; accent-color: var(--primary); } .gx-route-workspace .gx-replay { display: grid; gap: var(--sp-2); } .gx-route-workspace .gx-replay > .gx-notice, .gx-route-workspace .gx-replay > .gx-note, .gx-route-workspace .gx-replay > .gx-btn, .gx-route-workspace .gx-replay__gaps { margin-inline: var(--sp-3); } -.gx-route-workspace .gx-replay__media-controls { position: static; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: var(--sp-1) var(--sp-3) var(--sp-2); } -.gx-route-workspace .gx-replay__timeline { flex: 1 1 16rem; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--sp-2); } -.gx-route-workspace .gx-replay__controls { flex: 1 1 18rem; gap: var(--sp-1); } +.gx-route-workspace .gx-replay__media-controls { position: static; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); } +.gx-route-workspace .gx-replay__controls { gap: var(--sp-1); } .gx-route-workspace .gx-replay__controls .gx-btn { font-size: var(--fs-sm); } .gx-route-workspace .gx-replay__video { aspect-ratio: 2 / 1; max-height: 50dvh; object-fit: contain; } .gx-route-workspace .gx-replay__player:fullscreen .gx-replay__screen { flex: 1; min-height: 0; display: flex; align-items: center; } @@ -1743,7 +1742,7 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-replay__controls .gx-btn:focus-visible, .gx-replay__fullscreen:focus-visible { outline: 2px solid var(--replay-accent); outline-offset: 2px; } .gx-replay__fullscreen { position: absolute; top: 8px; right: 8px; width: var(--touch-target); background: rgb(8 8 10 / 82%); } .gx-replay__timeline { display: grid; gap: 0; font-size: clamp(12px, 1.3vw, 16px); } -.gx-replay__track { position: relative; display: block; height: var(--touch-target); --replay-thumb: 16px; --replay-rail: 20px; --replay-tick: 5px; --replay-gap: 2px; } +.gx-replay__track { position: relative; display: block; height: 48px; --replay-thumb: 16px; --replay-rail: 24px; --replay-tick: 5px; --replay-gap: 2px; } .gx-replay__rail { position: absolute; left: calc(var(--replay-thumb) / 2); right: calc(var(--replay-thumb) / 2); height: calc(var(--replay-rail) + var(--replay-tick) + var(--replay-gap)); top: 50%; transform: translateY(-50%); pointer-events: none; } .gx-replay__rail canvas { display: block; width: 100%; height: 100%; } .gx-replay__playhead { position: absolute; left: var(--replay-position); top: calc(var(--replay-tick) + var(--replay-gap) - 1px); width: 2px; height: calc(var(--replay-rail) + 2px); transform: translateX(-50%); background: #fff; } @@ -1765,7 +1764,7 @@ progress { max-width: 100%; accent-color: var(--primary); } .gx-replay__player:fullscreen { display: flex; flex-direction: column; justify-content: center; border-radius: 0; } .gx-replay__player:fullscreen .gx-replay__video { max-height: 100dvh; } @media (max-width: 600px) { - .gx-replay__track { --replay-rail: 16px; --replay-tick: 4px; } + .gx-replay__track { --replay-rail: 20px; --replay-tick: 4px; } .gx-replay__media-controls { position: static; padding-inline: 4px; } .gx-replay__controls { gap: 4px; } .gx-replay__player:fullscreen .gx-replay__video { min-height: 0; flex: 1; } diff --git a/prebuilt.json b/prebuilt.json index 4b13cd68b5..2afaf9ce8b 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "2a9adededf0b923acb27758cbd871bab480655d96dd4de529570442019412162", "schema_version": 1} +{"protected_inventory_sha256": "0461dbb098afa599b01e97b034e3abb4ce64f2cff182d133c7c29fddad5ec0dd", "schema_version": 1}