diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efbeb34..440c2f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ⚑ **Timeline**: A brand new **experimental** timeline Flame Chart that is up to **7X faster**. ([#446] [#251] [#92]) - - Enable it via **Settings -> Apex Log Analyzer -> Timeline -> Experimental -> Timeline**. +- ⚑ **Timeline**: A brand new **experimental** timeline Flame Chart that is up to **7X faster**. ([#446] [#251] [#92] [#564]) + - Revert to the legacy timeline via **Settings -> Apex Log Analyzer -> Timeline -> Legacy**. - Generally Improved performance, especially for large logs. - Text labels on Timeline events. - Zoom and pan are now **7X faster**. - The Time axis scales more naturally when zooming, with larger gaps between the markers on longer logs. - Search + highlight will grey out non matches to find matches more easily. + - Added 18 timeline color themes and improved the default theme for better contrast and readability. + - Supply multiple custom themes via **Settings -> Apex Log Analyzer -> Timeline -> Custom Themes**. + - Change the active theme via the new **Command Palette** command **Log: Timeline Theme** or via **Settings -> Apex Log Analyzer -> Timeline -> Active Theme**. ### Changed @@ -415,6 +418,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea +[#564]: https://github.com/certinia/debug-log-analyzer/issues/564 [#92]: https://github.com/certinia/debug-log-analyzer/issues/92 [#694]: https://github.com/certinia/debug-log-analyzer/issues/694 [#446]: https://github.com/certinia/debug-log-analyzer/issues/446 diff --git a/README.md b/README.md index 8beff021..6f943ce5 100644 --- a/README.md +++ b/README.md @@ -186,3 +186,5 @@ Copyright © Certinia Inc. All rights reserved. ## πŸ™ Acknowledgments This project uses [Tabulator Tables](http://tabulator.info/), an open-source table library, under the MIT license. Tabulator is a powerful and flexible table library that helped with the interactive table features in the Apex Log Analyzer extension. + +Additionally, the timeline color themes in Apex Log Analyzer draw inspiration from several open-source color palettes, editor themes, and UIs β€” including Salesforce UI, Chrome DevTools, and Firefox DevTools. We are grateful to the creators and maintainers of Catppuccin, Dracula, Nord, Solarized, Monokai Pro, Okabe–Ito, Material Design, and the broader theme communities whose work influenced the presets included in our timeline themes. diff --git a/lana-docs-site/docs/docs/features/timeline.md b/lana-docs-site/docs/docs/features/timeline.md index 1db7c291..dd8d07bb 100644 --- a/lana-docs-site/docs/docs/features/timeline.md +++ b/lana-docs-site/docs/docs/features/timeline.md @@ -15,24 +15,36 @@ image: https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/a hide_title: true --- -## πŸ”₯ Timeline / Flame chart +## πŸ”₯ Timeline / Flame Chart Use the Timeline to visualize code execution, event durations, and performance bottlenecks. Zoom, pan, and interact with detailed tooltips for efficient Salesforce apex log analysis and debugging. ![Timeline view screenshot showing a color-coded flame chart of Salesforce event types such as DB, Method, and SOQL, visualizing code execution duration and performance](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/v1.18/lana-timeline.png) -### Zoom + pan +The new experimental timeline is up to **7X faster** than the legacy timeline, with improved performance especially for large logs. It includes text labels on events, faster zoom/pan operations, and a more natural time axis scaling. -- Scroll up and down with the mouse to zoom in and out to an accuracy of 0.001ms, time markers are shown with a ms time value and white line e.g 9600.001 ms. -- When zooming the mouse pointer position is kept on screen. -- Scroll left and right on the mouse to move the time line left are right, when zoomed -- Click the mouse down and drag to move the timeline around both in the x and y direction, when zoomed +:::tip Legacy Timeline +To revert to the legacy timeline, navigate to **Settings β†’ Apex Log Analyzer β†’ Timeline β†’ Legacy** and enable it. +::: + +## Navigation + +### Zoom + Pan + +- **Scroll up and down** with the mouse to zoom in and out to an accuracy of 0.001ms. Time markers are shown with a ms time value and white line (e.g., 9600.001 ms). +- When zooming, the mouse pointer position is kept on screen. +- **Scroll left and right** on the mouse to move the timeline left or right when zoomed. +- **Click and drag** to move the timeline around both in the x and y direction when zoomed. ### Go to Call Tree -Clicking an event in the Timeline will go to and select that event in the Call Tree. +Clicking an event in the Timeline will navigate to and select that event in the Call Tree. + +### Search + Highlight -### Tooltip +The timeline supports search functionality that greys out non-matching events, making it easier to find specific matches visually. + +## Tooltip -Hovering over an element provides information on the item. If you click on an item it will take you to that row in the Call Tree. +Hovering over an element displays detailed information about that event. Clicking on an item navigates to that row in the Call Tree. + +The tooltip provides the following information: + +- **Event Name** - e.g., `METHOD_ENTRY`, `EXECUTION_STARTED`, `SOQL_EXECUTION_BEGIN` +- **Event Description** - Additional information about the event such as method name or SOQL query executed +- **Timestamp** - The start and end timestamp for the given event which can be cross-referenced in the log file +- **Duration** - Made up of **Total Time** (time spent in that event and its children) and **Self Time** (time directly spent in that event) +- **Rows** - Shows **Total Rows** (rows from that event and its children) and **Self Rows** (rows directly from that event) + +## Themes + +The timeline supports multiple color themes for better visual clarity and personalization. The extension includes 19 built-in themes with improved contrast and readability. + +### Built-in Themes + +The following themes are available out of the box: + +- **50 Shades of Green** (default) +- 50 Shades of Green Bright +- Botanical Twilight +- Catppuccin +- Chrome +- Dracula +- Dusty Aurora +- Firefox +- Flame +- Forest Floor +- Garish +- Material +- Modern +- Monokai Pro +- Nord +- Nord Forest +- Okabe-Ito +- Salesforce +- Solarized + +### Switching Themes + +There are two ways to change the active timeline theme: + +#### Command Palette + +1. Open the Command Palette (`Cmd+Shift+P` on macOS or `Ctrl+Shift+P` on Windows/Linux) +2. Type **"Log: Timeline Theme"** +3. Select a theme from the list +4. Preview themes by navigating through the options with arrow keys +5. Press `Enter` to confirm, or `Esc` to revert to the previous theme + +#### Settings + +Navigate to **Settings β†’ Apex Log Analyzer β†’ Timeline β†’ Active Theme** and select your preferred theme from the dropdown. + +### Custom Themes + +You can create custom color themes to match your preferences or specific use cases. + +#### Creating Custom Themes + +1. Navigate to **Settings β†’ Apex Log Analyzer β†’ Timeline β†’ Custom Themes** +2. Define your custom theme(s) using the following structure: + +```json +"lana.timeline.customThemes": { + "My Theme": { + "codeUnit": "#0176D3", + "workflow": "#CE4A6B", + "method": "#54698D", + "flow": "#9050E9", + "dml": "#D68128", + "soql": "#04844B", + "system": "#706E6B" + }, + "High Contrast": { + "codeUnit": "#722ED1", + "workflow": "#52C41A", + "method": "#1890FF", + "flow": "#00BCD4", + "dml": "#FF9100", + "soql": "#EB2F96", + "system": "#90A4AE" + } +} +``` + +#### Theme Color Properties + +Each theme requires the following color properties (in hex format): + +- **codeUnit** - Code Unit events +- **workflow** - Workflow and automation events +- **method** - Method entry/exit events +- **flow** - Flow execution events +- **dml** - DML operations (insert, update, delete, etc.) +- **soql** - SOQL queries +- **system** - System method calls + +Custom themes will appear in the theme selector alongside built-in themes and can be switched using the Command Palette or settings. -The tooltip provides the following information.\ -**Event Name** - e.g `METHOD_ENTRY`, `EXECUTION_STARTED`, `SOQL_EXECUTION_BEGIN` etc\ -**Event Description** - Additional information about the event such as method name or SOQL query executed.\ -**Timestamp** - The start and end timestamp for the given event which can be cross referenced in the log file.\ -**Duration** - Made up of **Total Time** (time spent in that event and its children) and **Self Time** (time directly spent in that event).\ -**Rows** - Shows **Total Rows** (rows from that event and its children) and **Self Rows** (rows directly from that event). +:::note +Custom theme names cannot override built-in theme names. If you use the same name as a built-in theme, the built-in theme will take precedence. +::: diff --git a/lana/package.json b/lana/package.json index 1e6f5b60..b691cf94 100644 --- a/lana/package.json +++ b/lana/package.json @@ -63,6 +63,11 @@ "command": "lana.showLogAnalysis", "title": "Log: Show Apex Log Analysis", "icon": "./certinia-icon-color.png" + }, + { + "command": "lana.switchTimelineTheme", + "title": "Log: Timeline Theme", + "icon": "./certinia-icon-color.png" } ], "languages": [ @@ -117,63 +122,182 @@ "type": "object", "title": "Apex Log Analyzer", "properties": { + "lana.timeline.activeTheme": { + "type": "string", + "default": "50 shades of green", + "markdownDescription": "Select a timeline theme or enter the name of a custom theme defined in `#lana.timeline.customThemes#`", + "order": 0, + "anyOf": [ + { + "enum": [ + "50 Shades of Green", + "50 Shades of Green Bright", + "Botanical Twilight", + "Catppuccin", + "Chrome", + "Dracula", + "Dusty Aurora", + "Firefox", + "Flame", + "Forest Floor", + "Garish", + "Material", + "Modern", + "Monokai Pro", + "Nord", + "Nord Forest", + "Okabe-Ito", + "Salesforce", + "Solarized" + ] + }, + { + "type": "string" + } + ] + }, + "lana.timeline.customThemes": { + "type": "object", + "title": "Custom Timeline themes", + "description": "Define custom themes. Keys are theme names, values are theme definitions.", + "order": 1, + "default": { + "Custom": { + "codeUnit": "#88AE58", + "workflow": "#51A16E", + "method": "#2B8F81", + "flow": "#5C8FA6", + "dml": "#B06868", + "soql": "#6D4C7D", + "system": "#8D6E63" + } + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "codeUnit", + "workflow", + "method", + "flow", + "dml", + "soql", + "system" + ], + "properties": { + "default": { + "codeUnit": "#88AE58", + "workflow": "#51A16E", + "method": "#2B8F81", + "flow": "#5C8FA6", + "dml": "#B06868", + "soql": "#6D4C7D", + "system": "#8D6E63" + }, + "codeUnit": { + "type": "string", + "format": "color-hex", + "default": "#88AE58" + }, + "workflow": { + "type": "string", + "format": "color-hex", + "default": "#51A16E" + }, + "method": { + "type": "string", + "format": "color-hex", + "default": "#2B8F81" + }, + "flow": { + "type": "string", + "format": "color-hex", + "default": "#5C8FA6" + }, + "dml": { + "type": "string", + "format": "color-hex", + "default": "#B06868" + }, + "soql": { + "type": "string", + "format": "color-hex", + "default": "#6D4C7D" + }, + "system": { + "type": "string", + "format": "color-hex", + "default": "#8D6E63" + } + } + } + }, + "lana.timeline.legacy": { + "title": "Enable/ disable the legacy timeline. Default is false.", + "type": "boolean", + "default": false, + "markdownDescription": "Enable/ disable the legacy timeline. Default is `false`.", + "order": 2 + }, "lana.timeline.colors": { "type": "object", + "title": "Timeline Event Colors", + "description": "Hex colors used for each log event type on the legacy Timeline.", + "additionalProperties": false, + "order": 3, "default": { "Code Unit": "#88AE58", "Workflow": "#51A16E", "Method": "#2B8F81", - "Flow": "#337986", - "DML": "#285663", - "SOQL": "#5D4963", - "System Method": "#5C3444" + "Flow": "#5C8FA6", + "DML": "#B06868", + "SOQL": "#6D4C7D", + "System Method": "#8D6E63" }, "properties": { "Code Unit": { "type": "string", "default": "#88AE58", - "description": "Hex color for Code Unit timeline events." + "description": "Hex color for Code Unit timeline events.", + "format": "color-hex" }, "Workflow": { "type": "string", "default": "#51A16E", - "description": "Hex color for Workflow timeline events." + "description": "Hex color for Workflow timeline events.", + "format": "color-hex" }, "Method": { "type": "string", "default": "#2B8F81", - "description": "Hex color for Method timeline events." + "description": "Hex color for Method timeline events.", + "format": "color-hex" }, "Flow": { "type": "string", - "default": "#337986", - "description": "Hex color for Flow timeline events." + "default": "#5C8FA6", + "description": "Hex color for Flow timeline events.", + "format": "color-hex" }, "DML": { "type": "string", - "default": "#285663", - "description": "Hex color for DML timeline events." + "default": "#B06868", + "description": "Hex color for DML timeline events.", + "format": "color-hex" }, "SOQL": { "type": "string", - "default": "#5D4963", - "description": "Hex color for SOQL timeline events." + "default": "#6D4C7D", + "description": "Hex color for SOQL timeline events.", + "format": "color-hex" }, "System Method": { "type": "string", - "default": "#5C3444", - "description": "Hex color for System Method timeline events." + "default": "#8D6E63", + "description": "Hex color for System Method timeline events.", + "format": "color-hex" } - }, - "title": "Timeline Event Colors", - "description": "Colors used for each event type on the Timeline", - "additionalProperties": false - }, - "lana.timeline.legacy": { - "title": "Enable/ disable the legacy timeline. Default is false.", - "type": "boolean", - "default": false, - "description": "Enable/ disable the legacy timeline. Default is false." + } } } } diff --git a/lana/src/Context.ts b/lana/src/Context.ts index 55511467..11905c59 100644 --- a/lana/src/Context.ts +++ b/lana/src/Context.ts @@ -6,6 +6,7 @@ import { workspace, type ExtensionContext } from 'vscode'; import { ShowAnalysisCodeLens } from './codelenses/ShowAnalysisCodeLens.js'; import { RetrieveLogFile } from './commands/RetrieveLogFile.js'; import { ShowLogAnalysis } from './commands/ShowLogAnalysis.js'; +import { SwitchTimelineTheme } from './commands/SwitchTimelineTheme.js'; import { Display } from './display/Display.js'; import { WhatsNewNotification } from './display/WhatsNewNotification.js'; import { SymbolFinder } from './salesforce/codesymbol/SymbolFinder.js'; @@ -29,6 +30,7 @@ export class Context { RetrieveLogFile.apply(this); ShowLogAnalysis.apply(this); + SwitchTimelineTheme.apply(this); ShowAnalysisCodeLens.apply(this); WhatsNewNotification.apply(this); } diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 0f5e5caa..5d76030e 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -10,6 +10,7 @@ import { Uri, commands, window as vscWindow, workspace, type WebviewPanel } from import { Context } from '../Context.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; +import { getConfig } from '../workspace/AppConfig.js'; interface WebViewLogFileRequest { requestId: string; @@ -19,6 +20,11 @@ interface WebViewLogFileRequest { export class LogView { private static helpUrl = 'https://certinia.github.io/debug-log-analyzer/'; + private static currentPanel: WebviewPanel | undefined; + + static getCurrentView() { + return LogView.currentPanel; + } static async createView( context: Context, @@ -31,6 +37,7 @@ export class LogView { 'Log: ' + logPath ? basename(logPath || '') : 'Untitled', [Uri.file(join(context.context.extensionPath, 'out')), Uri.file(dirname(logPath || ''))], ); + this.currentPanel = panel; const logViewerRoot = join(context.context.extensionPath, 'out'); const index = join(logViewerRoot, 'index.html'); @@ -82,7 +89,7 @@ export class LogView { panel.webview.postMessage({ requestId, cmd: 'getConfig', - payload: workspace.getConfiguration('lana'), + payload: getConfig(), }); break; } diff --git a/lana/src/commands/SwitchTimelineTheme.ts b/lana/src/commands/SwitchTimelineTheme.ts new file mode 100644 index 00000000..acfa6bf3 --- /dev/null +++ b/lana/src/commands/SwitchTimelineTheme.ts @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ +import { Uri, window } from 'vscode'; + +import { appName } from '../AppSettings.js'; +import { Context } from '../Context.js'; +import { getConfig, updateConfig } from '../workspace/AppConfig.js'; +import { Command } from './Command.js'; +import { LogView } from './LogView.js'; + +export class SwitchTimelineTheme { + static getCommand(context: Context): Command { + return new Command('switchTimelineTheme', 'Log: Timeline Theme', (uri: Uri) => + SwitchTimelineTheme.safeCommand(context, uri), + ); + } + + static apply(context: Context): void { + SwitchTimelineTheme.getCommand(context).register(context); + context.display.output(`Registered command '${appName}: Timeline Theme'`); + } + + private static async safeCommand(context: Context, uri: Uri): Promise { + try { + return SwitchTimelineTheme.command(context, uri); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + context.display.showErrorMessage(`Error changing timeline theme: ${msg}`); + return Promise.resolve(); + } + } + + private static async command(_context: Context, _uri: Uri): Promise { + const config = getConfig(); + const customThemesNames = Object.keys(config.timeline.customThemes || {}); + const allThemeNames = Array.from(new Set(THEMES.concat(customThemesNames))).sort(); + + const items = allThemeNames.map((label) => ({ + label, + description: label === DEFAULT_THEME ? 'default' : '', + })); + + // Create a QuickPick that allows custom text + const pick = window.createQuickPick(); + pick.items = items; + pick.placeholder = 'Select Timeline Theme...'; + + // Focus the currently active theme + let activeTheme = config.timeline.activeTheme || DEFAULT_THEME; + const activeItem = items.find((item) => item.label === activeTheme); + if (activeItem) { + pick.activeItems = [activeItem]; + } + + let selectedTheme = activeTheme; + pick.onDidChangeActive(async (selection) => { + // Update preview as user navigates + selectedTheme = selection[0]?.label ?? ''; + SwitchTimelineTheme.switchTheme(selectedTheme); + }); + + pick.onDidAccept(async () => { + if (selectedTheme) { + // Update the active theme in user settings on confirm + activeTheme = selectedTheme; + await updateConfig('timeline.activeTheme', selectedTheme); + pick.hide(); + } + }); + + pick.onDidHide(() => { + pick.dispose(); + // Revert to the original theme if no selection was made + if (selectedTheme !== activeTheme) { + SwitchTimelineTheme.switchTheme(activeTheme); + } + }); + + pick.show(); + } + + private static switchTheme(activeTheme: string) { + const currentView = LogView.getCurrentView(); + if (currentView) { + currentView.webview.postMessage({ + cmd: 'switchTimelineTheme', + payload: { activeTheme }, + }); + } + } +} + +// Note: Themes are defined in the log-viewer folder but there are no references to the files from here to maintain separation of concerns. +// They are kept in sync manually. +const THEMES = [ + '50 Shades of Green Bright', + '50 Shades of Green', + 'Botanical Twilight', + 'Catppuccin', + 'Chrome', + 'Dracula', + 'Dusty Aurora', + 'Firefox', + 'Flame', + 'Forest Floor', + 'Garish', + 'Material', + 'Modern', + 'Monokai Pro', + 'Nord', + 'Nord Forest', + 'Okabe-Ito', + 'Salesforce', + 'Solarized', +].sort(); + +const DEFAULT_THEME = '50 Shades of Green'; diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts new file mode 100644 index 00000000..b0b3fca3 --- /dev/null +++ b/lana/src/workspace/AppConfig.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 Certinia Inc. All rights reserved. + */ + +import { ConfigurationTarget, workspace } from 'vscode'; + +interface Config { + timeline: { + activeTheme: string; + colors: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'Code Unit': '#88AE58'; + Workflow: '#51A16E'; + Method: '#2B8F81'; + Flow: '#5C8FA6'; + DML: '#B06868'; + SOQL: '#6D4C7D'; + 'System Method': '#8D6E63'; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + customThemes: { + [key: string]: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'Code Unit': string; + Workflow: string; + Method: string; + Flow: string; + DML: string; + SOQL: string; + 'System Method': string; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + }; + legacy: boolean; + }; +} + +export function getConfig(): Config { + const config = workspace.getConfiguration('lana'); + // inspect returns an object separating defaults from user settings + const inspected = config.inspect('timeline.customThemes'); + + // We intentionally IGNORE inspected.defaultValue + // We only merge Global (User Settings) and Workspace (.vscode/settings.json) + const userThemes = { + ...(inspected?.globalValue || {}), + ...(inspected?.workspaceValue || {}), + }; + + const plainConfig = JSON.parse(JSON.stringify(config)); + // Override the customThemes with the merged themes, to exclude defaults + plainConfig.timeline.customThemes = userThemes; + return plainConfig; +} + +export function updateConfig(section: string, value: unknown): Thenable { + const config = workspace.getConfiguration('lana'); + return config.update(section, value, ConfigurationTarget.Global); +} diff --git a/log-viewer/src/core/messaging/VSCodeExtensionMessenger.ts b/log-viewer/src/core/messaging/VSCodeExtensionMessenger.ts index 35295b80..422cdabc 100644 --- a/log-viewer/src/core/messaging/VSCodeExtensionMessenger.ts +++ b/log-viewer/src/core/messaging/VSCodeExtensionMessenger.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2024 Certinia Inc. All rights reserved. */ -class VSCodeExtensionMessenger { +export class VSCodeExtensionMessenger { private static vscode: VSCodeAPI; private static instance: VSCodeExtensionMessenger; private static listeners = new Map(); @@ -71,7 +71,7 @@ class VSCodeExtensionMessenger { }); } - private static listen(callback: (event: MessageEvent>) => void): void { + public static listen(callback: (event: MessageEvent>) => void): void { window.addEventListener('message', callback); } } diff --git a/log-viewer/src/features/app/AppHeader.ts b/log-viewer/src/features/app/AppHeader.ts index 71f55425..3ee9c7f7 100644 --- a/log-viewer/src/features/app/AppHeader.ts +++ b/log-viewer/src/features/app/AppHeader.ts @@ -12,7 +12,6 @@ import { customElement, property } from 'lit/decorators.js'; import type { ApexLog } from '../../core/log-parser/LogEvents.js'; import { Notification } from '../notifications/components/NotificationPanel.js'; -import type { TimelineGroup } from '../timeline/services/Timeline.js'; // web components import '../../components/LogLevels.js'; @@ -45,8 +44,6 @@ export class AppHeader extends LitElement { parserIssues: Notification[] = []; @property() timelineRoot: ApexLog | null = null; - @property() - timelineKeys: TimelineGroup[] = []; static styles = [ globalStyles, diff --git a/log-viewer/src/features/app/LogViewer.ts b/log-viewer/src/features/app/LogViewer.ts index 22e31d07..60c8883f 100644 --- a/log-viewer/src/features/app/LogViewer.ts +++ b/log-viewer/src/features/app/LogViewer.ts @@ -11,9 +11,6 @@ import { Notification, type NotificationSeverity, } from '../notifications/components/NotificationPanel.js'; -import { getSettings } from '../settings/Settings.js'; -import type { TimelineGroup } from '../timeline/services/Timeline.js'; -import { keyMap, setColors } from '../timeline/services/Timeline.js'; // styles import codiconStyles from '../../styles/codicon.css'; @@ -38,8 +35,6 @@ export class LogViewer extends LitElement { parserIssues: Notification[] = []; @property() timelineRoot: ApexLog | null = null; - @property() - timelineKeys: TimelineGroup[] = []; @state() _selectedTab = 'timeline-tab'; @@ -92,11 +87,6 @@ export class LogViewer extends LitElement { document.addEventListener('show-tab', (e: Event) => { this._showTabEvent(e); }); - - getSettings().then((settings) => { - setColors(settings.timeline.colors); - this.timelineKeys = Array.from(keyMap.values()); - }); } render() { @@ -108,7 +98,6 @@ export class LogViewer extends LitElement { .notifications=${this.notifications} .parserIssues=${this.parserIssues} .timelineRoot=${this.timelineRoot} - .timelineKeys=${this.timelineKeys} > @@ -142,10 +131,7 @@ export class LogViewer extends LitElement { - + diff --git a/log-viewer/src/features/settings/Settings.ts b/log-viewer/src/features/settings/Settings.ts index 892758a0..b991f474 100644 --- a/log-viewer/src/features/settings/Settings.ts +++ b/log-viewer/src/features/settings/Settings.ts @@ -6,14 +6,26 @@ import { vscodeMessenger } from '../../core/messaging/VSCodeExtensionMessenger.j /* eslint-disable @typescript-eslint/naming-convention */ export type LanaSettings = { timeline: { + activeTheme: string; colors: { 'Code Unit': '#88AE58'; Workflow: '#51A16E'; Method: '#2B8F81'; - Flow: '#337986'; - DML: '#285663'; - SOQL: '#5D4963'; - 'System Method': '#5C3444'; + Flow: '#5C8FA6'; + DML: '#B06868'; + SOQL: '#6D4C7D'; + 'System Method': '#8D6E63'; + }; + customThemes: { + [key: string]: { + codeUnit: string; + workflow: string; + method: string; + flow: string; + dml: string; + soql: string; + system: string; + }; }; legacy: boolean; }; diff --git a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts index becd9f8f..af35b819 100644 --- a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts +++ b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts @@ -13,7 +13,6 @@ import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import type { ApexLog } from '../../../core/log-parser/LogEvents.js'; -import { getSettings } from '../../settings/Settings.js'; import { ApexLogTimeline } from '../optimised/ApexLogTimeline.js'; import type { TimelineOptions } from '../types/flamechart.types.js'; @@ -77,10 +76,13 @@ export class TimelineFlameChart extends LitElement { @property({ type: Object }) apexLog: ApexLog | null = null; + @property() + themeName: string | null = null; + /** * Optional configuration options. */ - @property({ type: Object }) + @state() options: TimelineOptions = {}; // ============================================================================ @@ -108,6 +110,10 @@ export class TimelineFlameChart extends LitElement { ) { this.initializeTimeline(); } + + if (changedProperties.has('themeName') || changedProperties.has('themeName')) { + this.apexLogTimeline?.setTheme(this.themeName ?? ''); + } } /** @@ -129,18 +135,13 @@ export class TimelineFlameChart extends LitElement { try { this.errorMessage = null; - // Fetch settings for custom colors - const settings = await getSettings(); - const customColors = settings.timeline.colors; - - // Merge custom colors with options - const optionsWithColors: TimelineOptions = { + const optionsWithTheme = { ...this.options, - colors: customColors, + themeName: this.themeName, }; this.apexLogTimeline = new ApexLogTimeline(); - await this.apexLogTimeline.init(this.containerRef, this.apexLog, optionsWithColors); + await this.apexLogTimeline.init(this.containerRef, this.apexLog, optionsWithTheme); } catch (error) { this.handleError(error); } diff --git a/log-viewer/src/features/timeline/components/TimelineKey.ts b/log-viewer/src/features/timeline/components/TimelineKey.ts index a971e3eb..0ac8b8f3 100644 --- a/log-viewer/src/features/timeline/components/TimelineKey.ts +++ b/log-viewer/src/features/timeline/components/TimelineKey.ts @@ -29,7 +29,6 @@ export class Timelinekey extends LitElement { font-size: 0.9rem; padding: 4px; margin-right: 5px; - color: #ffffff; font-family: monospace; } `, @@ -38,8 +37,12 @@ export class Timelinekey extends LitElement { render() { const keyParts = []; for (const keyMeta of this.timelineKeys) { + const textColor = this.getContrastingTextColor(keyMeta.fillColor); keyParts.push( - html`
+ html`
${keyMeta.label}
`, ); @@ -47,4 +50,50 @@ export class Timelinekey extends LitElement { return keyParts; } + + /** + * Calculate relative luminance of a hex color to determine if it's dark or light. + * Supports #RGB, #RGBA, #RRGGBB, and #RRGGBBAA formats. + * Uses WCAG formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ + private getContrastingTextColor(hexColor: string): string { + // Remove # if present + let hex = hexColor.replace('#', ''); + + // Normalize to 6-digit RGB format + if (hex.length === 3) { + // #RGB -> #RRGGBB + hex = hex + .split('') + .map((char) => char + char) + .join(''); + } else if (hex.length === 4) { + // #RGBA -> #RRGGBB (ignore alpha) + hex = hex + .substring(0, 3) + .split('') + .map((char) => char + char) + .join(''); + } else if (hex.length === 8) { + // #RRGGBBAA -> #RRGGBB (ignore alpha) + hex = hex.substring(0, 6); + } + + // Parse RGB values + const r = parseInt(hex.substring(0, 2), 16) / 255; + const g = parseInt(hex.substring(2, 4), 16) / 255; + const b = parseInt(hex.substring(4, 6), 16) / 255; + + // Apply gamma correction for sRGB + const rLin = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); + const gLin = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); + const bLin = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); + + // W3C relative luminance formula + const luminance = 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin; + + // Use dark text for light backgrounds, light text for dark backgrounds + // Threshold of 0.179 corresponds to ~50% perceived brightness + return luminance > 0.179 ? '#1e1e1e' : '#e3e3e3'; + } } diff --git a/log-viewer/src/features/timeline/components/TimelineView.ts b/log-viewer/src/features/timeline/components/TimelineView.ts index 4091fa67..10743084 100644 --- a/log-viewer/src/features/timeline/components/TimelineView.ts +++ b/log-viewer/src/features/timeline/components/TimelineView.ts @@ -5,8 +5,12 @@ import { LitElement, css, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { ApexLog } from '../../../core/log-parser/LogEvents.js'; +import { VSCodeExtensionMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { getSettings } from '../../settings/Settings.js'; -import { type TimelineGroup } from '../services/Timeline.js'; +import { type TimelineGroup, keyMap, setColors } from '../services/Timeline.js'; + +import { DEFAULT_THEME_NAME, type TimelineColors } from '../themes/Themes.js'; +import { addCustomThemes, getTheme } from '../themes/ThemeSelector.js'; // styles import { globalStyles } from '../../../styles/global.styles.js'; @@ -17,12 +21,30 @@ import './TimelineKey.js'; import './TimelineLegacy.js'; import './TimelineSkeleton.js'; +/* eslint-disable @typescript-eslint/naming-convention */ +interface ThemeSettings { + [key: string]: { + codeUnit: string; + workflow: string; + method: string; + flow: string; + dml: string; + soql: string; + system: string; + }; +} +/* eslint-enable @typescript-eslint/naming-convention */ + @customElement('timeline-view') export class TimelineView extends LitElement { @property() timelineRoot: ApexLog | null = null; - @property() - timelineKeys: TimelineGroup[] = []; + + @state() + activeTheme: string | null = null; + + @state() + private timelineKeys: TimelineGroup[] = []; @state() private useLegacyTimeline: boolean | null = null; @@ -47,25 +69,99 @@ export class TimelineView extends LitElement { async connectedCallback() { super.connectedCallback(); - const settings = await getSettings(); - this.useLegacyTimeline = settings.timeline.legacy; + + VSCodeExtensionMessenger.listen<{ activeTheme: string }>((event) => { + const { cmd, payload } = event.data; + if (cmd === 'switchTimelineTheme' && this.activeTheme !== payload.activeTheme) { + this.setTheme(payload.activeTheme ?? DEFAULT_THEME_NAME); + } + }); + + getSettings().then((settings) => { + const { timeline } = settings; + this.useLegacyTimeline = timeline.legacy; + + if (!this.useLegacyTimeline) { + addCustomThemes(this.toTheme(timeline.customThemes)); + this.setTheme(timeline.activeTheme ?? DEFAULT_THEME_NAME); + } else { + setColors(timeline.colors); + this.timelineKeys = Array.from(keyMap.values()); + } + }); } render() { - let timelineBody; if (!this.timelineRoot || this.useLegacyTimeline === null) { - timelineBody = html``; - } else if (!this.useLegacyTimeline) { - timelineBody = html` + `; + } + + if (!this.useLegacyTimeline) { + return html` + `; + } + return html``; - } else { - timelineBody = html``; + .themeName=${this.activeTheme} + >`; + } + + private setTheme(themeName: string) { + this.activeTheme = themeName ?? DEFAULT_THEME_NAME; + this.timelineKeys = this.toTimelineKeys(getTheme(themeName)); + } + + private toTheme(themeSettings: ThemeSettings): { [key: string]: TimelineColors } { + const themes: { [key: string]: TimelineColors } = {}; + for (const [name, colors] of Object.entries(themeSettings)) { + themes[name] = { + codeUnit: colors.codeUnit, + workflow: colors.workflow, + method: colors.method, + flow: colors.flow, + dml: colors.dml, + soql: colors.soql, + system: colors.system, + }; } + return themes; + } - return html` - ${timelineBody} - - `; + private toTimelineKeys(colors: TimelineColors): TimelineGroup[] { + return [ + { + label: 'Code Unit', + fillColor: colors.codeUnit, + }, + { + label: 'Workflow', + fillColor: colors.workflow, + }, + { + label: 'Method', + fillColor: colors.method, + }, + { + label: 'Flow', + fillColor: colors.flow, + }, + { + label: 'DML', + fillColor: colors.dml, + }, + { + label: 'SOQL', + fillColor: colors.soql, + }, + { + label: 'System Method', + fillColor: colors.system, + }, + ]; } } diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index aa0ed286..0cc7a8f8 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -19,6 +19,7 @@ import type { ApexLog, LogEvent } from '../../../core/log-parser/LogEvents.js'; import { goToRow } from '../../call-tree/components/CalltreeView.js'; +import { getTheme } from '../themes/ThemeSelector.js'; import type { EventNode, FindEventDetail, @@ -32,6 +33,10 @@ import { extractMarkers } from '../utils/marker-utils.js'; import { FlameChart } from './FlameChart.js'; import { TimelineTooltipManager } from './TimelineTooltipManager.js'; +interface ApexTimelineOptions extends TimelineOptions { + themeName?: string | null; +} + export class ApexLogTimeline { private flamechart: FlameChart; private tooltipManager: TimelineTooltipManager | null = null; @@ -51,19 +56,20 @@ export class ApexLogTimeline { public async init( container: HTMLElement, apexLog: ApexLog, - options: TimelineOptions = {}, + options: ApexTimelineOptions = {}, ): Promise { this.apexLog = apexLog; this.options = options; this.container = container; + const colorMap = this.themeToColors(options.themeName ?? ''); + options.colors = colorMap; + // Create tooltip manager for Apex-specific tooltips this.tooltipManager = new TimelineTooltipManager(container, { enableFlip: true, cursorOffset: 10, - categoryColors: { - ...options.colors, - }, + categoryColors: colorMap, apexLog: apexLog, }); @@ -135,9 +141,41 @@ export class ApexLogTimeline { this.flamechart.resize(newWidth, newHeight); } + /** + * Set timeline theme by name and apply colors. + * Retrieves theme colors from ThemeSelector and updates FlameChart. + */ + public setTheme(themeName: string): void { + const colorMap = this.themeToColors(themeName); + + // Update FlameChart colors (handles re-render) + this.flamechart.setColors(colorMap); + + // Update TooltipManager colors if available + if (this.tooltipManager) { + this.tooltipManager.updateCategoryColors(colorMap); + } + } + + private themeToColors(themeName: string) { + const theme = getTheme(themeName); + // Convert TimelineColors keys to the format expected by FlameChart + /* eslint-disable @typescript-eslint/naming-convention */ + return { + 'Code Unit': theme.codeUnit, + Workflow: theme.workflow, + Method: theme.method, + Flow: theme.flow, + DML: theme.dml, + SOQL: theme.soql, + 'System Method': theme.system, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + } + // ============================================================================ // APEX-SPECIFIC HANDLERS - // ============================================================================ + // ============================================================================} /** * Handle mouse move - show Apex-specific tooltips. diff --git a/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts b/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts index 1d2c930b..63dc38b4 100644 --- a/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts @@ -122,7 +122,7 @@ export class EventBatchRenderer { } // Set fill style for this batch - gfx.setFillStyle({ color: batch.color }); + gfx.setFillStyle({ color: batch.color, alpha: batch.alpha ?? 1 }); // Draw all rectangles in this batch with negative space separation const gap = TIMELINE_CONSTANTS.RECT_GAP; diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 69a52ed4..4b69b868 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -276,14 +276,16 @@ export class FlameChart { } // Create text label renderer (renders method names on rectangles) - if (this.worldContainer) { + if (this.worldContainer && this.state) { this.textLabelRenderer = new TextLabelRenderer(this.worldContainer); await this.textLabelRenderer.loadFont(); + this.textLabelRenderer.setBatches(this.state.batches); // SearchTextLabelRenderer uses composition - delegates matched labels to TextLabelRenderer this.searchTextLabelRenderer = new SearchTextLabelRenderer( this.worldContainer, this.textLabelRenderer, + this.state.batches, ); // Enable zIndex sorting for proper layering @@ -535,6 +537,30 @@ export class FlameChart { this.requestRender(); } + /** + * Update timeline colors and request a re-render. + * Updates batch colors and re-renders the timeline. + */ + public setColors(colors: Record): void { + if (!this.state) { + return; + } + + // Update batch colors + for (const [category, batch] of this.state.batches) { + const colorValue = colors[category]; + if (colorValue) { + const parsed = this.cssColorToPixi(colorValue); + batch.color = parsed.color; + batch.alpha = parsed.alpha; + batch.isDirty = true; + } + } + + // Request re-render + this.requestRender(); + } + // ============================================================================ // PRIVATE SETUP METHODS // ============================================================================ @@ -692,9 +718,11 @@ export class FlameChart { const categories = Object.keys(colors) as (keyof typeof colors)[]; for (const category of categories) { + const parsed = this.cssColorToPixi(colors[category] || '#000000'); batches.set(category, { category, - color: this.cssColorToPixi(colors[category] || '#000000'), + color: parsed.color, + alpha: parsed.alpha, rectangles: [], isDirty: true, }); @@ -714,20 +742,42 @@ export class FlameChart { }; } - private cssColorToPixi(cssColor: string): number { + private cssColorToPixi(cssColor: string): { color: number; alpha: number } { if (cssColor.startsWith('#')) { - return parseInt(cssColor.slice(1), 16); + const hex = cssColor.slice(1); + if (hex.length === 8) { + const rgb = hex.slice(0, 6); + const a = parseInt(hex.slice(6, 8), 16) / 255; + return { color: parseInt(rgb, 16), alpha: a }; + } + if (hex.length === 6) { + return { color: parseInt(hex, 16), alpha: 1 }; + } + if (hex.length === 4) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + const a = hex[3]!; + return { color: parseInt(r + r + g + g + b + b, 16), alpha: parseInt(a + a, 16) / 255 }; + } + if (hex.length === 3) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + return { color: parseInt(r + r + g + g + b + b, 16), alpha: 1 }; + } } - const rgbMatch = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + 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); - return (r << 16) | (g << 8) | b; + const a = rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1; + return { color: (r << 16) | (g << 8) | b, alpha: a }; } - return 0x000000; + return { color: 0x000000, alpha: 1 }; } // ============================================================================ diff --git a/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts index 3f5b518a..e42e34d4 100644 --- a/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts @@ -264,7 +264,7 @@ export class SearchHighlightRenderer { /** * Parse CSS color string to PixiJS numeric color. - * Handles hex format (#RRGGBB, #RRGGBBAA) and falls back to default. + * Handles hex format (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) and falls back to default. * * @param cssColor - CSS color string * @returns PixiJS numeric color (0xRRGGBB) @@ -283,6 +283,20 @@ export class SearchHighlightRenderer { if (hex.length === 6) { return { color: parseInt(hex, 16), alpha: 1 }; } + if (hex.length === 4) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + const a = hex[3]!; + const alpha = parseInt(a + a, 16) / 255; + return { color: parseInt(r + r + g + g + b + b, 16), alpha }; + } + if (hex.length === 3) { + const r = hex[0]!; + const g = hex[1]!; + const b = hex[2]!; + return { color: parseInt(r + r + g + g + b + b, 16), alpha: 1 }; + } } // rgba() fallback const rgba = cssColor.match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*(?:\.\d+)?))?\)/); diff --git a/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts b/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts index 1706b6c2..37260a0e 100644 --- a/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts @@ -75,7 +75,13 @@ export class SearchStyleRenderer { continue; } - this.renderCategoryWithSearch(gfx, rectangles, batch.color, matchedEventIds); + this.renderCategoryWithSearch( + gfx, + rectangles, + batch.color, + batch.alpha ?? 1, + matchedEventIds, + ); } } @@ -110,12 +116,14 @@ export class SearchStyleRenderer { * @param gfx - Graphics object for this category * @param rectangles - Rectangles to render * @param originalColor - Original category color + * @param originalAlpha - Original category alpha * @param matchedEventIds - Set of matched event IDs */ private renderCategoryWithSearch( gfx: PIXI.Graphics, rectangles: PrecomputedRect[], originalColor: number, + originalAlpha: number, matchedEventIds: ReadonlySet, ): void { const gap = TIMELINE_CONSTANTS.RECT_GAP; @@ -135,7 +143,7 @@ export class SearchStyleRenderer { } } if (hasMatched) { - gfx.fill({ color: originalColor }); + gfx.fill({ color: originalColor, alpha: originalAlpha }); } // Draw non-matched events with greyscale @@ -151,7 +159,7 @@ export class SearchStyleRenderer { } } if (hasNonMatched) { - gfx.fill({ color: greyColor }); + gfx.fill({ color: greyColor, alpha: originalAlpha }); } } diff --git a/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts b/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts index 02d760ef..67ea6897 100644 --- a/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts @@ -16,7 +16,7 @@ */ import { BitmapText, Container } from 'pixi.js'; -import type { ViewportState } from '../types/flamechart.types.js'; +import type { RenderBatch, ViewportState } from '../types/flamechart.types.js'; import { TEXT_LABEL_CONSTANTS, TIMELINE_CONSTANTS } from '../types/flamechart.types.js'; import type { PrecomputedRect } from './RectangleManager.js'; import type { TextLabelRenderer } from './TextLabelRenderer.js'; @@ -37,20 +37,26 @@ export class SearchTextLabelRenderer { /** Labels for unmatched events keyed by rectangle ID */ private labels: Map = new Map(); + /** Batch color data for contrast calculation */ + private batches: Map; + /** * Create a new SearchTextLabelRenderer. * * @param parentContainer - The worldContainer to add labels to * @param textLabelRenderer - TextLabelRenderer instance for rendering matched labels + * @param batches - Batch data for calculating contrasting text colors */ constructor( parentContainer: Container, private textLabelRenderer: TextLabelRenderer, + batches: Map, ) { this.container = new Container(); this.container.zIndex = TEXT_LABEL_CONSTANTS.Z_INDEX; this.container.label = 'SearchTextLabelRenderer'; parentContainer.addChild(this.container); + this.batches = batches; } /** @@ -174,6 +180,13 @@ export class SearchTextLabelRenderer { label.x = labelX; label.y = rect.y + fontYPositionOffset; label.alpha = DIMMED_ALPHA; + + // Apply contrasting text color based on background + const batch = this.batches.get(rect.category); + if (batch) { + label.tint = this.getContrastingTextColor(batch.color); + } + label.visible = true; } } @@ -234,4 +247,31 @@ export class SearchTextLabelRenderer { text.slice(0, startChars) + TEXT_LABEL_CONSTANTS.ELLIPSIS + text.slice(text.length - endChars) ); } + + /** + * Calculate contrasting text color based on background luminance. + * Uses W3C relative luminance formula for accessibility compliance. + * + * @param bgColor - Background color in PixiJS format (0xRRGGBB) + * @returns Dark text color for light backgrounds, light text color for dark backgrounds + */ + private getContrastingTextColor(bgColor: number): number { + const r = ((bgColor >> 16) & 0xff) / 255; + const g = ((bgColor >> 8) & 0xff) / 255; + const b = (bgColor & 0xff) / 255; + + // Apply gamma correction for sRGB + const rLin = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); + const gLin = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); + const bLin = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); + + // W3C relative luminance formula + const luminance = 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin; + + // Use dark text for light backgrounds, light text for dark backgrounds + // Threshold of 0.179 corresponds to ~50% perceived brightness + return luminance > 0.179 + ? TEXT_LABEL_CONSTANTS.FONT.LIGHT_THEME_COLOR + : TEXT_LABEL_CONSTANTS.FONT.DARK_THEME_COLOR; + } } diff --git a/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts b/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts index 4fb80d5c..cb764d2f 100644 --- a/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts @@ -23,7 +23,7 @@ */ import { BitmapFont, BitmapText, Container } from 'pixi.js'; -import type { ViewportState } from '../types/flamechart.types.js'; +import type { RenderBatch, ViewportState } from '../types/flamechart.types.js'; import { TEXT_LABEL_CONSTANTS, TIMELINE_CONSTANTS } from '../types/flamechart.types.js'; import type { PrecomputedRect } from './RectangleManager.js'; @@ -43,6 +43,9 @@ export class TextLabelRenderer { /** Whether the font has been loaded/created */ private fontReady = false; + /** Batch color data for contrast calculation */ + private batches: Map | null = null; + /** * Create a new TextLabelRenderer. * @@ -73,7 +76,7 @@ export class TextLabelRenderer { style: { fontFamily: 'monospace', fontSize: TEXT_LABEL_CONSTANTS.FONT.SIZE * 2, // Generate at 2x for quality - fill: TEXT_LABEL_CONSTANTS.FONT.COLOR, + fill: TEXT_LABEL_CONSTANTS.FONT.DARK_THEME_COLOR, fontWeight: 'lighter', }, chars, @@ -82,6 +85,15 @@ export class TextLabelRenderer { this.fontReady = true; } + /** + * Set batch data for dynamic text color contrast calculation. + * + * @param batches - Map of category to RenderBatch with color information + */ + public setBatches(batches: Map): void { + this.batches = batches; + } + /** * Update label visibility and truncation for visible rectangles. * Creates labels lazily for rectangles that are visible AND wide enough. @@ -166,6 +178,13 @@ export class TextLabelRenderer { label.x = labelX; // Position near top of rectangle (in inverted Y space) label.y = rect.y + fontYPositionOffset; + + // Apply contrasting text color based on background + const batch = this.batches?.get(rect.category); + if (batch) { + label.tint = this.getContrastingTextColor(batch.color); + } + label.visible = true; } } @@ -231,4 +250,31 @@ export class TextLabelRenderer { text.slice(0, startChars) + TEXT_LABEL_CONSTANTS.ELLIPSIS + text.slice(text.length - endChars) ); } + + /** + * Calculate contrasting text color based on background luminance. + * Uses W3C relative luminance formula for accessibility compliance. + * + * @param bgColor - Background color in PixiJS format (0xRRGGBB) + * @returns Dark text color for light backgrounds, light text color for dark backgrounds + */ + private getContrastingTextColor(bgColor: number): number { + const r = ((bgColor >> 16) & 0xff) / 255; + const g = ((bgColor >> 8) & 0xff) / 255; + const b = (bgColor & 0xff) / 255; + + // Apply gamma correction for sRGB + const rLin = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); + const gLin = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); + const bLin = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); + + // W3C relative luminance formula + const luminance = 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin; + + // Use dark text for light backgrounds, light text for dark backgrounds + // Threshold of 0.179 corresponds to ~50% perceived brightness + return luminance > 0.179 + ? TEXT_LABEL_CONSTANTS.FONT.LIGHT_THEME_COLOR + : TEXT_LABEL_CONSTANTS.FONT.DARK_THEME_COLOR; + } } diff --git a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts index a9eb9724..55a03316 100644 --- a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts +++ b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts @@ -136,6 +136,13 @@ export class TimelineTooltipManager { this.currentTruncationMarker = null; } + /** + * Update category colors (used when theme changes). + */ + public updateCategoryColors(colors: Record): void { + this.options.categoryColors = colors; + } + /** * Display tooltip with event information. */ diff --git a/log-viewer/src/features/timeline/services/Timeline.ts b/log-viewer/src/features/timeline/services/Timeline.ts index fe261932..1dbbbbf3 100644 --- a/log-viewer/src/features/timeline/services/Timeline.ts +++ b/log-viewer/src/features/timeline/services/Timeline.ts @@ -17,10 +17,10 @@ interface TimelineColors { 'Code Unit': '#88AE58'; Workflow: '#51A16E'; Method: '#2B8F81'; - Flow: '#337986'; - DML: '#285663'; - SOQL: '#5D4963'; - 'System Method': '#5C3444'; + Flow: '#5C8FA6'; + DML: '#B06868'; + SOQL: '#6D4C7D'; + 'System Method': '#8D6E63'; } /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/log-viewer/src/features/timeline/themes/ThemeSelector.ts b/log-viewer/src/features/timeline/themes/ThemeSelector.ts new file mode 100644 index 00000000..7ae1bc97 --- /dev/null +++ b/log-viewer/src/features/timeline/themes/ThemeSelector.ts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ + +import { DEFAULT_THEME_NAME, THEMES, type TimelineColors } from './Themes.js'; +const THEME_MAP = new Map( + THEMES.map((theme) => [theme.name, theme.colors]), +); + +const DEFAULT_THEME = THEME_MAP.get(DEFAULT_THEME_NAME)!; + +export function getTheme(themeName: string): TimelineColors { + // Merge with default to ensure all colors are present + const theme = THEME_MAP.get(themeName) ?? {}; + return { + ...getDefault(), + ...Object.fromEntries(Object.entries(theme).filter(([_, v]) => v !== null && v !== undefined)), + }; +} + +export function getDefault(): TimelineColors { + return DEFAULT_THEME; +} + +export function addCustomThemes(customThemes: { [key: string]: TimelineColors }): void { + for (const [name, colors] of Object.entries(customThemes)) { + // Skip if theme with this name already exists, avoid overriding built-in themes + if (THEME_MAP.has(name)) { + continue; + } + THEME_MAP.set(name, colors); + } +} diff --git a/log-viewer/src/features/timeline/themes/Themes.ts b/log-viewer/src/features/timeline/themes/Themes.ts new file mode 100644 index 00000000..a85bab7c --- /dev/null +++ b/log-viewer/src/features/timeline/themes/Themes.ts @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2025 Certinia Inc. All rights reserved. + */ + +interface TimelineTheme { + name: string; + colors: TimelineColors; +} + +/** + * Note: Eventual categories will be + * Apex , Code Unit, System, Automation, DML, SOQL, Validation, Callout, Visualforce + */ + +export interface TimelineColors { + codeUnit: string; + workflow: string; + method: string; + flow: string; + dml: string; + soql: string; + system: string; + reserved1?: string; // Callouts + reserved2?: string; // Validation + reserved3?: string; // Visualforce +} + +export const DEFAULT_THEME_NAME = '50 Shades of Green'; +export const THEMES: TimelineTheme[] = [ + { + name: '50 Shades of Green Bright', + colors: { + codeUnit: '#9CCC65', + workflow: '#66BB6A', + method: '#26A69A', + flow: '#5BA4CF', + dml: '#E57373', + soql: '#BA68C8', + system: '#A1887F', + reserved1: '#FFB74D', + reserved2: '#4DD0E1', + reserved3: '#78909C', + }, + }, + { + name: '50 Shades of Green', + colors: { + codeUnit: '#88AE58', + workflow: '#51A16E', + method: '#2B8F81', + flow: '#5C8FA6', + dml: '#B06868', + soql: '#6D4C7D', + system: '#8D6E63', + reserved1: '#CCA033', + reserved2: '#80CBC4', + reserved3: '#546E7A', + }, + }, + { + name: 'Botanical Twilight', + colors: { + codeUnit: '#93B376', + workflow: '#5CA880', + method: '#708B91', + flow: '#458593', + dml: '#C26D6D', + soql: '#8D7494', + system: '#666266', + reserved1: '#D4A76A', + reserved2: '#A8C2BF', + reserved3: '#566E7A', + }, + }, + { + name: 'Catppuccin', + colors: { + codeUnit: '#8AADF4', + workflow: '#94E2D5', + method: '#C6A0F6', + flow: '#F5A97F', + dml: '#F38BA8', + soql: '#A6DA95', + system: '#5B6078', + reserved1: '#EED49F', + reserved2: '#ED8796', + reserved3: '#F5E0DC', + }, + }, + { + name: 'Chrome', + colors: { + codeUnit: '#7986CB', + method: '#EBD272', + workflow: '#80CBC4', + flow: '#5DADE2', + dml: '#AF7AC5', + soql: '#7DCEA0', + system: '#CFD8DC', + reserved1: '#D98880', + reserved2: '#F0B27A', + reserved3: '#90A4AE', + }, + }, + { + name: 'Dracula', + colors: { + codeUnit: '#bd93f9', + workflow: '#8be9fd', + method: '#6272A4', + flow: '#FF79C6', + dml: '#FFB86C', + soql: '#50FA7B', + system: '#44475A', + reserved1: '#f1fa8c', + reserved2: '#FF5555', + reserved3: '#9580FF', + }, + }, + { + name: 'Dusty Aurora', + colors: { + codeUnit: '#455A64', + workflow: '#8CBFA2', + method: '#56949C', + flow: '#7CA5C9', + dml: '#D68C79', + soql: '#A693BD', + system: '#8D8078', + reserved1: '#CDBD7A', + reserved2: '#D67E7E', + reserved3: '#90A4AE', + }, + }, + { + name: 'Firefox', + colors: { + codeUnit: '#B4B4B9', + workflow: '#C49FCF', + method: '#D5C266', + flow: '#75B5AA', + dml: '#E37F81', + soql: '#8DC885', + system: '#8F8585', + reserved1: '#8484D1', + reserved2: '#E8A956', + reserved3: '#5283A4', + }, + }, + { + name: 'Flame', + colors: { + codeUnit: '#B71C1C', + workflow: '#E65100', + method: '#F57C00', + flow: '#FF7043', + dml: '#F44336', + soql: '#FFCA28', + system: '#8D6E63', + reserved1: '#C2185B', + reserved2: '#8E24AA', + reserved3: '#5D4037', + }, + }, + { + name: 'Forest Floor', + colors: { + codeUnit: '#2A9D8F', + workflow: '#264653', + method: '#6D6875', + flow: '#E9C46A', + dml: '#F4A261', + soql: '#E76F51', + system: '#455A64', + reserved1: '#606C38', + reserved2: '#BC4749', + reserved3: '#9C6644', + }, + }, + { + name: 'Garish', + colors: { + codeUnit: '#722ED1', + workflow: '#52C41A', + method: '#1890FF', + flow: '#00BCD4', + dml: '#FF9100', + soql: '#EB2F96', + system: '#90A4AE', + reserved1: '#F5222D', + reserved2: '#FFC400', + reserved3: '#651FFF', + }, + }, + { + name: 'Material', + colors: { + codeUnit: '#BA68C8', + workflow: '#4FC3F7', + method: '#676E95', + flow: '#FFCC80', + dml: '#E57373', + soql: '#91B859', + system: '#A1887F', + reserved1: '#F48FB1', + reserved2: '#9FA8DA', + reserved3: '#80CBC4', + }, + }, + { + name: 'Modern', + colors: { + codeUnit: '#6E7599', + workflow: '#4A918E', + method: '#6A7B8C', + flow: '#C47C46', + dml: '#CC5E5E', + soql: '#5CA376', + system: '#948C84', + reserved1: '#B86B86', + reserved2: '#4D8CB0', + reserved3: '#756CA8', + }, + }, + { + name: 'Monokai Pro', + colors: { + codeUnit: '#9E86C8', + workflow: '#FF6188', + method: '#7B8CA6', + flow: '#FFD866', + dml: '#FC9867', + soql: '#A9DC76', + system: '#9E938D', + reserved1: '#AB9DF2', + reserved2: '#78DCE8', + reserved3: '#8F8B76', + }, + }, + { + name: 'Nord', + colors: { + codeUnit: '#81a1c1', + workflow: '#b48ead', + method: '#5e81ac', + flow: '#d08770', + dml: '#bf616a', + soql: '#a3be8c', + system: '#4c566a', + reserved1: '#ebcb8b', + reserved2: '#88c0d0', + reserved3: '#8fbcbb', + }, + }, + { + name: 'Nord Forest', + colors: { + codeUnit: '#5E81AC', + workflow: '#EBCB8B', + method: '#7B8C7C', + flow: '#BF616A', + dml: '#D08770', + soql: '#B48EAD', + system: '#8C7B7E', + reserved1: '#687585', + reserved2: '#88C0D0', + reserved3: '#81A1C1', + }, + }, + { + name: 'Okabe-Ito', + colors: { + codeUnit: '#0072B2', + workflow: '#332288', + method: '#56B4E9', + flow: '#D55E00', + dml: '#CC79A7', + soql: '#009E73', + system: '#E69F00', + reserved1: '#882255', + reserved2: '#117733', + reserved3: '#AA4499', + }, + }, + { + name: 'Salesforce', + + colors: { + codeUnit: '#0176D3', + workflow: '#CE4A6B', + method: '#54698D', + flow: '#9050E9', + dml: '#D68128', + soql: '#04844B', + system: '#706E6B', + reserved1: '#D4B753', + reserved2: '#C23934', + reserved3: '#005FB2', + }, + }, + { + name: 'Solarized', + colors: { + codeUnit: '#268BD2', + workflow: '#2AA198', + method: '#586E75', + flow: '#6C71C4', + dml: '#DC322F', + soql: '#859900', + system: '#B58900', + reserved1: '#D33682', + reserved2: '#CB4B16', + reserved3: '#93a1a1', + }, + }, +]; diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index cbebb5d8..a52a0e72 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -112,6 +112,9 @@ export interface RenderBatch { /** PixiJS color value (0xRRGGBB). */ color: number; + /** Alpha transparency (0-1). */ + alpha?: number; + /** Rectangles to render (only visible events). */ rectangles: PrecomputedRect[]; @@ -228,10 +231,10 @@ export const TIMELINE_CONSTANTS = { 'Code Unit': '#88AE58', Workflow: '#51A16E', Method: '#2B8F81', - Flow: '#337986', - DML: '#285663', - SOQL: '#5D4963', - 'System Method': '#5C3444', + Flow: '#5C8FA6', + DML: '#B06868', + SOQL: '#6D4C7D', + 'System Method': '#8D6E63', } as TimelineColorMap, /** Maximum zoom level (0.01ms = 10 microsecond visible width in nanoseconds). */ @@ -386,7 +389,8 @@ export const TEXT_LABEL_CONSTANTS = { // COLOR: 0xd7d7d7, // COLOR: 0x333333, // COLOR: 0x000000, - COLOR: 0xe3e3e3, + DARK_THEME_COLOR: 0xe3e3e3, + LIGHT_THEME_COLOR: 0x1e1e1e, }, } as const; /* eslint-enable @typescript-eslint/naming-convention */