From 0312bce49189c84811124af1d2b391caa705f604 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:45:57 +0100 Subject: [PATCH] refactor(log-viewer): drop the sprite timeline renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh renderers replaced the sprite ones: FlameChart uses MeshRectangleRenderer, MeshMarkerRenderer and MeshAxisRenderer, and SearchOrchestrator uses MeshSearchStyleRenderer. No production file imported the sprite path. Deletes AxisRenderer, SearchStyleRenderer, TimelineMarkerRenderer, SpritePool and EventBatchRenderer, plus markers.test.ts and batching.test.ts, which constructed only those classes. TemporalSegmentTree.test.ts and RectangleCache.bucket.test.ts already cover the culling, size classification and bucketing that batching.test.ts exercised. sortMarkersByTimeAndSeverity had no coverage outside markers.test.ts, so its case moves to MarkerProcessor.test.ts. Marker viewport culling, end-time resolution and per-type colour lose their tests. They asserted sprite tint, alpha, x and width — internals of the deleted class. MeshMarkerRenderer keeps visibleIndicators private and had no suite of its own before this change either. --- .../timeline/__tests__/batching.test.ts | 564 -------------- .../timeline/__tests__/markers.test.ts | 699 ------------------ .../__tests__/search-highlight.test.ts | 2 +- .../timeline/optimised/EventBatchRenderer.ts | 144 ---- .../features/timeline/optimised/SpritePool.ts | 154 ---- .../__tests__/MarkerProcessor.test.ts | 12 + .../optimised/markers/MarkerHitTest.ts | 2 +- .../optimised/markers/MarkerProcessor.ts | 2 +- .../markers/TimelineMarkerRenderer.ts | 192 ----- .../optimised/rendering/HighlightRenderer.ts | 2 +- .../search/SearchHighlightRenderer.ts | 6 +- .../optimised/search/SearchStyleRenderer.ts | 209 ------ .../optimised/time-axis/AxisRenderer.ts | 287 ------- .../optimised/time-axis/timeAxisConstants.ts | 4 +- 14 files changed, 21 insertions(+), 2258 deletions(-) delete mode 100644 log-viewer/src/features/timeline/__tests__/batching.test.ts delete mode 100644 log-viewer/src/features/timeline/__tests__/markers.test.ts delete mode 100644 log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts delete mode 100644 log-viewer/src/features/timeline/optimised/SpritePool.ts delete mode 100644 log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts delete mode 100644 log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts delete mode 100644 log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts diff --git a/log-viewer/src/features/timeline/__tests__/batching.test.ts b/log-viewer/src/features/timeline/__tests__/batching.test.ts deleted file mode 100644 index 4aa3e91bc..000000000 --- a/log-viewer/src/features/timeline/__tests__/batching.test.ts +++ /dev/null @@ -1,564 +0,0 @@ -/* - * Copyright (c) 2020 Certinia Inc. All rights reserved. - */ - -/** - * Unit tests for EventBatchRenderer - * - * Tests batching and culling logic including: - * - Event grouping by category - * - View frustum culling (horizontal and vertical) - * - Minimum size filtering - * - Hierarchical rectangle collection - */ -import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; -import type { LogCategory, LogEvent } from 'apex-log-parser'; -import * as PIXI from 'pixi.js'; - -import type { BatchColorInfo } from '../optimised/BucketColorResolver.js'; -import { EventBatchRenderer } from '../optimised/EventBatchRenderer.js'; -import { RectangleCache } from '../optimised/RectangleCache.js'; -import type { RenderBatch, ViewportState } from '../types/flamechart.types.js'; -import { TIMELINE_CONSTANTS } from '../types/flamechart.types.js'; - -/** Empty batch colors — tests that don't assert color values use this. */ -const EMPTY_BATCH_COLORS: Map = new Map(); - -describe('EventBatchRenderer', () => { - let container: PIXI.Container; - let renderer: EventBatchRenderer; - let rectangleManager: RectangleCache; - let batches: Map; - - /** - * Helper to create a mock LogEvent - */ - function createEvent( - timestamp: number, - duration: number, - category: LogCategory, - children: LogEvent[] = [], - ): LogEvent { - return { - timestamp, - exitStamp: timestamp + duration, - duration: { - total: duration, - exclusive: duration, - }, - children, - text: `${category} at ${timestamp}`, - lineNumber: 0, - category, - } as unknown as LogEvent; - } - - /** - * Helper to create a viewport state - */ - function createViewport( - zoom: number = 1, - offsetX: number = 0, - offsetY: number = 0, - displayWidth: number = 1000, - displayHeight: number = 600, - ): ViewportState { - return { - zoom, - offsetX, - offsetY, - displayWidth, - displayHeight, - }; - } - - /** - * Helper to setup renderer and manager, and perform rendering - */ - function setupAndRender(events: LogEvent[], viewport: ViewportState): void { - const categories = new Set(batches.keys()); - rectangleManager = new RectangleCache(events, categories); - renderer = new EventBatchRenderer(container, batches); - - const { visibleRects, buckets } = rectangleManager.getCulledRectangles( - viewport, - EMPTY_BATCH_COLORS, - ); - renderer.render(visibleRects, buckets); - } - - beforeEach(() => { - container = new PIXI.Container(); - - // Create batches for common categories - batches = new Map([ - [ - 'Apex', - { - category: 'Apex', - color: 0x88ae58, - rectangles: [], - isDirty: false, - }, - ], - [ - 'SOQL', - { - category: 'SOQL', - color: 0x5d4963, - rectangles: [], - isDirty: false, - }, - ], - [ - 'DML', - { - category: 'DML', - color: 0x285663, - rectangles: [], - isDirty: false, - }, - ], - ]); - }); - - afterEach(() => { - container.destroy(); - renderer.destroy(); - }); - - describe('initialization', () => { - it('should create a SpritePool container', () => { - renderer = new EventBatchRenderer(container, batches); - - // SpritePool creates a container for sprites - expect(container.children).toHaveLength(1); - expect(container.children[0] instanceof PIXI.Container).toBe(true); - }); - - it('should create SpritePool container with empty batches', () => { - const emptyBatches = new Map(); - renderer = new EventBatchRenderer(container, emptyBatches); - - // SpritePool container is still created - expect(container.children).toHaveLength(1); - }); - }); - - describe('batching by category', () => { - it('should group events by category', () => { - const events = [ - createEvent(0, 100, 'Apex'), - createEvent(200, 100, 'SOQL'), - createEvent(400, 100, 'Apex'), - ]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - const soqlBatch = batches.get('SOQL'); - - expect(apexBatch?.rectangles).toHaveLength(2); - expect(soqlBatch?.rectangles).toHaveLength(1); - }); - - it('should separate nested events into correct batches', () => { - const child = createEvent(50, 20, 'SOQL'); - const parent = createEvent(0, 100, 'Apex', [child]); - const events = [parent]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex')!; - const soqlBatch = batches.get('SOQL')!; - - expect(apexBatch.rectangles).toHaveLength(1); - expect(soqlBatch.rectangles).toHaveLength(1); - }); - - it('should ignore events with unknown categories', () => { - const events = [ - createEvent(0, 100, 'Apex'), - createEvent(200, 100, 'UnknownCategory' as LogCategory), - ]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - - expect(apexBatch?.rectangles).toHaveLength(1); - expect(batches.get('UnknownCategory')).toBeUndefined(); - }); - }); - - describe('horizontal culling (time-based)', () => { - it('should render events within viewport time range', () => { - const events = [ - createEvent(0, 100, 'Apex'), - createEvent(200, 100, 'Apex'), - createEvent(400, 100, 'Apex'), - ]; - - const viewport = createViewport(1, 0, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - expect(apexBatch?.rectangles).toHaveLength(3); - }); - - it('should cull events before viewport', () => { - const events = [createEvent(0, 100, 'Apex'), createEvent(200, 100, 'Apex')]; - - const viewport = createViewport(1, 150, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - // First event should be culled, second should be visible - expect(apexBatch?.rectangles).toHaveLength(1); - expect(apexBatch?.rectangles[0]?.eventRef.timestamp).toBe(200); - }); - - it('should cull events after viewport', () => { - const events = [ - createEvent(0, 100, 'Apex'), - createEvent(1200, 100, 'Apex'), // Starts after viewport end - ]; - - const viewport = createViewport(1, 0, 0, 1000); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - // Second event should be culled - expect(apexBatch?.rectangles).toHaveLength(1); - expect(apexBatch?.rectangles[0]?.eventRef.timestamp).toBe(0); - }); - - it('should include partially visible events', () => { - const events = [ - createEvent(50, 200, 'Apex'), // Spans 50-250, viewport is 0-200 - ]; - - const viewport = createViewport(1, 0, 0, 200); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - // Should be included even though only partially visible - expect(apexBatch?.rectangles).toHaveLength(1); - }); - }); - - describe('vertical culling (depth-based)', () => { - it('should render events within viewport depth range', () => { - const level2 = createEvent(60, 10, 'DML'); - const level1 = createEvent(50, 30, 'SOQL', [level2]); - const level0 = createEvent(0, 100, 'Apex', [level1]); - const events = [level0]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - // All three events should be visible - expect(batches.get('Apex')?.rectangles).toHaveLength(1); - expect(batches.get('SOQL')?.rectangles).toHaveLength(1); - expect(batches.get('DML')?.rectangles).toHaveLength(1); - }); - - it('should cull events below viewport', () => { - const level2 = createEvent(60, 10, 'DML'); - const level1 = createEvent(50, 30, 'SOQL', [level2]); - const level0 = createEvent(0, 100, 'Apex', [level1]); - const events = [level0]; - - // Pan down so only depth 2+ is visible - const eventHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; - const viewport = createViewport(1, 0, eventHeight * 2.5); - setupAndRender(events, viewport); - - // Level 0 and 1 should be culled (depths < depthStart), level 2 should be visible - const apexBatch = batches.get('Apex'); - const soqlBatch = batches.get('SOQL'); - const dmlBatch = batches.get('DML'); - - // With the new implementation, events are pre-computed, so only depth filtering applies - // Depth 0 and 1 are culled, but depth 2 (DML) might still be visible - expect(apexBatch?.rectangles.length).toBeLessThanOrEqual(1); - expect(soqlBatch?.rectangles.length).toBeLessThanOrEqual(1); - // Depth 2 might be visible since it's in the viewport - expect(dmlBatch?.rectangles.length).toBeGreaterThanOrEqual(0); - }); - - it('should cull events above viewport', () => { - const level2 = createEvent(60, 10, 'DML'); - const level1 = createEvent(50, 30, 'SOQL', [level2]); - const level0 = createEvent(0, 100, 'Apex', [level1]); - const events = [level0]; - - // Viewport showing depths 0 and 1, but not 2 - // depthEnd = ceil((offsetY + height) / eventHeight) - // For depthEnd = 1: ceil(height / 15) = 1, so 0 < height <= 15 - const eventHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; - const viewport = createViewport(1, 0, 0, 1000, eventHeight); // exactly 1 event height - setupAndRender(events, viewport); - - // With height = 15: depthEnd = ceil(15/15) = ceil(1) = 1 - // So depths 0 and 1 are visible (0 <= depth <= 1) - // But depth 2 is not (2 > 1) - // However, since level 1 (SOQL) is visible and has children, - // those children will be checked. Level 2 (DML) at depth 2 should be culled. - expect(batches.get('Apex')?.rectangles).toHaveLength(1); - expect(batches.get('SOQL')?.rectangles).toHaveLength(1); - expect(batches.get('DML')?.rectangles).toHaveLength(0); - }); - }); - - describe('minimum size filtering', () => { - it('should cull events smaller than minimum size', () => { - const events = [ - createEvent(0, 0.01, 'Apex'), // Very small duration - ]; - - // At zoom=1, event width = 0.01px (< MIN_RECT_SIZE = 0.05) - const viewport = createViewport(1, 0, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - expect(apexBatch?.rectangles).toHaveLength(0); - }); - - it('should render events that meet minimum size threshold', () => { - const events = [ - createEvent(0, 3, 'Apex'), // Width = 3px at zoom=1 (> MIN_RECT_SIZE = 2px) - ]; - - const viewport = createViewport(1, 0, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - expect(apexBatch?.rectangles).toHaveLength(1); - }); - - it('should render small events when zoomed in', () => { - const events = [ - createEvent(0, 1, 'Apex'), // 1ns duration - ]; - - const viewport = createViewport(10, 0, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - expect(apexBatch?.rectangles).toHaveLength(1); - }); - }); - - describe('hierarchical collection', () => { - it('should collect rectangles at correct depths', () => { - const level2 = createEvent(60, 10, 'DML'); - const level1 = createEvent(50, 30, 'SOQL', [level2]); - const level0 = createEvent(0, 100, 'Apex', [level1]); - const events = [level0]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - const methodRect = batches.get('Apex')?.rectangles[0]; - const soqlRect = batches.get('SOQL')?.rectangles[0]; - const dmlRect = batches.get('DML')?.rectangles[0]; - - // Verify Y positions correspond to depths - const eventHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; - expect(methodRect?.y).toBe(0 * eventHeight); - expect(soqlRect?.y).toBe(1 * eventHeight); - expect(dmlRect?.y).toBe(2 * eventHeight); - }); - - it('should skip children if parent is not visible', () => { - const child = createEvent(2000, 100, 'SOQL'); - const parent = createEvent(1500, 600, 'Apex', [child]); - const events = [parent]; - - // Viewport shows time 0-1000, parent starts at 1500 - const viewport = createViewport(1, 0, 0, 1000); - setupAndRender(events, viewport); - - // Both parent and child should be culled - expect(batches.get('Apex')?.rectangles).toHaveLength(0); - expect(batches.get('SOQL')?.rectangles).toHaveLength(0); - }); - - it('should process children even if parent is partially visible', () => { - const child = createEvent(500, 100, 'SOQL'); - const parent = createEvent(400, 300, 'Apex', [child]); - const events = [parent]; - - // Viewport shows time 0-600, parent extends to 700 - const viewport = createViewport(1, 0, 0, 600); - setupAndRender(events, viewport); - - // Both should be visible - expect(batches.get('Apex')?.rectangles).toHaveLength(1); - expect(batches.get('SOQL')?.rectangles).toHaveLength(1); - }); - }); - - describe('rectangle calculations', () => { - it('should calculate correct rectangle positions with zoom', () => { - const events = [createEvent(100, 50, 'Apex')]; - - const viewport = createViewport(2, 0, 0); // 2x zoom - - setupAndRender(events, viewport); - - const rect = batches.get('Apex')?.rectangles[0]; - - // At 2x zoom: x = 100 * 2 = 200, width = 50 * 2 = 100 - expect(rect?.x).toBe(200); - expect(rect?.width).toBe(100); - }); - - it('should calculate correct rectangle height', () => { - const events = [createEvent(0, 100, 'Apex')]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - const rect = batches.get('Apex')?.rectangles[0]; - - expect(rect?.height).toBe(TIMELINE_CONSTANTS.EVENT_HEIGHT); - }); - - it('should preserve event reference in rectangle', () => { - const event = createEvent(0, 100, 'Apex'); - const events = [event]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - const rect = batches.get('Apex')?.rectangles[0]; - - expect(rect?.eventRef).toBe(event); - }); - }); - - describe('dirty flag management', () => { - it('should mark batches as dirty during render', () => { - const events = [createEvent(0, 100, 'Apex')]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - // After render, dirty flags should be cleared - expect(batches.get('Apex')?.isDirty).toBe(false); - }); - - it('should clear rectangles on each render', () => { - const events = [createEvent(0, 100, 'Apex'), createEvent(200, 100, 'Apex')]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - expect(batches.get('Apex')?.rectangles).toHaveLength(2); - - // Second render with different viewport (should recalculate) - const viewport2 = createViewport(1, 150, 0); // Pan to cull first event - const { visibleRects: visibleRects2, buckets: buckets2 } = - rectangleManager.getCulledRectangles(viewport2, EMPTY_BATCH_COLORS); - renderer.render(visibleRects2, buckets2); - expect(batches.get('Apex')?.rectangles).toHaveLength(1); - }); - }); - - describe('edge cases', () => { - it('should handle empty event array', () => { - const events: LogEvent[] = []; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - for (const batch of batches.values()) { - expect(batch.rectangles).toHaveLength(0); - } - }); - - it('should handle events without duration', () => { - const event = { - timestamp: 0, - category: 'Apex', - text: 'No duration', - lineNumber: 0, - children: [], - duration: { - total: 0, - exclusive: 0, - }, - } as unknown as LogEvent; - - const events = [event]; - - const viewport = createViewport(); - setupAndRender(events, viewport); - - expect(batches.get('Apex')?.rectangles).toHaveLength(0); - }); - - it('should handle zero zoom gracefully', () => { - const events = [createEvent(0, 100, 'Apex')]; - - const viewport = createViewport(0, 0, 0); - setupAndRender(events, viewport); - }); - }); - - describe('cleanup', () => { - it('should destroy SpritePool and remove all children', () => { - renderer = new EventBatchRenderer(container, batches); - - const childrenCount = container.children.length; - expect(childrenCount).toBeGreaterThan(0); - - renderer.destroy(); - // After destruction, SpritePool container is removed - // Container should have no children - expect(container.children.length).toBe(0); - }); - }); - - describe('integration with search highlights', () => { - it('should cull small rectangles from EventBatchRenderer while SearchHighlightRenderer still renders them', () => { - // This test verifies that when rectangles are too small to be rendered by EventBatchRenderer - // (< MIN_RECT_SIZE = 0.5px), they are culled from the rendering, but this doesn't - // affect SearchHighlightRenderer's ability to render highlights for those events. - - const events = [ - createEvent(0, 100, 'Apex'), // Will be culled when zoomed out - ]; - - // Zoom out so screenWidth = 100 * 0.002 = 0.2px (< MIN_RECT_SIZE = 0.5px) - const viewport = createViewport(0.002, 0, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - - // Event should be culled by EventBatchRenderer (too small) - expect(apexBatch?.rectangles).toHaveLength(0); - - // This confirms that EventBatchRenderer correctly culls small rectangles. - // SearchHighlightRenderer is tested separately to ensure it enforces minimum - // visible size (6px) for search highlights, independent of this culling. - }); - - it('should render events when they meet minimum size threshold after zoom', () => { - const events = [createEvent(0, 300, 'Apex')]; - - // Zoom in so screenWidth = 300 * 0.01 = 3px (> MIN_RECT_SIZE = 2px) - const viewport = createViewport(0.01, 0, 0); - setupAndRender(events, viewport); - - const apexBatch = batches.get('Apex'); - - // Event should be rendered (meets minimum size) - expect(apexBatch?.rectangles).toHaveLength(1); - }); - }); -}); diff --git a/log-viewer/src/features/timeline/__tests__/markers.test.ts b/log-viewer/src/features/timeline/__tests__/markers.test.ts deleted file mode 100644 index 3ca64f80d..000000000 --- a/log-viewer/src/features/timeline/__tests__/markers.test.ts +++ /dev/null @@ -1,699 +0,0 @@ -/* - * Copyright (c) 2025 Certinia Inc. All rights reserved. - */ - -/** - * Unit tests for TimelineMarkerRenderer - * - * Tests truncation indicator rendering including: - * - Color accuracy verification (T013) - * - End time resolution algorithm (T008) - * - Viewport culling behavior (T009) - * - Hit testing logic (T014) - */ - -import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; -import type * as PIXI from 'pixi.js'; - -// Mock PIXI.Sprite with test helpers -class MockSprite { - public x = 0; - public y = 0; - public width = 0; - public height = 0; - public tint = 0xffffff; - public alpha = 1; - public visible = true; - public parent: unknown = null; - public _zIndex = 0; - public didChange = false; - - position = { - set: (x: number, y: number) => { - this.x = x; - this.y = y; - }, - }; - - // Required for PIXI.Container.addChild - emit(): void { - // No-op for testing - } - - depthOfChildModified(): void { - // No-op for testing - } - - destroy(): void { - // No-op for testing - } -} - -// Track created mock sprite instances globally -const createdMockSpritesGlobal: MockSprite[] = []; - -// Mock PIXI module before imports -jest.mock('pixi.js', () => { - const actual = jest.requireActual('pixi.js'); - return { - ...(actual as Record), - - Sprite: jest.fn().mockImplementation(() => { - const mock = new MockSprite(); - createdMockSpritesGlobal.push(mock); - return mock; - }), - - Texture: { - WHITE: {}, - }, - }; -}); - -import { TimelineMarkerRenderer } from '../optimised/markers/TimelineMarkerRenderer.js'; -import { TimelineViewport } from '../optimised/TimelineViewport.js'; -import type { TimelineMarker } from '../types/flamechart.types.js'; -import { MARKER_ALPHA, MARKER_COLORS } from '../types/flamechart.types.js'; - -// Mock PIXI.Container -class MockContainer { - private children: unknown[] = []; - - addChild(child: unknown): void { - this.children.push(child); - } - - destroy(): void { - this.children = []; - } - - getChildren(): unknown[] { - return this.children; - } -} - -describe('TimelineMarkerRenderer', () => { - let mockContainer: MockContainer; - let viewport: TimelineViewport; - let renderer: TimelineMarkerRenderer; - let createdMockSprites: MockSprite[]; - - const DISPLAY_WIDTH = 1000; - const DISPLAY_HEIGHT = 600; - const TOTAL_DURATION = 1_000_000; // 1ms in nanoseconds - const MAX_DEPTH = 10; - - beforeEach(() => { - // Clear the global array and reference it - createdMockSpritesGlobal.length = 0; - createdMockSprites = createdMockSpritesGlobal; - - mockContainer = new MockContainer(); - viewport = new TimelineViewport(DISPLAY_WIDTH, DISPLAY_HEIGHT, TOTAL_DURATION, MAX_DEPTH); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('T013: Color Accuracy Verification', () => { - it('should render error markers with raw color and alpha via sprite tint', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test error' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // Find the sprite with error color and alpha - const errorSprites = createdMockSprites.filter( - (s) => s.visible && s.tint === MARKER_COLORS.error, - ); - expect(errorSprites.length).toBeGreaterThanOrEqual(1); - expect(errorSprites[0]!.alpha).toBe(MARKER_ALPHA); - }); - - it('should render skip markers with raw color and alpha via sprite tint', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'Test skip' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // Find the sprite with skip color and alpha - const skipSprites = createdMockSprites.filter( - (s) => s.visible && s.tint === MARKER_COLORS.skip, - ); - expect(skipSprites.length).toBeGreaterThanOrEqual(1); - expect(skipSprites[0]!.alpha).toBe(MARKER_ALPHA); - }); - - it('should render unexpected markers with raw color and alpha via sprite tint', () => { - const markers: TimelineMarker[] = [ - { - id: 'marker-unexpected', - type: 'unexpected', - startTime: 100_000, - summary: 'Test unexpected', - }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // Find the sprite with unexpected color and alpha - const unexpectedSprites = createdMockSprites.filter( - (s) => s.visible && s.tint === MARKER_COLORS.unexpected, - ); - expect(unexpectedSprites.length).toBeGreaterThanOrEqual(1); - expect(unexpectedSprites[0]!.alpha).toBe(MARKER_ALPHA); - }); - }); - - describe('End Time Resolution', () => { - it('should render a bounded marker across its exact range', () => { - // zoom = DISPLAY_WIDTH / TOTAL_DURATION = 0.001px per ns - const markers: TimelineMarker[] = [ - { - id: 'marker-skip', - type: 'skip', - startTime: 100_000, - endTime: 300_000, - summary: 'Bounded', - }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - expect(visibleSprites.length).toBe(1); - // (300_000 - 100_000) * 0.001 = 200px, drawn at its exact width (gap is owned by the - // following marker, so a lone band is not inset). - expect(visibleSprites[0]!.width).toBeCloseTo(200, 0); - }); - - it('should render a marker with no endTime as a min-width point (not extended to next marker)', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'Point' }, - { id: 'marker-error', type: 'error', startTime: 500_000, summary: 'Second' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites - .filter((s) => s.visible && s.width > 0) - .sort((a, b) => a.x - b.x); - - expect(visibleSprites.length).toBe(2); - // A point clamps to MARKER_MIN_WIDTH_PX (2) minus the 1px gap = 1px, - // NOT the 400px distance to the next marker. - expect(visibleSprites[0]!.width).toBeLessThan(5); - }); - - it('should handle multiple markers in sequence', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'First' }, - { id: 'marker-unexpected', type: 'unexpected', startTime: 300_000, summary: 'Second' }, - { id: 'marker-error', type: 'error', startTime: 600_000, summary: 'Third' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - - // All three markers should render (within viewport) - expect(visibleSprites.length).toBe(3); - }); - }); - - describe('T009: Viewport Culling Behavior', () => { - it('should render only markers within viewport time range', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'In viewport' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - - // Marker should be rendered - expect(visibleSprites.length).toBe(1); - }); - - it('should cull markers entirely before viewport', () => { - // Markers that end before the viewport starts should be culled - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'First marker' }, - { - id: 'marker-skip', - type: 'skip', - startTime: 200_000, - endTime: 1_000_000, - summary: 'Second marker', - }, - ]; - - // Zoom in 10x first (so we can actually pan) - // At 10x zoom: 1000px shows 100_000ns - viewport.setZoom(0.01, 0); - - // Now pan to the right so first marker is outside view - // At 0.01 zoom: visible time = 1000/0.01 = 100_000ns - // Pan to 250_000: viewport shows 250_000 to 350_000 - // First marker: point at 100_000 - before 250_000, should be culled - // Second marker: bounded 200_000 to 1_000_000 - overlaps 250_000, should be visible - viewport.setPan(250_000 * viewport.getState().zoom, 0); - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - - // First marker should be culled (point at 100_000 < viewport start 250_000) - // Second marker should be visible (200_000 to 1_000_000 overlaps 250_000) - expect(visibleSprites.length).toBe(1); - expect(visibleSprites[0]!.tint).toBe(MARKER_COLORS.skip); - }); - - it('should cull markers entirely after viewport', () => { - // Create a zoomed-in viewport showing only the start of the timeline - const zoomedViewport = new TimelineViewport( - DISPLAY_WIDTH, - DISPLAY_HEIGHT, - TOTAL_DURATION, - MAX_DEPTH, - ); - // Zoom in 100x so 1000px shows only 10_000ns (10μs) of timeline - zoomedViewport.setZoom(0.1, 0); - - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 900_000, summary: 'After viewport' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - zoomedViewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - - // Marker at 900_000 starts after the visible range (0 to ~10_000) at 0.1 zoom - // Actually at zoom 0.1, visible time = 1000/0.1 = 10_000ns - // So marker at 900_000 starts after viewport ends - expect(visibleSprites.length).toBe(0); - }); - - it('should render partially visible markers', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'Extends into viewport' }, - { id: 'marker-error', type: 'error', startTime: 900_000, summary: 'Starts before end' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - - // Both should be visible (first continues to second, second to end) - expect(visibleSprites.length).toBe(2); - }); - - it('should keep sub-pixel bounded markers visible via the min-width clamp', () => { - // A tiny bounded marker (1ns wide) would be sub-pixel at default zoom. - // Instead of being culled, it clamps to MARKER_MIN_WIDTH_PX so it stays visible. - const markers: TimelineMarker[] = [ - { - id: 'marker-skip', - type: 'skip', - startTime: 100_000, - endTime: 100_001, - summary: 'Tiny bounded marker', - }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - - // Clamped to min width rather than culled. - expect(visibleSprites.length).toBe(1); - expect(visibleSprites[0]!.width).toBeGreaterThan(0); - }); - }); - - describe('Initialization', () => { - it('should create a SpritePool container', () => { - const markers: TimelineMarker[] = []; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - - // SpritePool creates one container - expect(mockContainer.getChildren().length).toBe(1); - }); - - it('should sort markers by startTime on construction', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 300_000, summary: 'Third' }, - { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'First' }, - { id: 'marker-unexpected', type: 'unexpected', startTime: 200_000, summary: 'Second' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - - renderer.render(); - - // Get visible sprites sorted by x position - const visibleSprites = createdMockSprites - .filter((s) => s.visible && s.width > 0) - .sort((a, b) => a.x - b.x); - - // All three should be rendered in chronological order (by x position) - expect(visibleSprites.length).toBe(3); - - // First sprite (skip at 100_000) should be leftmost - expect(visibleSprites[0]!.tint).toBe(MARKER_COLORS.skip); - // Second sprite (unexpected at 200_000) - expect(visibleSprites[1]!.tint).toBe(MARKER_COLORS.unexpected); - // Third sprite (error at 300_000) - expect(visibleSprites[2]!.tint).toBe(MARKER_COLORS.error); - }); - }); - - describe('T014: Hit Testing', () => { - it('should return null when no indicators are hit', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // Hit test at position far before the marker - const result = renderer.hitTest(0, 300); - expect(result).toBeNull(); - }); - - it('should return marker when hit', () => { - const markers: TimelineMarker[] = [ - { - id: 'marker-error', - type: 'error', - startTime: 100_000, - endTime: 300_000, - summary: 'Test error', - }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // Hit test at a position that should be within the marker - const viewportState = viewport.getState(); - const markerScreenX = 100_000 * viewportState.zoom - viewportState.offsetX; - - if (markerScreenX >= 0 && markerScreenX < viewportState.displayWidth) { - const result = renderer.hitTest(markerScreenX + 5, 300); - expect(result).toBe(markers[0]); - } - }); - - it('should return highest severity marker when multiple overlap', () => { - // Two markers with overlapping time ranges - // Skip: 100_000 to 300_000 - // Error: 200_000 to timeline end - // At time 250_000, both markers overlap - const markers: TimelineMarker[] = [ - { - id: 'marker-skip', - type: 'skip', - startTime: 100_000, - endTime: 300_000, - summary: 'Skip marker', - }, - { - id: 'marker-error', - type: 'error', - startTime: 200_000, - endTime: 1_000_000, - summary: 'Error marker', - }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // Hit test at position where both markers should overlap - // Skip ends at 200_000 (error's start), error continues to end - // Actually, skip doesn't overlap with error - they meet at 200_000 - // Let's hit test at 150_000 (in skip region) and 250_000 (in error region) - const viewportState = viewport.getState(); - - // Test in skip-only region - const skipX = 150_000 * viewportState.zoom - viewportState.offsetX; - if (skipX >= 0 && skipX < viewportState.displayWidth) { - const result = renderer.hitTest(skipX, 300); - expect(result?.type).toBe('skip'); - } - - // Test in error-only region - const errorX = 250_000 * viewportState.zoom - viewportState.offsetX; - if (errorX >= 0 && errorX < viewportState.displayWidth) { - const result = renderer.hitTest(errorX, 300); - expect(result?.type).toBe('error'); - } - }); - - it('should work correctly with panned viewport', () => { - const markers: TimelineMarker[] = [ - { - id: 'marker-error', - type: 'error', - startTime: 500_000, - endTime: 700_000, - summary: 'Test error', - }, - ]; - - // Pan viewport to show the marker area - viewport.setPan(400_000 * viewport.getState().zoom, 0); - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const viewportState = viewport.getState(); - const markerScreenX = 500_000 * viewportState.zoom - viewportState.offsetX; - - if (markerScreenX >= 0 && markerScreenX < viewportState.displayWidth) { - const result = renderer.hitTest(markerScreenX + 5, 300); - expect(result).toBe(markers[0]); - } - }); - - it('should ignore Y coordinate for full-height indicators', () => { - const markers: TimelineMarker[] = [ - { - id: 'marker-error', - type: 'error', - startTime: 100_000, - endTime: 300_000, - summary: 'Test error', - }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const viewportState = viewport.getState(); - const markerScreenX = 100_000 * viewportState.zoom - viewportState.offsetX; - - if (markerScreenX >= 0 && markerScreenX < viewportState.displayWidth) { - // Hit test at various Y positions - should all hit the same marker - const result1 = renderer.hitTest(markerScreenX + 5, 0); - const result2 = renderer.hitTest(markerScreenX + 5, 300); - const result3 = renderer.hitTest(markerScreenX + 5, 599); - - expect(result1).toBe(markers[0]); - expect(result2).toBe(markers[0]); - expect(result3).toBe(markers[0]); - } - }); - }); - - describe('updateMarkers', () => { - it('should update markers array', () => { - const initialMarkers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Initial' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - initialMarkers, - ); - renderer.render(); - - // Should have 1 visible sprite initially - let visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - expect(visibleSprites.length).toBe(1); - - const newMarkers: TimelineMarker[] = [ - { id: 'marker-skip', type: 'skip', startTime: 50_000, summary: 'New first' }, - { id: 'marker-error', type: 'error', startTime: 200_000, summary: 'New second' }, - ]; - - renderer.updateMarkers(newMarkers); - renderer.render(); - - // After update, should have 2 visible sprites (reused from pool) - visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - expect(visibleSprites.length).toBe(2); - - // Verify the new markers are rendered with correct tints - const skipSprites = visibleSprites.filter((s) => s.tint === MARKER_COLORS.skip); - const errorSprites = visibleSprites.filter((s) => s.tint === MARKER_COLORS.error); - expect(skipSprites.length).toBe(1); - expect(errorSprites.length).toBe(1); - }); - }); - - describe('exception spacing and bucketing', () => { - it('collapses a dense exception cluster to fewer sprites but keeps them hit-testable', () => { - // At default zoom (0.001px/ns) these three exceptions are within ~0.2px of each other. - const markers: TimelineMarker[] = [ - { id: 'e1', type: 'exception', startTime: 100_000, summary: 'NullPointer' }, - { id: 'e2', type: 'exception', startTime: 100_100, summary: 'LimitException' }, - { id: 'e3', type: 'exception', startTime: 100_200, summary: 'DmlException' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - // The cluster collapses to a single drawn hairline... - const visibleSprites = createdMockSprites.filter((s) => s.visible && s.width > 0); - expect(visibleSprites.length).toBe(1); - - // ...but all three still hit-test, so the tooltip reports the aggregated count. - // Hover at ~101px: within the min-width hit region of all three (starts 100/100.1/100.2). - const result = renderer.hitTest(101, 300); - expect(result?.summary).toBe('3 exceptions'); - }); - - it('keeps a visible gap between two well-separated exceptions', () => { - const markers: TimelineMarker[] = [ - { id: 'e1', type: 'exception', startTime: 100_000, summary: 'First' }, - { id: 'e2', type: 'exception', startTime: 500_000, summary: 'Second' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - renderer.render(); - - const sprites = createdMockSprites - .filter((s) => s.visible && s.width > 0) - .sort((a, b) => a.x - b.x); - expect(sprites.length).toBe(2); - // Gap between the first sprite's end and the second's start is >= 1px. - expect(sprites[1]!.x - (sprites[0]!.x + sprites[0]!.width)).toBeGreaterThanOrEqual(1); - }); - }); - - describe('destroy', () => { - it('should clean up sprite pool', () => { - const markers: TimelineMarker[] = [ - { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test' }, - ]; - - renderer = new TimelineMarkerRenderer( - mockContainer as unknown as PIXI.Container, - viewport, - markers, - ); - - // Should not throw - expect(() => renderer.destroy()).not.toThrow(); - }); - }); -}); diff --git a/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts b/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts index 23c969572..5f6eb16d3 100644 --- a/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts +++ b/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts @@ -27,7 +27,7 @@ describe('SearchHighlightRenderer', () => { fill: jest.SpiedFunction; }; - // Create a mock LogEvent for testing (following batching.test.ts pattern) + // Create a mock LogEvent for testing const createMockEvent = (timestamp: number, duration: number): LogEvent => { const event = { timestamp, diff --git a/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts b/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts deleted file mode 100644 index 37dede84f..000000000 --- a/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2025 Certinia Inc. All rights reserved. - */ - -/** - * EventBatchRenderer - * - * Pure rectangle rendering for timeline events using PixiJS Sprites. - * Receives pre-computed, culled rectangles and renders them with original colors. - * - * Performance optimizations: - * - Uses SpritePool with shared 1x1 white texture for automatic GPU batching - * - Sprites are pooled and reused (no GC overhead after warmup) - * - Color applied via sprite.tint (efficient uniform update, no state changes) - * - Single draw call for all sprites regardless of color variations - * - * Responsibilities: - * - Render rectangles with their original colors - * - Render buckets (sub-pixel aggregated events) - * - * Does NOT: - * - Pre-compute rectangles (done by RectangleCache) - * - Perform culling (done by RectangleCache) - * - Handle search logic (done by SearchStyleRenderer) - */ - -import type { Container } from 'pixi.js'; -import type { PixelBucket, RenderBatch } from '../types/flamechart.types.js'; -import { BUCKET_CONSTANTS, TIMELINE_CONSTANTS } from '../types/flamechart.types.js'; -import type { PrecomputedRect } from './RectangleCache.js'; -import { SpritePool } from './SpritePool.js'; - -export class EventBatchRenderer { - private batches: Map; - private spritePool: SpritePool; - - constructor(container: Container, batches: Map) { - this.batches = batches; - this.spritePool = new SpritePool(container); - } - - /** - * Render culled rectangles and buckets. - * Receives pre-culled rectangles and aggregated buckets from RectangleCache. - * Both are keyed by category. - * - * @param culledRects - Rectangles grouped by category (events > 2px) - * @param buckets - Aggregated buckets grouped by category (events ≤ 2px) - * @param _viewport - Unused, for API compatibility with mesh renderer - */ - public render( - culledRects: Map, - buckets: Map, - _viewport?: unknown, - ): void { - // Release all sprites back to pool for reuse - this.spritePool.releaseAll(); - - // Clear all batches and populate from culled rectangles - // (maintains backward compatibility for tests and debugging) - for (const batch of this.batches.values()) { - batch.rectangles.length = 0; - batch.isDirty = true; - } - - // Pre-calculate constants outside loops - const gap = TIMELINE_CONSTANTS.RECT_GAP; - const halfGap = gap / 2; - - // Render rectangles per category - for (const [category, rectangles] of culledRects) { - const batch = this.batches.get(category); - if (!batch) { - continue; - } - - const color = batch.color; - - for (const rect of rectangles) { - // Store in batch for backward compatibility - batch.rectangles.push(rect); - - // Render as sprite - const sprite = this.spritePool.acquire(); - sprite.position.set(rect.x + halfGap, rect.y + halfGap); - sprite.width = Math.max(0, rect.width - gap); - sprite.height = Math.max(0, rect.height - gap); - sprite.tint = color; - } - - batch.isDirty = false; - } - - // Render all buckets - this.renderBuckets(buckets); - } - - /** - * Clear all sprites (hide them). - * Called when switching to search mode. - */ - public clear(): void { - this.spritePool.releaseAll(); - } - - /** - * Clean up sprite pool. - */ - public destroy(): void { - this.spritePool.destroy(); - } - - // ============================================================================ - // PRIVATE HELPERS - // ============================================================================ - - /** - * Render all buckets using sprites. - * - * Buckets have density-based colors (opacity pre-blended into the color). - * Each bucket gets its own sprite with the appropriate tint. - * - * @param buckets - Aggregated buckets grouped by category - */ - private renderBuckets(buckets: Map): void { - // Pre-calculate constants outside loops - const gap = TIMELINE_CONSTANTS.RECT_GAP; - const halfGap = gap / 2; - const blockWidth = BUCKET_CONSTANTS.BUCKET_BLOCK_WIDTH; - const eventHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; - const gappedHeight = Math.max(0, eventHeight - gap); - - // Render all buckets from all categories - for (const categoryBuckets of buckets.values()) { - for (const bucket of categoryBuckets) { - const sprite = this.spritePool.acquire(); - sprite.position.set(bucket.x + halfGap, bucket.y + halfGap); - sprite.width = blockWidth; - sprite.height = gappedHeight; - sprite.tint = bucket.color; - } - } - } -} diff --git a/log-viewer/src/features/timeline/optimised/SpritePool.ts b/log-viewer/src/features/timeline/optimised/SpritePool.ts deleted file mode 100644 index 06415ea6a..000000000 --- a/log-viewer/src/features/timeline/optimised/SpritePool.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ - -/** - * SpritePool - * - * Object pool for PixiJS Sprites using a shared 1x1 white texture. - * Provides efficient rectangle rendering by: - * - Reusing sprite instances to avoid GC overhead - * - Using a single shared texture for automatic GPU batching - * - Setting color via sprite.tint (efficient uniform update) - * - * Usage: - * const pool = new SpritePool(container, sharedTexture); - * // Each frame: - * pool.releaseAll(); - * for (const rect of rectangles) { - * const sprite = pool.acquire(); - * sprite.position.set(rect.x, rect.y); - * sprite.width = rect.width; - * sprite.height = rect.height; - * sprite.tint = rect.color; - * } - * // On cleanup: - * pool.destroy(); - * - * Performance characteristics: - * - Zero allocations after warmup (sprites reused via visibility toggle) - * - Single draw call for all sprites (automatic batching with shared texture) - * - O(1) acquire, O(n) releaseAll where n = active sprites - */ - -import { Container, Sprite, Texture } from 'pixi.js'; - -/** - * Get the shared 1x1 white texture. - * Uses PixiJS's built-in Texture.WHITE for maximum compatibility and batching. - * - * @returns The shared white texture - */ -export function getSharedWhiteTexture(): Texture { - return Texture.WHITE; -} - -/** - * Destroy the shared white texture. - * No-op since we use PixiJS's built-in Texture.WHITE which should not be destroyed. - */ -export function destroySharedWhiteTexture(): void { - // No-op: Texture.WHITE is a built-in texture that should not be destroyed -} - -/** - * SpritePool - Efficient sprite reuse for rectangle rendering. - * - * Manages a pool of sprites that share a common texture. - * Sprites are acquired for rendering and released back to the pool each frame. - */ -export class SpritePool { - /** Array of all sprites (both active and inactive) */ - private pool: Sprite[] = []; - - /** Number of currently active (visible) sprites */ - private activeCount = 0; - - /** The shared texture used by all sprites */ - private texture: Texture; - - /** Container that holds all sprites */ - private container: Container; - - /** - * Create a new SpritePool. - * - * @param parentContainer - The container to add sprites to - * @param texture - Optional texture to use (defaults to shared white texture) - */ - constructor(parentContainer: Container, texture?: Texture) { - this.texture = texture ?? getSharedWhiteTexture(); - this.container = new Container(); - this.container.label = 'SpritePool'; - parentContainer.addChild(this.container); - } - - /** - * Acquire a sprite from the pool. - * Returns an existing sprite if available, otherwise creates a new one. - * The returned sprite is visible and ready for configuration. - * - * @returns A sprite ready for use - */ - public acquire(): Sprite { - let sprite: Sprite; - - if (this.activeCount < this.pool.length) { - // Reuse existing sprite from pool - sprite = this.pool[this.activeCount]!; - sprite.visible = true; - } else { - // Create new sprite and add to pool - sprite = new Sprite(this.texture); - this.pool.push(sprite); - this.container.addChild(sprite); - } - - this.activeCount++; - return sprite; - } - - /** - * Release all sprites back to the pool. - * Hides all active sprites for reuse in the next frame. - * Call this at the start of each render cycle. - */ - public releaseAll(): void { - for (let i = 0; i < this.activeCount; i++) { - this.pool[i]!.visible = false; - } - this.activeCount = 0; - } - - /** - * Get the number of currently active sprites. - * - * @returns Number of sprites currently in use - */ - public getActiveCount(): number { - return this.activeCount; - } - - /** - * Get the total pool size (active + inactive sprites). - * - * @returns Total number of sprites in pool - */ - public getPoolSize(): number { - return this.pool.length; - } - - /** - * Clean up all sprites and remove from container. - * Does NOT destroy the shared texture (managed separately). - * Call this when the renderer is destroyed. - */ - public destroy(): void { - for (const sprite of this.pool) { - sprite.destroy(); - } - this.pool = []; - this.activeCount = 0; - this.container.destroy(); - } -} diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts index e7b45c986..fb34ce50c 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts @@ -7,6 +7,7 @@ import { layoutMarkerRects, noDataSpanAt, markerDuration, + sortMarkersByTimeAndSeverity, type MarkerLayoutItem, } from '../markers/MarkerProcessor.js'; @@ -95,6 +96,17 @@ describe('layoutMarkerRects', () => { }); }); +describe('sortMarkersByTimeAndSeverity', () => { + it('orders by startTime, higher severity first on a tie', () => { + const sorted = sortMarkersByTimeAndSeverity([ + { id: 'late', type: 'error', startTime: 300_000, summary: 'Late' }, + { id: 'skip', type: 'skip', startTime: 100_000, summary: 'Skip' }, + { id: 'exception', type: 'exception', startTime: 100_000, summary: 'Exception' }, + ]); + expect(sorted.map((m) => m.id)).toEqual(['exception', 'skip', 'late']); + }); +}); + describe('noDataSpanAt', () => { const spans = [ { startTime: 100, endTime: 500, summary: 'Skipped-Lines' }, diff --git a/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts b/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts index a68fb4ccd..eb6556ee2 100644 --- a/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts +++ b/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts @@ -6,7 +6,7 @@ * MarkerHitTest * * Shared utility for hit testing timeline markers. - * Used by TimelineMarkerRenderer and MeshMarkerRenderer. + * Used by MeshMarkerRenderer. */ import type { TimelineMarker } from '../../types/flamechart.types.js'; diff --git a/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts b/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts index b8a477470..d719aa365 100644 --- a/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts +++ b/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts @@ -7,7 +7,7 @@ * * Shared utilities for processing timeline markers (truncation regions). * Consolidates pre-blended color computation and marker processing logic - * used by both MeshMarkerRenderer and TimelineMarkerRenderer. + * used by the marker, metric-strip, minimap and selection renderers. */ import type { NoDataSpan, TimelineMarker } from '../../types/flamechart.types.js'; diff --git a/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts b/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts deleted file mode 100644 index 7adcd0746..000000000 --- a/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ - -/** - * TimelineMarkerRenderer - * - * Renders marker indicators as vertical bands behind the timeline using PixiJS Sprites. - * Handles time-range visualization, viewport culling, and severity-based stacking. - * - * Performance optimizations: - * - Uses SpritePool with shared 1x1 white texture for automatic GPU batching - * - Sprites are pooled and reused (no GC overhead after warmup) - * - Color applied via sprite.tint with true alpha transparency - */ - -import type { Container } from 'pixi.js'; -import type { TimelineMarker } from '../../types/flamechart.types.js'; -import { - MARKER_ALPHA_BY_TYPE, - MARKER_BUCKET_PX, - MARKER_COLORS, - MARKER_GAP_PX, - MARKER_MIN_WIDTH_PX, - SEVERITY_RANK, -} from '../../types/flamechart.types.js'; -import { SpritePool } from '../SpritePool.js'; -import type { TimelineViewport } from '../TimelineViewport.js'; -import { hitTestMarkers, type MarkerIndicator } from './MarkerHitTest.js'; -import { layoutMarkerRects } from './MarkerProcessor.js'; - -/** - * Renders marker indicators as semi-transparent vertical bands using sprites. - * - * Architecture: - * - Uses single SpritePool for efficient rendering - * - Renders in severity order (skip → unexpected → error) via z-ordering - * - Culls off-screen indicators for performance - * - Calculates end times for markers with null endTime - */ -export class TimelineMarkerRenderer { - private container: Container; - private viewport: TimelineViewport; - private markers: readonly TimelineMarker[]; - private spritePool: SpritePool; - private visibleIndicators: MarkerIndicator[] = []; - - /** - * Creates a new TimelineMarkerRenderer. - * - * @param container - PixiJS container to render into (should be at z-index 0) - * @param viewport - Viewport manager for coordinate transforms - * @param markers - Array of markers from parser - */ - constructor(container: Container, viewport: TimelineViewport, markers: TimelineMarker[]) { - this.container = container; - this.viewport = viewport; - - // Sort markers by startTime for efficient end time resolution - this.markers = [...markers].sort((a, b) => { - if (a.startTime !== b.startTime) { - return a.startTime - b.startTime; - } - return SEVERITY_RANK[b.type] - SEVERITY_RANK[a.type]; - }); - - // Create sprite pool for marker rendering - this.spritePool = new SpritePool(container); - } - - /** - * Renders all visible truncation indicators for current viewport. - * - * Algorithm: - * 1. Cull markers outside viewport - * 2. Resolve end times with overlap prevention - * 3. Transform to screen coordinates - * 4. Render as sprites with tinted colors - * - * Overlap behavior: - * - Higher priority marker takes precedence (error > unexpected > skip) - * - Same priority: current marker stops at start of next marker - * - * Performance: <10ms target for typical logs (<10 markers) - */ - public render(): void { - // Release all sprites back to pool - this.spritePool.releaseAll(); - - // Early exit if no markers - if (this.markers.length === 0) { - this.visibleIndicators = []; - return; - } - - // Get viewport bounds for culling - const bounds = this.viewport.getBounds(); - const renderState = this.viewport.getState(); - - // Process all markers (typically <10 truncation markers; exceptions can be many) - this.visibleIndicators = []; - - for (let i = 0; i < this.markers.length; i++) { - const marker = this.markers[i]; - if (!marker) { - continue; - } - - // A bounded marker (endTime set) shades its exact range; an unbounded marker is a - // point at startTime. We no longer extend to the next marker's start. - const resolvedEndTime = marker.endTime ?? marker.startTime; - - // T009: Viewport culling - skip markers outside visible time range - // Marker is visible if it overlaps [bounds.timeStart, bounds.timeEnd] - if (resolvedEndTime < bounds.timeStart || marker.startTime > bounds.timeEnd) { - continue; - } - - // T010: Transform to world coordinates for rendering - // World coordinates = time * zoom (no offset - container handles transform) - const worldStartX = marker.startTime * renderState.zoom; - const exactWidth = resolvedEndTime * renderState.zoom - worldStartX; - - // Clamp to a minimum width so markers stay visible when zoomed out. - const worldWidth = Math.max(exactWidth, MARKER_MIN_WIDTH_PX); - - const indicator: MarkerIndicator = { - marker, - resolvedEndTime, - screenStartX: worldStartX, - screenEndX: worldStartX + worldWidth, - screenWidth: worldWidth, - exactWidth: Math.max(exactWidth, 0), - color: MARKER_COLORS[marker.type], - alpha: MARKER_ALPHA_BY_TYPE[marker.type], - isVisible: true, - }; - - this.visibleIndicators.push(indicator); - } - - // Resolve overlapping markers into gapped, min-width rectangles (dense points collapse - // to one line). Hit testing still uses every indicator, so bucketed markers still count. - const rects = layoutMarkerRects( - this.visibleIndicators, - MARKER_MIN_WIDTH_PX, - MARKER_GAP_PX, - MARKER_BUCKET_PX, - ); - for (const rect of rects) { - const sprite = this.spritePool.acquire(); - sprite.position.set(rect.x, 0); - sprite.width = rect.width; - sprite.height = renderState.displayHeight; - sprite.tint = rect.color; - sprite.alpha = rect.alpha; - } - } - - /** - * Tests if a screen coordinate intersects any indicator. - * - * Used for hover detection. Returns marker with highest severity when multiple overlap. - * - * @param screenX - Mouse X coordinate in pixels (canvas-relative) - * @param _screenY - Mouse Y coordinate in pixels (unused - indicators span full height) - * @returns Marker under cursor (highest severity if multiple), or null if no hit - */ - public hitTest(screenX: number, _screenY: number): TimelineMarker | null { - const viewportState = this.viewport.getState(); - return hitTestMarkers(screenX, viewportState.offsetX, this.visibleIndicators); - } - - /** - * Updates the markers and triggers re-render. - * - * @param markers - New array of markers - */ - public updateMarkers(markers: readonly TimelineMarker[]): void { - (this.markers as TimelineMarker[]) = [...markers].sort((a, b) => a.startTime - b.startTime); - this.visibleIndicators = []; - } - - /** - * Cleans up sprite pool and removes from container. - * Must be called before discarding the renderer. - */ - public destroy(): void { - this.spritePool.destroy(); - this.container.destroy(); - } -} diff --git a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts index bb186d243..079de43de 100644 --- a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts @@ -70,7 +70,7 @@ export function renderWash( const screenX = timestamp * viewport.zoom; const screenWidth = duration * viewport.zoom; - // Must match rectangle rendering in EventBatchRenderer: x + halfGap, width - gap. + // Must match rectangle rendering in MeshRectangleRenderer: x + halfGap, width - gap. const halfGap = TIMELINE_CONSTANTS.RECT_GAP / 2; const y = depth * TIMELINE_CONSTANTS.EVENT_HEIGHT + halfGap; const height = TIMELINE_CONSTANTS.EVENT_HEIGHT - TIMELINE_CONSTANTS.RECT_GAP; diff --git a/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts index 5bc1269ca..94ffb3e89 100644 --- a/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts @@ -134,7 +134,7 @@ export class SearchHighlightRenderer { /** * Calculate culling bounds from viewport state. - * Same logic as EventBatchRenderer for consistency. + * Same logic as SelectionHighlightRenderer.calculateBounds for consistency. * * @param viewport - Viewport state * @returns Culling bounds in timeline coordinates @@ -157,7 +157,7 @@ export class SearchHighlightRenderer { /** * Check if rectangle is visible within culling bounds. - * Uses same culling logic as EventBatchRenderer. + * Uses same culling logic as SelectionHighlightRenderer.isVisible. * * @param match - Search match to test * @param rect - Rectangle to test @@ -179,7 +179,7 @@ export class SearchHighlightRenderer { return false; } // Always show current match highlight regardless of rect size - // (don't check rect.width > 0 because EventBatchRenderer might cull small rects) + // (don't check rect.width > 0 because MeshRectangleRenderer might cull small rects) return true; } } diff --git a/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts b/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts deleted file mode 100644 index 9c687891f..000000000 --- a/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright (c) 2025 Certinia Inc. All rights reserved. - */ - -/** - * SearchStyleRenderer - * - * Renders rectangles with search-aware styling using PixiJS Sprites (Chrome DevTools style). - * Matched events retain original colors, non-matched events are desaturated to greyscale. - * - * Performance optimizations: - * - Uses SpritePool with shared 1x1 white texture for automatic GPU batching - * - Sprites are pooled and reused (no GC overhead after warmup) - * - Color applied via sprite.tint (efficient uniform update) - * - Single draw call for all sprites regardless of color variations - * - * Responsibilities: - * - Render rectangles with search styling - * - Desaturate non-matched events - * - Maintain original colors for matched events - * - * Does NOT: - * - Pre-compute rectangles (done by RectangleCache) - * - Perform culling (done by RectangleCache) - * - Draw highlight borders (done by SearchHighlightRenderer) - * - Implement search logic - */ - -import type { Container } from 'pixi.js'; -import type { - CategoryAggregation, - PixelBucket, - RenderBatch, -} from '../../types/flamechart.types.js'; -import { BUCKET_CONSTANTS, TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; -import type { MatchedEventInfo } from '../../types/search.types.js'; -import { resolveColor } from '../BucketColorResolver.js'; -import type { PrecomputedRect } from '../RectangleCache.js'; -import { colorToGreyscale } from '../rendering/ColorUtils.js'; -import { SpritePool } from '../SpritePool.js'; - -/** - * SearchStyleRenderer - * - * Pure rendering class for search-aware styling of timeline events using sprites. - * Receives pre-computed, culled rectangles and matched events set. - */ -export class SearchStyleRenderer { - private batches: Map; - private spritePool: SpritePool; - - constructor(container: Container, batches: Map) { - this.batches = batches; - this.spritePool = new SpritePool(container); - } - - /** - * Render culled rectangles and buckets with search styling. - * Matched events: original colors - * Non-matched events: desaturated greyscale - * Buckets: search-aware styling based on matched events - * - * @param culledRects - Rectangles grouped by category (from RectangleCache) - * @param matchedEventIds - Set of event IDs that match search (retain original colors) - * @param buckets - Aggregated pixel buckets grouped by category - * @param _viewport - Unused, for API compatibility with mesh renderer - * @param matchedEventsInfo - Lightweight info about matched events for bucket highlighting - */ - public render( - culledRects: Map, - matchedEventIds: ReadonlySet, - buckets: Map = new Map(), - _viewport?: unknown, - matchedEventsInfo: ReadonlyArray = [], - ): void { - // Release all sprites back to pool for reuse - this.spritePool.releaseAll(); - - // Pre-calculate constants outside loops - const gap = TIMELINE_CONSTANTS.RECT_GAP; - const halfGap = gap / 2; - - // Render rectangles per category with search styling - for (const [category, rectangles] of culledRects) { - const batch = this.batches.get(category); - if (!batch) { - continue; - } - - const originalColor = batch.color; - const greyColor = colorToGreyscale(originalColor); - - for (const rect of rectangles) { - const sprite = this.spritePool.acquire(); - sprite.position.set(rect.x + halfGap, rect.y + halfGap); - sprite.width = Math.max(0, rect.width - gap); - sprite.height = Math.max(0, rect.height - gap); - - // Use original color for matched events, greyscale for non-matched - sprite.tint = matchedEventIds.has(rect.id) ? originalColor : greyColor; - } - } - - // Render all buckets with search styling - this.renderBucketsWithSearch(buckets, matchedEventsInfo); - } - - /** - * Clear all sprites (hide them). - * Called when exiting search mode to return to normal rendering. - */ - public clear(): void { - this.spritePool.releaseAll(); - } - - /** - * Clean up sprite pool. - */ - public destroy(): void { - this.spritePool.destroy(); - } - - // ============================================================================ - // PRIVATE HELPERS - // ============================================================================ - - /** - * Render all buckets with search styling using sprites. - * - * Each bucket's color is determined by whether it contains matched events. - * Buckets with matches use resolved color from matched events. - * Buckets without matches use desaturated greyscale. - * - * Uses time-range matching since bucket.eventRefs may be empty for memory-optimized buckets. - * - * @param buckets - Aggregated buckets grouped by category - * @param matchedEventsInfo - Lightweight info about matched events - */ - private renderBucketsWithSearch( - buckets: Map, - matchedEventsInfo: ReadonlyArray, - ): void { - // Build spatial index: Map> - const matchesByDepth = new Map>(); - for (const info of matchedEventsInfo) { - let depthMatches = matchesByDepth.get(info.depth); - if (!depthMatches) { - depthMatches = []; - matchesByDepth.set(info.depth, depthMatches); - } - depthMatches.push({ timestamp: info.timestamp, category: info.category }); - } - - // Pre-calculate constants outside loops - const gap = TIMELINE_CONSTANTS.RECT_GAP; - const halfGap = gap / 2; - const blockWidth = BUCKET_CONSTANTS.BUCKET_BLOCK_WIDTH; - const eventHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; - const gappedHeight = Math.max(0, eventHeight - gap); - - // Render all buckets from all categories - for (const categoryBuckets of buckets.values()) { - for (const bucket of categoryBuckets) { - // Find matched events in this bucket using time-range matching - const matchedCategoryStats = new Map(); - - const depthMatches = matchesByDepth.get(bucket.depth); - if (depthMatches) { - for (const match of depthMatches) { - if ( - match.timestamp >= bucket.timeStart && - match.timestamp < bucket.timeEnd && - match.category - ) { - let stats = matchedCategoryStats.get(match.category); - if (!stats) { - stats = { count: 0, totalDuration: 0 }; - matchedCategoryStats.set(match.category, stats); - } - stats.count++; - } - } - } - - let displayColor: number; - - if (matchedCategoryStats.size > 0) { - // Resolve color from matched events using priority rules - displayColor = resolveColor( - { - byCategory: matchedCategoryStats, - dominantCategory: '', - }, - this.batches, - ).color; - } else { - // No matches - desaturate the bucket's pre-blended color - displayColor = colorToGreyscale(bucket.color); - } - - const sprite = this.spritePool.acquire(); - sprite.position.set(bucket.x + halfGap, bucket.y + halfGap); - sprite.width = blockWidth; - sprite.height = gappedHeight; - sprite.tint = displayColor; - } - } - } -} diff --git a/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts b/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts deleted file mode 100644 index 757aaa879..000000000 --- a/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright (c) 2025 Certinia Inc. All rights reserved. - */ - -/** - * AxisRenderer - * - * Renders time scale axis with dynamic labels and tick marks using PixiJS Sprites. - * Adapts tick density and label precision based on zoom level. - * - * Performance optimizations: - * - Uses SpritePool for grid lines (automatic GPU batching) - * - Sprites are pooled and reused (no GC overhead after warmup) - * - Labels use PIXI.Text (optimal for dynamic text with caching) - * - * Tick levels (zoom-dependent): - * - Seconds: Major ticks at 1s, 2s, 5s, 10s intervals - * - Milliseconds: Major ticks at 1ms, 2ms, 5ms, 10ms intervals - * - Microseconds: Major ticks at 1μs, 2μs, 5μs, 10μs intervals - * - Nanoseconds: Major ticks at 1ns, 2ns, 5ns, 10ns intervals - */ - -import { Container, Text } from 'pixi.js'; -import type { ViewportState } from '../../types/flamechart.types.js'; -import { SpritePool } from '../SpritePool.js'; -import { NS_PER_MS, formatMilliseconds, selectInterval } from './timeAxisConstants.js'; - -/** - * Axis rendering configuration. - */ -interface AxisConfig { - /** Height of axis area in pixels */ - height: number; - /** Color of axis line and ticks */ - lineColor: number; - /** Color of axis labels */ - textColor: string; - /** Font size for labels */ - fontSize: number; - /** Minimum spacing between labels in pixels */ - minLabelSpacing: number; -} - -/** - * Time interval for tick marks. - */ -interface TickInterval { - /** Interval duration in nanoseconds */ - interval: number; - /** Skip factor (1 = show all, 2 = show every 2nd, 5 = show every 5th) */ - skipFactor: number; -} - -export class AxisRenderer { - private spritePool: SpritePool; - private labelsContainer: Container; - private screenSpaceContainer: Container | null = null; - private config: AxisConfig; - private labelCache: Map = new Map(); - /** Grid line color */ - private gridLineColor: number; - - constructor(container: Container, config?: Partial) { - // Default configuration - this.config = { - height: 30, - lineColor: 0x808080, // Medium gray that works in light and dark themes - textColor: '#808080', // Medium gray that works in light and dark themes - fontSize: 11, - minLabelSpacing: 80, - ...config, - }; - - this.gridLineColor = this.config.lineColor; - - // Create sprite pool for grid lines (in world space - will be transformed with stage) - this.spritePool = new SpritePool(container); - - // Labels container - will be added to screen space container when provided - this.labelsContainer = new Container(); - } - - /** - * Set the screen-space container for labels (not affected by stage transforms). - * This container should be added directly to app.stage at root level. - */ - public setScreenSpaceContainer(container: Container): void { - this.screenSpaceContainer = container; - - // Move labels to screen space container - if (this.labelsContainer.parent) { - this.labelsContainer.parent.removeChild(this.labelsContainer); - } - this.screenSpaceContainer.addChild(this.labelsContainer); - - // Set up coordinate system for labels (top-left origin, Y pointing down for text) - this.labelsContainer.position.set(0, 0); - this.labelsContainer.scale.set(1, 1); - } - - /** - * Render axis based on current viewport state. - * - * Implements dynamic tick calculation and label density management. - * @param viewport - Current viewport state - */ - public render(viewport: ViewportState): void { - // Release all sprites back to pool - this.spritePool.releaseAll(); - this.clearLabels(); - - // Calculate visible time range - const timeStart = viewport.offsetX / viewport.zoom; - const timeEnd = (viewport.offsetX + viewport.displayWidth) / viewport.zoom; - - // Calculate appropriate tick interval based on zoom - const tickInterval = this.calculateTickInterval(viewport); - - // Render tick marks (vertical lines from top to bottom, behind rectangles) - this.renderTicks(viewport, timeStart, timeEnd, tickInterval); - } - - /** - * Clean up resources. - */ - public destroy(): void { - this.spritePool.destroy(); - this.labelsContainer.destroy(); - this.labelCache.clear(); - } - - // ============================================================================ - // PRIVATE: TICK CALCULATION - // ============================================================================ - - /** - * Calculate appropriate tick interval based on zoom level. - * - * Uses 1-2-5 sequence for millisecond intervals. - * Implements skip factor when labels would be too close. - */ - private calculateTickInterval(viewport: ViewportState): TickInterval { - // Calculate pixels per nanosecond - const pixelsPerNs = viewport.zoom; - - // Target: one label every minLabelSpacing pixels - const targetIntervalNs = this.config.minLabelSpacing / pixelsPerNs; - - // Convert to milliseconds - const targetIntervalMs = targetIntervalNs / NS_PER_MS; - - // Find appropriate interval using 1-2-5 sequence - const { interval, skipFactor } = selectInterval(targetIntervalMs); - - return { - interval: interval * NS_PER_MS, // Convert back to nanoseconds - skipFactor, - }; - } - - // ============================================================================ - // PRIVATE: RENDERING - // ============================================================================ - - /** - * Render vertical tick lines with labels at the top. - */ - private renderTicks( - viewport: ViewportState, - timeStart: number, - timeEnd: number, - tickInterval: TickInterval, - ): void { - // Calculate first tick position (snap to interval boundary) - // Go back one extra tick to ensure we cover the left edge - const firstTickIndex = Math.floor(timeStart / tickInterval.interval) - 1; - - // Calculate last tick position - // Go forward one extra tick to ensure we cover the right edge - const lastTickIndex = Math.ceil(timeEnd / tickInterval.interval) + 1; - - // Track rendered pixel positions to prevent duplicates - const renderedPixels = new Set(); - - // Render all ticks in range - for (let i = firstTickIndex; i <= lastTickIndex; i++) { - const time = i * tickInterval.interval; - - // Calculate screen position and round to pixel - const screenX = time * viewport.zoom; - const pixelX = Math.round(screenX); - - // Skip if we already rendered a line at this pixel position - if (renderedPixels.has(pixelX)) { - continue; - } - renderedPixels.add(pixelX); - - // Calculate if this tick should show a label based on global position - // This ensures labels stay consistent when panning - const shouldShowLabel = i % tickInterval.skipFactor === 0; - - this.renderVerticalLine(screenX, viewport.displayHeight, time, shouldShowLabel, viewport); - } - } - - /** - * Render a vertical line from top to bottom with optional label at top. - */ - private renderVerticalLine( - screenX: number, - viewportHeight: number, - timeNs: number, - showLabel: boolean, - viewport: ViewportState, - ): void { - // Round screen position to prevent sub-pixel rendering issues - const roundedX = Math.round(screenX); - - // Draw vertical line using sprite (1px wide, full height) - const sprite = this.spritePool.acquire(); - sprite.position.set(roundedX, 0); - sprite.width = 1; - sprite.height = viewportHeight; - sprite.tint = this.gridLineColor; - - // Add label at top if requested - if (showLabel && this.screenSpaceContainer) { - const timeMs = timeNs / NS_PER_MS; - const labelText = formatMilliseconds(timeMs); - - // Only show label if not empty (skip zero) - if (labelText) { - const label = this.getOrCreateLabel(labelText); - - // Calculate screen-space X position (accounting for stage pan) - // screenX is in world space, need to convert to screen space - const screenSpaceX = screenX - viewport.offsetX; - - // Position label in screen space (top-left origin, Y pointing down) - // No minimap offset needed - main timeline has its own canvas - label.x = screenSpaceX - 3; // 3px to the left of line - label.y = 5; // 5px from top - label.anchor.set(1, 0); // Right-align to line, align top - } - } - } - - // ============================================================================ - // PRIVATE: LABEL MANAGEMENT - // ============================================================================ - - /** - * Get or create a PIXI.Text label from cache. - * Reuses labels to avoid constant object creation. - */ - private getOrCreateLabel(text: string): Text { - let label = this.labelCache.get(text); - - if (!label) { - label = new Text({ - text, - style: { - fontFamily: 'monospace', - fontSize: this.config.fontSize, - fill: this.config.textColor, - }, - }); - this.labelCache.set(text, label); - this.labelsContainer.addChild(label); - } - - // Make label visible - label.visible = true; - - return label; - } - - /** - * Hide all labels (for next render pass). - */ - private clearLabels(): void { - for (const label of this.labelCache.values()) { - label.visible = false; - } - } -} diff --git a/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts b/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts index d9fdc67b4..7950b68be 100644 --- a/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts +++ b/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts @@ -5,8 +5,8 @@ /** * Shared constants and utilities for time-axis rendering. * - * Consolidates duplicated logic from MeshAxisRenderer, AxisRenderer, - * ClockTimeAxisRenderer, ElapsedTimeAxisRenderer, and TimeGridCalculator. + * Consolidates duplicated logic from MeshAxisRenderer, ClockTimeAxisRenderer, + * ElapsedTimeAxisRenderer, and TimeGridCalculator. */ /**