From 1b95cb010d5d3a1bea03f17fd15be69ef225990e Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:06:58 +0100 Subject: [PATCH] refactor: consolidate color utilities and remove CATEGORY_COLORS fallback Move blendWithBackground, cssColorToPixi, and DEFAULT_BACKGROUND_COLOR from BucketColorResolver to ColorUtils, making ColorUtils the single module for all color conversion and blending. Make batchColors required across the rendering pipeline (resolveColor, getCulledRectangles, TemporalSegmentTree.query, legacyCullRectangles, resolveBucketSearchColor) so theme colors are always explicitly passed rather than silently falling back to hardcoded defaults. Remove unused setBatchColors methods and cached batchColors field from both TemporalSegmentTree and RectangleCache. --- .../timeline/__tests__/batching.test.ts | 11 +- .../timeline/optimised/BucketColorResolver.ts | 135 +----------------- .../features/timeline/optimised/FlameChart.ts | 2 +- .../optimised/LegacyViewportCuller.ts | 4 +- .../timeline/optimised/RectangleCache.ts | 21 +-- .../timeline/optimised/TemporalSegmentTree.ts | 30 +--- .../__tests__/BucketColorResolver.test.ts | 38 +++-- .../optimised/__tests__/ColorUtils.test.ts | 78 +++++++++- .../__tests__/RectangleCache.bucket.test.ts | 2 +- .../__tests__/TemporalSegmentTree.test.ts | 50 ++++--- .../optimised/rendering/ColorUtils.ts | 119 ++++++++++++++- .../search/MeshSearchStyleRenderer.ts | 2 +- .../optimised/search/SearchBucketMatcher.ts | 20 ++- .../optimised/search/SearchStyleRenderer.ts | 11 +- 14 files changed, 292 insertions(+), 231 deletions(-) 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);