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..09b804658 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/MinimapStaticTexture.test.ts @@ -0,0 +1,61 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as PIXI from 'pixi.js'; +import { MinimapRenderer } from '../minimap/MinimapRenderer.js'; + +describe('MinimapRenderer static texture', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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, + source: { resolution: options.resolution }, + destroy: jest.fn(), + })); + jest + .spyOn(PIXI.RenderTexture, 'create') + .mockImplementation(create as unknown as typeof PIXI.RenderTexture.create); + + 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(); + renderer.resolution = 2; + renderStatic(); + + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[1]?.[0]).toMatchObject({ width: 400, height: 60, resolution: 2 }); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts index d50e959c1..98eb00889 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts @@ -22,6 +22,20 @@ 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, so + // the size last seen is already dead. + const { width, height } = this.containerRef.getBoundingClientRect(); + this.lastResizeWidth = Math.round(width); + this.lastResizeHeight = Math.round(height); + + 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 +86,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 +103,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..d9c73dbb8 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/interaction/__tests__/TimelineResizeHandler.test.ts @@ -0,0 +1,63 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { TimelineResizeHandler } from '../TimelineResizeHandler.js'; + +describe('TimelineResizeHandler devicePixelRatio watching', () => { + let queries: { media: string; fire: () => void }[]; + 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 }); + } + + 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'); + setBox(400, 364); + setRatio(1); + + 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 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)'); + + setBox(500, 420); + setRatio(2); + queries[0]?.fire(); + + expect(renderer.resize).toHaveBeenCalledWith(500, 420); + expect(queries[1]?.media).toBe('(resolution: 2dppx)'); + }); + + it('stops watching once destroyed', () => { + const handler = new TimelineResizeHandler(container, renderer); + handler.setupResizeObserver(); + + handler.destroy(); + 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..ce566656f 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts @@ -425,16 +425,16 @@ export class MinimapRenderer { // If we have a renderer, cache to texture if (this.renderer && displayWidth > 0 && minimapHeight > 0) { // Create or resize texture + 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) {