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. */ /**