Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions log-viewer/src/features/timeline/optimised/FlameChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,9 @@ export class FlameChart<E extends EventNode = EventNode> {
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;

Expand Down Expand Up @@ -855,7 +858,8 @@ export class FlameChart<E extends EventNode = EventNode> {
newWidth === oldWidth &&
mainTimelineHeight === oldState.displayHeight &&
minimapHeight === this.appliedMinimapHeight &&
totalOverheadHeight === this.appliedOverheadHeight
totalOverheadHeight === this.appliedOverheadHeight &&
resolution === this.app.renderer.resolution
) {
return false;
}
Expand All @@ -872,20 +876,20 @@ export class FlameChart<E extends EventNode = EventNode> {

// 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
this.selectionOrchestrator?.setMainTimelineYOffset(this.mainTimelineYOffset);
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function stubbedChart(displayHeight = 300): {

const internals = chart as unknown as Record<string, unknown>;
internals['app'] = {
renderer: { resize: rendererResize },
renderer: { resize: rendererResize, resolution: 1 },
screen: { height: 300 },
render: appRender,
};
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -80,5 +103,8 @@ export class TimelineResizeHandler {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}

this.dprQuery?.removeEventListener('change', this.onDevicePixelRatioChange);
this.dprQuery = null;
}
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading