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 cd2c1986..b473d2f3 100644 --- a/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts +++ b/log-viewer/src/features/timeline/__tests__/search-highlight.test.ts @@ -100,7 +100,7 @@ describe('SearchHighlightRenderer', () => { timestamp: m.event.timestamp, duration: m.event.duration, depth: m.depth, - category: m.event.subCategory ?? '', + category: m.rect.category, })), ), }; diff --git a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts index 81c5a2c5..b360d280 100644 --- a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts +++ b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts @@ -56,13 +56,6 @@ describe('TimelineTooltipManager', () => { } as unknown as LogEvent; } - /** - * Helper to wait for async operations - */ - function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - beforeEach(() => { // Create container element container = document.createElement('div'); diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index 8340492e..a5b1ade8 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -39,7 +39,7 @@ import type { } from '../types/flamechart.types.js'; import type { SearchCursor } from '../types/search.types.js'; import { extractMarkers } from '../utils/marker-utils.js'; -import { logEventToTreeNode } from '../utils/tree-converter.js'; +import { logEventToTreeAndRects } from '../utils/tree-converter.js'; import { FlameChart } from './FlameChart.js'; import { TimelineTooltipManager } from './TimelineTooltipManager.js'; @@ -123,12 +123,36 @@ export class ApexLogTimeline { const markers = extractMarkers(this.apexLog); this.events = this.extractEvents(); - // Convert LogEvent to TreeNode structure for search and navigation - // This is Apex-specific: filters out 0-duration events that are invisible - // Also builds navigation maps during traversal to avoid duplicate O(n) work - const { treeNodes, maps } = logEventToTreeNode(this.events); + // Define categories for rectangle indexing (matches FlameChart batch categories) + const categories = new Set([ + 'Code Unit', + 'Workflow', + 'Method', + 'Flow', + 'DML', + 'SOQL', + 'System Method', + ]); - // Initialize FlameChart with Apex-specific callbacks + // Single-pass unified conversion: builds TreeNodes, navigation maps, + // PrecomputedRects, maxDepth, and totalDuration in one O(n) traversal. + // This eliminates redundant traversals previously done by: + // - logEventToTreeNode (tree + maps) + // - TimelineEventIndex.calculateMaxDepth + // - TimelineEventIndex.calculateTotalDuration + // - RectangleManager.flattenEvents + const { + treeNodes, + maps, + rectsByCategory, + rectsByDepth, + rectMap, + maxDepth, + totalDuration, + preSorted, + } = logEventToTreeAndRects(this.events, categories); + + // Initialize FlameChart with Apex-specific callbacks and precomputed data await this.flamechart.init( container, this.events, @@ -179,6 +203,8 @@ export class ApexLogTimeline { this.copyToClipboard(marker.summary); }, }, + // Pass precomputed data to skip redundant O(n) traversals + { maxDepth, totalDuration, rectsByCategory, rectsByDepth, rectMap, preSorted }, ); // Create context menu Lit element (using constructor ensures custom element is registered) @@ -231,7 +257,6 @@ export class ApexLogTimeline { duration: result.event.duration.total, type: result.event.type ?? result.event.subCategory ?? 'UNKNOWN', text: result.event.text, - subCategory: result.event.subCategory, original: result.event, }; diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 3d4233d8..d120e866 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -46,7 +46,7 @@ import { import { TimelineInteractionHandler } from './interaction/TimelineInteractionHandler.js'; import { TimelineResizeHandler } from './interaction/TimelineResizeHandler.js'; import type { MeasurementState } from './measurement/MeasurementManager.js'; -import { RectangleManager } from './RectangleManager.js'; +import { RectangleManager, type PrecomputedRect } from './RectangleManager.js'; import { CursorLineRenderer } from './rendering/CursorLineRenderer.js'; import { TimelineEventIndex } from './TimelineEventIndex.js'; import { TimelineViewport } from './TimelineViewport.js'; @@ -153,6 +153,9 @@ export class FlameChart { private hitTestManager: HitTestManager | null = null; + // Flag to track if ResizeObserver has been set up (deferred until after first render) + private resizeObserverActive = false; + // Selection orchestrator (owns selection state and rendering) private selectionOrchestrator: SelectionOrchestrator | null = null; private viewportAnimator: ViewportAnimator | null = null; @@ -176,6 +179,13 @@ export class FlameChart { // Used to convert canvas-relative coordinates to container-relative for tooltip positioning private mainTimelineYOffset = 0; + // Cached culled rectangles (reused when viewport unchanged - Phase 3 optimization) + // INVARIANT: These caches are invalidated when renderDirty.culling is set to true. + // Any code that changes viewport state must call invalidateAll() or set culling dirty flag. + private cachedVisibleRects: Map | null = null; + private cachedBuckets: Map | null = + null; + /** * Initialize the flamechart renderer. * @@ -186,6 +196,7 @@ export class FlameChart { * @param markers - Timeline markers (truncation regions, etc.) * @param options - Rendering options * @param callbacks - Event callbacks + * @param precomputed - Optional precomputed data from unified tree conversion */ public async init( container: HTMLElement, @@ -195,6 +206,14 @@ export class FlameChart { markers: TimelineMarker[] = [], options: TimelineOptions = {}, callbacks: FlameChartCallbacks = {}, + precomputed?: { + maxDepth: number; + totalDuration: number; + rectsByCategory: Map; + rectsByDepth?: Map; + rectMap: Map; + preSorted?: boolean; + }, ): Promise { // Validate inputs if (!container || !(container instanceof HTMLElement)) { @@ -223,7 +242,7 @@ export class FlameChart { // Store truncation markers for rendering this.markers.push(...markers); - // Get container dimensions + // Get container dimensions for validation const { width, height } = container.getBoundingClientRect(); if (width === 0 || height === 0) { throw new TimelineError( @@ -232,21 +251,23 @@ export class FlameChart { ); } - // Create event index - this.index = new TimelineEventIndex(events); + // Create event index (use precomputed metrics if available) + this.index = new TimelineEventIndex( + events, + precomputed + ? { maxDepth: precomputed.maxDepth, totalDuration: precomputed.totalDuration } + : undefined, + ); - // Calculate minimap and metric strip heights BEFORE creating viewport - // Viewport needs the available height for main timeline (excluding minimap + metric strip + gaps) - // Metric strip starts collapsed, so use collapsed height for initial layout - const minimapHeight = calculateMinimapHeight(height); - const metricStripHeight = METRIC_STRIP_COLLAPSED_HEIGHT; - const totalOverheadHeight = minimapHeight + MINIMAP_GAP + metricStripHeight + METRIC_STRIP_GAP; - const mainTimelineHeight = height - totalOverheadHeight; + // Initialize PixiJS Application first - creates DOM and measures dimensions after RAF. + // This ensures flex layout is computed before we measure mainDiv's actual height. + const { mainTimelineHeight } = await this.setupPixiApplication(width, height); - // Store offset for converting canvas-relative to container-relative coordinates - this.mainTimelineYOffset = totalOverheadHeight; + // Calculate mainTimelineYOffset from measured height + // This is the vertical offset from container top to main timeline canvas + this.mainTimelineYOffset = height - mainTimelineHeight; - // Create viewport manager with adjusted height for main timeline area + // Create viewport manager with measured height for main timeline area this.viewport = new TimelineViewport( width, mainTimelineHeight, @@ -254,9 +275,6 @@ export class FlameChart { this.index.maxDepth, ); - // Initialize PixiJS Application - await this.setupPixiApplication(width, height); - // Setup coordinate system (Y-axis inversion) this.setupCoordinateSystem(); @@ -308,9 +326,18 @@ export class FlameChart { } // Create RectangleManager (single source of truth for rectangle computation) + // Use precomputed rectangles if available (from unified tree conversion) if (this.state) { const categories = new Set(this.state.batches.keys()); - this.rectangleManager = new RectangleManager(events, categories); + const precomputedRects = precomputed + ? { + rectsByCategory: precomputed.rectsByCategory, + rectMap: precomputed.rectMap, + rectsByDepth: precomputed.rectsByDepth, + preSorted: precomputed.preSorted, + } + : undefined; + this.rectangleManager = new RectangleManager(events, categories, precomputedRects); } // Create batch renderer (pure rendering, receives rectangles from RectangleManager) @@ -380,12 +407,10 @@ export class FlameChart { // Setup keyboard handler this.setupKeyboardHandler(); - // Pass the same dimensions that init() used to create the viewport. - // This ensures ResizeObserver's initial callback (which fires with current container - // dimensions) is correctly skipped, even if DOM manipulation during init caused - // a layout shift that changed the container size. - this.resizeHandler = new TimelineResizeHandler(container, this, width, height); - this.resizeHandler.setupResizeObserver(); + // Container dimensions are unchanged by DOM setup (wrapper fills 100%). + // The fix is measuring mainTimelineHeight from actual flexbox layout. + // ResizeObserver setup is deferred until after first render to avoid double render on init. + this.resizeHandler = new TimelineResizeHandler(container, this); // Initialize search if enabled via options if (options.enableSearch) { @@ -606,19 +631,68 @@ export class FlameChart { /** * Request a redraw on next frame. + * Default: full render (all phases dirty). + * For optimized paths, use requestCursorRender() or requestHighlightsRender(). */ public requestRender(): void { if (!this.state) { return; } + // Default: full render (all phases dirty) + // Callers can use requestCursorRender/requestHighlightsRender for optimization + this.invalidateAll(); + + this.state.needsRender = true; + this.scheduleRender(); + } + + /** + * Request highlights render (5x faster than full render). + * Use for selection changes when viewport hasn't changed. + */ + private requestHighlightsRender(): void { + if (!this.state) { + return; + } + this.invalidateHighlights(); + this.state.needsRender = true; + this.scheduleRender(); + } + + /** + * Request cursor-only render (~1ms vs ~10ms for full render). + * Only invalidates overlays and minimap/metric strip. + * Reuses cached culling results - use for cursor moves on minimap/metric strip. + */ + private requestCursorRender(): void { + if (!this.state) { + return; + } + this.state.renderDirty.overlays = true; + this.state.renderDirty.minimap = true; + this.state.renderDirty.metricStrip = true; this.state.needsRender = true; + this.scheduleRender(); + } + /** + * Schedule a render on next animation frame. + * Shared by requestRender, requestCursorRender, and requestHighlightsRender. + */ + private scheduleRender(): void { if (this.renderLoopId === null) { this.renderLoopId = requestAnimationFrame(() => { if (this.state && this.state.needsRender) { this.render(); this.state.needsRender = false; + + // Setup ResizeObserver after first render to avoid double render on init. + // By this point, layout is finalized and ResizeObserver baseline matches rendered state. + if (this.resizeHandler && !this.resizeObserverActive) { + this.resizeHandler.setupResizeObserver(); + this.resizeObserverActive = true; + } } this.renderLoopId = null; }); @@ -627,6 +701,7 @@ export class FlameChart { /** * Handle window resize. + * Calculates main timeline height by subtracting visible component heights. */ public resize(newWidth: number, newHeight: number): void { if (!this.app || !this.viewport || !this.container || !this.index) { @@ -646,20 +721,31 @@ export class FlameChart { const visibleWorldYBottom = -oldState.offsetY; - // Calculate new minimap, metric strip, and main timeline heights - // Query actual metric strip height (respects collapsed/expanded state) + // Calculate component heights, only including visible components const minimapHeight = calculateMinimapHeight(newHeight); - const metricStripHeight = - this.metricStripOrchestrator?.getHeight() ?? METRIC_STRIP_COLLAPSED_HEIGHT; - const totalOverheadHeight = minimapHeight + MINIMAP_GAP + metricStripHeight + METRIC_STRIP_GAP; + + // Only include metric strip in calculation if it's visible (has data) + const metricStripVisible = this.metricStripOrchestrator?.getIsVisible() ?? false; + const metricStripHeight = metricStripVisible + ? (this.metricStripOrchestrator?.getHeight() ?? METRIC_STRIP_COLLAPSED_HEIGHT) + : 0; + const metricStripGap = metricStripVisible ? METRIC_STRIP_GAP : 0; + + // Calculate main timeline height by subtracting all visible component heights + const totalOverheadHeight = minimapHeight + MINIMAP_GAP + metricStripHeight + metricStripGap; const mainTimelineHeight = newHeight - totalOverheadHeight; + if (mainTimelineHeight <= 0) { + return; // Invalid state, skip resize + } + // Update offset for converting canvas-relative to container-relative coordinates this.mainTimelineYOffset = totalOverheadHeight; - // Update orchestrators with new offset - this.selectionOrchestrator?.setMainTimelineYOffset(this.mainTimelineYOffset); - this.searchOrchestrator?.setMainTimelineYOffset(this.mainTimelineYOffset); + // Update minimap div height + if (this.minimapDiv) { + this.minimapDiv.style.height = `${minimapHeight}px`; + } // Resize minimap orchestrator if (this.minimapOrchestrator) { @@ -671,17 +757,13 @@ export class FlameChart { this.metricStripOrchestrator.resize(newWidth); } + // Update orchestrators with new offset + this.selectionOrchestrator?.setMainTimelineYOffset(this.mainTimelineYOffset); + this.searchOrchestrator?.setMainTimelineYOffset(this.mainTimelineYOffset); + // Resize main timeline app this.app.renderer.resize(newWidth, mainTimelineHeight); - // Update wrapper div heights - if (this.wrapper) { - const minimapDiv = this.wrapper.children[0] as HTMLElement; - if (minimapDiv) { - minimapDiv.style.height = `${minimapHeight}px`; - } - } - const newZoom = newWidth / visibleTimeRange; const newOffsetX = visibleTimeStart * newZoom; const newOffsetY = -visibleWorldYBottom; @@ -734,6 +816,7 @@ export class FlameChart { /** * Update metric strip visibility based on whether there's data to display. * Hides the metric strip container and gap if no governor limit data exists. + * Triggers resize to recalculate main timeline height after visibility change. */ private updateMetricStripVisibility(): void { const isVisible = this.metricStripOrchestrator?.getIsVisible() ?? false; @@ -745,13 +828,22 @@ export class FlameChart { if (this.metricStripGapDiv) { this.metricStripGapDiv.style.display = display; } + + // Trigger resize to recalculate main timeline height after visibility change + if (this.container && isVisible) { + const { width, height } = this.container.getBoundingClientRect(); + this.resize(width, height); + } } // ============================================================================ // PRIVATE SETUP METHODS // ============================================================================ - private async setupPixiApplication(width: number, height: number): Promise { + private async setupPixiApplication( + width: number, + height: number, + ): Promise<{ mainTimelineHeight: number }> { const ticker = PIXI.Ticker.shared; ticker.autoStart = false; ticker.stop(); @@ -760,12 +852,10 @@ export class FlameChart { sysTicker.autoStart = false; sysTicker.stop(); - // Calculate minimap, metric strip, and main timeline heights + // Calculate minimap, metric strip heights for fixed-height elements // Metric strip starts collapsed, so use collapsed height for initial layout const minimapHeight = calculateMinimapHeight(height); const metricStripHeight = METRIC_STRIP_COLLAPSED_HEIGHT; - const totalOverheadHeight = minimapHeight + MINIMAP_GAP + metricStripHeight + METRIC_STRIP_GAP; - const mainTimelineHeight = height - totalOverheadHeight; // Create wrapper container with flexbox layout this.wrapper = document.createElement('div'); @@ -788,7 +878,7 @@ export class FlameChart { this.metricStripGapDiv = document.createElement('div'); this.metricStripGapDiv.style.cssText = `height:${METRIC_STRIP_GAP}px;width:100%;flex-shrink:0;background:transparent`; - // Main timeline container (fills remaining space) + // Main timeline container (fills remaining space via flex:1) const mainDiv = document.createElement('div'); mainDiv.style.cssText = 'flex:1;width:100%;min-height:0'; @@ -804,9 +894,21 @@ export class FlameChart { this.container.appendChild(this.wrapper); } + // Calculate main timeline height by subtracting fixed component heights. + // At init time, all components are visible (metric strip hides later via setHeatStripTimeSeries). + const mainTimelineHeight = + height - minimapHeight - MINIMAP_GAP - metricStripHeight - METRIC_STRIP_GAP; + + if (mainTimelineHeight <= 0) { + throw new TimelineError( + TimelineErrorCode.INVALID_CONTAINER, + 'Container height too small for timeline layout', + ); + } + // Minimap app is created by MinimapOrchestrator in setupMinimap() - // Create main timeline app + // Create main timeline app with measured height this.app = new PIXI.Application(); await this.app.init({ width, @@ -821,6 +923,8 @@ export class FlameChart { this.app.ticker.stop(); this.app.stage.eventMode = 'none'; mainDiv.appendChild(this.app.canvas); + + return { mainTimelineHeight }; } private setupCoordinateSystem(): void { @@ -886,12 +990,7 @@ export class FlameChart { this.handleDoubleClick(x, y); }, onMouseLeave: () => { - // Clear cursor position when mouse leaves main timeline - // Cursor is managed by the minimap and metric strip orchestrators - this.minimapOrchestrator?.setCursorFromMainTimeline(null); - this.metricStripOrchestrator?.setCursorFromMainTimeline(null); - this.requestRender(); - + // Notify callback that mouse left (clears tooltip) if (this.callbacks.onMouseMove) { this.callbacks.onMouseMove(0, 0, null, null); } @@ -1137,6 +1236,9 @@ export class FlameChart { requestRender: () => { this.requestRender(); }, + requestCursorRender: () => { + this.requestCursorRender(); + }, onResetZoom: () => { this.resetZoom(); }, @@ -1195,6 +1297,9 @@ export class FlameChart { requestRender: () => { this.requestRender(); }, + requestCursorRender: () => { + this.requestCursorRender(); + }, onZoom: (factor: number, anchorTimeNs: number) => { if (!this.viewport) { return; @@ -1312,7 +1417,8 @@ export class FlameChart { ); }, requestRender: () => { - this.requestRender(); + // Selection change only needs highlights + overlays (Phase 3 optimization) + this.requestHighlightsRender(); }, }); @@ -1385,14 +1491,13 @@ export class FlameChart { maxDepth, ); - // Update cursor + // Update cursor style based on hit test if (this.interactionHandler) { this.interactionHandler.updateCursor(eventNode !== null || marker !== null); } - // Update metric strip cursor (sync from main timeline) - const timeNs = (screenX + viewportState.offsetX) / viewportState.zoom; - this.metricStripOrchestrator?.setCursorFromMainTimeline(timeNs); + // No cursor line when hovering main timeline + // Cursor line only shows when hovering minimap or metric strip (bidirectional mirroring) // Notify callback with container-relative coordinates // (screenY is canvas-relative, add minimap offset for container-relative positioning) @@ -1656,12 +1761,50 @@ export class FlameChart { * Consolidates duplicated callback pattern. */ private notifyViewportChange(): void { + // requestRender() now defaults to full render (invalidateAll) this.requestRender(); if (this.callbacks.onViewportChange && this.viewport) { this.callbacks.onViewportChange(this.viewport.getState()); } } + // ============================================================================ + // RENDER INVALIDATION (Phase 3 optimization) + // ============================================================================ + + /** + * Invalidate all render phases (full render needed). + * Used when viewport changes (zoom, pan). + */ + private invalidateAll(): void { + if (!this.state) { + return; + } + this.state.renderDirty = { + background: true, + culling: true, + eventRendering: true, + highlights: true, + overlays: true, + minimap: true, + metricStrip: true, + }; + } + + /** + * Invalidate highlights and overlays (for selection change). + * Skips expensive culling but re-renders highlights. + */ + private invalidateHighlights(): void { + if (!this.state) { + return; + } + this.state.renderDirty.highlights = true; + this.state.renderDirty.overlays = true; + this.state.renderDirty.minimap = true; + this.state.renderDirty.metricStrip = true; + } + // ============================================================================ // SELECTION API (delegated to SelectionOrchestrator) // ============================================================================ @@ -1809,6 +1952,15 @@ export class FlameChart { }, needsRender: true, isInitialized: true, + renderDirty: { + background: true, + culling: true, + eventRendering: true, + highlights: true, + overlays: true, + minimap: true, + metricStrip: true, + }, }; } @@ -1831,8 +1983,12 @@ export class FlameChart { // ============================================================================ /** - * Main render loop - coordinates all rendering phases. - * Simplified to ~50 lines by delegating to helper methods and orchestrators. + * Main render loop - coordinates all rendering phases with dirty flag optimization. + * + * Phase 3 optimization: Skip expensive phases when only cursor/overlay changed. + * - Mouse move: ~1ms (overlays only) vs ~10ms (full render) + * - Selection change: ~2ms (highlights + overlays) vs ~10ms + * - Viewport change: ~10ms (all phases - unchanged) */ private render(): void { if (!this.canRender()) { @@ -1841,36 +1997,73 @@ export class FlameChart { const viewportState = this.viewport!.getState(); this.state!.viewport = viewportState; + const dirty = this.state!.renderDirty; // Phase 1: Position containers and render background layers - this.renderBackground(viewportState); + if (dirty.background) { + this.renderBackground(viewportState); + dirty.background = false; + } // Phase 2: Cull visible rectangles and update hit testing - const { visibleRects, buckets } = this.rectangleManager!.getCulledRectangles( - viewportState, - this.state!.batchColorsCache, - ); - this.hitTestManager?.setVisibleRects(visibleRects); - this.hitTestManager?.setBuckets(buckets); + // This is the most expensive phase - cache results when viewport unchanged + let visibleRects: Map; + let buckets: Map; + + if (dirty.culling || !this.cachedVisibleRects || !this.cachedBuckets) { + const culled = this.rectangleManager!.getCulledRectangles( + viewportState, + this.state!.batchColorsCache, + ); + visibleRects = culled.visibleRects; + buckets = culled.buckets; + + // Cache for reuse when only cursor moves + this.cachedVisibleRects = visibleRects; + this.cachedBuckets = buckets; + + this.hitTestManager?.setVisibleRects(visibleRects); + this.hitTestManager?.setBuckets(buckets); + dirty.culling = false; + } else { + // Reuse cached culling results + visibleRects = this.cachedVisibleRects; + buckets = this.cachedBuckets; + } // Phase 3: Render events and labels (search mode vs normal mode) - const searchContext = { viewportState, visibleRects, buckets }; - this.renderEventsAndLabels(viewportState, visibleRects, buckets, searchContext); + if (dirty.eventRendering) { + const searchContext = { viewportState, visibleRects, buckets }; + this.renderEventsAndLabels(viewportState, visibleRects, buckets, searchContext); + dirty.eventRendering = false; + } // Phase 4: Render highlights (selection or search, mutually exclusive) - this.renderHighlights(viewportState); + if (dirty.highlights) { + this.renderHighlights(viewportState); + dirty.highlights = false; + } // Phase 5: Render overlays (measurement, cursor line) - this.renderOverlays(viewportState); + if (dirty.overlays) { + this.renderOverlays(viewportState); + dirty.overlays = false; + } - // Phase 6: Render main timeline canvas + // Phase 6: Render main timeline canvas (always needed after any changes) this.app!.render(); // Phase 7: Render minimap - this.renderMinimap(viewportState); + if (dirty.minimap) { + this.renderMinimap(viewportState); + dirty.minimap = false; + } // Phase 8: Render metric strip - this.renderMetricStrip(viewportState); + if (dirty.metricStrip) { + this.renderMetricStrip(viewportState); + dirty.metricStrip = false; + } } /** @@ -1958,7 +2151,8 @@ export class FlameChart { // Measurement and area zoom overlays this.measurementOrchestrator?.render({ viewportState }); - // Cursor line (bidirectional cursor mirroring with minimap) + // Cursor line (bidirectional cursor mirroring) + // Only shows when hovering minimap or metric strip, not main timeline if (this.cursorLineRenderer && this.minimapOrchestrator) { const cursorTimeNs = this.minimapOrchestrator.getCursorTimeNs(); this.cursorLineRenderer.render(viewportState, cursorTimeNs); diff --git a/log-viewer/src/features/timeline/optimised/RectangleManager.ts b/log-viewer/src/features/timeline/optimised/RectangleManager.ts index 7e6f0495..8d2c1f93 100644 --- a/log-viewer/src/features/timeline/optimised/RectangleManager.ts +++ b/log-viewer/src/features/timeline/optimised/RectangleManager.ts @@ -73,6 +73,19 @@ export interface PrecomputedRect extends RenderRectangle { height: number; } +/** + * Precomputed data from unified tree conversion (single-pass optimization). + * When provided, RectangleManager skips its own flattenEvents traversal. + */ +export interface PrecomputedRectData { + rectsByCategory: Map; + rectMap: Map; + /** Pre-grouped by depth for TemporalSegmentTree (optional - computed if not provided) */ + rectsByDepth?: Map; + /** Whether rectsByCategory arrays are pre-sorted by timeStart (skips sorting) */ + preSorted?: boolean; +} + /** * RectangleManager * @@ -89,16 +102,42 @@ export class RectangleManager { /** Map from LogEvent to RenderRectangle for search functionality */ private rectMap: Map = new Map(); + /** Cached map from rect ID to PrecomputedRect (lazy-built on first access) */ + private rectMapById: Map | null = null; + /** Segment tree for O(log n) viewport culling */ private segmentTree: TemporalSegmentTree; /** + * Create RectangleManager with either raw events or precomputed data. + * * @param events - Event tree to pre-compute rectangles from * @param categories - Set of valid categories for spatial indexing + * @param precomputed - Optional precomputed rectangle data from unified conversion */ - constructor(events: LogEvent[], categories: Set) { - this.precomputeRectangles(events, categories); - this.segmentTree = new TemporalSegmentTree(this.rectsByCategory); + constructor(events: LogEvent[], categories: Set, precomputed?: PrecomputedRectData) { + if (precomputed) { + // Use precomputed data from unified conversion (skips flattenEvents traversal) + this.rectsByCategory = precomputed.rectsByCategory; + this.rectMap = precomputed.rectMap; + + // PERF: Only sort if not already pre-sorted (~15-20ms saved) + if (!precomputed.preSorted) { + for (const rects of this.rectsByCategory.values()) { + rects.sort((a, b) => a.timeStart - b.timeStart); + } + } + } else { + // Legacy path: compute rectangles from events + this.precomputeRectangles(events, categories); + } + + // Pass pre-grouped rectsByDepth if available (saves ~12ms grouping iteration) + this.segmentTree = new TemporalSegmentTree( + this.rectsByCategory, + undefined, // batchColors + precomputed?.rectsByDepth, + ); } /** @@ -131,6 +170,25 @@ export class RectangleManager { return this.rectMap; } + /** + * Get map from rect ID to PrecomputedRect. + * Lazy-built on first access to avoid O(n) iteration at init time. + * Used by SearchOrchestrator for O(1) rect lookup by ID. + * + * PERF: Saves ~18ms by avoiding redundant map rebuild in SearchOrchestrator.init() + * + * @returns Map from rect ID string to PrecomputedRect + */ + public getRectMapById(): Map { + if (!this.rectMapById) { + this.rectMapById = new Map(); + for (const rect of this.rectMap.values()) { + this.rectMapById.set(rect.id, rect); + } + } + return this.rectMapById; + } + /** * Update batch colors for segment tree (for theme changes). * diff --git a/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts b/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts index ba5ca375..75a02329 100644 --- a/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts +++ b/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts @@ -39,6 +39,7 @@ import type { } from '../types/flamechart.types.js'; import { BUCKET_CONSTANTS, + mergeNodeCategoryStats, SEGMENT_TREE_CONSTANTS, TIMELINE_CONSTANTS, } from '../types/flamechart.types.js'; @@ -96,9 +97,15 @@ export class TemporalSegmentTree { /** Cached batch colors for theme support */ private batchColors?: Map; + /** + * Unsorted frames collected during tree construction. + * Sorting is deferred to first getAllFramesSorted() call. + */ + private unsortedFrames: SkylineFrame[] | null = null; + /** * Cached sorted frames for minimap density computation. - * Pre-sorted by timeStart during tree construction for O(1) access. + * Lazily sorted on first access to defer ~25ms sort cost to minimap render. */ private cachedSortedFrames: SkylineFrame[] | null = null; @@ -107,13 +114,15 @@ export class TemporalSegmentTree { * * @param rectsByCategory - Rectangles grouped by category (from RectangleManager) * @param batchColors - Optional colors for theme support + * @param rectsByDepth - Optional pre-grouped by depth (from unified conversion, saves ~12ms) */ constructor( rectsByCategory: Map, batchColors?: Map, + rectsByDepth?: Map, ) { this.batchColors = batchColors; - this.buildTrees(rectsByCategory); + this.buildTrees(rectsByCategory, rectsByDepth); } /** @@ -246,15 +255,21 @@ export class TemporalSegmentTree { /** * Get all frames sorted by timeStart for minimap density computation. - * Frames are pre-built and sorted during tree construction for O(1) access. + * Frames are collected during tree construction but sorting is deferred + * to first access to avoid blocking init when minimap isn't immediately visible. * - * Performance: Pre-sorting during construction eliminates ~120ms of - * recursive tree traversal that previously occurred on first access. + * Performance: Lazy sorting defers ~25ms cost to first minimap render, + * reducing init time when minimap isn't immediately needed. * * @returns Array of SkylineFrame sorted by timeStart */ public getAllFramesSorted(): SkylineFrame[] { - // Frames are pre-built during construction + // Lazy sort on first access + if (!this.cachedSortedFrames && this.unsortedFrames) { + this.cachedSortedFrames = this.unsortedFrames; + this.cachedSortedFrames.sort((a, b) => a.timeStart - b.timeStart); + this.unsortedFrames = null; // Release reference + } return this.cachedSortedFrames ?? []; } @@ -443,34 +458,64 @@ export class TemporalSegmentTree { /** * Build segment trees for all depth levels. * - * PERF: Also collects and sorts frames for minimap density computation - * during this iteration, avoiding a separate O(N) tree traversal later. + * PERF optimizations: + * - Uses pre-grouped rectsByDepth when available (~12ms saved) + * - Collects frames for minimap during iteration (avoids O(N) traversal) + * - Defers frame sorting to first getAllFramesSorted() call (~25ms saved) + * + * @param rectsByCategory - Rectangles grouped by category + * @param preGroupedByDepth - Optional pre-grouped by depth from unified conversion */ - private buildTrees(rectsByCategory: Map): void { - // Group all rectangles by depth - const rectsByDepth = new Map(); - + private buildTrees( + rectsByCategory: Map, + preGroupedByDepth?: Map, + ): void { // Collect frames during iteration (avoids separate tree traversal later) const allFrames: SkylineFrame[] = []; - for (const rects of rectsByCategory.values()) { - for (const rect of rects) { - let depthRects = rectsByDepth.get(rect.depth); - if (!depthRects) { - depthRects = []; - rectsByDepth.set(rect.depth, depthRects); + // Use pre-grouped rectsByDepth if available, otherwise group from category map + let rectsByDepth: Map; + + if (preGroupedByDepth) { + // PERF: Use pre-grouped data (~12ms saved by skipping grouping iteration) + rectsByDepth = preGroupedByDepth; + + // Still need to collect frames and track maxDepth + for (const [depth, rects] of rectsByDepth) { + this.maxDepth = Math.max(this.maxDepth, depth); + for (const rect of rects) { + allFrames.push({ + timeStart: rect.timeStart, + timeEnd: rect.timeEnd, + depth: rect.depth, + category: rect.category, + selfDuration: rect.selfDuration, + }); + } + } + } else { + // Fallback: Group all rectangles by depth + rectsByDepth = new Map(); + + for (const rects of rectsByCategory.values()) { + for (const rect of rects) { + let depthRects = rectsByDepth.get(rect.depth); + if (!depthRects) { + depthRects = []; + rectsByDepth.set(rect.depth, depthRects); + } + depthRects.push(rect); + this.maxDepth = Math.max(this.maxDepth, rect.depth); + + // Collect frame directly (eliminates recursive tree traversal in getAllFramesSorted) + allFrames.push({ + timeStart: rect.timeStart, + timeEnd: rect.timeEnd, + depth: rect.depth, + category: rect.category, + selfDuration: rect.selfDuration, + }); } - depthRects.push(rect); - this.maxDepth = Math.max(this.maxDepth, rect.depth); - - // Collect frame directly (eliminates recursive tree traversal in getAllFramesSorted) - allFrames.push({ - timeStart: rect.timeStart, - timeEnd: rect.timeEnd, - depth: rect.depth, - category: rect.category, - selfDuration: rect.selfDuration, - }); } } @@ -482,9 +527,8 @@ export class TemporalSegmentTree { } } - // Sort frames by timeStart once during construction (O(N log N) but only once) - allFrames.sort((a, b) => a.timeStart - b.timeStart); - this.cachedSortedFrames = allFrames; + // PERF: Defer sorting to first getAllFramesSorted() call (~25ms saved at init) + this.unsortedFrames = allFrames; } /** @@ -510,24 +554,28 @@ export class TemporalSegmentTree { /** * Create a leaf node from a PrecomputedRect. + * + * PERF: Uses leafCategory/leafDuration instead of Map to avoid 500k+ Map allocations. + * categoryStats is null for leaf nodes - aggregation creates Maps only for branch nodes. */ private createLeafNode(rect: PrecomputedRect, depth: number): SegmentNode { const duration = Math.max(SEGMENT_TREE_CONSTANTS.MIN_NODE_SPAN, rect.duration); - const categoryStats = new Map([ - [rect.category, { count: 1, totalDuration: duration }], - ]); - return { timeStart: rect.timeStart, timeEnd: rect.timeEnd, nodeSpan: duration, - categoryStats, + // PERF: null instead of Map - saves ~35-40ms for 500k leaf nodes + categoryStats: null, dominantCategory: rect.category, // PERF: Pre-compute priority to avoid PRIORITY_MAP lookups during query dominantPriority: PRIORITY_MAP.get(rect.category) ?? Infinity, + // Leaf-specific fields (avoid Map allocation) + leafCategory: rect.category, + leafDuration: duration, + eventCount: 1, eventRef: rect.eventRef, rectRef: rect, // Direct reference for O(1) lookup in addVisibleRect @@ -535,7 +583,6 @@ export class TemporalSegmentTree { children: null, isLeaf: true, - y: depth * TIMELINE_CONSTANTS.EVENT_HEIGHT, depth, }; } @@ -572,6 +619,7 @@ export class TemporalSegmentTree { * Create a branch node from a range of children (avoids array allocation). * * PERF: Uses start/end indices instead of creating a sliced array. + * PERF: Handles leaf nodes with leafCategory/leafDuration instead of categoryStats Map. */ private createBranchNodeFromRange( children: SegmentNode[], @@ -579,38 +627,28 @@ export class TemporalSegmentTree { end: number, ): SegmentNode { const firstChild = children[start]!; - const lastChild = children[end - 1]!; const timeStart = firstChild.timeStart; - const timeEnd = lastChild.timeEnd; - const nodeSpan = Math.max(SEGMENT_TREE_CONSTANTS.MIN_NODE_SPAN, timeEnd - timeStart); - // Aggregate statistics + // Aggregate statistics and compute max timeEnd in a single pass + // Children are sorted by timeStart, but an earlier child may have a longer duration + // and thus a later timeEnd than subsequent children + let timeEnd = firstChild.timeEnd; let totalEventCount = 0; const categoryStats = new Map(); for (let i = start; i < end; i++) { const child = children[i]!; - totalEventCount += child.eventCount; - // Merge category stats - for (const [cat, stats] of child.categoryStats) { - const existing = categoryStats.get(cat); - if (existing) { - existing.count += stats.count; - existing.totalDuration += stats.totalDuration; - } else { - categoryStats.set(cat, { count: stats.count, totalDuration: stats.totalDuration }); - } + // Track max timeEnd + if (child.timeEnd > timeEnd) { + timeEnd = child.timeEnd; } - } - // Determine dominant category - const dominantCategory = this.resolveDominantCategory(categoryStats); - // PERF: Pre-compute priority to avoid PRIORITY_MAP lookups during query - const dominantPriority = PRIORITY_MAP.get(dominantCategory) ?? Infinity; + totalEventCount += child.eventCount; + mergeNodeCategoryStats(categoryStats, child); + } - // Store actual child references for tree traversal - const childNodes = children.slice(start, end); + const nodeSpan = Math.max(SEGMENT_TREE_CONSTANTS.MIN_NODE_SPAN, timeEnd - timeStart); return { timeStart, @@ -618,15 +656,15 @@ export class TemporalSegmentTree { nodeSpan, categoryStats, - dominantCategory, - dominantPriority, + // Branch nodes don't use dominantCategory/Priority - buckets resolve from categoryStats + dominantCategory: '', + dominantPriority: Infinity, eventCount: totalEventCount, - children: childNodes, + children: children.slice(start, end), isLeaf: false, - y: firstChild.y, depth: firstChild.depth, }; } @@ -700,6 +738,8 @@ export class TemporalSegmentTree { * Aggregate a segment node into a grid-aligned bucket. * Multiple nodes that fall into the same grid cell are merged into one bucket. * Dominant category is resolved after all nodes are aggregated. + * + * PERF: Handles leaf nodes with leafCategory/leafDuration instead of categoryStats Map. */ private aggregateIntoBucket( node: SegmentNode, @@ -727,20 +767,7 @@ export class TemporalSegmentTree { // Aggregate node stats into bucket bucket.eventCount += node.eventCount; - - // Merge category stats - for (const [category, stats] of node.categoryStats) { - const existing = bucket.categoryStats.get(category); - if (existing) { - existing.count += stats.count; - existing.totalDuration += stats.totalDuration; - } else { - bucket.categoryStats.set(category, { - count: stats.count, - totalDuration: stats.totalDuration, - }); - } - } + mergeNodeCategoryStats(bucket.categoryStats, node); } /** diff --git a/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts b/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts index d34b85a1..9b69fbe3 100644 --- a/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts +++ b/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts @@ -12,15 +12,30 @@ import type { LogEvent } from 'apex-log-parser'; import type { ViewportBounds, ViewportState } from '../types/flamechart.types.js'; +/** + * Precomputed metrics from unified tree conversion (single-pass optimization). + * When provided, TimelineEventIndex skips its own O(n) calculation methods. + */ +export interface PrecomputedMetrics { + maxDepth: number; + totalDuration: number; +} + export class TimelineEventIndex { private rootEvents: LogEvent[]; private _maxDepth: number; private _totalDuration: number; - constructor(events: LogEvent[]) { + /** + * Create TimelineEventIndex with either computed or precomputed metrics. + * + * @param events - Root events for spatial queries + * @param precomputed - Optional precomputed metrics from unified conversion + */ + constructor(events: LogEvent[], precomputed?: PrecomputedMetrics) { this.rootEvents = events; - this._maxDepth = this.calculateMaxDepth(events); - this._totalDuration = this.calculateTotalDuration(events); + this._maxDepth = precomputed?.maxDepth ?? this.calculateMaxDepth(events); + this._totalDuration = precomputed?.totalDuration ?? this.calculateTotalDuration(events); } /** diff --git a/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts new file mode 100644 index 00000000..9e4af847 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ + +import { parseColorToHex } from '../rendering/ColorUtils.js'; + +/** + * Tests for ColorUtils - CSS color string to hex conversion. + */ + +describe('ColorUtils', () => { + describe('parseColorToHex', () => { + describe('hex formats', () => { + it('should parse 6-character hex (#RRGGBB)', () => { + expect(parseColorToHex('#ff0000')).toBe(0xff0000); + expect(parseColorToHex('#00ff00')).toBe(0x00ff00); + expect(parseColorToHex('#0000ff')).toBe(0x0000ff); + expect(parseColorToHex('#264f78')).toBe(0x264f78); + }); + + it('should parse 3-character hex (#RGB)', () => { + expect(parseColorToHex('#f00')).toBe(0xff0000); + expect(parseColorToHex('#0f0')).toBe(0x00ff00); + expect(parseColorToHex('#00f')).toBe(0x0000ff); + expect(parseColorToHex('#abc')).toBe(0xaabbcc); + }); + + it('should parse 8-character hex (#RRGGBBAA) ignoring alpha', () => { + expect(parseColorToHex('#ff0000ff')).toBe(0xff0000); + expect(parseColorToHex('#00ff0080')).toBe(0x00ff00); + expect(parseColorToHex('#264f7899')).toBe(0x264f78); + }); + + it('should parse 4-character hex (#RGBA) ignoring alpha', () => { + expect(parseColorToHex('#f00f')).toBe(0xff0000); + expect(parseColorToHex('#0f08')).toBe(0x00ff00); + expect(parseColorToHex('#abc9')).toBe(0xaabbcc); + }); + }); + + describe('rgb/rgba formats', () => { + it('should parse rgb() format', () => { + expect(parseColorToHex('rgb(255, 0, 0)')).toBe(0xff0000); + expect(parseColorToHex('rgb(0, 255, 0)')).toBe(0x00ff00); + expect(parseColorToHex('rgb(0, 0, 255)')).toBe(0x0000ff); + expect(parseColorToHex('rgb(38, 79, 120)')).toBe(0x264f78); + }); + + it('should parse rgba() format ignoring alpha', () => { + expect(parseColorToHex('rgba(255, 0, 0, 1)')).toBe(0xff0000); + expect(parseColorToHex('rgba(0, 255, 0, 0.5)')).toBe(0x00ff00); + expect(parseColorToHex('rgba(38, 79, 120, 0.5)')).toBe(0x264f78); + }); + }); + + describe('default handling', () => { + it('should return default for empty string', () => { + expect(parseColorToHex('')).toBe(0x1e1e1e); + expect(parseColorToHex('', 0xff0000)).toBe(0xff0000); + }); + + it('should return default for invalid formats', () => { + expect(parseColorToHex('invalid')).toBe(0x1e1e1e); + expect(parseColorToHex('red')).toBe(0x1e1e1e); // CSS color names not supported + expect(parseColorToHex('#12')).toBe(0x1e1e1e); // Invalid hex length + }); + + it('should use custom default color when provided', () => { + expect(parseColorToHex('', 0x264f78)).toBe(0x264f78); + expect(parseColorToHex('invalid', 0xffffff)).toBe(0xffffff); + }); + }); + + describe('edge cases', () => { + it('should handle lowercase hex', () => { + expect(parseColorToHex('#abcdef')).toBe(0xabcdef); + }); + + it('should handle uppercase hex', () => { + expect(parseColorToHex('#ABCDEF')).toBe(0xabcdef); + }); + + it('should handle mixed case hex', () => { + expect(parseColorToHex('#AbCdEf')).toBe(0xabcdef); + }); + + it('should handle rgb with no spaces', () => { + expect(parseColorToHex('rgb(255,128,64)')).toBe(0xff8040); + }); + }); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/LabelPositioning.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/LabelPositioning.test.ts new file mode 100644 index 00000000..e217f161 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/LabelPositioning.test.ts @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ + +import { calculateLabelPosition, type LabelPositionParams } from '../rendering/LabelPositioning.js'; + +/** + * Tests for LabelPositioning - smart label positioning for timeline overlays. + */ + +describe('LabelPositioning', () => { + describe('calculateLabelPosition', () => { + const defaultParams: LabelPositionParams = { + labelWidth: 100, + labelHeight: 50, + screenStartX: 200, + screenEndX: 400, + displayWidth: 800, + displayHeight: 600, + padding: 8, + }; + + describe('fully visible selection', () => { + it('should center label in visible portion when space permits', () => { + const result = calculateLabelPosition(defaultParams); + + // Selection is 200-400, visible width = 200, label width = 100 + // Centered: 200 + (200 - 100) / 2 = 250 + expect(result.left).toBe(250); + }); + + it('should center label vertically in viewport', () => { + const result = calculateLabelPosition(defaultParams); + + // Viewport height = 600, label height = 50 + // Centered: 600 / 2 - 50 / 2 = 275 + expect(result.top).toBe(275); + }); + + it('should center small selection label on selection', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: 350, + screenEndX: 370, // Only 20px wide + }; + + const result = calculateLabelPosition(params); + + // Small selection (20px) < labelWidth + padding*2 (116px) + // Should still center on visible: 350 + (20 - 100) / 2 = 310 + // But clamped to padding: max(8, 310) = 310 + expect(result.left).toBe(310); + }); + }); + + describe('partially offscreen selection', () => { + it('should stick to left edge when left side is offscreen and visible portion is small', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: -100, + screenEndX: 50, // Visible portion (0-50) smaller than label + padding + }; + + const result = calculateLabelPosition(params); + + // Left edge offscreen, visible portion = 50px < label + padding (116px) + // Stick to left edge with padding = 8 + expect(result.left).toBe(8); + }); + + it('should center in visible portion when left side offscreen but enough space', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: -100, + screenEndX: 300, // Visible portion (0-300) wide enough + }; + + const result = calculateLabelPosition(params); + + // Visible portion = 0-300 (300px) >= labelWidth + padding*2 (116px) + // Center in visible: 0 + (300 - 100) / 2 = 100 + expect(result.left).toBe(100); + }); + + it('should stick to right edge when right side is offscreen and visible portion is small', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: 750, + screenEndX: 900, // Visible portion (750-800) smaller than label + padding + }; + + const result = calculateLabelPosition(params); + + // Right edge offscreen, visible portion = 50px < label + padding (116px) + // Stick to right edge: 800 - 100 - 8 = 692 + expect(result.left).toBe(692); + }); + + it('should center in visible portion when right side offscreen but enough space', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: 600, + screenEndX: 900, // Visible portion (600-800) = 200px + }; + + const result = calculateLabelPosition(params); + + // Visible portion = 600-800 (200px) >= labelWidth + padding*2 (116px) + // Center in visible: 600 + (200 - 100) / 2 = 650 + expect(result.left).toBe(650); + }); + + it('should center on viewport when both edges are offscreen', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: -100, + screenEndX: 900, + }; + + const result = calculateLabelPosition(params); + + // Both edges offscreen: center on viewport + // Centered: (800 - 100) / 2 = 350 + expect(result.left).toBe(350); + }); + }); + + describe('vertical clamping', () => { + it('should clamp to top padding when vertical center would be negative', () => { + const params: LabelPositionParams = { + ...defaultParams, + labelHeight: 700, // Taller than viewport + }; + + const result = calculateLabelPosition(params); + + // Center would be 600/2 - 350 = -50, clamped to padding = 8 + expect(result.top).toBe(8); + }); + + it('should clamp to padding when label almost fits', () => { + const params: LabelPositionParams = { + ...defaultParams, + labelHeight: 50, + displayHeight: 60, // Very short viewport + }; + + const result = calculateLabelPosition(params); + + // The implementation uses Math.max(padding, min(available, centered)) + // available = 60 - 50 - 8 = 2 + // centered = 60/2 - 25 = 5 + // min(2, 5) = 2, then max(8, 2) = 8 + expect(result.top).toBe(8); + }); + }); + + describe('horizontal clamping', () => { + it('should clamp left position to padding', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: 0, + screenEndX: 20, // Very small selection at left edge + }; + + const result = calculateLabelPosition(params); + + // Would want to center at 0 + (20-100)/2 = -40 + // Clamped to padding = 8 + expect(result.left).toBe(8); + }); + + it('should clamp right position to viewport boundary', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: 780, + screenEndX: 800, // Very small selection at right edge + }; + + const result = calculateLabelPosition(params); + + // displayWidth (800) - labelWidth (100) - padding (8) = 692 + expect(result.left).toBe(692); + }); + }); + + describe('custom padding', () => { + it('should respect custom padding value for left stick', () => { + const params: LabelPositionParams = { + ...defaultParams, + screenStartX: -100, + screenEndX: 50, // Small visible portion + padding: 20, + }; + + const result = calculateLabelPosition(params); + + // Left edge offscreen, small visible portion, stick to left with custom padding = 20 + expect(result.left).toBe(20); + }); + + it('should use default padding of 8 when not specified', () => { + const params: LabelPositionParams = { + labelWidth: 100, + labelHeight: 50, + screenStartX: -100, + screenEndX: 50, // Small visible portion + displayWidth: 800, + displayHeight: 600, + // No padding specified + }; + + const result = calculateLabelPosition(params); + + expect(result.left).toBe(8); + }); + }); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MarkerHitTest.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MarkerHitTest.test.ts new file mode 100644 index 00000000..a062516b --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/MarkerHitTest.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ + +import type { TimelineMarker } from '../../types/flamechart.types.js'; +import { hitTestMarkers, type MarkerIndicator } from '../markers/MarkerHitTest.js'; + +/** + * Tests for MarkerHitTest - marker hit testing for hover detection. + */ + +// Helper to create marker +function createMarker( + id: string, + type: 'error' | 'skip' | 'unexpected', + startTime: number, +): TimelineMarker { + return { + id, + type, + summary: `${type} marker`, + startTime, + }; +} + +// Helper to create indicator +function createIndicator( + marker: TimelineMarker, + screenStartX: number, + screenEndX: number, +): MarkerIndicator { + return { + marker, + resolvedEndTime: marker.startTime + 1000, + screenStartX, + screenEndX, + screenWidth: screenEndX - screenStartX, + color: 0xff0000, + isVisible: true, + }; +} + +describe('MarkerHitTest', () => { + describe('hitTestMarkers', () => { + describe('single marker', () => { + it('should return marker when click is within bounds', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 200); + + const result = hitTestMarkers(50, 100, [indicator]); // worldX = 150 + + expect(result).toBe(marker); + }); + + it('should return null when click is before marker', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 200); + + const result = hitTestMarkers(0, 50, [indicator]); // worldX = 50 + + expect(result).toBeNull(); + }); + + it('should return null when click is after marker', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 200); + + const result = hitTestMarkers(200, 50, [indicator]); // worldX = 250 + + expect(result).toBeNull(); + }); + + it('should handle click exactly on left edge', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 200); + + const result = hitTestMarkers(0, 100, [indicator]); // worldX = 100 + + expect(result).toBe(marker); + }); + + it('should handle click exactly on right edge', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 200); + + const result = hitTestMarkers(100, 100, [indicator]); // worldX = 200 + + expect(result).toBe(marker); + }); + }); + + describe('multiple markers without overlap', () => { + it('should return correct marker for each region', () => { + const marker1 = createMarker('m1', 'error', 1000); + const marker2 = createMarker('m2', 'skip', 2000); + const indicators = [createIndicator(marker1, 100, 200), createIndicator(marker2, 300, 400)]; + + expect(hitTestMarkers(50, 100, indicators)).toBe(marker1); // worldX = 150 + expect(hitTestMarkers(250, 100, indicators)).toBe(marker2); // worldX = 350 + }); + + it('should return null in gap between markers', () => { + const marker1 = createMarker('m1', 'error', 1000); + const marker2 = createMarker('m2', 'skip', 2000); + const indicators = [createIndicator(marker1, 100, 200), createIndicator(marker2, 300, 400)]; + + const result = hitTestMarkers(150, 100, indicators); // worldX = 250 + + expect(result).toBeNull(); + }); + }); + + describe('overlapping markers with severity', () => { + it('should return highest severity marker (error > unexpected > skip)', () => { + const errorMarker = createMarker('m1', 'error', 1000); + const skipMarker = createMarker('m2', 'skip', 1000); + const unexpectedMarker = createMarker('m3', 'unexpected', 1000); + + const indicators = [ + createIndicator(skipMarker, 100, 300), + createIndicator(unexpectedMarker, 100, 300), + createIndicator(errorMarker, 100, 300), + ]; + + const result = hitTestMarkers(100, 100, indicators); // worldX = 200 + + expect(result).toBe(errorMarker); + }); + + it('should return unexpected over skip when no error present', () => { + const skipMarker = createMarker('m1', 'skip', 1000); + const unexpectedMarker = createMarker('m2', 'unexpected', 1000); + + const indicators = [ + createIndicator(skipMarker, 100, 300), + createIndicator(unexpectedMarker, 100, 300), + ]; + + const result = hitTestMarkers(100, 100, indicators); // worldX = 200 + + expect(result).toBe(unexpectedMarker); + }); + + it('should handle partial overlap correctly', () => { + const errorMarker = createMarker('m1', 'error', 1000); + const skipMarker = createMarker('m2', 'skip', 1500); + + const indicators = [ + createIndicator(errorMarker, 100, 200), + createIndicator(skipMarker, 150, 300), // Overlaps 150-200 + ]; + + // Click in overlap region - error wins + expect(hitTestMarkers(75, 100, indicators)).toBe(errorMarker); // worldX = 175 + + // Click in skip-only region + expect(hitTestMarkers(150, 100, indicators)).toBe(skipMarker); // worldX = 250 + + // Click in error-only region + expect(hitTestMarkers(20, 100, indicators)).toBe(errorMarker); // worldX = 120 + }); + }); + + describe('viewport offset handling', () => { + it('should correctly apply viewport offset', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 500, 600); // World coords + + // With offset 400, screenX 150 -> worldX 550 (inside marker) + expect(hitTestMarkers(150, 400, [indicator])).toBe(marker); + + // With offset 400, screenX 50 -> worldX 450 (before marker) + expect(hitTestMarkers(50, 400, [indicator])).toBeNull(); + + // With offset 100, screenX 350 -> worldX 450 (before marker) + expect(hitTestMarkers(350, 100, [indicator])).toBeNull(); + + // With offset 100, screenX 450 -> worldX 550 (inside marker) + expect(hitTestMarkers(450, 100, [indicator])).toBe(marker); + }); + }); + + describe('edge cases', () => { + it('should return null for empty indicators array', () => { + const result = hitTestMarkers(100, 0, []); + + expect(result).toBeNull(); + }); + + it('should handle zero-width marker', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 100); // Zero width + + // Click exactly on the point + const result = hitTestMarkers(0, 100, [indicator]); // worldX = 100 + + expect(result).toBe(marker); + }); + + it('should handle very large offsetX values', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 1_000_000, 1_000_100); // Far into timeline + + // With large offset, screenX 50 -> worldX = 1_000_050 (inside marker) + expect(hitTestMarkers(50, 1_000_000, [indicator])).toBe(marker); + + // With large offset, screenX 0 -> worldX = 1_000_000 (on left edge) + expect(hitTestMarkers(0, 1_000_000, [indicator])).toBe(marker); + + // Miss: screenX 200 -> worldX = 1_000_200 (after marker) + expect(hitTestMarkers(200, 1_000_000, [indicator])).toBeNull(); + }); + + it('should handle markers at world coordinate zero', () => { + const marker = createMarker('m1', 'error', 0); + const indicator = createIndicator(marker, 0, 100); // Starts at world origin + + // Click at world origin with no offset + expect(hitTestMarkers(0, 0, [indicator])).toBe(marker); + + // Click inside marker + expect(hitTestMarkers(50, 0, [indicator])).toBe(marker); + + // Click after marker + expect(hitTestMarkers(150, 0, [indicator])).toBeNull(); + }); + + it('should handle single-pixel-wide marker', () => { + const marker = createMarker('m1', 'error', 1000); + const indicator = createIndicator(marker, 100, 101); // 1px wide + + // Hit the marker + expect(hitTestMarkers(0, 100, [indicator])).toBe(marker); // worldX = 100 + expect(hitTestMarkers(1, 100, [indicator])).toBe(marker); // worldX = 101 + + // Miss the marker + expect(hitTestMarkers(2, 100, [indicator])).toBeNull(); // worldX = 102 + }); + }); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts index dc57e7cf..36bcc40b 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts @@ -343,6 +343,69 @@ describe('TemporalSegmentTree', () => { }); }); + describe('branch node bounds calculation', () => { + it('should include long events when later short events end earlier', () => { + // Bug scenario: Children sorted by timeStart, but earlier child has longer duration + // Child A: timeStart=0, timeEnd=100 (long event) + // Child B: timeStart=50, timeEnd=60 (short event, ends before A) + // Query for time range [70, 90] should return Child A + const events = [ + createEvent(0, 100, 'Method'), // timeStart=0, timeEnd=100 + createEvent(50, 10, 'SOQL'), // timeStart=50, timeEnd=60 + ]; + const manager = new RectangleManager(events, categories); + const tree = new TemporalSegmentTree(manager.getRectsByCategory()); + + // Query time range that only intersects with the long event (not the short one) + // Using viewport that shows time [70, 90] + // At zoom=1, offset=70, width=20: timeStart=70, timeEnd=90 + const viewport = createViewport(1, 70, 0, 20, 500); + const result = tree.query(viewport); + + // The long event (Method) should be visible because it spans [0, 100] + // and overlaps with query range [70, 90] + const totalEvents = result.stats.visibleCount + result.stats.bucketedEventCount; + expect(totalEvents).toBe(1); + expect(result.visibleRects.get('Method')?.length).toBe(1); + }); + + it('should correctly compute branch timeEnd as max of all children', () => { + // Multiple events with varying durations to test max computation + const events = [ + createEvent(0, 50, 'Method'), // timeEnd=50 + createEvent(10, 100, 'SOQL'), // timeEnd=110 (longest) + createEvent(20, 30, 'DML'), // timeEnd=50 + createEvent(30, 20, 'Method'), // timeEnd=50 + ]; + const manager = new RectangleManager(events, categories); + const tree = new TemporalSegmentTree(manager.getRectsByCategory()); + + // Query time range [80, 120] - only overlaps with the SOQL event (timeEnd=110) + const viewport = createViewport(1, 80, 0, 40, 500); + const result = tree.query(viewport); + + const totalEvents = result.stats.visibleCount + result.stats.bucketedEventCount; + expect(totalEvents).toBe(1); + expect(result.visibleRects.get('SOQL')?.length).toBe(1); + }); + + it('should find events via queryEventsInRegion with correct branch bounds', () => { + // Same scenario but using queryEventsInRegion for hit testing + const events = [ + createEvent(0, 100, 'Method'), // timeStart=0, timeEnd=100 + createEvent(50, 10, 'SOQL'), // timeStart=50, timeEnd=60 + ]; + const manager = new RectangleManager(events, categories); + const tree = new TemporalSegmentTree(manager.getRectsByCategory()); + + // Query region that only intersects with the long event + const eventsInRegion = tree.queryEventsInRegion(70, 90, 0, 0); + + expect(eventsInRegion).toHaveLength(1); + expect(eventsInRegion[0]?.subCategory).toBe('Method'); + }); + }); + describe('fill ratio and brightness', () => { it('should calculate fill ratio for buckets', () => { // Single short event in a wider time span = low fill ratio @@ -374,5 +437,26 @@ describe('TemporalSegmentTree', () => { // Color should be defined and brighter than a low-density bucket expect(allBuckets[0]!.color).toBeDefined(); }); + + it('should resolve bucket dominant category using priority order', () => { + // CATEGORY_PRIORITY: DML=0 (highest), SOQL=1, Method=2 + // DML should win even though SOQL has more duration and count + const events = [ + createEvent(0, 1, 'SOQL'), // priority 1, duration 1 + createEvent(1, 1, 'SOQL'), // priority 1, duration 1 (SOQL total: 2 events, 2 duration) + createEvent(2, 1, 'DML'), // priority 0, duration 1 (DML total: 1 event, 1 duration) + ]; + const manager = new RectangleManager(events, categories); + const tree = new TemporalSegmentTree(manager.getRectsByCategory()); + + // Zoom out so all events aggregate into one bucket + const viewport = createViewport(0.01, 0, 0, 1000); + const result = tree.query(viewport); + + // Bucket should be categorized as DML (priority 0 beats priority 1) + // despite SOQL having more total duration (2 vs 1) and count (2 vs 1) + expect(result.buckets.get('DML')?.length).toBe(1); + expect(result.buckets.get('SOQL')?.length ?? 0).toBe(0); + }); }); }); diff --git a/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts b/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts index 90f1282c..85dadd26 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts @@ -167,7 +167,6 @@ export class HitTestManager { duration: logEvent.duration?.total ?? 0, type: logEvent.type ?? logEvent.subCategory ?? 'UNKNOWN', text: logEvent.text, - subCategory: logEvent.subCategory, original: logEvent, }; } diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts index 3d231ec2..647c54c1 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts @@ -9,7 +9,11 @@ * Manages zoom (wheel), pan (drag), and event selection. */ -import type { ModifierKeys } from '../../types/flamechart.types.js'; +import { + type DragModeState, + InteractionModeType, + type ModifierKeys, +} from '../../types/flamechart.types.js'; import type { TimelineViewport } from '../TimelineViewport.js'; /** @@ -130,35 +134,14 @@ export class TimelineInteractionHandler { private mouseDownY = 0; private static readonly DRAG_THRESHOLD = 3; // px - movement required to count as drag - // Measurement mode state (Shift+drag) - private isMeasuring = false; - private measureStartX = 0; - private didMeasureDrag = false; // Track if actual drag occurred during measurement - private lastMeasureScreenX = 0; // Track last mouse position for auto-scroll updates + // Unified drag mode state (replaces separate isMeasuring, isAreaZooming, isResizing flags) + private activeMode: DragModeState | null = null; - // Area zoom mode state (Alt+drag) - private isAreaZooming = false; - private areaZoomStartX = 0; - private didAreaZoomDrag = false; // Track if actual drag occurred during area zoom - private lastAreaZoomScreenX = 0; // Track last mouse position for auto-scroll updates - - // Resize mode state (drag existing measurement edge) - private isResizing = false; - private resizeEdge: 'left' | 'right' | null = null; - private didResizeDrag = false; // Track if actual drag occurred during resize - private lastResizeScreenX = 0; // Track last mouse position for auto-scroll updates - - // Threshold for hitting resize handle - private static readonly RESIZE_HANDLE_THRESHOLD = 8; // px from edge - - // Auto-scroll state for measurement/areaZoom/resize + // Auto-scroll state for drag modes private static readonly EDGE_ZONE = 50; // px from edge to trigger auto-scroll private static readonly AUTO_SCROLL_SPEED = 10; // px per frame private autoScrollId: number | null = null; private autoScrollYId: number | null = null; - private lastMeasureScreenY = 0; // Track Y for vertical auto-scroll - private lastAreaZoomScreenY = 0; - private lastResizeScreenY = 0; // Double-click detection state private lastClickTime = 0; @@ -209,6 +192,167 @@ export class TimelineInteractionHandler { this.detachEventListeners(); } + // ============================================================================ + // DRAG MODE HELPERS + // ============================================================================ + + /** + * Start a drag mode (measure, area zoom, or resize). + */ + private startDragMode( + type: InteractionModeType, + screenX: number, + clientX: number, + clientY: number, + edge?: 'left' | 'right', + ): void { + this.activeMode = { + type, + isActive: true, + startX: screenX, + didDrag: false, + lastScreenX: screenX, + lastScreenY: 0, + mouseDownX: clientX, + mouseDownY: clientY, + edge, + }; + } + + /** + * Check if drag threshold has been exceeded for the active mode. + * Updates didDrag flag if threshold is exceeded. + * + * @returns true if threshold was just crossed, false otherwise + */ + private checkDragThreshold(clientX: number, clientY: number): boolean { + if (!this.activeMode || this.activeMode.didDrag) { + return false; + } + + const distanceX = Math.abs(clientX - this.activeMode.mouseDownX); + const distanceY = Math.abs(clientY - this.activeMode.mouseDownY); + const distance = Math.max(distanceX, distanceY); + + if (distance >= TimelineInteractionHandler.DRAG_THRESHOLD) { + this.activeMode.didDrag = true; + return true; + } + return false; + } + + /** + * Update the active mode's last screen position. + */ + private updateModePosition(screenX: number, screenY: number): void { + if (this.activeMode) { + this.activeMode.lastScreenX = screenX; + this.activeMode.lastScreenY = screenY; + } + } + + /** + * Handle auto-scroll in X direction based on screen position. + */ + private handleAutoScrollX(screenX: number): void { + const viewportState = this.viewport.getState(); + if (screenX < TimelineInteractionHandler.EDGE_ZONE) { + this.startAutoScroll('left'); + } else if (screenX > viewportState.displayWidth - TimelineInteractionHandler.EDGE_ZONE) { + this.startAutoScroll('right'); + } else { + this.stopAutoScroll(); + } + } + + /** + * Handle auto-scroll in Y direction based on screen position. + */ + private handleAutoScrollY(screenY: number): void { + const viewportState = this.viewport.getState(); + if (screenY < TimelineInteractionHandler.EDGE_ZONE) { + this.startAutoScrollY('up'); + } else if (screenY > viewportState.displayHeight - TimelineInteractionHandler.EDGE_ZONE) { + this.startAutoScrollY('down'); + } else { + this.stopAutoScrollY(); + } + } + + /** + * Get the appropriate update callback for the current mode. + */ + private invokeUpdateCallback(screenX: number): void { + if (!this.activeMode) { + return; + } + + switch (this.activeMode.type) { + case InteractionModeType.MEASURE: + this.callbacks.onMeasureUpdate?.(screenX); + break; + case InteractionModeType.AREA_ZOOM: + this.callbacks.onAreaZoomUpdate?.(screenX); + break; + case InteractionModeType.RESIZE: + this.callbacks.onResizeUpdate?.(screenX); + break; + } + } + + /** + * End the active drag mode with success (drag occurred). + */ + private endDragMode(): void { + if (!this.activeMode) { + return; + } + + switch (this.activeMode.type) { + case InteractionModeType.MEASURE: + this.callbacks.onMeasureEnd?.(); + break; + case InteractionModeType.AREA_ZOOM: + this.callbacks.onAreaZoomEnd?.(); + break; + case InteractionModeType.RESIZE: + this.callbacks.onResizeEnd?.(); + break; + } + + this.activeMode = null; + } + + /** + * Cancel the active drag mode (no drag occurred or escape pressed). + */ + private cancelDragMode(): void { + if (!this.activeMode) { + return; + } + + switch (this.activeMode.type) { + case InteractionModeType.MEASURE: + this.callbacks.onMeasureCancel?.(); + break; + case InteractionModeType.AREA_ZOOM: + this.callbacks.onAreaZoomCancel?.(); + break; + case InteractionModeType.RESIZE: + this.callbacks.onResizeCancel?.(); + break; + } + + this.activeMode = null; + } + + /** + * Check if any drag mode is currently active. + */ + private isDragModeActive(): boolean { + return this.activeMode !== null && this.activeMode.isActive; + } + // ============================================================================ // EVENT LISTENER SETUP // ============================================================================ @@ -477,14 +621,8 @@ export class TimelineInteractionHandler { // Check for Alt+drag to start area zoom (check before Shift since Alt takes priority) if (event.altKey && this.callbacks.onAreaZoomStart) { - this.isAreaZooming = true; - this.areaZoomStartX = screenX; - this.didAreaZoomDrag = false; // Reset drag flag - this.mouseDownX = event.clientX; - this.mouseDownY = event.clientY; + this.startDragMode(InteractionModeType.AREA_ZOOM, screenX, event.clientX, event.clientY); this.canvas.style.cursor = 'zoom-in'; - - // Call onAreaZoomStart immediately (show range on alt+click) this.callbacks.onAreaZoomStart(screenX); return; } @@ -492,28 +630,22 @@ export class TimelineInteractionHandler { // Check for click on measurement resize edge (before Shift+drag check) const resizeEdge = this.callbacks.getMeasurementResizeEdge?.(screenX); if (resizeEdge && this.callbacks.onResizeStart) { - this.isResizing = true; - this.resizeEdge = resizeEdge; - this.didResizeDrag = false; // Reset drag flag - this.mouseDownX = event.clientX; - this.mouseDownY = event.clientY; + this.startDragMode( + InteractionModeType.RESIZE, + screenX, + event.clientX, + event.clientY, + resizeEdge, + ); this.canvas.style.cursor = 'ew-resize'; - - // Call onResizeStart immediately this.callbacks.onResizeStart(screenX, resizeEdge); return; } // Check for Shift+drag to start measurement if (event.shiftKey && this.callbacks.onMeasureStart) { - this.isMeasuring = true; - this.measureStartX = screenX; - this.didMeasureDrag = false; // Reset drag flag - this.mouseDownX = event.clientX; - this.mouseDownY = event.clientY; + this.startDragMode(InteractionModeType.MEASURE, screenX, event.clientX, event.clientY); this.canvas.style.cursor = 'col-resize'; - - // Call onMeasureStart immediately (show range on shift+click) this.callbacks.onMeasureStart(screenX); return; } @@ -541,122 +673,18 @@ export class TimelineInteractionHandler { const screenX = event.clientX - rect.left; const screenY = event.clientY - rect.top; - // Handle area zoom mode - if (this.isAreaZooming) { - // Track if we've dragged enough to count as a real drag (vs just a click) - if (!this.didAreaZoomDrag) { - const distanceX = Math.abs(event.clientX - this.mouseDownX); - const distanceY = Math.abs(event.clientY - this.mouseDownY); - const distance = Math.max(distanceX, distanceY); - - if (distance >= TimelineInteractionHandler.DRAG_THRESHOLD) { - this.didAreaZoomDrag = true; - } - } - - // Update area zoom position and track for auto-scroll - this.lastAreaZoomScreenX = screenX; - this.lastAreaZoomScreenY = screenY; - this.callbacks.onAreaZoomUpdate?.(screenX); - - // Auto-scroll when dragging near viewport edges (X direction) - const viewportState = this.viewport.getState(); - if (screenX < TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScroll('left', 'areaZoom'); - } else if (screenX > viewportState.displayWidth - TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScroll('right', 'areaZoom'); - } else { - this.stopAutoScroll(); - } - - // Auto-scroll when dragging near viewport edges (Y direction) - if (screenY < TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScrollY('up'); - } else if (screenY > viewportState.displayHeight - TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScrollY('down'); - } else { - this.stopAutoScrollY(); - } - - return; - } - - // Handle resize mode (dragging measurement edge) - if (this.isResizing) { - // Track if we've dragged enough to count as a real drag (vs just a click) - if (!this.didResizeDrag) { - const distanceX = Math.abs(event.clientX - this.mouseDownX); - const distanceY = Math.abs(event.clientY - this.mouseDownY); - const distance = Math.max(distanceX, distanceY); - - if (distance >= TimelineInteractionHandler.DRAG_THRESHOLD) { - this.didResizeDrag = true; - } - } - - // Update resize position and track for auto-scroll - this.lastResizeScreenX = screenX; - this.lastResizeScreenY = screenY; - this.callbacks.onResizeUpdate?.(screenX); - - // Auto-scroll when dragging near viewport edges (X direction) - const viewportState = this.viewport.getState(); - if (screenX < TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScroll('left', 'resize'); - } else if (screenX > viewportState.displayWidth - TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScroll('right', 'resize'); - } else { - this.stopAutoScroll(); - } - - // Auto-scroll when dragging near viewport edges (Y direction) - if (screenY < TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScrollY('up'); - } else if (screenY > viewportState.displayHeight - TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScrollY('down'); - } else { - this.stopAutoScrollY(); - } - - return; - } - - // Handle measurement mode - if (this.isMeasuring) { - // Track if we've dragged enough to count as a real drag (vs just a click) - if (!this.didMeasureDrag) { - const distanceX = Math.abs(event.clientX - this.mouseDownX); - const distanceY = Math.abs(event.clientY - this.mouseDownY); - const distance = Math.max(distanceX, distanceY); - - if (distance >= TimelineInteractionHandler.DRAG_THRESHOLD) { - this.didMeasureDrag = true; - } - } - - // Update measurement position and track for auto-scroll - this.lastMeasureScreenX = screenX; - this.lastMeasureScreenY = screenY; - this.callbacks.onMeasureUpdate?.(screenX); + // Handle active drag mode (measure, area zoom, or resize) + if (this.isDragModeActive()) { + // Check drag threshold and update position + this.checkDragThreshold(event.clientX, event.clientY); + this.updateModePosition(screenX, screenY); + this.invokeUpdateCallback(screenX); // Auto-scroll when dragging near viewport edges (X direction) - const viewportState = this.viewport.getState(); - if (screenX < TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScroll('left', 'measure'); - } else if (screenX > viewportState.displayWidth - TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScroll('right', 'measure'); - } else { - this.stopAutoScroll(); - } + this.handleAutoScrollX(screenX); // Auto-scroll when dragging near viewport edges (Y direction) - if (screenY < TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScrollY('up'); - } else if (screenY > viewportState.displayHeight - TimelineInteractionHandler.EDGE_ZONE) { - this.startAutoScrollY('down'); - } else { - this.stopAutoScrollY(); - } + this.handleAutoScrollY(screenY); return; } @@ -720,59 +748,23 @@ export class TimelineInteractionHandler { const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; - // Handle area zoom mode end - if (this.isAreaZooming) { - this.isAreaZooming = false; + // Handle active drag mode end (measure, area zoom, or resize) + if (this.isDragModeActive()) { this.stopAutoScroll(); this.stopAutoScrollY(); - // If drag occurred, apply the zoom; otherwise cancel it - if (this.didAreaZoomDrag) { - this.callbacks.onAreaZoomEnd?.(); + // If drag occurred, finalize; otherwise cancel + // Note: Resize mode always finalizes (no cancel needed) + if (this.activeMode!.didDrag) { + this.endDragMode(); // IMPORTANT: Set this flag to skip the click event that fires after mouseup this.didPanDuringClick = true; + } else if (this.activeMode!.type !== InteractionModeType.RESIZE) { + // Only measure and area zoom have cancel callbacks for non-drag clicks + this.cancelDragMode(); } else { - this.callbacks.onAreaZoomCancel?.(); - } - - // Restore cursor - this.canvas.style.cursor = this.isOverEvent ? 'pointer' : 'grab'; - return; - } - - // Handle resize mode end - if (this.isResizing) { - this.isResizing = false; - this.stopAutoScroll(); - this.stopAutoScrollY(); - - // If drag occurred, finalize the resize - if (this.didResizeDrag) { - this.callbacks.onResizeEnd?.(); - // IMPORTANT: Set this flag to skip the click event that fires after mouseup - this.didPanDuringClick = true; - } - - this.resizeEdge = null; - - // Restore cursor - this.canvas.style.cursor = this.isOverEvent ? 'pointer' : 'grab'; - return; - } - - // Handle measurement mode end - if (this.isMeasuring) { - this.isMeasuring = false; - this.stopAutoScroll(); - this.stopAutoScrollY(); - - // If drag occurred, persist the measurement; otherwise cancel it - if (this.didMeasureDrag) { - this.callbacks.onMeasureEnd?.(); - // IMPORTANT: Set this flag to skip the click event that fires after mouseup - this.didPanDuringClick = true; - } else { - this.callbacks.onMeasureCancel?.(); + // Resize with no drag - just clear the mode + this.activeMode = null; } // Restore cursor @@ -889,35 +881,11 @@ export class TimelineInteractionHandler { return; } - // Cancel area zoom if active - if (this.isAreaZooming) { - this.isAreaZooming = false; - this.stopAutoScroll(); - this.stopAutoScrollY(); - this.callbacks.onAreaZoomCancel?.(); - this.canvas.style.cursor = this.isOverEvent ? 'pointer' : 'grab'; - event.preventDefault(); - return; - } - - // Cancel measurement if active - if (this.isMeasuring) { - this.isMeasuring = false; + // Cancel any active drag mode (measure, area zoom, or resize) + if (this.isDragModeActive()) { this.stopAutoScroll(); this.stopAutoScrollY(); - this.callbacks.onMeasureCancel?.(); - this.canvas.style.cursor = this.isOverEvent ? 'pointer' : 'grab'; - event.preventDefault(); - return; - } - - // Cancel resize if active - if (this.isResizing) { - this.isResizing = false; - this.stopAutoScroll(); - this.stopAutoScrollY(); - this.callbacks.onResizeCancel?.(); - this.resizeEdge = null; + this.cancelDragMode(); this.canvas.style.cursor = this.isOverEvent ? 'pointer' : 'grab'; event.preventDefault(); return; @@ -926,16 +894,12 @@ export class TimelineInteractionHandler { /** * Start auto-scrolling the viewport in the given direction. - * During measurement, area zoom, or resize, updates the boundary after each pan - * so it moves smoothly with the viewport. + * During drag modes (measurement, area zoom, or resize), updates the boundary + * after each pan so it moves smoothly with the viewport. * * @param direction - Scroll direction - * @param mode - Which mode triggered the auto-scroll ('measure', 'areaZoom', or 'resize') */ - private startAutoScroll( - direction: 'left' | 'right', - mode?: 'measure' | 'areaZoom' | 'resize', - ): void { + private startAutoScroll(direction: 'left' | 'right'): void { if (this.autoScrollId !== null) { return; // Already scrolling } @@ -951,20 +915,10 @@ export class TimelineInteractionHandler { // Notify viewport change this.callbacks.onViewportChange?.(); - // During measurement, update the boundary position after pan + // During drag mode, update the boundary position after pan // The screenX stays the same but the TIME at that screenX has changed - if (mode === 'measure' && this.isMeasuring) { - this.callbacks.onMeasureUpdate?.(this.lastMeasureScreenX); - } - - // During area zoom, update the boundary position after pan - if (mode === 'areaZoom' && this.isAreaZooming) { - this.callbacks.onAreaZoomUpdate?.(this.lastAreaZoomScreenX); - } - - // During resize, update the boundary position after pan - if (mode === 'resize' && this.isResizing) { - this.callbacks.onResizeUpdate?.(this.lastResizeScreenX); + if (this.isDragModeActive()) { + this.invokeUpdateCallback(this.activeMode!.lastScreenX); } } @@ -1032,53 +986,18 @@ export class TimelineInteractionHandler { const mouseX = event.clientX - rect.left; const viewportState = this.viewport.getState(); - // If area zooming and mouse left on left/right edge, update to edge and start auto-scroll - if (this.isAreaZooming) { - // Determine which edge the mouse left from and update area zoom to that edge - if (mouseX <= 0) { - // Mouse left on left side - update to left edge (0) and start auto-scroll left - this.lastAreaZoomScreenX = 0; - this.callbacks.onAreaZoomUpdate?.(0); - this.startAutoScroll('left', 'areaZoom'); - } else if (mouseX >= viewportState.displayWidth) { - // Mouse left on right side - update to right edge and start auto-scroll right - this.lastAreaZoomScreenX = viewportState.displayWidth; - this.callbacks.onAreaZoomUpdate?.(viewportState.displayWidth); - this.startAutoScroll('right', 'areaZoom'); - } - // If mouse left top/bottom, just keep the current position - } - - // If resizing and mouse left on left/right edge, update resize to edge and start auto-scroll - if (this.isResizing) { - // Determine which edge the mouse left from and update resize to that edge - if (mouseX <= 0) { - // Mouse left on left side - update to left edge (0) and start auto-scroll left - this.lastResizeScreenX = 0; - this.callbacks.onResizeUpdate?.(0); - this.startAutoScroll('left', 'resize'); - } else if (mouseX >= viewportState.displayWidth) { - // Mouse left on right side - update to right edge and start auto-scroll right - this.lastResizeScreenX = viewportState.displayWidth; - this.callbacks.onResizeUpdate?.(viewportState.displayWidth); - this.startAutoScroll('right', 'resize'); - } - // If mouse left top/bottom, just keep the current position - } - - // If measuring and mouse left on left/right edge, update measurement to edge and start auto-scroll - if (this.isMeasuring) { - // Determine which edge the mouse left from and update measurement to that edge + // If in a drag mode and mouse left on left/right edge, update to edge and start auto-scroll + if (this.isDragModeActive()) { if (mouseX <= 0) { // Mouse left on left side - update to left edge (0) and start auto-scroll left - this.lastMeasureScreenX = 0; - this.callbacks.onMeasureUpdate?.(0); - this.startAutoScroll('left', 'measure'); + this.updateModePosition(0, this.activeMode!.lastScreenY); + this.invokeUpdateCallback(0); + this.startAutoScroll('left'); } else if (mouseX >= viewportState.displayWidth) { // Mouse left on right side - update to right edge and start auto-scroll right - this.lastMeasureScreenX = viewportState.displayWidth; - this.callbacks.onMeasureUpdate?.(viewportState.displayWidth); - this.startAutoScroll('right', 'measure'); + this.updateModePosition(viewportState.displayWidth, this.activeMode!.lastScreenY); + this.invokeUpdateCallback(viewportState.displayWidth); + this.startAutoScroll('right'); } // If mouse left top/bottom, just keep the current position } @@ -1235,8 +1154,8 @@ export class TimelineInteractionHandler { * @param isOverEvent - Whether cursor is over an event */ public updateCursor(isOverEvent: boolean): void { - if (this.isDragging || this.isResizing) { - // Don't change cursor while dragging or resizing + if (this.isDragging || this.isDragModeActive()) { + // Don't change cursor while dragging or in a drag mode return; } diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts index 35a6b506..dcbc7e61 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts @@ -26,34 +26,15 @@ export class TimelineResizeHandler { /** * @param containerRef - The container element to observe for resize * @param renderer - The resizable component to notify on resize - * @param initialWidth - Initial width used by init (pass to ensure consistency) - * @param initialHeight - Initial height used by init (pass to ensure consistency) */ - constructor( - containerRef: HTMLElement, - renderer: IResizable, - initialWidth?: number, - initialHeight?: number, - ) { + constructor(containerRef: HTMLElement, renderer: IResizable) { this.containerRef = containerRef; this.renderer = renderer; - // Pre-populate with the SAME dimensions that init() used. - // This prevents double render on init: FlameChart.init() calls requestRender(), - // and ResizeObserver fires immediately on observe() with the same dimensions. - // - // IMPORTANT: We must use the same dimensions that init() used to create the viewport, - // not re-read from the container. DOM manipulation during init (adding canvases) - // can cause layout shifts that change container dimensions between when init() - // reads them and when this constructor runs. - - // Fallback to reading from container (legacy behavior) - const { width, height } = - initialWidth && initialHeight - ? { width: initialWidth, height: initialHeight } - : containerRef.getBoundingClientRect(); - this.lastResizeWidth = Math.round(width); - this.lastResizeHeight = Math.round(height); + // Dimensions will be populated when setupResizeObserver() is called. + // This is deferred until after first render to avoid double render on init. + this.lastResizeWidth = 0; + this.lastResizeHeight = 0; } public setupResizeObserver(): void { @@ -61,16 +42,35 @@ export class TimelineResizeHandler { return; } + // Read current dimensions as baseline (after layout is finalized from first render). + // This ensures ResizeObserver only triggers for actual subsequent resizes. + const { width, height } = this.containerRef.getBoundingClientRect(); + this.lastResizeWidth = Math.round(width); + this.lastResizeHeight = Math.round(height); + this.resizeObserver = new ResizeObserver(() => { - // Debounce resize handling to prevent flickering - // Clear any existing frame request + // Check dimensions immediately - handles initial callback naturally + // If dimensions match what init() used, skip (no redundant render) + // If dimensions changed (layout shift during init), handle it + const { width, height } = this.containerRef.getBoundingClientRect(); + const roundedWidth = Math.round(width); + const roundedHeight = Math.round(height); + + if (roundedWidth === this.lastResizeWidth && roundedHeight === this.lastResizeHeight) { + return; // Skip if unchanged (covers initial callback case) + } + + // Update dimensions before debounce to prevent rapid duplicate checks + this.lastResizeWidth = roundedWidth; + this.lastResizeHeight = roundedHeight; + + // Debounce actual resize handling to prevent flickering if (this.resizeDebounceFrameId !== null) { cancelAnimationFrame(this.resizeDebounceFrameId); } - // Schedule resize handling on next frame this.resizeDebounceFrameId = requestAnimationFrame(() => { - this.handleResize(); + this.renderer?.resize(roundedWidth, roundedHeight); this.resizeDebounceFrameId = null; }); }); @@ -91,35 +91,4 @@ export class TimelineResizeHandler { this.resizeObserver = null; } } - - /** - * Handle container resize efficiently without full re-initialization. - * Preserves viewport zoom/pan state. - */ - private handleResize(): void { - if (!this.containerRef || !this.renderer) { - return; - } - - const { width, height } = this.containerRef.getBoundingClientRect(); - if (width <= 0 || height <= 0) { - return; - } - - // Round to prevent sub-pixel resize thrashing - const roundedWidth = Math.round(width); - const roundedHeight = Math.round(height); - - // Skip if dimensions haven't actually changed (prevents duplicate calls) - if (roundedWidth === this.lastResizeWidth && roundedHeight === this.lastResizeHeight) { - return; - } - - // Update last resize dimensions - this.lastResizeWidth = roundedWidth; - this.lastResizeHeight = roundedHeight; - - // Use efficient resize method that preserves state - this.renderer.resize(roundedWidth, roundedHeight); - } } diff --git a/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts b/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts new file mode 100644 index 00000000..6cabf8af --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/markers/MarkerHitTest.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * MarkerHitTest + * + * Shared utility for hit testing timeline markers. + * Used by TimelineMarkerRenderer and MeshMarkerRenderer. + */ + +import type { TimelineMarker } from '../../types/flamechart.types.js'; +import { SEVERITY_RANK } from '../../types/flamechart.types.js'; + +/** + * Internal representation of a marker indicator's visual state. + * Used for hit testing against rendered markers. + */ +export interface MarkerIndicator { + marker: TimelineMarker; + resolvedEndTime: number; + screenStartX: number; + screenEndX: number; + screenWidth: number; + color: number; + isVisible: boolean; +} + +/** + * Tests if a screen coordinate intersects any marker indicator. + * + * Used for hover detection. Returns marker with highest severity when multiple overlap. + * + * Algorithm: + * 1. Convert screen X to world X using viewport offset + * 2. Iterate through visible indicators (already culled during render) + * 3. Check AABB collision: worldX falls within [screenStartX, screenEndX] + * 4. Sort matches by severity rank (error > unexpected > skip) + * 5. Return highest priority marker, or null if no hits + * + * @param screenX - Mouse X coordinate in pixels (canvas-relative) + * @param offsetX - Viewport horizontal offset in pixels + * @param indicators - Array of visible marker indicators + * @returns Marker under cursor (highest severity if multiple), or null if no hit + */ +export function hitTestMarkers( + screenX: number, + offsetX: number, + indicators: readonly MarkerIndicator[], +): TimelineMarker | null { + // Convert screen coordinates to world coordinates + // Container is positioned at -offsetX, so add offsetX to convert screen to world + const worldX = screenX + offsetX; + + // Collect all indicators under cursor + const hits: TimelineMarker[] = []; + + for (const indicator of indicators) { + // AABB collision test: check if world X coordinate falls within indicator bounds + if (worldX >= indicator.screenStartX && worldX <= indicator.screenEndX) { + hits.push(indicator.marker); + } + } + + // No hits + if (hits.length === 0) { + return null; + } + + // Single hit - return immediately + if (hits.length === 1) { + return hits[0]!; + } + + // Multiple hits - return highest severity + hits.sort((a, b) => SEVERITY_RANK[b.type] - SEVERITY_RANK[a.type]); + return hits[0]!; +} diff --git a/log-viewer/src/features/timeline/optimised/markers/MeshMarkerRenderer.ts b/log-viewer/src/features/timeline/optimised/markers/MeshMarkerRenderer.ts index 1bd84080..9eee1bb9 100644 --- a/log-viewer/src/features/timeline/optimised/markers/MeshMarkerRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/markers/MeshMarkerRenderer.ts @@ -22,6 +22,7 @@ import { blendWithBackground } from '../BucketColorResolver.js'; import { RectangleGeometry, type ViewportTransform } from '../RectangleGeometry.js'; import { createRectangleShader } from '../RectangleShader.js'; import type { TimelineViewport } from '../TimelineViewport.js'; +import { hitTestMarkers, type MarkerIndicator } from './MarkerHitTest.js'; /** * Pre-blended opaque marker colors (MARKER_COLORS blended at MARKER_ALPHA opacity). @@ -33,19 +34,6 @@ const MARKER_COLORS_BLENDED: Record = { unexpected: blendWithBackground(MARKER_COLORS.unexpected, MARKER_ALPHA), }; -/** - * Internal representation of a marker indicator's visual state. - */ -interface MarkerIndicator { - marker: TimelineMarker; - resolvedEndTime: number; - screenStartX: number; - screenEndX: number; - screenWidth: number; - color: number; - isVisible: boolean; -} - /** * Renders marker indicators as semi-transparent vertical bands using Mesh. * @@ -236,46 +224,13 @@ export class MeshMarkerRenderer { * * Used for hover detection. Returns marker with highest severity when multiple overlap. * - * Algorithm: - * 1. Convert screen coordinates to world coordinates (account for container pan) - * 2. Iterate through visibleIndicators (already culled during render) - * 3. Check AABB collision: worldX falls within [worldStartX, worldEndX] - * 4. Sort matches by severity rank (error > unexpected > skip) - * 5. Return highest priority marker, or null if no hits - * * @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 { - // Convert screen coordinates to world coordinates - // Container is positioned at -offsetX, so add offsetX to convert screen to world const viewportState = this.viewport.getState(); - const worldX = screenX + viewportState.offsetX; - - // Collect all indicators under cursor - const hits: TimelineMarker[] = []; - - for (const indicator of this.visibleIndicators) { - // AABB collision test: check if world X coordinate falls within indicator bounds - if (worldX >= indicator.screenStartX && worldX <= indicator.screenEndX) { - hits.push(indicator.marker); - } - } - - // No hits - if (hits.length === 0) { - return null; - } - - // Single hit - return immediately - if (hits.length === 1) { - return hits[0]!; - } - - // Multiple hits - return highest severity - hits.sort((a, b) => SEVERITY_RANK[b.type] - SEVERITY_RANK[a.type]); - return hits[0]!; + return hitTestMarkers(screenX, viewportState.offsetX, this.visibleIndicators); } /** diff --git a/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts b/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts index 20b0386f..b4847807 100644 --- a/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/markers/TimelineMarkerRenderer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** @@ -20,6 +20,7 @@ import { MARKER_ALPHA, MARKER_COLORS, SEVERITY_RANK } from '../../types/flamecha import { blendWithBackground } from '../BucketColorResolver.js'; import { SpritePool } from '../SpritePool.js'; import type { TimelineViewport } from '../TimelineViewport.js'; +import { hitTestMarkers, type MarkerIndicator } from './MarkerHitTest.js'; /** * Pre-blended opaque marker colors (MARKER_COLORS blended at MARKER_ALPHA opacity). @@ -31,19 +32,6 @@ const MARKER_COLORS_BLENDED: Record = { unexpected: blendWithBackground(MARKER_COLORS.unexpected, MARKER_ALPHA), }; -/** - * Internal representation of a marker indicator's visual state. - */ -interface MarkerIndicator { - marker: TimelineMarker; - resolvedEndTime: number; - screenStartX: number; - screenEndX: number; - screenWidth: number; - color: number; - isVisible: boolean; -} - /** * Renders marker indicators as semi-transparent vertical bands using sprites. * @@ -181,46 +169,13 @@ export class TimelineMarkerRenderer { * * Used for hover detection. Returns marker with highest severity when multiple overlap. * - * Algorithm: - * 1. Convert screen coordinates to world coordinates (account for container pan) - * 2. Iterate through visibleIndicators (already culled during render) - * 3. Check AABB collision: worldX falls within [worldStartX, worldEndX] - * 4. Sort matches by severity rank (error > unexpected > skip) - * 5. Return highest priority marker, or null if no hits - * * @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 { - // Convert screen coordinates to world coordinates - // Container is positioned at -offsetX, so add offsetX to convert screen to world const viewportState = this.viewport.getState(); - const worldX = screenX + viewportState.offsetX; - - // Collect all indicators under cursor - const hits: TimelineMarker[] = []; - - for (const indicator of this.visibleIndicators) { - // AABB collision test: check if world X coordinate falls within indicator bounds - if (worldX >= indicator.screenStartX && worldX <= indicator.screenEndX) { - hits.push(indicator.marker); - } - } - - // No hits - if (hits.length === 0) { - return null; - } - - // Single hit - return immediately - if (hits.length === 1) { - return hits[0]!; - } - - // Multiple hits - return highest severity - hits.sort((a, b) => SEVERITY_RANK[b.type] - SEVERITY_RANK[a.type]); - return hits[0]!; + return hitTestMarkers(screenX, viewportState.offsetX, this.visibleIndicators); } /** diff --git a/log-viewer/src/features/timeline/optimised/measurement/AreaZoomRenderer.ts b/log-viewer/src/features/timeline/optimised/measurement/AreaZoomRenderer.ts index 04386b04..b93b9cdb 100644 --- a/log-viewer/src/features/timeline/optimised/measurement/AreaZoomRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/measurement/AreaZoomRenderer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** @@ -19,6 +19,7 @@ import * as PIXI from 'pixi.js'; import { formatDuration } from '../../../../core/utility/Util.js'; import type { ViewportState } from '../../types/flamechart.types.js'; +import { calculateLabelPosition, createTimelineLabel } from '../rendering/LabelPositioning.js'; import type { MeasurementState } from './MeasurementManager.js'; /** Opacity for the dim overlay outside the selection */ @@ -66,26 +67,7 @@ export class AreaZoomRenderer { * Create the HTML label element with styling. */ private createLabelElement(): HTMLDivElement { - const label = document.createElement('div'); - label.className = 'area-zoom-label'; - label.style.cssText = ` - position: absolute; - display: none; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 8px 12px; - border-radius: 4px; - background: var(--vscode-editorWidget-background, #252526); - border: 1px solid var(--vscode-editorWidget-border, #454545); - color: var(--vscode-editorWidget-foreground, #cccccc); - font-family: var(--vscode-font-family, sans-serif); - font-size: 12px; - pointer-events: none; - z-index: 100; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - `; - return label; + return createTimelineLabel('area-zoom-label'); } /** @@ -178,48 +160,14 @@ export class AreaZoomRenderer { // Position label after content is set (need to measure label size) requestAnimationFrame(() => { const labelRect = this.labelElement.getBoundingClientRect(); - const labelWidth = labelRect.width; - const labelHeight = labelRect.height; - const padding = 8; - - // Calculate visible portion of selection - const visibleStartX = Math.max(screenStartX, 0); - const visibleEndX = Math.min(screenEndX, viewport.displayWidth); - const visibleWidth = visibleEndX - visibleStartX; - - // Determine horizontal position - center in visible portion - let left: number; - - const centeredLeft = visibleStartX + (visibleWidth - labelWidth) / 2; - - if (visibleWidth >= labelWidth + padding * 2) { - // Visible portion is wide enough: center tooltip in visible portion - left = centeredLeft; - } else if (screenStartX < 0 && screenEndX > viewport.displayWidth) { - // Both edges offscreen: center on viewport - left = (viewport.displayWidth - labelWidth) / 2; - } else if (screenStartX < 0) { - // Left edge offscreen, right visible: stick to left edge of viewport - left = padding; - } else if (screenEndX > viewport.displayWidth) { - // Right edge offscreen, left visible: stick to right edge of viewport - left = viewport.displayWidth - labelWidth - padding; - } else { - // Selection is small but fully visible: center on selection (may extend outside) - left = centeredLeft; - } - - // Clamp to viewport bounds - left = Math.max(padding, Math.min(viewport.displayWidth - labelWidth - padding, left)); - - // Vertical center - const top = Math.max( - padding, - Math.min( - viewport.displayHeight - labelHeight - padding, - viewport.displayHeight / 2 - labelHeight / 2, - ), - ); + const { left, top } = calculateLabelPosition({ + labelWidth: labelRect.width, + labelHeight: labelRect.height, + screenStartX, + screenEndX, + displayWidth: viewport.displayWidth, + displayHeight: viewport.displayHeight, + }); this.labelElement.style.left = `${left}px`; this.labelElement.style.top = `${top}px`; diff --git a/log-viewer/src/features/timeline/optimised/measurement/MeasureRangeRenderer.ts b/log-viewer/src/features/timeline/optimised/measurement/MeasureRangeRenderer.ts index ee142d28..65a677ee 100644 --- a/log-viewer/src/features/timeline/optimised/measurement/MeasureRangeRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/measurement/MeasureRangeRenderer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** @@ -19,6 +19,8 @@ import * as PIXI from 'pixi.js'; import { formatDuration, formatTimeRange } from '../../../../core/utility/Util.js'; import type { ViewportState } from '../../types/flamechart.types.js'; +import { parseColorToHex } from '../rendering/ColorUtils.js'; +import { calculateLabelPosition, createTimelineLabel } from '../rendering/LabelPositioning.js'; import type { MeasurementState } from './MeasurementManager.js'; /** @@ -49,8 +51,14 @@ export class MeasureRangeRenderer { /** HTML container for the label (avoids PIXI coordinate inversion issues) */ private labelElement: HTMLDivElement; - /** Parent HTML container for positioning */ - private container: HTMLElement; + /** Duration text element (reused to avoid innerHTML) */ + private durationText!: HTMLDivElement; + + /** Range text element (reused to avoid innerHTML) */ + private rangeText!: HTMLDivElement; + + /** Zoom icon button (created once, shown/hidden as needed) */ + private zoomIcon: HTMLButtonElement | null = null; /** Cached colors from CSS variables */ private colors: MeasurementColors; @@ -64,7 +72,6 @@ export class MeasureRangeRenderer { * @param onZoomClick - Optional callback when zoom icon is clicked */ constructor(pixiContainer: PIXI.Container, htmlContainer: HTMLElement, onZoomClick?: () => void) { - this.container = htmlContainer; this.onZoomClick = onZoomClick; // Create graphics for overlay - render above frames but below tooltips @@ -81,29 +88,89 @@ export class MeasureRangeRenderer { } /** - * Create the HTML label element with styling. + * Create the HTML label element with full structure. + * Creates all child elements once to avoid recreating on each update. */ private createLabelElement(): HTMLDivElement { - const label = document.createElement('div'); - label.className = 'measure-range-label'; - label.style.cssText = ` - position: absolute; + const label = createTimelineLabel('measure-range-label'); + + // Create container for flex layout + const contentWrapper = document.createElement('div'); + contentWrapper.style.cssText = 'display: flex; align-items: center;'; + + // Create text container + const textContainer = document.createElement('div'); + textContainer.style.cssText = 'text-align: center;'; + + // Duration text + this.durationText = document.createElement('div'); + this.durationText.style.cssText = 'font-size: 14px; font-weight: 600;'; + + // Range text + this.rangeText = document.createElement('div'); + this.rangeText.style.cssText = 'font-size: 11px; opacity: 0.8; margin-top: 2px;'; + + textContainer.appendChild(this.durationText); + textContainer.appendChild(this.rangeText); + contentWrapper.appendChild(textContainer); + + // Create zoom icon button (created once, shown/hidden as needed) + if (this.onZoomClick) { + this.zoomIcon = this.createZoomIcon(); + contentWrapper.appendChild(this.zoomIcon); + } + + label.appendChild(contentWrapper); + return label; + } + + /** + * Create the zoom icon button with event listeners attached once. + */ + private createZoomIcon(): HTMLButtonElement { + const button = document.createElement('button'); + button.className = 'measure-zoom-icon'; + button.title = 'Zoom to fit (double-click)'; + button.style.cssText = ` display: none; - flex-direction: column; align-items: center; justify-content: center; - padding: 8px 12px; + width: 24px; + height: 24px; + margin-left: 8px; + padding: 0; + border: none; border-radius: 4px; - background: var(--vscode-editorWidget-background, #252526); - border: 1px solid var(--vscode-editorWidget-border, #454545); - color: var(--vscode-editorWidget-foreground, #cccccc); - font-family: var(--vscode-font-family, sans-serif); - font-size: 12px; - pointer-events: none; - z-index: 100; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background: var(--vscode-button-secondaryBackground, #3a3d41); + color: var(--vscode-button-secondaryForeground, #cccccc); + cursor: pointer; + pointer-events: auto; + transition: background 0.1s; `; - return label; + + // SVG icon + button.innerHTML = ` + + + + + `; + + // Attach event listeners once + button.addEventListener('click', (e) => { + e.stopPropagation(); + this.onZoomClick?.(); + }); + + button.addEventListener('mouseenter', () => { + button.style.background = 'var(--vscode-button-secondaryHoverBackground, #45494e)'; + }); + + button.addEventListener('mouseleave', () => { + button.style.background = 'var(--vscode-button-secondaryBackground, #3a3d41)'; + }); + + return button; } /** @@ -124,54 +191,15 @@ export class MeasureRangeRenderer { computedStyle.getPropertyValue('--vscode-focusBorder').trim() || '#007fd4'; + // Default blue (0x264f78) for measurement overlay + const defaultBlue = 0x264f78; + return { - fillColor: this.parseColorToHex(fillStr), - borderColor: this.parseColorToHex(borderStr), + fillColor: parseColorToHex(fillStr, defaultBlue), + borderColor: parseColorToHex(borderStr, defaultBlue), }; } - /** - * Parse CSS color string to numeric hex (RGB only). - */ - private parseColorToHex(cssColor: string): number { - if (!cssColor) { - return 0x264f78; // Default blue - } - - if (cssColor.startsWith('#')) { - const hex = cssColor.slice(1); - if (hex.length === 8) { - return parseInt(hex.slice(0, 6), 16); - } - if (hex.length === 6) { - return parseInt(hex, 16); - } - if (hex.length === 4) { - const r = hex[0]!; - const g = hex[1]!; - const b = hex[2]!; - return parseInt(r + r + g + g + b + b, 16); - } - if (hex.length === 3) { - const r = hex[0]!; - const g = hex[1]!; - const b = hex[2]!; - return parseInt(r + r + g + g + b + b, 16); - } - } - - // rgba() fallback - const rgba = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/); - if (rgba) { - const r = parseInt(rgba[1]!, 10); - const g = parseInt(rgba[2]!, 10); - const b = parseInt(rgba[3]!, 10); - return (r << 16) | (g << 8) | b; - } - - return 0x264f78; // Default blue - } - /** * Render the measurement overlay and label. * @@ -232,65 +260,14 @@ export class MeasureRangeRenderer { return; } - // Format times using shared utilities - const durationStr = formatDuration(duration); - const rangeStr = formatTimeRange(startTime, endTime); - - // Only show zoom icon when measurement is finished (not while dragging) - const zoomIconHtml = - !isActive && this.onZoomClick - ? `` - : ''; - - // Update label content with centered duration - this.labelElement.innerHTML = ` -
-
-
${durationStr}
-
${rangeStr}
-
- ${zoomIconHtml} -
- `; + // Update text content (no innerHTML - reuse existing elements) + this.durationText.textContent = formatDuration(duration); + this.rangeText.textContent = formatTimeRange(startTime, endTime); - // Add click listener to zoom icon - if (!isActive && this.onZoomClick) { - const zoomIcon = this.labelElement.querySelector('.measure-zoom-icon'); - if (zoomIcon) { - zoomIcon.addEventListener('click', (e) => { - e.stopPropagation(); - this.onZoomClick?.(); - }); - // Hover effect - zoomIcon.addEventListener('mouseenter', () => { - (zoomIcon as HTMLElement).style.background = - 'var(--vscode-button-secondaryHoverBackground, #45494e)'; - }); - zoomIcon.addEventListener('mouseleave', () => { - (zoomIcon as HTMLElement).style.background = - 'var(--vscode-button-secondaryBackground, #3a3d41)'; - }); - } + // Show/hide zoom icon based on measurement state + // Only show when measurement is finished (not while dragging) + if (this.zoomIcon) { + this.zoomIcon.style.display = !isActive ? 'flex' : 'none'; } // Show label @@ -299,49 +276,14 @@ export class MeasureRangeRenderer { // Position label after content is set (need to measure label size) requestAnimationFrame(() => { const labelRect = this.labelElement.getBoundingClientRect(); - const labelWidth = labelRect.width; - const labelHeight = labelRect.height; - const padding = 8; - - // Calculate visible portion of measurement overlay - const visibleStartX = Math.max(screenStartX, 0); - const visibleEndX = Math.min(screenEndX, viewport.displayWidth); - const visibleWidth = visibleEndX - visibleStartX; - - // Determine horizontal position - let left: number; - - // Try to center in visible portion first - const centeredLeft = visibleStartX + (visibleWidth - labelWidth) / 2; - - if (visibleWidth >= labelWidth + padding * 2) { - // Visible portion is wide enough: center tooltip in visible portion - left = centeredLeft; - } else if (screenStartX < 0 && screenEndX > viewport.displayWidth) { - // Both edges offscreen: center on viewport - left = (viewport.displayWidth - labelWidth) / 2; - } else if (screenStartX < 0) { - // Left edge offscreen, right visible: stick to left edge of viewport - left = padding; - } else if (screenEndX > viewport.displayWidth) { - // Right edge offscreen, left visible: stick to right edge of viewport - left = viewport.displayWidth - labelWidth - padding; - } else { - // Overlay is small but fully visible: center on overlay (may extend outside) - left = centeredLeft; - } - - // Clamp to viewport bounds (safety) - left = Math.max(padding, Math.min(viewport.displayWidth - labelWidth - padding, left)); - - // Vertical center - const top = Math.max( - padding, - Math.min( - viewport.displayHeight - labelHeight - padding, - viewport.displayHeight / 2 - labelHeight / 2, - ), - ); + const { left, top } = calculateLabelPosition({ + labelWidth: labelRect.width, + labelHeight: labelRect.height, + screenStartX, + screenEndX, + displayWidth: viewport.displayWidth, + displayHeight: viewport.displayHeight, + }); this.labelElement.style.left = `${left}px`; this.labelElement.style.top = `${top}px`; diff --git a/log-viewer/src/features/timeline/optimised/measurement/MeasurementManager.ts b/log-viewer/src/features/timeline/optimised/measurement/MeasurementManager.ts index ac1db308..e3927af5 100644 --- a/log-viewer/src/features/timeline/optimised/measurement/MeasurementManager.ts +++ b/log-viewer/src/features/timeline/optimised/measurement/MeasurementManager.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index 0d23bc8e..91882605 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -66,10 +66,16 @@ export interface MetricStripOrchestratorCallbacks { onCursorMove: (timeNs: number | null) => void; /** - * Called when metric strip needs a re-render. + * Called when metric strip needs a re-render (full render including culling). */ requestRender: () => void; + /** + * Called when only cursor-related rendering is needed (~1ms vs ~10ms). + * Use for cursor moves that don't change viewport. + */ + requestCursorRender: () => void; + /** * Called when wheel zoom is applied at a cursor position. * @@ -340,7 +346,7 @@ export class MetricStripOrchestrator { */ public setCursorFromMainTimeline(timeNs: number | null): void { this.cursorTimeNs = timeNs; - this.callbacks.requestRender(); + this.callbacks.requestCursorRender(); } /** @@ -515,7 +521,7 @@ export class MetricStripOrchestrator { this.cursorTimeNs = null; this.callbacks.onCursorMove(null); this.tooltipRenderer?.hide(); - this.callbacks.requestRender(); + this.callbacks.requestCursorRender(); }; private handleMouseMove = (event: MouseEvent): void => { @@ -561,7 +567,7 @@ export class MetricStripOrchestrator { } } - this.callbacks.requestRender(); + this.callbacks.requestCursorRender(); }; private handleClick = (event: MouseEvent): void => { diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts index bc16c04b..8248adc8 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts @@ -332,6 +332,9 @@ export class MinimapDensityQuery { * - New: O(N) single pass + O(B × k) skyline computation = ~10-20ms * (where k = avg frames per bucket, much smaller than N) * + * PERF: Uses binary search to find frames for each bucket on-demand, + * avoiding O(N × bucketSpan) frame collection (~10-15ms saved). + * * @param bucketCount - Number of output buckets * @returns MinimapDensityData */ @@ -347,19 +350,16 @@ export class MinimapDensityQuery { const frames = this.segmentTree.getAllFramesSorted(); const bucketTimeWidth = this.totalDuration / bucketCount; + const frameCount = frames.length; // Pre-allocate bucket arrays const maxDepths = new Uint16Array(bucketCount); const eventCounts = new Uint32Array(bucketCount); const selfDurationSums = new Float64Array(bucketCount); - // Collect frames per bucket for skyline computation - const framesPerBucket: SkylineFrame[][] = new Array(bucketCount); - for (let i = 0; i < bucketCount; i++) { - framesPerBucket[i] = []; - } - - // Single pass through sorted frames + // First pass: compute maxDepth, eventCount, and selfDurationSums per bucket + // This still needs to iterate frames spanning multiple buckets, but we avoid + // storing frame references (which was the main memory/allocation cost) for (const frame of frames) { const startBucket = Math.max(0, Math.floor(frame.timeStart / bucketTimeWidth)); const endBucket = Math.min(bucketCount - 1, Math.floor(frame.timeEnd / bucketTimeWidth)); @@ -385,16 +385,16 @@ export class MinimapDensityQuery { const overlapRatio = frameDuration > 0 ? visibleTime / frameDuration : 0; const proportionalSelfDuration = frame.selfDuration * overlapRatio; selfDurationSums[b]! += proportionalSelfDuration; - - // Collect frame for skyline computation - framesPerBucket[b]!.push(frame); } } - // Build output buckets with skyline category resolution + // Build output buckets with on-demand frame lookup for skyline computation const buckets: MinimapDensityBucket[] = new Array(bucketCount); let maxEventCount = 0; + // Track search position for binary search optimization + let searchStartIdx = 0; + for (let i = 0; i < bucketCount; i++) { const eventCount = eventCounts[i]!; if (eventCount > maxEventCount) { @@ -404,9 +404,25 @@ export class MinimapDensityQuery { const bucketStart = i * bucketTimeWidth; const bucketEnd = (i + 1) * bucketTimeWidth; + // PERF: Use binary search to find frames for this bucket on-demand + // instead of pre-collecting all frames into all buckets + const bucketFrames = this.getFramesInRange( + frames, + frameCount, + bucketStart, + bucketEnd, + searchStartIdx, + ); + + // Update search start for next bucket (frames are sorted by timeStart) + // Advance searchStartIdx to skip frames that end before this bucket + while (searchStartIdx < frameCount && frames[searchStartIdx]!.timeEnd <= bucketStart) { + searchStartIdx++; + } + // Resolve dominant category using skyline (on-top time) algorithm const dominantCategory = this.resolveCategoryFromSkyline( - framesPerBucket[i]!, + bucketFrames, bucketStart, bucketEnd, ); @@ -429,6 +445,56 @@ export class MinimapDensityQuery { }; } + /** + * Binary search to find frames overlapping a time range. + * Uses the fact that frames are sorted by timeStart. + * + * PERF: O(log N + k) where k = frames in range, vs O(N) for pre-collection. + * + * @param frames - Sorted frames array + * @param frameCount - Number of frames + * @param bucketStart - Bucket start time + * @param bucketEnd - Bucket end time + * @param startIdx - Starting index for search (optimization for sequential buckets) + * @returns Frames overlapping the bucket + */ + private getFramesInRange( + frames: SkylineFrame[], + frameCount: number, + bucketStart: number, + bucketEnd: number, + startIdx: number, + ): SkylineFrame[] { + // Binary search for first frame that could overlap (timeEnd > bucketStart) + let lo = startIdx; + let hi = frameCount; + + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (frames[mid]!.timeEnd <= bucketStart) { + lo = mid + 1; + } else { + hi = mid; + } + } + + // Linear scan from found position to collect overlapping frames + const result: SkylineFrame[] = []; + for (let i = lo; i < frameCount; i++) { + const frame = frames[i]!; + // Frames are sorted by timeStart, so stop when past bucket + if (frame.timeStart >= bucketEnd) { + break; + } + // Check for overlap: frame overlaps bucket if frame.timeEnd > bucketStart + if (frame.timeEnd > bucketStart) { + result.push(frame); + } + } + + return result; + } + /** * Compute density data using per-bucket tree queries. * O(B × log N) complexity - for each bucket, query the segment tree. @@ -511,6 +577,9 @@ export class MinimapDensityQuery { * 4. Accumulate on-top time per category * 5. Apply CATEGORY_WEIGHTS to determine winner * + * PERF: Uses Set for O(1) add/remove instead of indexOf+splice (O(k²) → O(k)). + * PERF: Tracks max depth incrementally to avoid rescanning on every frame exit. + * * @param frames - Frames overlapping this bucket * @param bucketStart - Bucket start time * @param bucketEnd - Bucket end time @@ -565,38 +634,45 @@ export class MinimapDensityQuery { }); // Sweep through events tracking on-top time per category + // PERF: Use Set for O(1) add/remove instead of array indexOf+splice const onTopTime = new Map(); - const activeFrames: SkylineFrame[] = []; + const activeFrames = new Set(); + let currentMaxDepth = -1; + let currentDeepestFrame: SkylineFrame | null = null; let lastTime = bucketStart; for (const event of events) { const currentTime = event.time; // Accumulate on-top time for the deepest frame since lastTime - if (activeFrames.length > 0 && currentTime > lastTime) { - // Find deepest frame - let deepest = activeFrames[0]!; - for (let i = 1; i < activeFrames.length; i++) { - if (activeFrames[i]!.depth > deepest.depth) { - deepest = activeFrames[i]!; - } - } - + if (currentDeepestFrame && currentTime > lastTime) { const duration = currentTime - lastTime; - const existing = onTopTime.get(deepest.category) ?? 0; - onTopTime.set(deepest.category, existing + duration); + const existing = onTopTime.get(currentDeepestFrame.category) ?? 0; + onTopTime.set(currentDeepestFrame.category, existing + duration); } lastTime = currentTime; // Update active frames if (event.type === SkylineEventType.Enter) { - activeFrames.push(event.frame); + activeFrames.add(event.frame); + // Update max depth tracking if this frame is deeper + if (event.frame.depth > currentMaxDepth) { + currentMaxDepth = event.frame.depth; + currentDeepestFrame = event.frame; + } } else { - // Remove frame from active list - const idx = activeFrames.indexOf(event.frame); - if (idx !== -1) { - activeFrames.splice(idx, 1); + activeFrames.delete(event.frame); // O(1) instead of O(k) + // Only recompute max if we removed the deepest frame + if (event.frame === currentDeepestFrame) { + currentMaxDepth = -1; + currentDeepestFrame = null; + for (const f of activeFrames) { + if (f.depth > currentMaxDepth) { + currentMaxDepth = f.depth; + currentDeepestFrame = f; + } + } } } } diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts index 5136f818..5f64ee0a 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapRenderer.ts @@ -31,6 +31,7 @@ import type { MarkerType, TimelineMarker } from '../../types/flamechart.types.js import { MARKER_ALPHA, MARKER_COLORS } from '../../types/flamechart.types.js'; import { blendWithBackground } from '../BucketColorResolver.js'; import { createRectangleShader } from '../RectangleShader.js'; +import { parseColorToHex } from '../rendering/ColorUtils.js'; import { MinimapAxisRenderer } from './MinimapAxisRenderer.js'; import { MinimapBarGeometry } from './MinimapBarGeometry.js'; import type { MinimapDensityData } from './MinimapDensityQuery.js'; @@ -578,7 +579,7 @@ export class MinimapRenderer { private renderMarkers( manager: MinimapManager, markers: TimelineMarker[], - minimapHeight: number, + _minimapHeight: number, ): void { const state = manager.getState(); @@ -631,7 +632,7 @@ export class MinimapRenderer { private render2DCurtain( manager: MinimapManager, selection: Readonly, - minimapHeight: number, + _minimapHeight: number, ): void { const state = manager.getState(); @@ -698,7 +699,7 @@ export class MinimapRenderer { private renderLens( manager: MinimapManager, selection: Readonly, - minimapHeight: number, + _minimapHeight: number, ): void { // Axis is at TOP - chart area is below it // When heat strip has data, lens ends above the heat strip (it's separate) @@ -787,45 +788,12 @@ export class MinimapRenderer { const borderStr = computedStyle.getPropertyValue('--vscode-focusBorder').trim() || '#007fd4'; return { - curtain: this.parseColorToHex(curtainStr), - lensBorder: this.parseColorToHex(borderStr), - edgeHandle: this.parseColorToHex(borderStr), + curtain: parseColorToHex(curtainStr), + lensBorder: parseColorToHex(borderStr), + edgeHandle: parseColorToHex(borderStr), }; } - /** - * Parse CSS color string to numeric hex (RGB only). - */ - private parseColorToHex(cssColor: string): number { - if (!cssColor) { - return 0x1e1e1e; // Default dark - } - - if (cssColor.startsWith('#')) { - const hex = cssColor.slice(1); - if (hex.length === 6) { - return parseInt(hex, 16); - } - if (hex.length === 3) { - const r = hex[0]!; - const g = hex[1]!; - const b = hex[2]!; - return parseInt(r + r + g + g + b + b, 16); - } - } - - // rgba() fallback - const rgba = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/); - if (rgba) { - const r = parseInt(rgba[1]!, 10); - const g = parseInt(rgba[2]!, 10); - const b = parseInt(rgba[3]!, 10); - return (r << 16) | (g << 8) | b; - } - - return 0x1e1e1e; // Default dark - } - /** * Refresh colors from CSS variables (e.g., after VS Code theme change). * Also invalidates static content to trigger re-render with new colors. diff --git a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts index 100c9795..c4bfa492 100644 --- a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts @@ -76,10 +76,16 @@ export interface MinimapOrchestratorCallbacks { onCursorMove: (timeNs: number | null) => void; /** - * Called when minimap needs a re-render. + * Called when minimap needs a re-render (full render including culling). */ requestRender: () => void; + /** + * Called when only cursor-related rendering is needed (~1ms vs ~10ms). + * Use for cursor moves that don't change viewport. + */ + requestCursorRender: () => void; + /** * Called when reset zoom is requested (double-click or keyboard). */ @@ -297,7 +303,7 @@ export class MinimapOrchestrator { */ public setCursorFromMainTimeline(timeNs: number | null): void { this.cursorTimeNs = timeNs; - this.callbacks.requestRender(); + this.callbacks.requestCursorRender(); } /** @@ -367,16 +373,6 @@ export class MinimapOrchestrator { this.callbacks.onDepthPan(deltaY); } - /** - * Zoom the minimap selection. - * - * @param direction - 'in' to narrow the lens, 'out' to widen it - */ - public handleZoom(direction: 'in' | 'out'): void { - // This is handled by FlameChart via ViewportAnimator - // The orchestrator doesn't own the viewport, so we delegate - } - /** * Jump to timeline start. */ @@ -498,7 +494,7 @@ export class MinimapOrchestrator { onCursorMove: (timeNs: number | null) => { this.cursorTimeNs = timeNs; this.callbacks.onCursorMove(timeNs); - this.callbacks.requestRender(); + this.callbacks.requestCursorRender(); }, onHorizontalPan: (deltaPixels: number) => { if (!this.viewport || !this.manager) { diff --git a/log-viewer/src/features/timeline/optimised/orchestrators/SearchOrchestrator.ts b/log-viewer/src/features/timeline/optimised/orchestrators/SearchOrchestrator.ts index df990898..9e020bd2 100644 --- a/log-viewer/src/features/timeline/optimised/orchestrators/SearchOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/orchestrators/SearchOrchestrator.ts @@ -102,6 +102,18 @@ export class SearchOrchestrator { private searchHighlightRenderer: SearchHighlightRenderer | null = null; private searchTextLabelRenderer: SearchTextLabelRenderer | null = null; + // ============================================================================ + // DEFERRED INIT STATE + // ============================================================================ + /** Stored for deferred renderer initialization */ + private deferredInitData: { + worldContainer: PIXI.Container; + batches: Map; + textLabelRenderer: TextLabelRenderer; + useMeshRenderer: boolean; + stage?: PIXI.Container; + } | null = null; + // ============================================================================ // EXTERNAL REFERENCES (not owned) // ============================================================================ @@ -143,20 +155,41 @@ export class SearchOrchestrator { this.viewport = viewport; this.mainTimelineYOffset = mainTimelineYOffset; - // Build rectMap by ID from PrecomputedRect - const rectMap = new Map(); - const logEventRectMap = rectangleManager.getRectMap(); + // PERF: Use cached rectMapById instead of rebuilding (~18ms saved) + const rectMap = rectangleManager.getRectMapById(); + + // Initialize SearchManager eagerly (needed for search() calls) + this.searchManager = new SearchManager(treeNodes, rectMap); - for (const [_event, rect] of logEventRectMap.entries()) { - rectMap.set(rect.id, rect); + // PERF: Defer renderer initialization to first search() call (~6ms saved at init) + // Store data needed for deferred initialization + this.deferredInitData = { + worldContainer, + batches, + textLabelRenderer, + useMeshRenderer, + }; + } + + /** + * Ensure search renderers are initialized (lazy initialization). + * Called on first search() or render operation. + */ + private ensureRenderersInitialized(): void { + if (this.searchStyleRenderer || !this.deferredInitData) { + return; // Already initialized or no data to initialize with } - // Initialize SearchManager - this.searchManager = new SearchManager(treeNodes, rectMap); + const { worldContainer, batches, textLabelRenderer, useMeshRenderer, stage } = + this.deferredInitData; // Initialize search style renderer (renders with desaturation for search mode) if (useMeshRenderer) { this.searchStyleRenderer = new MeshSearchStyleRenderer(worldContainer, batches); + // If stage was set before renderers were initialized, apply it now + if (stage) { + (this.searchStyleRenderer as MeshSearchStyleRenderer).setStageContainer(stage); + } } else { this.searchStyleRenderer = new SearchStyleRenderer(worldContainer, batches); } @@ -170,6 +203,9 @@ export class SearchOrchestrator { textLabelRenderer, batches, ); + + // Clear deferred data - no longer needed + this.deferredInitData = null; } /** @@ -178,6 +214,12 @@ export class SearchOrchestrator { * @param stage - PixiJS stage container */ public setStageContainer(stage: PIXI.Container): void { + // Store for deferred initialization if renderers not yet created + if (this.deferredInitData) { + this.deferredInitData.stage = stage; + } + + // Apply immediately if renderer already exists if (this.searchStyleRenderer && 'setStageContainer' in this.searchStyleRenderer) { (this.searchStyleRenderer as MeshSearchStyleRenderer).setStageContainer(stage); } @@ -256,6 +298,9 @@ export class SearchOrchestrator { return null; } + // PERF: Initialize renderers on first search call (~6ms deferred from init) + this.ensureRenderersInitialized(); + // Clear selection when starting a new search to show search highlights this.callbacks.onClearSelection(); @@ -294,7 +339,14 @@ export class SearchOrchestrator { * @param context - Render context with viewport state, visible rects, and buckets */ public renderStyledEvents(context: SearchRenderContext): void { - if (!this.searchStyleRenderer || !this.searchManager) { + if (!this.searchManager) { + return; + } + + // Ensure renderers are initialized before rendering + this.ensureRenderersInitialized(); + + if (!this.searchStyleRenderer) { return; } @@ -332,7 +384,14 @@ export class SearchOrchestrator { * @param context - Render context with viewport state and visible rects */ public renderStyledLabels(context: SearchRenderContext): void { - if (!this.searchTextLabelRenderer || !this.searchManager) { + if (!this.searchManager) { + return; + } + + // Ensure renderers are initialized before rendering + this.ensureRenderersInitialized(); + + if (!this.searchTextLabelRenderer) { return; } @@ -366,7 +425,14 @@ export class SearchOrchestrator { * @param viewportState - Current viewport state */ public renderHighlight(viewportState: ViewportState): void { - if (!this.searchHighlightRenderer || !this.searchManager) { + if (!this.searchManager) { + return; + } + + // Ensure renderers are initialized before rendering + this.ensureRenderersInitialized(); + + if (!this.searchHighlightRenderer) { return; } diff --git a/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts b/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts new file mode 100644 index 00000000..43e4d0ac --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * ColorUtils + * + * Shared utilities for parsing CSS color strings to PixiJS numeric hex format. + * Used by renderers that need to extract colors from CSS variables. + */ + +/** + * Parse a CSS color string to a numeric hex value (0xRRGGBB). + * + * Supports: + * - Hex formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA + * - RGB/RGBA functions: rgb(r, g, b), rgba(r, g, b, a) + * + * @param cssColor - CSS color string to parse + * @param defaultColor - Fallback color if parsing fails (default: 0x1e1e1e dark gray) + * @returns Numeric hex color (0xRRGGBB format) + */ +export function parseColorToHex(cssColor: string, defaultColor: number = 0x1e1e1e): number { + if (!cssColor) { + return defaultColor; + } + + if (cssColor.startsWith('#')) { + const hex = cssColor.slice(1); + + // #RRGGBBAA (8 chars) - extract RGB, ignore alpha + if (hex.length === 8) { + return parseInt(hex.slice(0, 6), 16); + } + + // #RRGGBB (6 chars) + if (hex.length === 6) { + return parseInt(hex, 16); + } + + // #RGB (3 chars) or #RGBA (4 chars) - expand first 3 chars to 6, ignore alpha + if (hex.length === 3 || hex.length === 4) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + return parseInt(r + r + g + g + b + b, 16); + } + } + + // rgb() or rgba() format + const rgba = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/); + if (rgba) { + const r = parseInt(rgba[1]!, 10); + const g = parseInt(rgba[2]!, 10); + const b = parseInt(rgba[3]!, 10); + return (r << 16) | (g << 8) | b; + } + + return defaultColor; +} diff --git a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts index 2c1079b9..d4d31a7c 100644 --- a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** diff --git a/log-viewer/src/features/timeline/optimised/rendering/LabelPositioning.ts b/log-viewer/src/features/timeline/optimised/rendering/LabelPositioning.ts new file mode 100644 index 00000000..07e49690 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/rendering/LabelPositioning.ts @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * LabelPositioning + * + * Shared utilities for creating and positioning HTML labels in timeline overlays. + * Used by MeasureRangeRenderer and AreaZoomRenderer for consistent tooltip styling. + */ + +/** + * Parameters for calculating label position. + */ +export interface LabelPositionParams { + /** Label width in pixels (from getBoundingClientRect) */ + labelWidth: number; + /** Label height in pixels (from getBoundingClientRect) */ + labelHeight: number; + /** Screen X position of selection start (time * zoom - offsetX) */ + screenStartX: number; + /** Screen X position of selection end (time * zoom - offsetX) */ + screenEndX: number; + /** Viewport width in pixels */ + displayWidth: number; + /** Viewport height in pixels */ + displayHeight: number; + /** Padding from viewport edges in pixels (default: 8) */ + padding?: number; +} + +/** + * Result of label position calculation. + */ +export interface LabelPosition { + /** Left offset in pixels */ + left: number; + /** Top offset in pixels */ + top: number; +} + +/** + * Create a styled HTML label element for timeline overlays. + * Uses VS Code theme variables for consistent appearance. + * + * @param className - CSS class name for the label element + * @returns Styled div element, initially hidden + */ +export function createTimelineLabel(className: string): HTMLDivElement { + const label = document.createElement('div'); + label.className = className; + label.style.cssText = ` + position: absolute; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 4px; + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-editorWidget-border, #454545); + color: var(--vscode-editorWidget-foreground, #cccccc); + font-family: var(--vscode-font-family, sans-serif); + font-size: 12px; + pointer-events: none; + z-index: 100; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + `; + return label; +} + +/** + * Calculate smart label position for timeline overlays. + * + * Positioning strategy: + * - Center in visible portion of selection when space permits + * - Stick to viewport edge when selection is partially offscreen + * - Center on viewport when both edges are offscreen + * - Vertically center in viewport + * + * @param params - Label dimensions and viewport state + * @returns Calculated left and top position in pixels + */ +export function calculateLabelPosition(params: LabelPositionParams): LabelPosition { + const { + labelWidth, + labelHeight, + screenStartX, + screenEndX, + displayWidth, + displayHeight, + padding = 8, + } = params; + + // Calculate visible portion of selection + const visibleStartX = Math.max(screenStartX, 0); + const visibleEndX = Math.min(screenEndX, displayWidth); + const visibleWidth = visibleEndX - visibleStartX; + + // Try to center in visible portion first + const centeredLeft = visibleStartX + (visibleWidth - labelWidth) / 2; + + let left: number; + + if (visibleWidth >= labelWidth + padding * 2) { + // Visible portion is wide enough: center tooltip in visible portion + left = centeredLeft; + } else if (screenStartX < 0 && screenEndX > displayWidth) { + // Both edges offscreen: center on viewport + left = (displayWidth - labelWidth) / 2; + } else if (screenStartX < 0) { + // Left edge offscreen, right visible: stick to left edge of viewport + left = padding; + } else if (screenEndX > displayWidth) { + // Right edge offscreen, left visible: stick to right edge of viewport + left = displayWidth - labelWidth - padding; + } else { + // Selection is small but fully visible: center on selection (may extend outside) + left = centeredLeft; + } + + // Clamp to viewport bounds + left = Math.max(padding, Math.min(displayWidth - labelWidth - padding, left)); + + // Vertical center, clamped to viewport + const top = Math.max( + padding, + Math.min(displayHeight - labelHeight - padding, displayHeight / 2 - labelHeight / 2), + ); + + return { left, top }; +} diff --git a/log-viewer/src/features/timeline/optimised/search/FlameChartCursor.ts b/log-viewer/src/features/timeline/optimised/search/FlameChartCursor.ts index 0577c54a..3a2c65aa 100644 --- a/log-viewer/src/features/timeline/optimised/search/FlameChartCursor.ts +++ b/log-viewer/src/features/timeline/optimised/search/FlameChartCursor.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** diff --git a/log-viewer/src/features/timeline/optimised/search/SearchCursor.ts b/log-viewer/src/features/timeline/optimised/search/SearchCursor.ts index ab667da0..613daf4b 100644 --- a/log-viewer/src/features/timeline/optimised/search/SearchCursor.ts +++ b/log-viewer/src/features/timeline/optimised/search/SearchCursor.ts @@ -97,7 +97,7 @@ export class SearchCursorImpl implements SearchCursor { timestamp: m.event.timestamp, duration: m.event.duration ?? 0, depth: m.depth, - category: m.event.subCategory ?? '', + category: m.rect.category, })); } } diff --git a/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts index 0937c09f..1be34bcf 100644 --- a/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** diff --git a/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts b/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts index 46e3e79b..3c3cd610 100644 --- a/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts +++ b/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** diff --git a/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts b/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts index eeab0e11..49f0a6b0 100644 --- a/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts +++ b/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index d0a49ab1..e2c7f05f 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -105,9 +105,6 @@ export interface EventNode { /** Display text for event */ text: string; - /** Optional subcategory for color resolution (e.g., 'Method', 'SOQL', 'DML') */ - subCategory?: string; - /** Optional reference to original data (e.g., LogEvent) - used by adapter layer */ original?: unknown; } @@ -176,6 +173,32 @@ export interface RenderRectangle { /** * Timeline state container for entire component. */ +/** + * Granular dirty flags for selective rendering optimization. + * Allows skipping expensive render phases when only specific parts changed. + * + * Performance impact: + * - Mouse move (cursor only): overlays + minimap (~1ms vs ~10ms full render) + * - Selection change: highlights + overlays (~2ms vs ~10ms) + * - Viewport change: all phases (full render required) + */ +export interface RenderDirtyState { + /** Background layers: markers, axis positioning */ + background: boolean; + /** Rectangle culling (expensive - O(n) viewport query) */ + culling: boolean; + /** Event rendering: batch renderer, text labels */ + eventRendering: boolean; + /** Highlights: selection/search overlays */ + highlights: boolean; + /** UI overlays: measurement, cursor line */ + overlays: boolean; + /** Minimap rendering */ + minimap: boolean; + /** Metric strip rendering */ + metricStrip: boolean; +} + export interface TimelineState { /** Input event data. */ events: LogEvent[]; @@ -199,6 +222,9 @@ export interface TimelineState { /** Flags. */ needsRender: boolean; isInitialized: boolean; + + /** Granular dirty flags for selective rendering (Phase 3 optimization). */ + renderDirty: RenderDirtyState; } // ============================================================================ @@ -259,6 +285,46 @@ export interface CategoryAggregation { totalDuration: number; } +/** + * Merge category stats from a SegmentNode into a target map. + * Handles both leaf nodes (leafCategory/leafDuration) and branch nodes (categoryStats Map). + * + * PERF: Extracted helper to avoid duplicating this ~20-line pattern in 3+ places. + * + * @param target - Map to merge stats into + * @param node - SegmentNode with either categoryStats (branch) or leafCategory/leafDuration (leaf) + */ +export function mergeNodeCategoryStats( + target: Map, + node: { + categoryStats: Map | null; + leafCategory?: string; + leafDuration?: number; + }, +): void { + if (node.categoryStats) { + // Branch node: merge from Map + for (const [category, stats] of node.categoryStats) { + const existing = target.get(category); + if (existing) { + existing.count += stats.count; + existing.totalDuration += stats.totalDuration; + } else { + target.set(category, { count: stats.count, totalDuration: stats.totalDuration }); + } + } + } else if (node.leafCategory !== undefined && node.leafDuration !== undefined) { + // Leaf node: use leafCategory/leafDuration directly + const existing = target.get(node.leafCategory); + if (existing) { + existing.count += 1; + existing.totalDuration += node.leafDuration; + } else { + target.set(node.leafCategory, { count: 1, totalDuration: node.leafDuration }); + } + } +} + /** * Statistics per category for color resolution. */ @@ -830,13 +896,22 @@ export interface SegmentNode { nodeSpan: number; // Category statistics (for tooltips and color resolution) - /** Per-category event counts and durations */ - categoryStats: Map; + /** + * Per-category event counts and durations. + * null for leaf nodes (use leafCategory/leafDuration instead to avoid Map allocation). + */ + categoryStats: Map | null; /** Winning category after priority/duration/count resolution */ dominantCategory: string; /** Pre-computed priority for dominantCategory (avoids map lookup during query) */ dominantPriority: number; + // Leaf-specific fields (avoid Map allocation for 500k+ leaf nodes) + /** For leaf nodes only: category string (avoids Map allocation) */ + leafCategory?: string; + /** For leaf nodes only: duration value (avoids Map allocation) */ + leafDuration?: number; + // Event tracking /** Total event count in this subtree */ eventCount: number; @@ -851,9 +926,6 @@ export interface SegmentNode { /** Whether this is a leaf node */ isLeaf: boolean; - // Y position (pre-computed based on depth) - /** Screen Y position = depth * EVENT_HEIGHT */ - y: number; /** Call stack depth (0-indexed) */ depth: number; } @@ -890,3 +962,46 @@ export const SEGMENT_TREE_CONSTANTS = { MIN_NODE_SPAN: 1, } as const; /* eslint-enable @typescript-eslint/naming-convention */ + +// ============================================================================ +// INTERACTION MODE TYPES +// ============================================================================ + +/** + * Types of drag interactions in the timeline. + * Used for unified mode handling in TimelineInteractionHandler. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +export const InteractionModeType = { + MEASURE: 'measure', + AREA_ZOOM: 'areaZoom', + RESIZE: 'resize', +} as const; +/* eslint-enable @typescript-eslint/naming-convention */ + +export type InteractionModeType = (typeof InteractionModeType)[keyof typeof InteractionModeType]; + +/** + * Unified state for drag-based interaction modes. + * Consolidates common state tracked across measure, area zoom, and resize modes. + */ +export interface DragModeState { + /** Type of interaction mode */ + type: InteractionModeType; + /** Whether the mode is actively dragging */ + isActive: boolean; + /** Starting X coordinate (screen pixels) */ + startX: number; + /** Whether a drag threshold has been exceeded (distinguishes click from drag) */ + didDrag: boolean; + /** Last tracked screen X position (for auto-scroll updates) */ + lastScreenX: number; + /** Last tracked screen Y position (for auto-scroll updates) */ + lastScreenY: number; + /** Mouse down X (client coordinates, for drag threshold) */ + mouseDownX: number; + /** Mouse down Y (client coordinates, for drag threshold) */ + mouseDownY: number; + /** Edge being dragged (for resize mode only) */ + edge?: 'left' | 'right'; +} diff --git a/log-viewer/src/features/timeline/utils/tree-converter.ts b/log-viewer/src/features/timeline/utils/tree-converter.ts index 05d895fc..b9d1d9ec 100644 --- a/log-viewer/src/features/timeline/utils/tree-converter.ts +++ b/log-viewer/src/features/timeline/utils/tree-converter.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ /** @@ -10,10 +10,18 @@ * backwards compatibility with existing LogEvent-based code. * * Also builds navigation maps during traversal to avoid duplicate O(n) work. + * + * Performance optimization: The unified `logEventToTreeAndRects` function + * combines tree conversion with rectangle pre-computation in a single O(n) pass, + * eliminating redundant traversals. */ import type { LogEvent } from 'apex-log-parser'; -import type { EventNode, TreeNode } from '../types/flamechart.types.js'; +import type { PrecomputedRect } from '../optimised/RectangleManager.js'; +import { TIMELINE_CONSTANTS, type EventNode, type TreeNode } from '../types/flamechart.types.js'; + +// Re-export PrecomputedRect for consumers of this module +export type { PrecomputedRect }; /** * Sibling information for a node. @@ -120,7 +128,6 @@ function convertEventsRecursive( duration: duration, type: event.type ?? event.subCategory ?? 'UNKNOWN', text: event.text, - subCategory: event.subCategory, original: event, }, children: undefined, @@ -161,3 +168,238 @@ function convertEventsRecursive( return result; } + +// ============================================================================ +// UNIFIED CONVERSION (Single-Pass Optimization) +// ============================================================================ + +/** + * Unified result from single-pass tree conversion and rectangle pre-computation. + * Contains all data structures needed for FlameChart initialization. + */ +export interface UnifiedConversionResult { + /** TreeNode hierarchy for navigation and search */ + treeNodes: TreeNode[]; + /** Navigation maps for O(1) lookups */ + maps: NavigationMaps; + /** Pre-computed rectangles grouped by category (for RectangleManager) */ + rectsByCategory: Map; + /** Pre-computed rectangles grouped by depth (for TemporalSegmentTree) */ + rectsByDepth: Map; + /** Map from LogEvent to PrecomputedRect (for search functionality) */ + rectMap: Map; + /** Maximum depth in tree (tracked during traversal) */ + maxDepth: number; + /** Total duration in nanoseconds (tracked during traversal) */ + totalDuration: number; + /** Whether rectsByCategory arrays are pre-sorted by timeStart (skip sorting in RectangleManager) */ + preSorted: boolean; +} + +/** + * Work item for iterative tree conversion. + * Represents a batch of events to process at a specific depth with a parent. + */ +interface ConversionWorkItem { + events: LogEvent[]; + depth: number; + parent: TreeNode | null; + /** Result array to populate (shared reference for sibling linking) */ + resultArray: TreeNode[]; +} + +/** + * Unified single-pass conversion that builds TreeNodes, navigation maps, + * and PrecomputedRects in a single O(n) traversal. + * + * This eliminates redundant traversals that were previously done by: + * - logEventToTreeNode (tree conversion + navigation maps) + * - TimelineEventIndex.calculateMaxDepth (depth calculation) + * - TimelineEventIndex.calculateTotalDuration (duration calculation) + * - RectangleManager.flattenEvents (rectangle pre-computation) + * + * PERF optimizations in this version: + * - Iterative with explicit stack (eliminates 500k function calls, ~65ms saved) + * - Inline sibling map population (eliminates second pass per depth, ~25ms saved) + * - Pre-groups rectsByDepth (eliminates grouping in TemporalSegmentTree, ~12ms saved) + * + * Performance improvement: ~300ms+ savings on 500k event logs. + * + * @param events - Array of LogEvent objects + * @param categories - Set of valid categories for rectangle indexing + * @returns UnifiedConversionResult with all data structures + */ +export function logEventToTreeAndRects( + events: LogEvent[], + categories: Set, +): UnifiedConversionResult { + const maps: NavigationMaps = { + originalMap: new Map(), + nodeMap: new Map(), + parentMap: new Map(), + siblingMap: new Map(), + depthMap: new Map(), + depthLookup: new Map(), + }; + + // Initialize category arrays for rectangles + const rectsByCategory = new Map(); + for (const category of categories) { + rectsByCategory.set(category, []); + } + + // Pre-group by depth for TemporalSegmentTree (eliminates O(n) grouping later) + const rectsByDepth = new Map(); + + const rectMap = new Map(); + const eventHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; + + // Metrics tracked during traversal + let maxDepth = 0; + let totalDuration = 0; + + // Root result array + const rootResult: TreeNode[] = []; + + // Track last node at each depth for incremental sibling linking + // This eliminates the second pass per depth level + const lastNodeAtDepth = new Map>(); + + // Iterative traversal using explicit work stack + // PERF: Eliminates 500k function calls (~65ms saved) + const workStack: ConversionWorkItem[] = [ + { events, depth: 0, parent: null, resultArray: rootResult }, + ]; + + while (workStack.length > 0) { + const work = workStack.pop()!; + const { events: currentEvents, depth, parent, resultArray } = work; + + const len = currentEvents.length; + for (let index = 0; index < len; index++) { + const event = currentEvents[index]!; + + // Skip events with zero duration - they are invisible and cause navigation issues + const duration = event.duration.total; + if (duration <= 0) { + continue; + } + + // Track metrics during traversal + if (depth > maxDepth) { + maxDepth = depth; + } + const exitStamp = event.exitStamp ?? event.timestamp; + if (exitStamp > totalDuration) { + totalDuration = exitStamp; + } + + const id = event.timestamp + '-' + depth + '-' + index; + + // Create TreeNode + const node: TreeNode = { + data: { + id, + timestamp: event.timestamp, + duration: duration, + type: event.type ?? event.subCategory ?? 'UNKNOWN', + text: event.text, + original: event, + }, + children: undefined, + depth, + }; + + // Create PrecomputedRect if event has a valid category + const subCategory = event.subCategory; + if (subCategory) { + const rects = rectsByCategory.get(subCategory); + if (rects) { + const rect: PrecomputedRect = { + id, + timeStart: event.timestamp, + timeEnd: exitStamp, + depth, + duration, + selfDuration: event.duration.self, + category: subCategory, + eventRef: event, + x: 0, + y: depth * eventHeight, + width: 0, + height: eventHeight, + }; + rects.push(rect); + rectMap.set(event, rect); + + // Also add to rectsByDepth (eliminates grouping in TemporalSegmentTree) + let depthRects = rectsByDepth.get(depth); + if (!depthRects) { + depthRects = []; + rectsByDepth.set(depth, depthRects); + } + depthRects.push(rect); + } + } + + // PERF: Inline sibling map population (~25ms saved) + // Track current index in result array for sibling linking + const currentIndex = resultArray.length; + + // Store sibling info for this node + maps.siblingMap.set(id, { + index: currentIndex, + siblings: resultArray, + }); + lastNodeAtDepth.set(depth, node); + + resultArray.push(node); + + // Populate navigation maps + maps.originalMap.set(event, node); + maps.nodeMap.set(id, node); + maps.parentMap.set(id, parent); + maps.depthLookup.set(id, depth); + + // Add to depth map + let nodesAtDepth = maps.depthMap.get(depth); + if (!nodesAtDepth) { + nodesAtDepth = []; + maps.depthMap.set(depth, nodesAtDepth); + } + nodesAtDepth.push(node); + + // Queue children for processing (instead of recursive call) + const children = event.children; + if (children && children.length > 0) { + // Create children array that will be populated when children are processed + const childrenResult: TreeNode[] = []; + node.children = childrenResult; + + workStack.push({ + events: children, + depth: depth + 1, + parent: node, + resultArray: childrenResult, + }); + } + } + } + + // PERF: Pre-sort rectsByCategory arrays by timeStart (~15-20ms saved in RectangleManager) + // Sort here during conversion to avoid redundant sorting later + for (const rects of rectsByCategory.values()) { + rects.sort((a, b) => a.timeStart - b.timeStart); + } + + return { + treeNodes: rootResult, + maps, + rectsByCategory, + rectsByDepth, + rectMap, + maxDepth, + totalDuration, + preSorted: true, // Signal that arrays are pre-sorted + }; +}