diff --git a/log-viewer/src/features/timeline/__tests__/batching.test.ts b/log-viewer/src/features/timeline/__tests__/batching.test.ts index 4945ab57..0024f46a 100644 --- a/log-viewer/src/features/timeline/__tests__/batching.test.ts +++ b/log-viewer/src/features/timeline/__tests__/batching.test.ts @@ -14,11 +14,15 @@ import type { LogCategory, LogEvent } from 'apex-log-parser'; import * as PIXI from 'pixi.js'; +import type { BatchColorInfo } from '../optimised/BucketColorResolver.js'; import { EventBatchRenderer } from '../optimised/EventBatchRenderer.js'; import { RectangleCache } from '../optimised/RectangleCache.js'; import type { RenderBatch, ViewportState } from '../types/flamechart.types.js'; import { TIMELINE_CONSTANTS } from '../types/flamechart.types.js'; +/** Empty batch colors — tests that don't assert color values use this. */ +const EMPTY_BATCH_COLORS: Map = new Map(); + describe('EventBatchRenderer', () => { let container: PIXI.Container; let renderer: EventBatchRenderer; @@ -75,7 +79,10 @@ describe('EventBatchRenderer', () => { rectangleManager = new RectangleCache(events, categories); renderer = new EventBatchRenderer(container, batches); - const { visibleRects, buckets } = rectangleManager.getCulledRectangles(viewport); + const { visibleRects, buckets } = rectangleManager.getCulledRectangles( + viewport, + EMPTY_BATCH_COLORS, + ); renderer.render(visibleRects, buckets); } @@ -455,7 +462,7 @@ describe('EventBatchRenderer', () => { // Second render with different viewport (should recalculate) const viewport2 = createViewport(1, 150, 0); // Pan to cull first event const { visibleRects: visibleRects2, buckets: buckets2 } = - rectangleManager.getCulledRectangles(viewport2); + rectangleManager.getCulledRectangles(viewport2, EMPTY_BATCH_COLORS); renderer.render(visibleRects2, buckets2); expect(batches.get('Apex')?.rectangles).toHaveLength(1); }); diff --git a/log-viewer/src/features/timeline/optimised/BucketColorResolver.ts b/log-viewer/src/features/timeline/optimised/BucketColorResolver.ts index d983cffe..fc1f7a9e 100644 --- a/log-viewer/src/features/timeline/optimised/BucketColorResolver.ts +++ b/log-viewer/src/features/timeline/optimised/BucketColorResolver.ts @@ -14,24 +14,6 @@ import type { CategoryStats } from '../types/flamechart.types.js'; import { BUCKET_CONSTANTS } from '../types/flamechart.types.js'; -/** - * Map category names to their hex colors (numeric format). - * Colors match TIMELINE_CONSTANTS.DEFAULT_COLORS but in numeric format. - * - * This is the single source of truth for category colors in numeric format. - * All timeline code should use this via import or batchColors from theme. - */ -export const CATEGORY_COLORS: Record = { - Apex: 0x2b8f81, // #2B8F81 - 'Code Unit': 0x88ae58, // #88AE58 - System: 0x8d6e63, // #8D6E63 - Automation: 0x51a16e, // #51A16E - DML: 0xb06868, // #B06868 - SOQL: 0x6d4c7d, // #6D4C7D - Callout: 0xcca033, // #CCA033 - Validation: 0x5c8fa6, // #5C8FA6 -}; - /** * Default gray color for unknown categories. */ @@ -72,12 +54,12 @@ export interface BatchColorInfo { * 4. Tie-break by event count (higher count wins) * * @param categoryStats - Statistics for all categories in the bucket - * @param batchColors - Optional colors from RenderBatch (for theme support) + * @param batchColors - Colors from RenderBatch (theme-aware category colors) * @returns Color and dominant category */ export function resolveColor( categoryStats: CategoryStats, - batchColors?: Map, + batchColors: Map, ): ColorResolutionResult { const { byCategory } = categoryStats; @@ -119,121 +101,10 @@ export function resolveColor( } } - // Get color for winning category (prefer batch colors for theme support) - const color = - batchColors?.get(winningCategory)?.color ?? - CATEGORY_COLORS[winningCategory] ?? - UNKNOWN_CATEGORY_COLOR; + const color = batchColors.get(winningCategory)?.color ?? UNKNOWN_CATEGORY_COLOR; return { color, dominantCategory: winningCategory, }; } - -// ============================================================================ -// COLOR BLENDING UTILITIES -// ============================================================================ - -/** - * Default dark theme background color for alpha blending. - * This is the standard VS Code dark theme background. - */ -const DEFAULT_BACKGROUND_COLOR = 0x1e1e1e; - -/** - * Blend a color with a background color based on opacity. - * Returns an opaque color that simulates the visual appearance of - * the original color at the given opacity over the background. - * - * Formula: result = foreground * alpha + background * (1 - alpha) - * - * @param foregroundColor - The foreground color (0xRRGGBB) - * @param opacity - Opacity value (0 to 1) - * @param backgroundColor - Background color to blend against (default: dark theme background) - * @returns Opaque blended color (0xRRGGBB) - */ -export function blendWithBackground( - foregroundColor: number, - opacity: number, - backgroundColor: number = DEFAULT_BACKGROUND_COLOR, -): number { - // Extract RGB components from foreground - const fgR = (foregroundColor >> 16) & 0xff; - const fgG = (foregroundColor >> 8) & 0xff; - const fgB = foregroundColor & 0xff; - - // Extract RGB components from background - const bgR = (backgroundColor >> 16) & 0xff; - const bgG = (backgroundColor >> 8) & 0xff; - const bgB = backgroundColor & 0xff; - - // Blend each channel: result = fg * alpha + bg * (1 - alpha) - const invAlpha = 1 - opacity; - const resultR = Math.round(fgR * opacity + bgR * invAlpha); - const resultG = Math.round(fgG * opacity + bgG * invAlpha); - const resultB = Math.round(fgB * opacity + bgB * invAlpha); - - // Combine back to a single color value - return (resultR << 16) | (resultG << 8) | resultB; -} - -/** - * Parse CSS color string to PixiJS numeric color (opaque). - * If the color has alpha < 1, it will be pre-blended with the background - * to produce an opaque result for better GPU performance. - * - * Supported formats: - * - #RGB (3 hex digits) - * - #RGBA (4 hex digits) - * - #RRGGBB (6 hex digits) - * - #RRGGBBAA (8 hex digits) - * - rgb(r, g, b) - * - rgba(r, g, b, a) - * - * @param cssColor - CSS color string - * @returns Opaque PixiJS numeric color (0xRRGGBB) - */ -export function cssColorToPixi(cssColor: string): number { - let color = 0x000000; - let alpha = 1; - - if (cssColor.startsWith('#')) { - const hex = cssColor.slice(1); - if (hex.length === 8) { - const rgb = hex.slice(0, 6); - alpha = parseInt(hex.slice(6, 8), 16) / 255; - color = parseInt(rgb, 16); - } else if (hex.length === 6) { - color = parseInt(hex, 16); - } else if (hex.length === 4) { - const r = hex[0]!; - const g = hex[1]!; - const b = hex[2]!; - const a = hex[3]!; - color = parseInt(r + r + g + g + b + b, 16); - alpha = parseInt(a + a, 16) / 255; - } else if (hex.length === 3) { - const r = hex[0]!; - const g = hex[1]!; - const b = hex[2]!; - color = parseInt(r + r + g + g + b + b, 16); - } - } else { - const rgbMatch = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d*(?:\.\d+)?))?\)/); - if (rgbMatch) { - const r = parseInt(rgbMatch[1] ?? '0', 10); - const g = parseInt(rgbMatch[2] ?? '0', 10); - const b = parseInt(rgbMatch[3] ?? '0', 10); - alpha = rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1; - color = (r << 16) | (g << 8) | b; - } - } - - // Pre-blend with background if color has alpha < 1 - if (alpha < 1) { - return blendWithBackground(color, alpha); - } - - return color; -} diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index ca727380..415a1247 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -32,7 +32,6 @@ import { MeshAxisRenderer } from './time-axis/MeshAxisRenderer.js'; import { TextLabelRenderer } from './TextLabelRenderer.js'; -import { cssColorToPixi } from './BucketColorResolver.js'; import { HitDetector } from './interaction/HitDetector.js'; import { KEYBOARD_CONSTANTS, @@ -44,6 +43,7 @@ import { TimelineInteractionHandler } from './interaction/TimelineInteractionHan import { TimelineResizeHandler } from './interaction/TimelineResizeHandler.js'; import type { MeasurementSnapshot } from './measurement/MeasurementState.js'; import { RectangleCache, type PrecomputedRect } from './RectangleCache.js'; +import { cssColorToPixi } from './rendering/ColorUtils.js'; import { CursorLineRenderer } from './rendering/CursorLineRenderer.js'; import { TimelineEventIndex } from './TimelineEventIndex.js'; import { TimelineViewport } from './TimelineViewport.js'; diff --git a/log-viewer/src/features/timeline/optimised/LegacyViewportCuller.ts b/log-viewer/src/features/timeline/optimised/LegacyViewportCuller.ts index f112a3c8..31d29c18 100644 --- a/log-viewer/src/features/timeline/optimised/LegacyViewportCuller.ts +++ b/log-viewer/src/features/timeline/optimised/LegacyViewportCuller.ts @@ -35,13 +35,13 @@ import { calculateViewportBounds } from './ViewportUtils.js'; * * @param rectsByCategory - Spatial index of rectangles by category * @param viewport - Current viewport state - * @param batchColors - Optional colors from RenderBatch (for theme support) + * @param batchColors - Theme-aware category colors * @returns CulledRenderData with visible rectangles, buckets, and stats */ export function legacyCullRectangles( rectsByCategory: Map, viewport: ViewportState, - batchColors?: Map, + batchColors: Map, ): CulledRenderData { const bounds = calculateViewportBounds(viewport); const visibleRects = new Map(); diff --git a/log-viewer/src/features/timeline/optimised/RectangleCache.ts b/log-viewer/src/features/timeline/optimised/RectangleCache.ts index 0e2afc67..b247d143 100644 --- a/log-viewer/src/features/timeline/optimised/RectangleCache.ts +++ b/log-viewer/src/features/timeline/optimised/RectangleCache.ts @@ -133,11 +133,7 @@ export class RectangleCache { } // Pass pre-grouped rectsByDepth if available (saves ~12ms grouping iteration) - this.segmentTree = new TemporalSegmentTree( - this.rectsByCategory, - undefined, // batchColors - precomputed?.rectsByDepth, - ); + this.segmentTree = new TemporalSegmentTree(this.rectsByCategory, precomputed?.rectsByDepth); } /** @@ -148,14 +144,14 @@ export class RectangleCache { * Events <= MIN_RECT_SIZE are aggregated into time-aligned buckets. * * @param viewport - Current viewport state - * @param batchColors - Optional colors from RenderBatch (for theme support) + * @param batchColors - Theme-aware category colors for bucket color resolution * @returns CulledRenderData with visible rectangles, buckets, and stats * * Performance target: <5ms for 50,000 events */ public getCulledRectangles( viewport: ViewportState, - batchColors?: Map, + batchColors: Map, ): CulledRenderData { return this.segmentTree.query(viewport, batchColors); } @@ -189,17 +185,6 @@ export class RectangleCache { return this.rectMapById; } - /** - * Update batch colors for segment tree (for theme changes). - * - * @param batchColors - New batch colors from theme - */ - public setBatchColors(batchColors: Map): void { - if (this.segmentTree) { - this.segmentTree.setBatchColors(batchColors); - } - } - /** * Get spatial index of rectangles by category. * Used for search functionality and segment tree construction. diff --git a/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts b/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts index 2e5d5f1a..69255af6 100644 --- a/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts +++ b/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts @@ -43,11 +43,7 @@ import { SEGMENT_TREE_CONSTANTS, TIMELINE_CONSTANTS, } from '../types/flamechart.types.js'; -import { - CATEGORY_COLORS, - UNKNOWN_CATEGORY_COLOR, - type BatchColorInfo, -} from './BucketColorResolver.js'; +import { UNKNOWN_CATEGORY_COLOR, type BatchColorInfo } from './BucketColorResolver.js'; import type { PrecomputedRect } from './RectangleCache.js'; import { calculateViewportBounds } from './ViewportUtils.js'; @@ -94,9 +90,6 @@ export class TemporalSegmentTree { /** Maximum depth in the tree */ private maxDepth = 0; - /** Cached batch colors for theme support */ - private batchColors?: Map; - /** * Unsorted frames collected during tree construction. * Sorting is deferred to first getAllFramesSorted() call. @@ -113,38 +106,26 @@ export class TemporalSegmentTree { * Build segment trees from pre-computed rectangles. * * @param rectsByCategory - Rectangles grouped by category (from RectangleCache) - * @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, rectsByDepth); } - /** - * Update batch colors (for theme changes). - */ - public setBatchColors(batchColors: Map): void { - this.batchColors = batchColors; - // Note: We don't rebuild trees - colors are resolved at query time - } - /** * Query the segment tree for nodes to render at current viewport. * * @param viewport - Current viewport state - * @param batchColors - Optional colors from RenderBatch (for theme support) + * @param batchColors - Theme-aware category colors for bucket color resolution * @returns CulledRenderData compatible with existing rendering pipeline */ public query( viewport: ViewportState, - batchColors?: Map, + batchColors: Map, ): CulledRenderData { - const effectiveBatchColors = batchColors ?? this.batchColors; const bounds = calculateViewportBounds(viewport); // T = 2px / zoom (ns) - used for both threshold check and bucket width const bucketTimeWidth = BUCKET_CONSTANTS.BUCKET_WIDTH / viewport.zoom; @@ -162,10 +143,7 @@ export class TemporalSegmentTree { visibleRects.set(category, []); bucketsByCategory.set(category, []); // Pre-cache base color for each known category - const baseColor = - effectiveBatchColors?.get(category)?.color ?? - CATEGORY_COLORS[category] ?? - UNKNOWN_CATEGORY_COLOR; + const baseColor = batchColors.get(category)?.color ?? UNKNOWN_CATEGORY_COLOR; categoryBaseColors.set(category, baseColor); } diff --git a/log-viewer/src/features/timeline/optimised/__tests__/BucketColorResolver.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/BucketColorResolver.test.ts index 1200bc63..039a3b95 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/BucketColorResolver.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/BucketColorResolver.test.ts @@ -3,7 +3,7 @@ */ import type { CategoryAggregation, CategoryStats } from '../../types/flamechart.types.js'; -import { resolveColor } from '../BucketColorResolver.js'; +import { type BatchColorInfo, resolveColor } from '../BucketColorResolver.js'; /** * Tests for BucketColorResolver - resolves bucket color from category statistics. @@ -12,6 +12,18 @@ import { resolveColor } from '../BucketColorResolver.js'; * Tie-breakers: total duration → event count */ +/** Default theme colors used for test assertions. */ +const TEST_BATCH_COLORS: Map = new Map([ + ['Apex', { color: 0x2b8f81 }], + ['Code Unit', { color: 0x88ae58 }], + ['System', { color: 0x8d6e63 }], + ['Automation', { color: 0x51a16e }], + ['DML', { color: 0xb06868 }], + ['SOQL', { color: 0x6d4c7d }], + ['Callout', { color: 0xcca033 }], + ['Validation', { color: 0x5c8fa6 }], +]); + // Helper to create CategoryStats function createCategoryStats( categories: Record, @@ -35,7 +47,7 @@ describe('BucketColorResolver', () => { Apex: { count: 100, totalDuration: 10000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // DML color is #B06868 = 0xB06868 expect(result.color).toBe(0xb06868); @@ -49,7 +61,7 @@ describe('BucketColorResolver', () => { 'Code Unit': { count: 100, totalDuration: 10000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // SOQL color is #6D4C7D = 0x6D4C7D expect(result.color).toBe(0x6d4c7d); @@ -63,7 +75,7 @@ describe('BucketColorResolver', () => { System: { count: 100, totalDuration: 10000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // Callout color is #CCA033 = 0xCCA033 expect(result.color).toBe(0xcca033); @@ -77,7 +89,7 @@ describe('BucketColorResolver', () => { System: { count: 100, totalDuration: 10000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // Apex color is #2B8F81 = 0x2B8F81 expect(result.color).toBe(0x2b8f81); @@ -90,7 +102,7 @@ describe('BucketColorResolver', () => { Automation: { count: 10, totalDuration: 1000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // System color is #8D6E63 = 0x8D6E63 expect(result.color).toBe(0x8d6e63); @@ -103,7 +115,7 @@ describe('BucketColorResolver', () => { Validation: { count: 10, totalDuration: 1000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // Automation color is #51A16E = 0x51A16E expect(result.color).toBe(0x51a16e); @@ -115,7 +127,7 @@ describe('BucketColorResolver', () => { Validation: { count: 5, totalDuration: 500 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // Validation color is #5C8FA6 = 0x5C8FA6 expect(result.color).toBe(0x5c8fa6); @@ -129,7 +141,7 @@ describe('BucketColorResolver', () => { Apex: { count: 5, totalDuration: 1000 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); expect(result.dominantCategory).toBe('Apex'); }); }); @@ -140,7 +152,7 @@ describe('BucketColorResolver', () => { DML: { count: 10, totalDuration: 500 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); expect(result.dominantCategory).toBe('DML'); }); }); @@ -151,7 +163,7 @@ describe('BucketColorResolver', () => { UnknownCategory: { count: 5, totalDuration: 500 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // Gray fallback is #888888 = 0x888888 expect(result.color).toBe(0x888888); @@ -164,7 +176,7 @@ describe('BucketColorResolver', () => { Automation: { count: 1, totalDuration: 100 }, }); - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); // Automation is known, so it should win over unknown expect(result.color).toBe(0x51a16e); @@ -179,7 +191,7 @@ describe('BucketColorResolver', () => { dominantCategory: '', }; - const result = resolveColor(stats); + const result = resolveColor(stats, TEST_BATCH_COLORS); expect(result.color).toBe(0x888888); expect(result.dominantCategory).toBe(''); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts index 9e4af847..1e9a0536 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/ColorUtils.test.ts @@ -2,10 +2,15 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { parseColorToHex } from '../rendering/ColorUtils.js'; +import { + DEFAULT_BACKGROUND_COLOR, + blendWithBackground, + cssColorToPixi, + parseColorToHex, +} from '../rendering/ColorUtils.js'; /** - * Tests for ColorUtils - CSS color string to hex conversion. + * Tests for ColorUtils - color conversion and blending utilities. */ describe('ColorUtils', () => { @@ -89,4 +94,73 @@ describe('ColorUtils', () => { }); }); }); + + describe('blendWithBackground', () => { + it('should return foreground at full opacity', () => { + expect(blendWithBackground(0xff0000, 1)).toBe(0xff0000); + expect(blendWithBackground(0x00ff00, 1)).toBe(0x00ff00); + }); + + it('should return background at zero opacity', () => { + expect(blendWithBackground(0xff0000, 0, 0x000000)).toBe(0x000000); + expect(blendWithBackground(0xff0000, 0, 0xffffff)).toBe(0xffffff); + }); + + it('should blend 50% red over black', () => { + const result = blendWithBackground(0xff0000, 0.5, 0x000000); + // 255 * 0.5 = 128 (0x80), green and blue stay 0 + expect(result).toBe(0x800000); + }); + + it('should use default dark background when not specified', () => { + const result = blendWithBackground(0xff0000, 0.5); + // Red: round(255*0.5 + 30*0.5) = round(142.5) = 143 = 0x8F + // Green: round(0*0.5 + 30*0.5) = round(15) = 15 = 0x0F + // Blue: round(0*0.5 + 30*0.5) = round(15) = 15 = 0x0F + expect(result).toBe(0x8f0f0f); + }); + }); + + describe('cssColorToPixi', () => { + it('should parse opaque hex colors (same as parseColorToHex)', () => { + expect(cssColorToPixi('#ff0000')).toBe(0xff0000); + expect(cssColorToPixi('#2B8F81')).toBe(0x2b8f81); + expect(cssColorToPixi('#abc')).toBe(0xaabbcc); + }); + + it('should pre-blend alpha hex colors with dark background', () => { + // #ff000080 = red at ~50% alpha over default background (0x1e1e1e) + const result = cssColorToPixi('#ff000080'); + // alpha = 0x80/255 ≈ 0.502 + // Red: round(255*0.502 + 30*0.498) = round(142.9) = 143 = 0x8F + // Green: round(0*0.502 + 30*0.498) = round(14.9) = 15 = 0x0F + // Blue: round(0*0.502 + 30*0.498) = round(14.9) = 15 = 0x0F + expect(result).toBe(0x8f0f0f); + }); + + it('should pre-blend 4-char hex alpha', () => { + // #f008 = red at ~0x88/255 alpha + const result = cssColorToPixi('#f008'); + const alpha = 0x88 / 255; // ≈ 0.533 + const invAlpha = 1 - alpha; + const r = Math.round(255 * alpha + 30 * invAlpha); + const g = Math.round(0 * alpha + 30 * invAlpha); + const b = Math.round(0 * alpha + 30 * invAlpha); + expect(result).toBe((r << 16) | (g << 8) | b); + }); + + it('should parse opaque rgb()', () => { + expect(cssColorToPixi('rgb(255, 0, 0)')).toBe(0xff0000); + expect(cssColorToPixi('rgb(43, 143, 129)')).toBe(0x2b8f81); + }); + + it('should pre-blend rgba() with alpha', () => { + const result = cssColorToPixi('rgba(255, 0, 0, 0.5)'); + expect(result).toBe(blendWithBackground(0xff0000, 0.5, DEFAULT_BACKGROUND_COLOR)); + }); + + it('should return opaque color for rgba with alpha 1', () => { + expect(cssColorToPixi('rgba(255, 0, 0, 1)')).toBe(0xff0000); + }); + }); }); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts index 428c1434..26aa5e7a 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/RectangleCache.bucket.test.ts @@ -61,7 +61,7 @@ function cullRectanglesLegacy( viewport: ViewportState, ) { const manager = new RectangleCache(events, categories); - return legacyCullRectangles(manager.getRectsByCategory(), viewport); + return legacyCullRectangles(manager.getRectsByCategory(), viewport, new Map()); } // Helper to flatten buckets Map into array for testing 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 8269c0d5..cb69f7a2 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/TemporalSegmentTree.test.ts @@ -5,10 +5,14 @@ import type { LogCategory, LogEvent } from 'apex-log-parser'; import type { PixelBucket, ViewportState } from '../../types/flamechart.types.js'; import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js'; +import type { BatchColorInfo } from '../BucketColorResolver.js'; import { legacyCullRectangles } from '../LegacyViewportCuller.js'; import { RectangleCache } from '../RectangleCache.js'; import { TemporalSegmentTree } from '../TemporalSegmentTree.js'; +/** Empty batch colors — tests that don't assert color values use this. */ +const EMPTY_BATCH_COLORS: Map = new Map(); + /** * Tests for TemporalSegmentTree. * @@ -120,7 +124,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(1, 0, 0); - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); expect(result.visibleRects.get('Apex')).toHaveLength(1); expect(countBuckets(result.buckets)).toBe(0); @@ -134,7 +138,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(1, 0, 0); - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Pre-initialized map has empty arrays for known categories expect(result.visibleRects.get('Apex')).toHaveLength(0); @@ -153,7 +157,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(0.1, 0, 0); - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // All events should be bucketed at this zoom level expect(result.stats.bucketedEventCount).toBe(3); @@ -173,11 +177,11 @@ describe('TemporalSegmentTree', () => { // Zoomed out: all events are small const zoomedOut = createViewport(0.1, 0, 0, 1000); - const resultOut = tree.query(zoomedOut); + const resultOut = tree.query(zoomedOut, EMPTY_BATCH_COLORS); // Zoomed in: all events are visible const zoomedIn = createViewport(2, 0, 0, 1000); - const resultIn = tree.query(zoomedIn); + const resultIn = tree.query(zoomedIn, EMPTY_BATCH_COLORS); // More visible rects when zoomed in expect(resultIn.stats.visibleCount).toBeGreaterThanOrEqual(resultOut.stats.visibleCount); @@ -196,9 +200,9 @@ describe('TemporalSegmentTree', () => { const zoomed2 = createViewport(1, 0, 0, 1000); const zoomed3 = createViewport(10, 0, 0, 1000); - const result1 = tree.query(zoomed1); - const result2 = tree.query(zoomed2); - const result3 = tree.query(zoomed3); + const result1 = tree.query(zoomed1, EMPTY_BATCH_COLORS); + const result2 = tree.query(zoomed2, EMPTY_BATCH_COLORS); + const result3 = tree.query(zoomed3, EMPTY_BATCH_COLORS); // Total events (visible + bucketed) should be consistent const total1 = result1.stats.visibleCount + result1.stats.bucketedEventCount; @@ -223,7 +227,7 @@ describe('TemporalSegmentTree', () => { // Viewport only shows time 50-150 (should only include second event) const viewport = createViewport(1, 50, 0, 100); - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Only the middle event should be visible const totalEvents = result.stats.visibleCount + result.stats.bucketedEventCount; @@ -248,7 +252,7 @@ describe('TemporalSegmentTree', () => { 1000, TIMELINE_CONSTANTS.EVENT_HEIGHT * 2, // shows depths 0-1 ); - const resultSmall = tree.query(viewportSmall); + const resultSmall = tree.query(viewportSmall, EMPTY_BATCH_COLORS); // Create a larger viewport that shows all 3 depths const viewportLarge = createViewport( @@ -258,7 +262,7 @@ describe('TemporalSegmentTree', () => { 1000, TIMELINE_CONSTANTS.EVENT_HEIGHT * 4, // shows depths 0-3 ); - const resultLarge = tree.query(viewportLarge); + const resultLarge = tree.query(viewportLarge, EMPTY_BATCH_COLORS); // Smaller viewport should have fewer or equal events const smallTotal = resultSmall.stats.visibleCount + resultSmall.stats.bucketedEventCount; @@ -276,7 +280,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(1, 0, 0); - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); const allBuckets = getAllBuckets(result.buckets); expect(allBuckets).toHaveLength(1); @@ -295,7 +299,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(0.5, 0, 0); // threshold = 4ns - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // All events should be in buckets with correct count expect(result.stats.bucketedEventCount).toBe(3); @@ -307,7 +311,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(0.1, 0, 0); // threshold = 20ns - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // Bucket should have stats for both categories const allBuckets = getAllBuckets(result.buckets); @@ -330,10 +334,14 @@ describe('TemporalSegmentTree', () => { // This test verifies the manager produces consistent results const manager = new RectangleCache(events, categories); const viewport = createViewport(1, 0, 0); - const result = manager.getCulledRectangles(viewport); + const result = manager.getCulledRectangles(viewport, EMPTY_BATCH_COLORS); // For comparison with legacy, use the legacy culler directly - const legacyResult = legacyCullRectangles(manager.getRectsByCategory(), viewport); + const legacyResult = legacyCullRectangles( + manager.getRectsByCategory(), + viewport, + EMPTY_BATCH_COLORS, + ); const treeResult = result; // Same total events @@ -361,7 +369,7 @@ describe('TemporalSegmentTree', () => { // 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); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // The long event (Method) should be visible because it spans [0, 100] // and overlaps with query range [70, 90] @@ -383,7 +391,7 @@ describe('TemporalSegmentTree', () => { // 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 result = tree.query(viewport, EMPTY_BATCH_COLORS); const totalEvents = result.stats.visibleCount + result.stats.bucketedEventCount; expect(totalEvents).toBe(1); @@ -415,7 +423,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(0.1, 0, 0); // threshold = 20ns, event = 1ns - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); const allBuckets = getAllBuckets(result.buckets); expect(allBuckets.length).toBeGreaterThan(0); @@ -430,7 +438,7 @@ describe('TemporalSegmentTree', () => { const tree = new TemporalSegmentTree(manager.getRectsByCategory()); const viewport = createViewport(1, 0, 0); // threshold = 2ns, event = 1ns - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); const allBuckets = getAllBuckets(result.buckets); expect(allBuckets).toHaveLength(1); @@ -452,7 +460,7 @@ describe('TemporalSegmentTree', () => { // Zoom out so all events aggregate into one bucket const viewport = createViewport(0.01, 0, 0, 1000); - const result = tree.query(viewport); + const result = tree.query(viewport, EMPTY_BATCH_COLORS); // 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) diff --git a/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts b/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts index 3f23080b..47cf0fe9 100644 --- a/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts +++ b/log-viewer/src/features/timeline/optimised/rendering/ColorUtils.ts @@ -11,7 +11,10 @@ */ /** - * Parse a CSS color string to a numeric hex value (0xRRGGBB). + * Parse a CSS color string to a numeric hex value (0xRRGGBB), stripping any alpha channel. + * + * Use this when the renderer handles transparency separately (e.g., via `rgbToABGR(color, alpha)` + * for GPU vertex buffers). For opaque pre-blended colors, use `cssColorToPixi()` instead. * * Supports: * - Hex formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA @@ -19,7 +22,7 @@ * * @param cssColor - CSS color string to parse * @param defaultColor - Fallback color if parsing fails (default: 0x1e1e1e dark gray) - * @returns Numeric hex color (0xRRGGBB format) + * @returns Numeric hex color (0xRRGGBB format), alpha is stripped */ export function parseColorToHex(cssColor: string, defaultColor: number = 0x1e1e1e): number { if (!cssColor) { @@ -116,3 +119,115 @@ export function rgbToABGR(color: number, alpha: number = 1.0): number { const a = Math.round(alpha * 255) & 0xff; return (a << 24) | (b << 16) | (g << 8) | r; } + +// ============================================================================ +// COLOR BLENDING UTILITIES +// ============================================================================ + +/** + * Default dark theme background color for alpha blending. + * This is the standard VS Code dark theme background. + */ +export const DEFAULT_BACKGROUND_COLOR = 0x1e1e1e; + +/** + * Blend a color with a background color based on opacity. + * Returns an opaque color that simulates the visual appearance of + * the original color at the given opacity over the background. + * + * Formula: result = foreground * alpha + background * (1 - alpha) + * + * @param foregroundColor - The foreground color (0xRRGGBB) + * @param opacity - Opacity value (0 to 1) + * @param backgroundColor - Background color to blend against (default: dark theme background) + * @returns Opaque blended color (0xRRGGBB) + */ +export function blendWithBackground( + foregroundColor: number, + opacity: number, + backgroundColor: number = DEFAULT_BACKGROUND_COLOR, +): number { + // Extract RGB components from foreground + const fgR = (foregroundColor >> 16) & 0xff; + const fgG = (foregroundColor >> 8) & 0xff; + const fgB = foregroundColor & 0xff; + + // Extract RGB components from background + const bgR = (backgroundColor >> 16) & 0xff; + const bgG = (backgroundColor >> 8) & 0xff; + const bgB = backgroundColor & 0xff; + + // Blend each channel: result = fg * alpha + bg * (1 - alpha) + const invAlpha = 1 - opacity; + const resultR = Math.round(fgR * opacity + bgR * invAlpha); + const resultG = Math.round(fgG * opacity + bgG * invAlpha); + const resultB = Math.round(fgB * opacity + bgB * invAlpha); + + // Combine back to a single color value + return (resultR << 16) | (resultG << 8) | resultB; +} + +/** + * Parse CSS color string to PixiJS numeric color (opaque), pre-blending alpha with the background. + * + * Use this for colors that will be rendered as opaque rectangles (event bars, bucket fills). + * If the color has alpha < 1, it is blended with the dark background to produce an opaque result, + * avoiding per-vertex alpha and enabling single-draw-call batch rendering. + * + * For colors where the renderer handles alpha separately (e.g., editor UI overlays), + * use `parseColorToHex()` instead. + * + * Supported formats: + * - #RGB (3 hex digits) + * - #RGBA (4 hex digits) + * - #RRGGBB (6 hex digits) + * - #RRGGBBAA (8 hex digits) + * - rgb(r, g, b) + * - rgba(r, g, b, a) + * + * @param cssColor - CSS color string + * @returns Opaque PixiJS numeric color (0xRRGGBB) + */ +export function cssColorToPixi(cssColor: string): number { + let color = 0x000000; + let alpha = 1; + + if (cssColor.startsWith('#')) { + const hex = cssColor.slice(1); + if (hex.length === 8) { + const rgb = hex.slice(0, 6); + alpha = parseInt(hex.slice(6, 8), 16) / 255; + color = parseInt(rgb, 16); + } else if (hex.length === 6) { + color = parseInt(hex, 16); + } else if (hex.length === 4) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + const a = hex[3]!; + color = parseInt(r + r + g + g + b + b, 16); + alpha = parseInt(a + a, 16) / 255; + } else if (hex.length === 3) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + color = parseInt(r + r + g + g + b + b, 16); + } + } else { + const rgbMatch = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d*(?:\.\d+)?))?\)/); + if (rgbMatch) { + const r = parseInt(rgbMatch[1] ?? '0', 10); + const g = parseInt(rgbMatch[2] ?? '0', 10); + const b = parseInt(rgbMatch[3] ?? '0', 10); + alpha = rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1; + color = (r << 16) | (g << 8) | b; + } + } + + // Pre-blend with background if color has alpha < 1 + if (alpha < 1) { + return blendWithBackground(color, alpha); + } + + return color; +} diff --git a/log-viewer/src/features/timeline/optimised/search/MeshSearchStyleRenderer.ts b/log-viewer/src/features/timeline/optimised/search/MeshSearchStyleRenderer.ts index 88495cf6..31ed7961 100644 --- a/log-viewer/src/features/timeline/optimised/search/MeshSearchStyleRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/search/MeshSearchStyleRenderer.ts @@ -231,7 +231,7 @@ export class MeshSearchStyleRenderer { // Write all buckets from all categories for (const categoryBuckets of buckets.values()) { for (const bucket of categoryBuckets) { - const displayColor = resolveBucketSearchColor(bucket, matchIndex); + const displayColor = resolveBucketSearchColor(bucket, matchIndex, this.batches); this.geometry.writeRectangle( rectIndex, diff --git a/log-viewer/src/features/timeline/optimised/search/SearchBucketMatcher.ts b/log-viewer/src/features/timeline/optimised/search/SearchBucketMatcher.ts index 6cef2059..3d285767 100644 --- a/log-viewer/src/features/timeline/optimised/search/SearchBucketMatcher.ts +++ b/log-viewer/src/features/timeline/optimised/search/SearchBucketMatcher.ts @@ -14,7 +14,7 @@ import type { CategoryAggregation, PixelBucket } from '../../types/flamechart.types.js'; import type { MatchedEventInfo } from '../../types/search.types.js'; -import { resolveColor } from '../BucketColorResolver.js'; +import { type BatchColorInfo, resolveColor } from '../BucketColorResolver.js'; import { colorToGreyscale } from '../rendering/ColorUtils.js'; /** @@ -53,9 +53,14 @@ export function buildMatchIndex( * * @param bucket - The pixel bucket to resolve color for * @param matchIndex - Spatial index from buildMatchIndex() + * @param batchColors - Theme-aware category colors * @returns Resolved display color (0xRRGGBB) */ -export function resolveBucketSearchColor(bucket: PixelBucket, matchIndex: MatchesByDepth): number { +export function resolveBucketSearchColor( + bucket: PixelBucket, + matchIndex: MatchesByDepth, + batchColors: Map, +): number { const matchedCategoryStats = new Map(); const depthMatches = matchIndex.get(bucket.depth); @@ -77,10 +82,13 @@ export function resolveBucketSearchColor(bucket: PixelBucket, matchIndex: Matche } if (matchedCategoryStats.size > 0) { - return resolveColor({ - byCategory: matchedCategoryStats, - dominantCategory: '', - }).color; + return resolveColor( + { + byCategory: matchedCategoryStats, + dominantCategory: '', + }, + batchColors, + ).color; } return colorToGreyscale(bucket.color); diff --git a/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts b/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts index 0b8263ef..9c687891 100644 --- a/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/search/SearchStyleRenderer.ts @@ -186,10 +186,13 @@ export class SearchStyleRenderer { if (matchedCategoryStats.size > 0) { // Resolve color from matched events using priority rules - displayColor = resolveColor({ - byCategory: matchedCategoryStats, - dominantCategory: '', - }).color; + displayColor = resolveColor( + { + byCategory: matchedCategoryStats, + dominantCategory: '', + }, + this.batches, + ).color; } else { // No matches - desaturate the bucket's pre-blended color displayColor = colorToGreyscale(bucket.color);