From 6c9e88ccd6f3ac8e96a1937687bbca49ac5fa7d3 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:52:25 +0100 Subject: [PATCH 1/3] fix(log-viewer): keep timeline text crisp when display scaling changes The flame chart, governor strip and minimap read devicePixelRatio once at load, so their text stayed at the old ratio after a scaling change or a move to a monitor with a different scale factor, until the log was reopened. Read the ratio on every resize and add it to the skip guard, so a resize that changes only the resolution still applies. Watch for ratio changes, which resize nothing and so never reach the resize observer, and re-measure there rather than replaying a stale size. Key the minimap's cached static texture on resolution too, or the skyline, markers and axis keep the old one. --- CHANGELOG.md | 1 + .../features/timeline/optimised/FlameChart.ts | 12 +- .../__tests__/FlameChartResize.test.ts | 19 ++- .../__tests__/MinimapStaticTexture.test.ts | 96 ++++++++++++ .../interaction/TimelineResizeHandler.ts | 30 ++++ .../__tests__/TimelineResizeHandler.test.ts | 137 ++++++++++++++++++ .../metric-strip/MetricStripOrchestrator.ts | 5 +- .../optimised/minimap/MinimapRenderer.ts | 7 +- .../orchestrators/MinimapOrchestrator.ts | 5 +- 9 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccabe0c20..ee8ee8f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 📊 **Governor limits strip**: where the log recorded nothing, the strip drew its last reading across the gap as though it had been measured; the gap is now blank, and the tooltip names the reason and range. ([#828]) - 🗄️ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element are now counted. Needs `WORKFLOW` at `FINER` or above. ([#871]). - ⚡ **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge. +- 🪟 **Timeline sharpness**: the Flame Chart, governor strip and minimap blurred after a display-scaling change or a move to a monitor with a different scale factor, until the log was reopened. - 🖱️ **Governor limits strip**: reading across the 15px collapsed strip lost the tooltip on the smallest wobble; the hover now holds until the pointer is clear of it. - 🐛 **Go to Code** matches methods with namespace or `System` qualified parameter types. ([#834]) - 🎨 **Theme switch**: Timeline and view colours update straight away instead of needing the log reopened. diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index ce4e8a9f6..6857465fe 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -814,6 +814,9 @@ export class FlameChart { return false; } + // Read once, so the three renderers below cannot land on different ratios. + const resolution = window.devicePixelRatio || 1; + const oldState = this.viewport.getState(); const oldWidth = oldState.displayWidth; @@ -855,7 +858,8 @@ export class FlameChart { newWidth === oldWidth && mainTimelineHeight === oldState.displayHeight && minimapHeight === this.appliedMinimapHeight && - totalOverheadHeight === this.appliedOverheadHeight + totalOverheadHeight === this.appliedOverheadHeight && + resolution === this.app.renderer.resolution ) { return false; } @@ -872,12 +876,12 @@ export class FlameChart { // Resize minimap orchestrator if (this.minimapOrchestrator) { - this.minimapOrchestrator.resize(newWidth, newHeight); + this.minimapOrchestrator.resize(newWidth, newHeight, resolution); } // Resize metric strip orchestrator if (this.metricStripOrchestrator) { - this.metricStripOrchestrator.resize(newWidth); + this.metricStripOrchestrator.resize(newWidth, resolution); } // Update orchestrators with new offset @@ -885,7 +889,7 @@ export class FlameChart { this.searchOrchestrator?.setMainTimelineYOffset(this.mainTimelineYOffset); // Resize main timeline app - this.app.renderer.resize(newWidth, mainTimelineHeight); + this.app.renderer.resize(newWidth, mainTimelineHeight, resolution); const newZoom = newWidth / visibleTimeRange; const newOffsetX = visibleTimeStart * newZoom; diff --git a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts index c0e77fc96..8fbe2f536 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts @@ -27,7 +27,7 @@ function stubbedChart(displayHeight = 300): { const internals = chart as unknown as Record; internals['app'] = { - renderer: { resize: rendererResize }, + renderer: { resize: rendererResize, resolution: 1 }, screen: { height: 300 }, render: appRender, }; @@ -70,8 +70,14 @@ function stubbedChart(displayHeight = 300): { } describe('FlameChart.resize', () => { + const realDevicePixelRatio = window.devicePixelRatio; + afterEach(() => { jest.restoreAllMocks(); + Object.defineProperty(window, 'devicePixelRatio', { + value: realDevicePixelRatio, + configurable: true, + }); }); it('paints before it returns, so the cleared canvas is never composited', () => { @@ -142,6 +148,17 @@ describe('FlameChart.resize', () => { expect(internals['mainTimelineYOffset']).toBe(83); }); + it('draws when only the device pixel ratio moved', () => { + const { chart, rendererResize, appRender } = stubbedChart(); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + Object.defineProperty(window, 'devicePixelRatio', { value: 2, configurable: true }); + + // The geometry the skip case above rejects, so only the ratio is left to act on. + expect(chart.resize(400, 364)).toBe(true); + expect(rendererResize).toHaveBeenCalledWith(400, 300, 2); + expect(appRender).toHaveBeenCalled(); + }); + // The metric strip resizes its own canvas before asking the host to relayout, so a resize // that cannot run has to say so — otherwise nothing draws the strip it just blanked. it('reports whether it applied, so a caller can draw instead', () => { diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts new file mode 100644 index 000000000..b9d6ba25d --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts @@ -0,0 +1,96 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * The skyline, markers and axis are cached into one RenderTexture. A ratio change leaves the + * minimap's box alone, so only the texture's own resolution says the cache is stale. + */ + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as PIXI from 'pixi.js'; +import { MinimapRenderer } from '../minimap/MinimapRenderer.js'; + +/** The collaborators the static path touches, and nothing else. */ +function stubbedRenderer(): { + renderStatic: () => void; + setResolution: (value: number) => void; +} { + const minimap = Object.create(MinimapRenderer.prototype) as MinimapRenderer; + const internals = minimap as unknown as Record; + + internals['backgroundGraphics'] = { clear: jest.fn() }; + internals['markerGraphics'] = { clear: jest.fn() }; + internals['axisRenderer'] = { render: jest.fn() }; + internals['container'] = { addChildAt: jest.fn() }; + internals['staticContainer'] = {}; + // Non-null, so the texture swap takes the assignment branch and never builds a real Sprite. + internals['staticSprite'] = { texture: null }; + internals['staticTexture'] = null; + internals['renderer'] = { resolution: 1, render: jest.fn() }; + internals['renderSkyline'] = jest.fn(); + internals['renderMarkers'] = jest.fn(); + + const manager = { getState: () => ({ height: 60, displayWidth: 400 }) }; + const renderStaticContent = internals['renderStaticContent'] as ( + manager: unknown, + densityData: unknown, + markers: unknown, + batchColors: unknown, + ) => void; + + return { + renderStatic: () => renderStaticContent.call(minimap, manager, {}, [], new Map()), + setResolution: (value: number) => { + (internals['renderer'] as { resolution: number }).resolution = value; + }, + }; +} + +describe('MinimapRenderer static texture', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + function spyOnCreate(): jest.Mock { + const create = jest.fn((options: { width: number; height: number; resolution: number }) => ({ + width: options.width, + height: options.height, + source: { resolution: options.resolution }, + destroy: jest.fn(), + })); + jest + .spyOn(PIXI.RenderTexture, 'create') + .mockImplementation(create as unknown as typeof PIXI.RenderTexture.create); + return create as unknown as jest.Mock; + } + + it('rebuilds the cached texture when only the device pixel ratio moved', () => { + const create = spyOnCreate(); + const { renderStatic, setResolution } = stubbedRenderer(); + + renderStatic(); + expect(create).toHaveBeenCalledTimes(1); + + // Same box, new ratio: the texture is the only thing that knows it is stale. + setResolution(2); + renderStatic(); + + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[1]?.[0]).toMatchObject({ width: 400, height: 60, resolution: 2 }); + }); + + it('keeps the cached texture when nothing moved', () => { + const create = spyOnCreate(); + const { renderStatic } = stubbedRenderer(); + + renderStatic(); + renderStatic(); + + expect(create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts index d50e959c1..69cdc056a 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts @@ -22,6 +22,24 @@ export class TimelineResizeHandler { private lastResizeWidth: number; private lastResizeHeight: number; + private dprQuery: MediaQueryList | null = null; + + private readonly onDevicePixelRatioChange = (): void => { + this.watchDevicePixelRatio(); + + // A zoom step moves the ratio and the box together, and this runs before the observer's + // callback. Replaying the old size would paint a box the zoom has left, then paint again. + const { width, height } = this.containerRef.getBoundingClientRect(); + this.lastResizeWidth = Math.round(width); + this.lastResizeHeight = Math.round(height); + + if (this.lastResizeWidth <= 0 || this.lastResizeHeight <= 0) { + return; + } + + this.renderer?.resize(this.lastResizeWidth, this.lastResizeHeight); + }; + /** * @param containerRef - The container element to observe for resize * @param renderer - The resizable component to notify on resize @@ -72,6 +90,15 @@ export class TimelineResizeHandler { }); this.resizeObserver.observe(this.containerRef); + this.watchDevicePixelRatio(); + } + + private watchDevicePixelRatio(): void { + // No event reports a ratio change, and a query only matches the ratio it was made at, so it + // is re-made each time it stops matching. + this.dprQuery = + globalThis.matchMedia?.(`(resolution: ${window.devicePixelRatio || 1}dppx)`) ?? null; + this.dprQuery?.addEventListener('change', this.onDevicePixelRatioChange, { once: true }); } public destroy(): void { @@ -80,5 +107,8 @@ export class TimelineResizeHandler { this.resizeObserver.disconnect(); this.resizeObserver = null; } + + this.dprQuery?.removeEventListener('change', this.onDevicePixelRatioChange); + this.dprQuery = null; } } diff --git a/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts b/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts new file mode 100644 index 000000000..0241eaefa --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts @@ -0,0 +1,137 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** A ratio change resizes nothing, so the ResizeObserver never fires and only the watcher sees it. */ + +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { TimelineResizeHandler } from '../TimelineResizeHandler.js'; + +class FakeMediaQueryList { + public listener: (() => void) | null = null; + public once = false; + public readonly media: string; + + constructor(media: string) { + this.media = media; + } + + addEventListener(_type: string, listener: () => void, options?: { once?: boolean }): void { + this.listener = listener; + this.once = options?.once ?? false; + } + + removeEventListener(): void { + this.listener = null; + } + + fire(): void { + const listener = this.listener; + if (this.once) { + this.listener = null; + } + listener?.(); + } +} + +describe('TimelineResizeHandler devicePixelRatio watching', () => { + const realDevicePixelRatio = window.devicePixelRatio; + let queries: FakeMediaQueryList[]; + let renderer: { resize: jest.Mock<(width: number, height: number) => void> }; + let container: HTMLElement; + + function setRatio(value: number): void { + Object.defineProperty(window, 'devicePixelRatio', { value, configurable: true }); + } + + beforeEach(() => { + queries = []; + renderer = { resize: jest.fn<(width: number, height: number) => void>() }; + container = document.createElement('div'); + container.getBoundingClientRect = () => ({ width: 400, height: 364 }) as DOMRect; + + setRatio(1); + (globalThis as unknown as Record)['matchMedia'] = (media: string) => { + const query = new FakeMediaQueryList(media); + queries.push(query); + return query; + }; + }); + + afterEach(() => { + setRatio(realDevicePixelRatio); + delete (globalThis as unknown as Record)['matchMedia']; + }); + + it('binds the query to the ratio in force, since no event reports a change', () => { + setRatio(1.25); + new TimelineResizeHandler(container, renderer).setupResizeObserver(); + + expect(queries).toHaveLength(1); + expect(queries[0]?.media).toBe('(resolution: 1.25dppx)'); + }); + + it('re-renders at the current size and re-arms on the new ratio', () => { + new TimelineResizeHandler(container, renderer).setupResizeObserver(); + + setRatio(2); + queries[0]?.fire(); + + // The box is unchanged here; `resize` re-reads the ratio itself and acts on that. + expect(renderer.resize).toHaveBeenNthCalledWith(1, 400, 364); + expect(queries).toHaveLength(2); + expect(queries[1]?.media).toBe('(resolution: 2dppx)'); + + setRatio(3); + queries[1]?.fire(); + expect(renderer.resize).toHaveBeenCalledTimes(2); + }); + + // A zoom step moves the ratio and the box together, and the watcher runs first. Replaying the + // size measured before the zoom would paint a dead box, then paint again for the observer. + it('re-measures rather than replaying the size it last saw', () => { + const handler = new TimelineResizeHandler(container, renderer); + handler.setupResizeObserver(); + + container.getBoundingClientRect = () => ({ width: 500, height: 420 }) as DOMRect; + setRatio(2); + queries[0]?.fire(); + + expect(renderer.resize).toHaveBeenCalledWith(500, 420); + }); + + it('leaves a fired query inert, so the re-arm cannot double up', () => { + new TimelineResizeHandler(container, renderer).setupResizeObserver(); + + setRatio(2); + queries[0]?.fire(); + queries[0]?.fire(); + + expect(renderer.resize).toHaveBeenCalledTimes(1); + }); + + it('waits for a measurement rather than resizing to nothing', () => { + container.getBoundingClientRect = () => ({ width: 0, height: 0 }) as DOMRect; + new TimelineResizeHandler(container, renderer).setupResizeObserver(); + + setRatio(2); + queries[0]?.fire(); + + expect(renderer.resize).not.toHaveBeenCalled(); + }); + + it('stops watching once destroyed', () => { + const handler = new TimelineResizeHandler(container, renderer); + handler.setupResizeObserver(); + + handler.destroy(); + setRatio(2); + queries[0]?.fire(); + + expect(renderer.resize).not.toHaveBeenCalled(); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index febc47724..fb76c477d 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -305,10 +305,11 @@ export class MetricStripOrchestrator { * Handle resize of the metric strip container. * * @param newWidth - New canvas width + * @param resolution - devicePixelRatio to render at */ - public resize(newWidth: number): void { + public resize(newWidth: number, resolution: number): void { if (this.app) { - this.app.renderer.resize(newWidth, this.getHeight()); + this.app.renderer.resize(newWidth, this.getHeight(), resolution); } } diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts index 8f0c6ab1b..8f70c1a59 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts @@ -425,16 +425,17 @@ export class MinimapRenderer { // If we have a renderer, cache to texture if (this.renderer && displayWidth > 0 && minimapHeight > 0) { // Create or resize texture + // Create texture at device pixel ratio for crisp rendering + const resolution = this.renderer.resolution; if ( !this.staticTexture || this.staticTexture.width !== displayWidth || - this.staticTexture.height !== minimapHeight + this.staticTexture.height !== minimapHeight || + this.staticTexture.source.resolution !== resolution ) { if (this.staticTexture) { this.staticTexture.destroy(true); } - // Create texture at device pixel ratio for crisp rendering - const resolution = this.renderer.resolution; this.staticTexture = PIXI.RenderTexture.create({ width: displayWidth, height: minimapHeight, diff --git a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts index 7b568a1a6..aeb043a25 100644 --- a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts @@ -268,12 +268,13 @@ export class MinimapOrchestrator { * * @param newWidth - New canvas width * @param newHeight - New full container height + * @param resolution - devicePixelRatio to render at */ - public resize(newWidth: number, newHeight: number): void { + public resize(newWidth: number, newHeight: number, resolution: number): void { const minimapHeight = calculateMinimapHeight(newHeight); if (this.app) { - this.app.renderer.resize(newWidth, minimapHeight); + this.app.renderer.resize(newWidth, minimapHeight, resolution); } if (this.minimapViewport) { From 1f3b82a1f88b0d6cb1a43511bb6b93e8a66b282f Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:19:32 +0100 Subject: [PATCH 2/3] docs: drop the timeline sharpness changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8ee8f1d..ccabe0c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 📊 **Governor limits strip**: where the log recorded nothing, the strip drew its last reading across the gap as though it had been measured; the gap is now blank, and the tooltip names the reason and range. ([#828]) - 🗄️ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element are now counted. Needs `WORKFLOW` at `FINER` or above. ([#871]). - ⚡ **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge. -- 🪟 **Timeline sharpness**: the Flame Chart, governor strip and minimap blurred after a display-scaling change or a move to a monitor with a different scale factor, until the log was reopened. - 🖱️ **Governor limits strip**: reading across the 15px collapsed strip lost the tooltip on the smallest wobble; the hover now holds until the pointer is clear of it. - 🐛 **Go to Code** matches methods with namespace or `System` qualified parameter types. ([#834]) - 🎨 **Theme switch**: Timeline and view colours update straight away instead of needing the log reopened. From 159d281af68a616df9c43cf343d5a84e7ef97cc5 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:19:39 +0100 Subject: [PATCH 3/3] refactor(log-viewer): trim the display-scaling fix and its tests Drop the zero guard in the ratio handler: FlameChart is the only IResizable and already rejects a width or height of zero. Cut the tests to the three that fail without a fix, and drop the comments the code already says. --- .../__tests__/MinimapStaticTexture.test.ts | 87 ++++---------- .../interaction/TimelineResizeHandler.ts | 12 +- .../__tests__/TimelineResizeHandler.test.ts | 112 +++--------------- .../optimised/minimap/MinimapRenderer.ts | 1 - 4 files changed, 49 insertions(+), 163 deletions(-) diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts index b9d6ba25d..09b804658 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts @@ -6,57 +6,16 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -/** - * The skyline, markers and axis are cached into one RenderTexture. A ratio change leaves the - * minimap's box alone, so only the texture's own resolution says the cache is stale. - */ - import { afterEach, describe, expect, it, jest } from '@jest/globals'; import * as PIXI from 'pixi.js'; import { MinimapRenderer } from '../minimap/MinimapRenderer.js'; -/** The collaborators the static path touches, and nothing else. */ -function stubbedRenderer(): { - renderStatic: () => void; - setResolution: (value: number) => void; -} { - const minimap = Object.create(MinimapRenderer.prototype) as MinimapRenderer; - const internals = minimap as unknown as Record; - - internals['backgroundGraphics'] = { clear: jest.fn() }; - internals['markerGraphics'] = { clear: jest.fn() }; - internals['axisRenderer'] = { render: jest.fn() }; - internals['container'] = { addChildAt: jest.fn() }; - internals['staticContainer'] = {}; - // Non-null, so the texture swap takes the assignment branch and never builds a real Sprite. - internals['staticSprite'] = { texture: null }; - internals['staticTexture'] = null; - internals['renderer'] = { resolution: 1, render: jest.fn() }; - internals['renderSkyline'] = jest.fn(); - internals['renderMarkers'] = jest.fn(); - - const manager = { getState: () => ({ height: 60, displayWidth: 400 }) }; - const renderStaticContent = internals['renderStaticContent'] as ( - manager: unknown, - densityData: unknown, - markers: unknown, - batchColors: unknown, - ) => void; - - return { - renderStatic: () => renderStaticContent.call(minimap, manager, {}, [], new Map()), - setResolution: (value: number) => { - (internals['renderer'] as { resolution: number }).resolution = value; - }, - }; -} - describe('MinimapRenderer static texture', () => { afterEach(() => { jest.restoreAllMocks(); }); - function spyOnCreate(): jest.Mock { + it('rebuilds the cached texture when only the device pixel ratio moved', () => { const create = jest.fn((options: { width: number; height: number; resolution: number }) => ({ width: options.width, height: options.height, @@ -66,31 +25,37 @@ describe('MinimapRenderer static texture', () => { jest .spyOn(PIXI.RenderTexture, 'create') .mockImplementation(create as unknown as typeof PIXI.RenderTexture.create); - return create as unknown as jest.Mock; - } - it('rebuilds the cached texture when only the device pixel ratio moved', () => { - const create = spyOnCreate(); - const { renderStatic, setResolution } = stubbedRenderer(); + const minimap = Object.create(MinimapRenderer.prototype) as MinimapRenderer; + const internals = minimap as unknown as Record; + const renderer = { resolution: 1, render: jest.fn() }; + internals['backgroundGraphics'] = { clear: jest.fn() }; + internals['markerGraphics'] = { clear: jest.fn() }; + internals['axisRenderer'] = { render: jest.fn() }; + internals['staticContainer'] = {}; + // Non-null, so the texture swap takes the assignment branch and never builds a real Sprite. + internals['staticSprite'] = { texture: null }; + internals['staticTexture'] = null; + internals['renderer'] = renderer; + internals['renderSkyline'] = jest.fn(); + internals['renderMarkers'] = jest.fn(); + + const manager = { getState: () => ({ height: 60, displayWidth: 400 }) }; + const renderStatic = (): void => + ( + internals['renderStaticContent'] as ( + manager: unknown, + densityData: unknown, + markers: unknown, + batchColors: unknown, + ) => void + ).call(minimap, manager, {}, [], new Map()); renderStatic(); - expect(create).toHaveBeenCalledTimes(1); - - // Same box, new ratio: the texture is the only thing that knows it is stale. - setResolution(2); + renderer.resolution = 2; renderStatic(); expect(create).toHaveBeenCalledTimes(2); expect(create.mock.calls[1]?.[0]).toMatchObject({ width: 400, height: 60, resolution: 2 }); }); - - it('keeps the cached texture when nothing moved', () => { - const create = spyOnCreate(); - const { renderStatic } = stubbedRenderer(); - - renderStatic(); - renderStatic(); - - expect(create).toHaveBeenCalledTimes(1); - }); }); diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts index 69cdc056a..98eb00889 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts @@ -27,16 +27,12 @@ export class TimelineResizeHandler { private readonly onDevicePixelRatioChange = (): void => { this.watchDevicePixelRatio(); - // A zoom step moves the ratio and the box together, and this runs before the observer's - // callback. Replaying the old size would paint a box the zoom has left, then paint again. + // A zoom step moves the ratio and the box together, and this runs before the observer, so + // the size last seen is already dead. const { width, height } = this.containerRef.getBoundingClientRect(); this.lastResizeWidth = Math.round(width); this.lastResizeHeight = Math.round(height); - if (this.lastResizeWidth <= 0 || this.lastResizeHeight <= 0) { - return; - } - this.renderer?.resize(this.lastResizeWidth, this.lastResizeHeight); }; @@ -94,8 +90,8 @@ export class TimelineResizeHandler { } private watchDevicePixelRatio(): void { - // No event reports a ratio change, and a query only matches the ratio it was made at, so it - // is re-made each time it stops matching. + // No event reports a ratio change, and a query only matches the ratio it was made at, so it is + // re-made each time it stops matching. this.dprQuery = globalThis.matchMedia?.(`(resolution: ${window.devicePixelRatio || 1}dppx)`) ?? null; this.dprQuery?.addEventListener('change', this.onDevicePixelRatioChange, { once: true }); diff --git a/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts b/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts index 0241eaefa..d9c73dbb8 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts @@ -6,41 +6,11 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -/** A ratio change resizes nothing, so the ResizeObserver never fires and only the watcher sees it. */ - -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { TimelineResizeHandler } from '../TimelineResizeHandler.js'; -class FakeMediaQueryList { - public listener: (() => void) | null = null; - public once = false; - public readonly media: string; - - constructor(media: string) { - this.media = media; - } - - addEventListener(_type: string, listener: () => void, options?: { once?: boolean }): void { - this.listener = listener; - this.once = options?.once ?? false; - } - - removeEventListener(): void { - this.listener = null; - } - - fire(): void { - const listener = this.listener; - if (this.once) { - this.listener = null; - } - listener?.(); - } -} - describe('TimelineResizeHandler devicePixelRatio watching', () => { - const realDevicePixelRatio = window.devicePixelRatio; - let queries: FakeMediaQueryList[]; + let queries: { media: string; fire: () => void }[]; let renderer: { resize: jest.Mock<(width: number, height: number) => void> }; let container: HTMLElement; @@ -48,80 +18,37 @@ describe('TimelineResizeHandler devicePixelRatio watching', () => { Object.defineProperty(window, 'devicePixelRatio', { value, configurable: true }); } + function setBox(width: number, height: number): void { + container.getBoundingClientRect = () => ({ width, height }) as DOMRect; + } + beforeEach(() => { queries = []; renderer = { resize: jest.fn<(width: number, height: number) => void>() }; container = document.createElement('div'); - container.getBoundingClientRect = () => ({ width: 400, height: 364 }) as DOMRect; - + setBox(400, 364); setRatio(1); - (globalThis as unknown as Record)['matchMedia'] = (media: string) => { - const query = new FakeMediaQueryList(media); - queries.push(query); - return query; - }; - }); - - afterEach(() => { - setRatio(realDevicePixelRatio); - delete (globalThis as unknown as Record)['matchMedia']; - }); - - it('binds the query to the ratio in force, since no event reports a change', () => { - setRatio(1.25); - new TimelineResizeHandler(container, renderer).setupResizeObserver(); - expect(queries).toHaveLength(1); - expect(queries[0]?.media).toBe('(resolution: 1.25dppx)'); + window.matchMedia = ((media: string) => { + let listener: (() => void) | null = null; + queries.push({ media, fire: () => listener?.() }); + return { + addEventListener: (_type: string, handler: () => void) => (listener = handler), + removeEventListener: () => (listener = null), + }; + }) as unknown as typeof window.matchMedia; }); - it('re-renders at the current size and re-arms on the new ratio', () => { + it('re-renders the measured box on a ratio change, then re-arms on the new ratio', () => { new TimelineResizeHandler(container, renderer).setupResizeObserver(); + expect(queries[0]?.media).toBe('(resolution: 1dppx)'); - setRatio(2); - queries[0]?.fire(); - - // The box is unchanged here; `resize` re-reads the ratio itself and acts on that. - expect(renderer.resize).toHaveBeenNthCalledWith(1, 400, 364); - expect(queries).toHaveLength(2); - expect(queries[1]?.media).toBe('(resolution: 2dppx)'); - - setRatio(3); - queries[1]?.fire(); - expect(renderer.resize).toHaveBeenCalledTimes(2); - }); - - // A zoom step moves the ratio and the box together, and the watcher runs first. Replaying the - // size measured before the zoom would paint a dead box, then paint again for the observer. - it('re-measures rather than replaying the size it last saw', () => { - const handler = new TimelineResizeHandler(container, renderer); - handler.setupResizeObserver(); - - container.getBoundingClientRect = () => ({ width: 500, height: 420 }) as DOMRect; + setBox(500, 420); setRatio(2); queries[0]?.fire(); expect(renderer.resize).toHaveBeenCalledWith(500, 420); - }); - - it('leaves a fired query inert, so the re-arm cannot double up', () => { - new TimelineResizeHandler(container, renderer).setupResizeObserver(); - - setRatio(2); - queries[0]?.fire(); - queries[0]?.fire(); - - expect(renderer.resize).toHaveBeenCalledTimes(1); - }); - - it('waits for a measurement rather than resizing to nothing', () => { - container.getBoundingClientRect = () => ({ width: 0, height: 0 }) as DOMRect; - new TimelineResizeHandler(container, renderer).setupResizeObserver(); - - setRatio(2); - queries[0]?.fire(); - - expect(renderer.resize).not.toHaveBeenCalled(); + expect(queries[1]?.media).toBe('(resolution: 2dppx)'); }); it('stops watching once destroyed', () => { @@ -129,7 +56,6 @@ describe('TimelineResizeHandler devicePixelRatio watching', () => { handler.setupResizeObserver(); handler.destroy(); - setRatio(2); queries[0]?.fire(); expect(renderer.resize).not.toHaveBeenCalled(); diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts index 8f70c1a59..ce566656f 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts @@ -425,7 +425,6 @@ export class MinimapRenderer { // If we have a renderer, cache to texture if (this.renderer && displayWidth > 0 && minimapHeight > 0) { // Create or resize texture - // Create texture at device pixel ratio for crisp rendering const resolution = this.renderer.resolution; if ( !this.staticTexture ||