diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb8cda96..2148fb0d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,25 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **β‘ Performance**: Improved performance to handle huge Apex debug logs; Zoom + pan are **7X faster** with smoother motion; Adaptive level-of-detail bucketing reveals richer timeline detail as you zoom while keeping the view clean and fast.
+ - **Visual Display**: Dynamic labels on Timeline events for faster log scanning; Time axis auto-spaces markers intelligently and more naturally as you zoom; Search + highlight dims non-matches for fast scanning.
+
- β‘ **Timeline**: A brand new **experimental** timeline Flame Chart built for massive logs and up to **7X faster**. ([#446] [#251] [#92] [#564])
- - Toggle the legacy timeline anytime via **Settings -> Apex Log Analyzer -> Timeline -> Legacy**.
- - Improved performance to handle huge Apex debug logs.
- - Dynamic labels on Timeline events for faster log scanning.
- - Zoom + pan are **7X faster** with smoother motion.
- - Time axis auto-spaces markers intelligently and more naturally as you zoom.
- - Search + highlight dims non-matches for fast scanning.
- - 18 curated timeline themes plus the default theme has been improved for better contrast and readability.
- - Add your own multiple custom themes via **Settings -> Apex Log Analyzer -> Timeline -> Custom Themes**.
- - Fast theme switching via **Command Palette**: **Log: Timeline Theme** or **Settings -> Apex Log Analyzer -> Timeline -> Active Theme**.
- - Adaptive level-of-detail bucketing reveals richer timeline detail as you zoom while keeping keeping the view clean and fast.
+ - **β‘ Performance**: Improved performance to handle huge Apex debug logs.
+ - Zoom + pan are **7X faster** with smoother motion.
+ - **Visual Display**:
+ - Adaptive level-of-detail bucketing reveals richer timeline detail as you zoom while keeping the view clean and fast.
+ - Dynamic labels on Timeline events for faster log scanning.
+ - Time axis auto-spaces markers intelligently and more naturally as you zoom.
+ - Search + highlight dims non-matches for fast scanning.
+ - **π¨ Themes**:
+ - 18 curated timeline themes plus the default theme has been improved for better contrast and readability.
+ - Add your own multiple custom themes via **Settings -> Apex Log Analyzer -> Timeline -> Custom Themes**.
+ - Fast theme switching via **Command Palette**: **Log: Timeline Theme** or **Settings -> Apex Log Analyzer -> Timeline -> Active Theme**.
+ - **Keyboard and Mouse Navigation**: Comprehensive interaction controls for the timeline. ([#573] [#366] [#353] [#296] [#295])
+ - **View Control**:
+ - **Zoom**: Scroll wheel (mouse-anchored), or `W`/`S`/`+`/`-` keys.
+ - **Horizontal Pan**: `Alt/Option+Scroll`, trackpad swipe, `A`/`D` keys, or click and drag.
+ - **Vertical Pan**: `Shift+Scroll` or `Shift+W`/`Shift+S` to pan through stack depth.
+ - **Reset Zoom**: Press `Home` or `0` to reset zoom and fit all content.
+ - **Frame Control**:
+ - **Frame Selection**: Click to select and highlight a frame without navigating away.
+ - **Frame Navigation**: Arrow keys traverse the call stack (Up=child, Down=parent, Left/Right=siblings).
+ - **Focus/Zoom to Frame**: Double-click or press `Enter`/`Z` to zoom and fit the selected frame.
+ - **Navigation & Actions**:
+ - **Jump to Call Tree**: Press `J` or `Cmd/Ctrl+Click` to navigate to the frame in the Call Tree.
+ - **Context Menu**: Right-click for Go to Source, Copy Name, Copy Details, and Copy Call Stack.
+ - **Copy**: `Cmd/Ctrl+C` copies the selected frame name.
+ - **Marker Navigation**: Click markers to select; arrow Left/Right to navigate between markers.
+ - **Clear Selection**: Press `Escape` to deselect the current frame or marker.
+ - **Legacy Support**: Toggle the legacy timeline anytime via **Settings -> Apex Log Analyzer -> Timeline -> Legacy**.
### Changed
- π― **Call Tree Go To**: Go-to links in call tree now navigate to method definition instead of where method was called from ([#632] [#200])
+- π **Search Navigation**: `Shift+Enter` navigates to the previous search result; hold `Enter` to continuously navigate.
- β‘ **Log Parsing**: Improved performance ([#552])
- β¨ **Duration Formatting**: Human-readable duration formatting in tooltips (30000 ms -> 30s and 0.01 ms -> 10 Β΅s) ([#671])
- π― **Number Precision**: Total and Self Time column precision changed to 2 decimal places for improved readability ([#671])
-- π¨ Navigation Bar: Redesigned to better match VS Codeβs look and feel ([#694])
+- π¨ **Navigation Bar**: Redesigned to better match VS Codeβs look and feel ([#694])
## [1.18.1] 2025-07-09
@@ -419,6 +441,11 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
+[#295]: https://github.com/certinia/debug-log-analyzer/issues/295
+[#296]: https://github.com/certinia/debug-log-analyzer/issues/296
+[#353]: https://github.com/certinia/debug-log-analyzer/issues/353
+[#366]: https://github.com/certinia/debug-log-analyzer/issues/366
+[#573]: https://github.com/certinia/debug-log-analyzer/issues/573
[#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
diff --git a/README.md b/README.md
index 6f943ce5..d2e7bcd5 100644
--- a/README.md
+++ b/README.md
@@ -85,9 +85,14 @@ Use `Log: Retrieve Apex Log And Show Analysis` from the Command Palette.
The Flame Chart view shows a timeline of the Salesforce Apex log execution β including methods, SOQL queries, DML operations, workflows, flows, and more.
-- **Zoom & Pan** β Navigate your logs down to 0.001 ms with precision zoom.
+- **β‘ Fast** β Blazing-fast zoom, pan, and rendering even on massive logs (500k+ lines).
+- **π― Frame Selection & Navigation** β Click to select frames, use arrow keys to navigate the call stack, double-click or press `Enter` to zoom and focus.
+- **Zoom & Pan** β Navigate your logs down to 0.001 ms with precision zoom. `W`/`S` keys or scroll wheel for zoom; `A`/`D` keys or drag for pan.
+- **Dynamic Labels** β Instantly see method names on timeline events for faster scanning.
+- **19 Curated Themes** β Choose from beautiful, optimized color themes or create your own via Settings.
+- **Smart Interaction** β Right-click for context actions; `Cmd/Ctrl+Click` to jump directly to the Call Tree; `Cmd/Ctrl+C` to copy frame names.
- **Tooltips** β Hover for duration, event name, SOQL/DML/Exception counts, SOQL/DML rows, and more.
-- **Click to Navigate** β Click any event to instantly view it in the interactive Call Tree.
+- **Adaptive Detail** β Level-of-detail bucketing reveals richer detail as you zoom while keeping performance snappy.
- **Stacked by Time** β See how execution time is distributed across nested method calls and system events.

diff --git a/lana-docs-site/docs/docs/features/timeline.md b/lana-docs-site/docs/docs/features/timeline.md
index dd8d07bb..72c91cf5 100644
--- a/lana-docs-site/docs/docs/features/timeline.md
+++ b/lana-docs-site/docs/docs/features/timeline.md
@@ -31,18 +31,75 @@ To revert to the legacy timeline, navigate to **Settings β Apex Log Analyzer
### 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.
+| Action | Mouse | Keyboard |
+| ---------------- | ---------------------------------------------- | ---------------------- |
+| Zoom In/Out | Scroll wheel (mouse-anchored) | `W` / `S` or `+` / `-` |
+| Pan Horizontally | `Alt/Option` + Scroll, Trackpad swipe, or Drag | `A` / `D` |
+| Pan Vertically | `Shift` + Scroll or Drag | `Shift+W` / `Shift+S` |
+| Reset Zoom | β | `Home` or `0` |
+
+- When zooming, the mouse pointer position is kept on screen (mouse-anchored zoom).
+- Trackpad users can swipe left/right for natural horizontal panning.
+- Time markers are shown with a ms time value (e.g., 9600.001 ms).
+
+### Frame Selection
+
+Click on any event to **select** and highlight it. Selection enables keyboard navigation through the call stack.
+
+| Action | Mouse | Keyboard |
+| ------------------- | ----------------- | ---------------------------- |
+| Select Frame | Click | β |
+| Clear Selection | Click empty space | `Escape` |
+| Navigate to Parent | β | `Arrow Down` |
+| Navigate to Child | β | `Arrow Up` |
+| Navigate to Sibling | β | `Arrow Left` / `Arrow Right` |
+| Focus/Zoom to Frame | Double-click | `Enter` or `Z` |
+
+:::tip Arrow Key Behavior
+When no frame is selected, arrow keys pan the viewport. When a frame is selected, arrow keys navigate the call stack. Hold `Shift` to always pan.
+:::
### Go to Call Tree
-Clicking an event in the Timeline will navigate to and select that event in the Call Tree.
+| Action | Mouse | Keyboard |
+| ----------------- | ------------------ | -------- |
+| Jump to Call Tree | `Cmd/Ctrl` + Click | `J` |
+| Show Context Menu | Right-click | β |
+
+Use `J` or `Cmd/Ctrl+Click` to navigate to the selected frame in the Call Tree. Right-click opens a context menu with additional actions.
+
+### Context Menu
+
+Right-click on any frame to access:
+
+- **Show in Call Tree** (`J`) β Navigate to the frame in the Call Tree
+- **Go to Source** β Jump to the source method in your project (when available)
+- **Zoom to Frame** (`Z`) β Zoom and center the selected frame
+- **Copy Name** (`Cmd/Ctrl+C`) β Copy the frame name to clipboard
+- **Copy Details** β Copy tooltip information
+- **Copy Call Stack** β Copy the full call stack
+
+Right-click on empty space shows **Reset Zoom** (`0`).
+
+### Markers
+
+Log issue markers (truncation, errors, etc.) can be selected and navigated:
+
+| Action | Mouse | Keyboard |
+| ----------------- | ------------------ | --------------------------------------------------- |
+| Select Marker | Click | β |
+| Navigate Markers | β | `Arrow Left` / `Arrow Right` (when marker selected) |
+| Jump to Call Tree | `Cmd/Ctrl` + Click | `J` |
### Search + Highlight
-The timeline supports search functionality that greys out non-matching events, making it easier to find specific matches visually.
+The timeline supports search functionality that dims non-matching events, making it easier to find specific matches visually.
+
+| Action | Keyboard |
+| --------------------- | ----------------------------- |
+| Next Match | `Enter` |
+| Previous Match | `Shift+Enter` |
+| Continuous Navigation | Hold `Enter` or `Shift+Enter` |
## Tooltip
@@ -54,7 +111,7 @@ style={{
}}
loading="lazy"/>
-Hovering over an element displays detailed information about that event. Clicking on an item navigates to that row in the Call Tree.
+Hovering over an element displays detailed information about that event. Use `J` or `Cmd/Ctrl+Click` to navigate to the frame in the Call Tree.
The tooltip provides the following information:
diff --git a/log-viewer/src/components/ContextMenu.ts b/log-viewer/src/components/ContextMenu.ts
new file mode 100644
index 00000000..e23f2a54
--- /dev/null
+++ b/log-viewer/src/components/ContextMenu.ts
@@ -0,0 +1,269 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * ContextMenu - Reusable context menu Lit component
+ *
+ * A lightweight context menu styled to match VS Code's native appearance.
+ * Uses Shadow DOM for style encapsulation and works in VS Code webview CSP.
+ *
+ * Usage:
+ * ```html
+ * handleSelect(e.detail.itemId)}"
+ * @menu-close="${() => handleClose()}"
+ * >
+ * ```
+ *
+ * ```typescript
+ * const menu = document.querySelector('context-menu');
+ * menu.show([
+ * { id: 'copy', label: 'Copy', shortcut: 'Ctrl+C' },
+ * { id: 'sep', label: '', separator: true },
+ * { id: 'delete', label: 'Delete', disabled: true }
+ * ], clientX, clientY);
+ * ```
+ */
+
+import { LitElement, css, html, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+
+export interface ContextMenuItem {
+ /** Unique identifier for the menu item */
+ id: string;
+ /** Display label */
+ label: string;
+ /** Optional keyboard shortcut hint (display only) */
+ shortcut?: string;
+ /** If true, renders a separator line instead of a clickable item */
+ separator?: boolean;
+ /** If true, the item is grayed out and not clickable */
+ disabled?: boolean;
+}
+
+/**
+ * Context menu component styled to match VS Code's native menus.
+ *
+ * @fires menu-select - Fired when a menu item is selected. Detail: { itemId: string }
+ * @fires menu-close - Fired when the menu is closed (click outside, Escape, or after selection)
+ */
+@customElement('context-menu')
+export class ContextMenu extends LitElement {
+ static styles = css`
+ :host {
+ position: fixed;
+ z-index: 10000;
+ display: none;
+ }
+
+ :host([visible]) {
+ display: block;
+ }
+
+ .menu {
+ min-width: 180px;
+ padding: 6px 0;
+ background-color: var(--vscode-menu-background, #252526);
+ border: 1px solid var(--vscode-menu-border, #454545);
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif);
+ font-size: 13px;
+ color: var(--vscode-menu-foreground, #cccccc);
+ outline: none;
+ }
+
+ .menu-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 20px 6px 12px;
+ cursor: pointer;
+ user-select: none;
+ border-radius: 4px;
+ margin: 0 6px;
+ }
+
+ .menu-item:hover:not(.disabled) {
+ background-color: var(--vscode-menu-selectionBackground, #094771);
+ color: var(--vscode-menu-selectionForeground, #ffffff);
+ }
+
+ .menu-item.disabled {
+ color: var(--vscode-disabledForeground, #6e6e6e);
+ cursor: default;
+ }
+
+ .label {
+ flex: 1;
+ }
+
+ .shortcut {
+ margin-left: 32px;
+ opacity: 0.7;
+ font-size: 12px;
+ }
+
+ .separator {
+ height: 1px;
+ margin: 6px 12px;
+ background-color: var(--vscode-menu-separatorBackground, #454545);
+ }
+ `;
+
+ @property({ type: Array }) items: ContextMenuItem[] = [];
+ @property({ type: Number }) x = 0;
+ @property({ type: Number }) y = 0;
+ @state() private _visible = false;
+
+ private boundHandleClickOutside = this.handleClickOutside.bind(this);
+ private boundHandleKeyDown = this.handleKeyDown.bind(this);
+
+ /**
+ * Show the context menu at the specified screen coordinates.
+ */
+ public show(items: ContextMenuItem[], x: number, y: number): void {
+ // Hide any existing menu first
+ this.hide();
+
+ this.items = items;
+ this.x = x;
+ this.y = y;
+ this._visible = true;
+
+ // Add visible attribute for CSS
+ this.setAttribute('visible', '');
+
+ // Position the menu
+ this.style.left = `${x}px`;
+ this.style.top = `${y}px`;
+
+ // Add event listeners (on next tick to avoid catching the triggering click)
+ requestAnimationFrame(() => {
+ document.addEventListener('mousedown', this.boundHandleClickOutside, true);
+ document.addEventListener('keydown', this.boundHandleKeyDown, true);
+
+ // Adjust position if menu goes off-screen (after render)
+ this.updateComplete.then(() => this.adjustPosition());
+
+ // Focus the menu for keyboard navigation
+ this.shadowRoot?.querySelector('.menu')?.setAttribute('tabindex', '-1');
+ (this.shadowRoot?.querySelector('.menu') as HTMLElement)?.focus();
+ });
+ }
+
+ /**
+ * Hide and close the context menu.
+ */
+ public hide(): void {
+ if (!this._visible) {
+ return;
+ }
+
+ document.removeEventListener('mousedown', this.boundHandleClickOutside, true);
+ document.removeEventListener('keydown', this.boundHandleKeyDown, true);
+
+ this._visible = false;
+ this.removeAttribute('visible');
+ this.items = [];
+ }
+
+ /**
+ * Check if the menu is currently visible.
+ */
+ public isVisible(): boolean {
+ return this._visible;
+ }
+
+ private handleClickOutside(e: MouseEvent): void {
+ // Check if click is outside the menu
+ const path = e.composedPath();
+ if (!path.includes(this)) {
+ this.hide();
+ this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true }));
+ }
+ }
+
+ private handleKeyDown(e: KeyboardEvent): void {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.hide();
+ this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true }));
+ }
+ }
+
+ private handleItemClick(item: ContextMenuItem): void {
+ if (item.disabled) {
+ return;
+ }
+
+ this.dispatchEvent(
+ new CustomEvent('menu-select', {
+ detail: { itemId: item.id },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ this.hide();
+ this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true }));
+ }
+
+ private adjustPosition(): void {
+ const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement;
+ if (!menu) {
+ return;
+ }
+
+ const rect = menu.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ // Adjust horizontal position if menu goes off right edge
+ if (this.x + rect.width > viewportWidth) {
+ const newLeft = Math.max(0, viewportWidth - rect.width - 8);
+ this.style.left = `${newLeft}px`;
+ }
+
+ // Adjust vertical position if menu goes off bottom edge
+ if (this.y + rect.height > viewportHeight) {
+ const newTop = Math.max(0, viewportHeight - rect.height - 8);
+ this.style.top = `${newTop}px`;
+ }
+ }
+
+ render() {
+ if (!this._visible) {
+ return nothing;
+ }
+
+ return html`
+
+ `;
+ }
+
+ private renderItem(item: ContextMenuItem) {
+ if (item.separator) {
+ return html``;
+ }
+
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'context-menu': ContextMenu;
+ }
+}
diff --git a/log-viewer/src/features/find/components/FindWidget.ts b/log-viewer/src/features/find/components/FindWidget.ts
index 30e19ad1..370892ae 100644
--- a/log-viewer/src/features/find/components/FindWidget.ts
+++ b/log-viewer/src/features/find/components/FindWidget.ts
@@ -21,7 +21,7 @@ export class FindWidget extends LitElement {
@state() matchCase = false;
lastMatch: string | null = null;
- nextMatchDirection = true;
+ nextMatchDirection = true; // Remembers last direction: true=next, false=previous
constructor() {
super();
@@ -187,6 +187,7 @@ export class FindWidget extends LitElement {
_matchCase() {
this._resetCounts();
this.matchCase = !this.matchCase;
+ this.nextMatchDirection = true; // Reset to forward direction
this._triggerFind();
}
@@ -202,15 +203,22 @@ export class FindWidget extends LitElement {
const inputBox = this.inputbox;
if (inputBox) {
this.isVisble = true;
+ this.nextMatchDirection = true; // Reset to forward direction
inputBox.focus();
inputBox.select();
}
}
- _previousMatch() {
+ /**
+ * Navigate to previous match.
+ * @param setDirection - If true, remembers this direction for Enter key. Button clicks pass true, Shift+Enter passes false.
+ */
+ _previousMatch(setDirection = true) {
if (this.currentMatch !== null) {
this.currentMatch--;
- this.nextMatchDirection = false;
+ if (setDirection) {
+ this.nextMatchDirection = false;
+ }
if (this.currentMatch < 1) {
this.currentMatch = this.totalMatches;
}
@@ -219,10 +227,16 @@ export class FindWidget extends LitElement {
}
}
- _nextMatch() {
+ /**
+ * Navigate to next match.
+ * @param setDirection - If true, remembers this direction for Enter key. Button clicks pass true, Enter key passes false.
+ */
+ _nextMatch(setDirection = true) {
if (this.currentMatch !== null) {
this.currentMatch++;
- this.nextMatchDirection = true;
+ if (setDirection) {
+ this.nextMatchDirection = true;
+ }
if (this.currentMatch > this.totalMatches) {
this.currentMatch = 1;
}
@@ -246,12 +260,14 @@ export class FindWidget extends LitElement {
}
_keyPress(e: KeyboardEvent) {
- if (e.repeat) {
+ // Allow Enter to repeat for holding, block other repeated keys
+ if (e.repeat && e.key !== 'Enter') {
return;
}
if (e.key === 'f' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
e.preventDefault();
+ this.nextMatchDirection = true; // Reset to forward direction
if (!this.isVisble && !this.totalMatches) {
this._triggerFind();
@@ -269,22 +285,27 @@ export class FindWidget extends LitElement {
switch (e.key) {
case 'Escape':
+ this.nextMatchDirection = true; // Reset to forward direction
this._closeFind();
-
break;
case 'Enter': {
+ e.preventDefault(); // Prevent double-firing from input field
if (this._hasMatchValueChanged() || !this.totalMatches) {
this._triggerFind();
+ } else if (e.shiftKey) {
+ this._previousMatch(false); // Shift+Enter β always previous, don't change remembered direction
} else if (this.nextMatchDirection) {
- this._nextMatch();
+ this._nextMatch(false); // Enter β next (default direction)
} else {
- this._previousMatch();
+ this._previousMatch(false); // Enter β previous (if Prev button was last clicked)
}
break;
}
default:
+ // Any other key resets direction to forward
+ this.nextMatchDirection = true;
break;
}
}
diff --git a/log-viewer/src/features/timeline/__tests__/SelectionManager.test.ts b/log-viewer/src/features/timeline/__tests__/SelectionManager.test.ts
new file mode 100644
index 00000000..dca67e62
--- /dev/null
+++ b/log-viewer/src/features/timeline/__tests__/SelectionManager.test.ts
@@ -0,0 +1,316 @@
+/**
+ * @jest-environment jsdom
+ */
+
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * Unit tests for SelectionManager
+ *
+ * Tests selection state management and navigation:
+ * - select/clear/getSelected lifecycle
+ * - hasSelection state tracking
+ * - navigate() for all directions
+ * - findByOriginal for hit test integration
+ */
+
+import { SelectionManager } from '../optimised/selection/SelectionManager.js';
+import type { EventNode, TreeNode } from '../types/flamechart.types.js';
+import type { NavigationMaps } from '../utils/tree-converter.js';
+
+describe('SelectionManager', () => {
+ /**
+ * Helper to create a mock EventNode
+ */
+ function createEvent(id: string, text: string = `Event ${id}`): EventNode {
+ return {
+ id,
+ timestamp: parseInt(id) * 1000,
+ duration: 1000,
+ type: 'METHOD_ENTRY',
+ text,
+ };
+ }
+
+ /**
+ * Helper to create a TreeNode wrapping an EventNode
+ */
+ function createNode(
+ event: EventNode,
+ children?: TreeNode[],
+ depth = 0,
+ ): TreeNode {
+ return {
+ data: event,
+ children,
+ depth,
+ };
+ }
+
+ /**
+ * Helper to build NavigationMaps from root nodes (for testing).
+ * Mimics what logEventToTreeNode does during production.
+ */
+ function buildMapsFromNodes(rootNodes: TreeNode[]): NavigationMaps {
+ const maps: NavigationMaps = {
+ originalMap: new Map(),
+ nodeMap: new Map(),
+ parentMap: new Map(),
+ siblingMap: new Map(),
+ depthMap: new Map(),
+ depthLookup: new Map(),
+ };
+
+ function processNode(
+ node: TreeNode,
+ parent: TreeNode | null,
+ siblings: TreeNode[],
+ siblingIndex: number,
+ ): void {
+ const depth = node.depth ?? 0;
+ maps.nodeMap.set(node.data.id, node);
+ maps.parentMap.set(node.data.id, parent);
+ maps.siblingMap.set(node.data.id, { index: siblingIndex, siblings });
+ maps.depthLookup.set(node.data.id, depth);
+
+ let nodesAtDepth = maps.depthMap.get(depth);
+ if (!nodesAtDepth) {
+ nodesAtDepth = [];
+ maps.depthMap.set(depth, nodesAtDepth);
+ }
+ nodesAtDepth.push(node);
+
+ if (node.children) {
+ for (let i = 0; i < node.children.length; i++) {
+ processNode(node.children[i]!, node, node.children, i);
+ }
+ }
+ }
+
+ for (let i = 0; i < rootNodes.length; i++) {
+ processNode(rootNodes[i]!, null, rootNodes, i);
+ }
+
+ return maps;
+ }
+
+ describe('selection lifecycle', () => {
+ let manager: SelectionManager;
+ let node1: TreeNode;
+ let node2: TreeNode;
+
+ beforeEach(() => {
+ node1 = createNode(createEvent('1'));
+ node2 = createNode(createEvent('2'));
+ const rootNodes = [node1, node2];
+ manager = new SelectionManager(rootNodes, buildMapsFromNodes(rootNodes));
+ });
+
+ it('should have no selection initially', () => {
+ expect(manager.getSelected()).toBeNull();
+ expect(manager.hasSelection()).toBe(false);
+ });
+
+ it('should select a node', () => {
+ manager.select(node1);
+
+ expect(manager.getSelected()).toBe(node1);
+ expect(manager.hasSelection()).toBe(true);
+ });
+
+ it('should change selection when selecting different node', () => {
+ manager.select(node1);
+ manager.select(node2);
+
+ expect(manager.getSelected()).toBe(node2);
+ expect(manager.hasSelection()).toBe(true);
+ });
+
+ it('should clear selection', () => {
+ manager.select(node1);
+ manager.clear();
+
+ expect(manager.getSelected()).toBeNull();
+ expect(manager.hasSelection()).toBe(false);
+ });
+
+ it('should handle clearing when nothing is selected', () => {
+ manager.clear(); // Should not throw
+
+ expect(manager.getSelected()).toBeNull();
+ expect(manager.hasSelection()).toBe(false);
+ });
+ });
+
+ describe('navigation', () => {
+ // Tree structure:
+ // root1 (id: 1)
+ // βββ child1 (id: 11)
+ // β βββ grandchild1 (id: 111)
+ // βββ child2 (id: 12)
+ // root2 (id: 2)
+
+ let manager: SelectionManager;
+ let root1: TreeNode;
+ let root2: TreeNode;
+ let child1: TreeNode;
+ let child2: TreeNode;
+ let grandchild1: TreeNode;
+
+ beforeEach(() => {
+ grandchild1 = createNode(createEvent('111'), undefined, 2);
+ child1 = createNode(createEvent('11'), [grandchild1], 1);
+ child2 = createNode(createEvent('12'), undefined, 1);
+ root1 = createNode(createEvent('1'), [child1, child2], 0);
+ root2 = createNode(createEvent('2'), undefined, 0);
+
+ const rootNodes = [root1, root2];
+ manager = new SelectionManager(rootNodes, buildMapsFromNodes(rootNodes));
+ });
+
+ it('should return null when navigating with no selection', () => {
+ expect(manager.navigate('up')).toBeNull();
+ expect(manager.navigate('down')).toBeNull();
+ expect(manager.navigate('left')).toBeNull();
+ expect(manager.navigate('right')).toBeNull();
+ });
+
+ describe('navigate up (into children)', () => {
+ it('should navigate up to first child', () => {
+ manager.select(root1);
+ const result = manager.navigate('up');
+
+ expect(result).toBe(child1);
+ expect(manager.getSelected()).toBe(child1);
+ });
+
+ it('should navigate up to grandchild', () => {
+ manager.select(child1);
+ const result = manager.navigate('up');
+
+ expect(result).toBe(grandchild1);
+ expect(manager.getSelected()).toBe(grandchild1);
+ });
+
+ it('should return null at leaf node', () => {
+ manager.select(grandchild1);
+ const result = manager.navigate('up');
+
+ expect(result).toBeNull();
+ expect(manager.getSelected()).toBe(grandchild1); // Selection unchanged
+ });
+ });
+
+ describe('navigate down (to parent)', () => {
+ it('should navigate down to parent', () => {
+ manager.select(child1);
+ const result = manager.navigate('down');
+
+ expect(result).toBe(root1);
+ expect(manager.getSelected()).toBe(root1);
+ });
+
+ it('should return null at root', () => {
+ manager.select(root1);
+ const result = manager.navigate('down');
+
+ expect(result).toBeNull();
+ expect(manager.getSelected()).toBe(root1); // Selection unchanged
+ });
+ });
+
+ describe('navigate left (previous sibling)', () => {
+ it('should navigate left to previous sibling', () => {
+ manager.select(child2);
+ const result = manager.navigate('left');
+
+ expect(result).toBe(child1);
+ expect(manager.getSelected()).toBe(child1);
+ });
+
+ it('should navigate left between root siblings', () => {
+ manager.select(root2);
+ const result = manager.navigate('left');
+
+ expect(result).toBe(root1);
+ expect(manager.getSelected()).toBe(root1);
+ });
+
+ it('should return null at first sibling', () => {
+ manager.select(child1);
+ const result = manager.navigate('left');
+
+ expect(result).toBeNull();
+ expect(manager.getSelected()).toBe(child1); // Selection unchanged
+ });
+ });
+
+ describe('navigate right (next sibling)', () => {
+ it('should navigate right to next sibling', () => {
+ manager.select(child1);
+ const result = manager.navigate('right');
+
+ expect(result).toBe(child2);
+ expect(manager.getSelected()).toBe(child2);
+ });
+
+ it('should navigate right between root siblings', () => {
+ manager.select(root1);
+ const result = manager.navigate('right');
+
+ expect(result).toBe(root2);
+ expect(manager.getSelected()).toBe(root2);
+ });
+
+ it('should return null at last sibling', () => {
+ manager.select(child2);
+ const result = manager.navigate('right');
+
+ expect(result).toBeNull();
+ expect(manager.getSelected()).toBe(child2); // Selection unchanged
+ });
+ });
+ });
+
+ describe('findById', () => {
+ let manager: SelectionManager;
+ let node1: TreeNode;
+ let node2: TreeNode;
+
+ beforeEach(() => {
+ const child = createNode(createEvent('11'), undefined, 1);
+ node1 = createNode(createEvent('1'), [child], 0);
+ node2 = createNode(createEvent('2'), undefined, 0);
+ const rootNodes = [node1, node2];
+ manager = new SelectionManager(rootNodes, buildMapsFromNodes(rootNodes));
+ });
+
+ it('should find node by id', () => {
+ expect(manager.findById('1')).toBe(node1);
+ expect(manager.findById('2')).toBe(node2);
+ });
+
+ it('should find nested node by id', () => {
+ const found = manager.findById('11');
+ expect(found).not.toBeNull();
+ expect(found?.data.id).toBe('11');
+ });
+
+ it('should return null for non-existent id', () => {
+ expect(manager.findById('999')).toBeNull();
+ });
+ });
+
+ describe('empty tree', () => {
+ it('should handle empty tree', () => {
+ const rootNodes: TreeNode[] = [];
+ const manager = new SelectionManager(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(manager.hasSelection()).toBe(false);
+ expect(manager.getSelected()).toBeNull();
+ expect(manager.findById('1')).toBeNull();
+ });
+ });
+});
diff --git a/log-viewer/src/features/timeline/__tests__/TreeNavigator.test.ts b/log-viewer/src/features/timeline/__tests__/TreeNavigator.test.ts
new file mode 100644
index 00000000..e2123673
--- /dev/null
+++ b/log-viewer/src/features/timeline/__tests__/TreeNavigator.test.ts
@@ -0,0 +1,487 @@
+/**
+ * @jest-environment jsdom
+ */
+
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * Unit tests for TreeNavigator
+ *
+ * Tests tree navigation functionality for flame chart selection:
+ * - Parent traversal (Arrow Up)
+ * - Child traversal (Arrow Down - first child)
+ * - Sibling traversal (Arrow Left/Right)
+ * - Edge cases: root nodes, leaf nodes, only-child scenarios
+ */
+
+import { TreeNavigator } from '../optimised/selection/TreeNavigator.js';
+import type { EventNode, TreeNode } from '../types/flamechart.types.js';
+import type { NavigationMaps } from '../utils/tree-converter.js';
+
+describe('TreeNavigator', () => {
+ /**
+ * Helper to create a mock EventNode
+ */
+ function createEvent(id: string, text: string = `Event ${id}`): EventNode {
+ return {
+ id,
+ timestamp: parseInt(id) * 1000,
+ duration: 1000,
+ type: 'METHOD_ENTRY',
+ text,
+ };
+ }
+
+ /**
+ * Helper to create a TreeNode wrapping an EventNode
+ */
+ function createNode(
+ event: EventNode,
+ children?: TreeNode[],
+ depth = 0,
+ ): TreeNode {
+ return {
+ data: event,
+ children,
+ depth,
+ };
+ }
+
+ /**
+ * Helper to build NavigationMaps from root nodes (for testing).
+ * Mimics what logEventToTreeNode does during production.
+ */
+ function buildMapsFromNodes(rootNodes: TreeNode[]): NavigationMaps {
+ const maps: NavigationMaps = {
+ originalMap: new Map(),
+ nodeMap: new Map(),
+ parentMap: new Map(),
+ siblingMap: new Map(),
+ depthMap: new Map(),
+ depthLookup: new Map(),
+ };
+
+ function processNode(
+ node: TreeNode,
+ parent: TreeNode | null,
+ siblings: TreeNode[],
+ siblingIndex: number,
+ ): void {
+ const depth = node.depth ?? 0;
+ maps.nodeMap.set(node.data.id, node);
+ maps.parentMap.set(node.data.id, parent);
+ maps.siblingMap.set(node.data.id, { index: siblingIndex, siblings });
+ maps.depthLookup.set(node.data.id, depth);
+
+ let nodesAtDepth = maps.depthMap.get(depth);
+ if (!nodesAtDepth) {
+ nodesAtDepth = [];
+ maps.depthMap.set(depth, nodesAtDepth);
+ }
+ nodesAtDepth.push(node);
+
+ if (node.children) {
+ for (let i = 0; i < node.children.length; i++) {
+ processNode(node.children[i]!, node, node.children, i);
+ }
+ }
+ }
+
+ for (let i = 0; i < rootNodes.length; i++) {
+ processNode(rootNodes[i]!, null, rootNodes, i);
+ }
+
+ return maps;
+ }
+
+ describe('basic tree structure', () => {
+ // Tree structure:
+ // root1 (id: 1)
+ // βββ child1 (id: 11)
+ // β βββ grandchild1 (id: 111)
+ // βββ child2 (id: 12)
+ // root2 (id: 2)
+
+ let navigator: TreeNavigator;
+ let root1: TreeNode;
+ let root2: TreeNode;
+ let child1: TreeNode;
+ let child2: TreeNode;
+ let grandchild1: TreeNode;
+
+ beforeEach(() => {
+ grandchild1 = createNode(createEvent('111'), undefined, 2);
+ child1 = createNode(createEvent('11'), [grandchild1], 1);
+ child2 = createNode(createEvent('12'), undefined, 1);
+ root1 = createNode(createEvent('1'), [child1, child2], 0);
+ root2 = createNode(createEvent('2'), undefined, 0);
+
+ const rootNodes = [root1, root2];
+ navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+ });
+
+ describe('findById', () => {
+ it('should find root nodes by id', () => {
+ expect(navigator.findById('1')).toBe(root1);
+ expect(navigator.findById('2')).toBe(root2);
+ });
+
+ it('should find child nodes by id', () => {
+ expect(navigator.findById('11')).toBe(child1);
+ expect(navigator.findById('12')).toBe(child2);
+ });
+
+ it('should find deeply nested nodes by id', () => {
+ expect(navigator.findById('111')).toBe(grandchild1);
+ });
+
+ it('should return null for non-existent id', () => {
+ expect(navigator.findById('999')).toBeNull();
+ });
+ });
+
+ describe('getParent', () => {
+ it('should return null for root nodes', () => {
+ expect(navigator.getParent(root1)).toBeNull();
+ expect(navigator.getParent(root2)).toBeNull();
+ });
+
+ it('should return parent for child nodes', () => {
+ expect(navigator.getParent(child1)).toBe(root1);
+ expect(navigator.getParent(child2)).toBe(root1);
+ });
+
+ it('should return parent for deeply nested nodes', () => {
+ expect(navigator.getParent(grandchild1)).toBe(child1);
+ });
+ });
+
+ describe('getFirstChild', () => {
+ it('should return first child for parent nodes', () => {
+ expect(navigator.getFirstChild(root1)).toBe(child1);
+ expect(navigator.getFirstChild(child1)).toBe(grandchild1);
+ });
+
+ it('should return null for leaf nodes', () => {
+ expect(navigator.getFirstChild(root2)).toBeNull();
+ expect(navigator.getFirstChild(child2)).toBeNull();
+ expect(navigator.getFirstChild(grandchild1)).toBeNull();
+ });
+ });
+
+ describe('getNextSibling', () => {
+ it('should return next sibling', () => {
+ expect(navigator.getNextSibling(child1)).toBe(child2);
+ });
+
+ it('should return null for last sibling', () => {
+ expect(navigator.getNextSibling(child2)).toBeNull();
+ });
+
+ it('should return next root sibling', () => {
+ expect(navigator.getNextSibling(root1)).toBe(root2);
+ });
+
+ it('should return null for last root sibling', () => {
+ expect(navigator.getNextSibling(root2)).toBeNull();
+ });
+
+ it('should return null for only child', () => {
+ expect(navigator.getNextSibling(grandchild1)).toBeNull();
+ });
+ });
+
+ describe('getPrevSibling', () => {
+ it('should return previous sibling', () => {
+ expect(navigator.getPrevSibling(child2)).toBe(child1);
+ });
+
+ it('should return null for first sibling', () => {
+ expect(navigator.getPrevSibling(child1)).toBeNull();
+ });
+
+ it('should return previous root sibling', () => {
+ expect(navigator.getPrevSibling(root2)).toBe(root1);
+ });
+
+ it('should return null for first root sibling', () => {
+ expect(navigator.getPrevSibling(root1)).toBeNull();
+ });
+
+ it('should return null for only child', () => {
+ expect(navigator.getPrevSibling(grandchild1)).toBeNull();
+ });
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty tree', () => {
+ const rootNodes: TreeNode[] = [];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(navigator.findById('1')).toBeNull();
+ });
+
+ it('should handle single node tree', () => {
+ const singleNode = createNode(createEvent('1'));
+ const rootNodes = [singleNode];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(navigator.findById('1')).toBe(singleNode);
+ expect(navigator.getParent(singleNode)).toBeNull();
+ expect(navigator.getFirstChild(singleNode)).toBeNull();
+ expect(navigator.getNextSibling(singleNode)).toBeNull();
+ expect(navigator.getPrevSibling(singleNode)).toBeNull();
+ });
+
+ it('should handle nodes with empty children array', () => {
+ const nodeWithEmptyChildren = createNode(createEvent('1'), []);
+ const rootNodes = [nodeWithEmptyChildren];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(navigator.getFirstChild(nodeWithEmptyChildren)).toBeNull();
+ });
+
+ it('should handle deep nesting', () => {
+ // Create a deep chain: 1 -> 2 -> 3 -> 4 -> 5
+ const level5 = createNode(createEvent('5'), undefined, 4);
+ const level4 = createNode(createEvent('4'), [level5], 3);
+ const level3 = createNode(createEvent('3'), [level4], 2);
+ const level2 = createNode(createEvent('2'), [level3], 1);
+ const level1 = createNode(createEvent('1'), [level2], 0);
+
+ const rootNodes = [level1];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // Navigate down
+ expect(navigator.getFirstChild(level1)).toBe(level2);
+ expect(navigator.getFirstChild(level2)).toBe(level3);
+ expect(navigator.getFirstChild(level3)).toBe(level4);
+ expect(navigator.getFirstChild(level4)).toBe(level5);
+ expect(navigator.getFirstChild(level5)).toBeNull();
+
+ // Navigate up
+ expect(navigator.getParent(level5)).toBe(level4);
+ expect(navigator.getParent(level4)).toBe(level3);
+ expect(navigator.getParent(level3)).toBe(level2);
+ expect(navigator.getParent(level2)).toBe(level1);
+ expect(navigator.getParent(level1)).toBeNull();
+ });
+
+ it('should handle multiple siblings at same level', () => {
+ const sibling1 = createNode(createEvent('1'));
+ const sibling2 = createNode(createEvent('2'));
+ const sibling3 = createNode(createEvent('3'));
+ const sibling4 = createNode(createEvent('4'));
+
+ const rootNodes = [sibling1, sibling2, sibling3, sibling4];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // Forward traversal
+ expect(navigator.getNextSibling(sibling1)).toBe(sibling2);
+ expect(navigator.getNextSibling(sibling2)).toBe(sibling3);
+ expect(navigator.getNextSibling(sibling3)).toBe(sibling4);
+ expect(navigator.getNextSibling(sibling4)).toBeNull();
+
+ // Backward traversal
+ expect(navigator.getPrevSibling(sibling4)).toBe(sibling3);
+ expect(navigator.getPrevSibling(sibling3)).toBe(sibling2);
+ expect(navigator.getPrevSibling(sibling2)).toBe(sibling1);
+ expect(navigator.getPrevSibling(sibling1)).toBeNull();
+ });
+ });
+
+ describe('complex tree scenarios', () => {
+ it('should handle asymmetric tree', () => {
+ // Tree structure:
+ // a
+ // βββ b
+ // β βββ d
+ // β β βββ g
+ // β βββ e
+ // βββ c
+ // βββ f
+
+ const g = createNode(createEvent('g'), undefined, 3);
+ const d = createNode(createEvent('d'), [g], 2);
+ const e = createNode(createEvent('e'), undefined, 2);
+ const f = createNode(createEvent('f'), undefined, 2);
+ const b = createNode(createEvent('b'), [d, e], 1);
+ const c = createNode(createEvent('c'), [f], 1);
+ const a = createNode(createEvent('a'), [b, c], 0);
+
+ const rootNodes = [a];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // Siblings at different levels
+ expect(navigator.getNextSibling(d)).toBe(e);
+ expect(navigator.getPrevSibling(e)).toBe(d);
+ expect(navigator.getNextSibling(e)).toBeNull();
+
+ // f is only child
+ expect(navigator.getNextSibling(f)).toBeNull();
+ expect(navigator.getPrevSibling(f)).toBeNull();
+
+ // Cross-branch - siblings should only be in same parent
+ expect(navigator.getNextSibling(b)).toBe(c);
+ expect(navigator.getPrevSibling(c)).toBe(b);
+ });
+ });
+
+ describe('cross-parent navigation (getNextAtDepth/getPrevAtDepth)', () => {
+ /**
+ * Helper to create an event with specific timestamp and duration
+ */
+ function createTimedEvent(id: string, timestamp: number, duration: number): EventNode {
+ return {
+ id,
+ timestamp,
+ duration,
+ type: 'METHOD_ENTRY',
+ text: `Event ${id}`,
+ };
+ }
+
+ it('should navigate to next frame at same depth across parents', () => {
+ // Timeline visualization:
+ // Depth 0: [βββββββββAβββββββββ] [βββββββBβββββββ]
+ // Depth 1: [ββCββ][ββDββ] [ββEββ][ββFββ]
+ //
+ // At D, getNextAtDepth should return E (cross-parent)
+
+ const c = createNode(createTimedEvent('c', 0, 100), undefined, 1);
+ const d = createNode(createTimedEvent('d', 100, 100), undefined, 1);
+ const e = createNode(createTimedEvent('e', 300, 100), undefined, 1);
+ const f = createNode(createTimedEvent('f', 400, 100), undefined, 1);
+ const a = createNode(createTimedEvent('a', 0, 200), [c, d], 0);
+ const b = createNode(createTimedEvent('b', 300, 200), [e, f], 0);
+
+ const rootNodes = [a, b];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // D's next sibling is null (last sibling in parent A)
+ expect(navigator.getNextSibling(d)).toBeNull();
+
+ // But getNextAtDepth should find E (first child of B, same depth)
+ expect(navigator.getNextAtDepth(d)).toBe(e);
+
+ // E's next at depth should be F
+ expect(navigator.getNextAtDepth(e)).toBe(f);
+
+ // F is last at this depth
+ expect(navigator.getNextAtDepth(f)).toBeNull();
+ });
+
+ it('should navigate to prev frame at same depth across parents', () => {
+ // Same structure as above
+ const c = createNode(createTimedEvent('c', 0, 100), undefined, 1);
+ const d = createNode(createTimedEvent('d', 100, 100), undefined, 1);
+ const e = createNode(createTimedEvent('e', 300, 100), undefined, 1);
+ const f = createNode(createTimedEvent('f', 400, 100), undefined, 1);
+ const a = createNode(createTimedEvent('a', 0, 200), [c, d], 0);
+ const b = createNode(createTimedEvent('b', 300, 200), [e, f], 0);
+
+ const rootNodes = [a, b];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // E's prev sibling is null (first sibling in parent B)
+ expect(navigator.getPrevSibling(e)).toBeNull();
+
+ // But getPrevAtDepth should find D (last child of A, same depth)
+ expect(navigator.getPrevAtDepth(e)).toBe(d);
+
+ // D's prev at depth should be C
+ expect(navigator.getPrevAtDepth(d)).toBe(c);
+
+ // C is first at this depth
+ expect(navigator.getPrevAtDepth(c)).toBeNull();
+ });
+
+ it('should return null when already at first/last of depth', () => {
+ const a = createNode(createTimedEvent('a', 0, 100), undefined, 0);
+ const b = createNode(createTimedEvent('b', 200, 100), undefined, 0);
+
+ const rootNodes = [a, b];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(navigator.getPrevAtDepth(a)).toBeNull();
+ expect(navigator.getNextAtDepth(b)).toBeNull();
+ });
+
+ it('should handle gaps between frames correctly', () => {
+ // Timeline with gaps:
+ // Depth 0: [ββAββ] [ββBββ] [ββCββ]
+ // 0-100 300-400 600-700
+
+ const a = createNode(createTimedEvent('a', 0, 100), undefined, 0);
+ const b = createNode(createTimedEvent('b', 300, 100), undefined, 0);
+ const c = createNode(createTimedEvent('c', 600, 100), undefined, 0);
+
+ const rootNodes = [a, b, c];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(navigator.getNextAtDepth(a)).toBe(b);
+ expect(navigator.getNextAtDepth(b)).toBe(c);
+ expect(navigator.getPrevAtDepth(c)).toBe(b);
+ expect(navigator.getPrevAtDepth(b)).toBe(a);
+ });
+
+ it('should handle multiple depths independently', () => {
+ // Timeline:
+ // Depth 0: [βββββββββββββROOTβββββββββββββ]
+ // Depth 1: [ββAββ] [ββBββ]
+ // Depth 2: [βXβ] [βYβ]
+
+ const x = createNode(createTimedEvent('x', 0, 50), undefined, 2);
+ const y = createNode(createTimedEvent('y', 300, 50), undefined, 2);
+ const a = createNode(createTimedEvent('a', 0, 100), [x], 1);
+ const b = createNode(createTimedEvent('b', 300, 100), [y], 1);
+ const root = createNode(createTimedEvent('root', 0, 500), [a, b], 0);
+
+ const rootNodes = [root];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // At depth 1, A -> B
+ expect(navigator.getNextAtDepth(a)).toBe(b);
+ expect(navigator.getPrevAtDepth(b)).toBe(a);
+
+ // At depth 2, X -> Y (cross-parent)
+ expect(navigator.getNextAtDepth(x)).toBe(y);
+ expect(navigator.getPrevAtDepth(y)).toBe(x);
+ });
+
+ it('should not skip overlapping frames at same depth', () => {
+ // Overlapping scenario (shouldn't happen in practice but test the algorithm)
+ // Depth 0: [ββAββ]
+ // [ββBββ] (overlaps with A)
+ // [ββCββ]
+
+ const a = createNode(createTimedEvent('a', 0, 100), undefined, 0);
+ const b = createNode(createTimedEvent('b', 50, 100), undefined, 0); // overlaps with A
+ const c = createNode(createTimedEvent('c', 150, 100), undefined, 0);
+
+ const rootNodes = [a, b, c];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ // From A (ends at 100), next should be C (starts at 150), not B (starts at 50 which is < 100)
+ // Actually B starts at 50 which is before A ends, but B ends at 150
+ // The algorithm finds first node that STARTS after current ENDS
+ // A ends at 100, so we look for nodes starting > 100
+ // B starts at 50, C starts at 150
+ // So getNextAtDepth(a) should return c
+ expect(navigator.getNextAtDepth(a)).toBe(c);
+ });
+
+ it('should handle single node at depth', () => {
+ const single = createNode(createTimedEvent('single', 0, 100), undefined, 0);
+
+ const rootNodes = [single];
+ const navigator = new TreeNavigator(rootNodes, buildMapsFromNodes(rootNodes));
+
+ expect(navigator.getNextAtDepth(single)).toBeNull();
+ expect(navigator.getPrevAtDepth(single)).toBeNull();
+ });
+ });
+});
diff --git a/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts b/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts
new file mode 100644
index 00000000..7777f62b
--- /dev/null
+++ b/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts
@@ -0,0 +1,598 @@
+/**
+ * @jest-environment jsdom
+ */
+
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * Unit tests for KeyboardHandler
+ *
+ * Tests keyboard input handling for flame chart viewport controls:
+ * - Pan via Arrow keys and A/D keys
+ * - Zoom via W/S and +/-/= keys
+ * - Reset zoom via Home / 0 keys
+ * - Escape key for cancel/deselect
+ */
+
+import {
+ KEYBOARD_CONSTANTS,
+ KeyboardHandler,
+ type KeyboardCallbacks,
+} from '../optimised/interaction/KeyboardHandler.js';
+import { TimelineViewport } from '../optimised/TimelineViewport.js';
+
+describe('KeyboardHandler', () => {
+ const DISPLAY_WIDTH = 1000;
+ const DISPLAY_HEIGHT = 600;
+ const TOTAL_DURATION = 1_000_000;
+ const MAX_DEPTH = 10;
+
+ let container: HTMLElement;
+ let viewport: TimelineViewport;
+ let handler: KeyboardHandler;
+ let callbacks: Required;
+
+ beforeEach(() => {
+ // Create a mock container element
+ container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // Create viewport
+ viewport = new TimelineViewport(DISPLAY_WIDTH, DISPLAY_HEIGHT, TOTAL_DURATION, MAX_DEPTH);
+
+ // Create mock callbacks
+ callbacks = {
+ onPan: jest.fn(),
+ onZoom: jest.fn(),
+ onResetZoom: jest.fn(),
+ onEscape: jest.fn(),
+ onMarkerNav: jest.fn(),
+ onFrameNav: jest.fn(),
+ onJumpToCallTree: jest.fn(),
+ onFocus: jest.fn(),
+ onCopy: jest.fn(),
+ };
+
+ handler = new KeyboardHandler(container, viewport, callbacks);
+ handler.attach();
+ });
+
+ afterEach(() => {
+ handler.destroy();
+ document.body.removeChild(container);
+ jest.clearAllMocks();
+ });
+
+ /**
+ * Helper to dispatch a keyboard event
+ */
+ function dispatchKeyEvent(
+ type: 'keydown' | 'keyup',
+ key: string,
+ options: Partial = {},
+ ): KeyboardEvent {
+ const event = new KeyboardEvent(type, {
+ key,
+ bubbles: true,
+ cancelable: true,
+ ...options,
+ });
+ container.dispatchEvent(event);
+ return event;
+ }
+
+ describe('pan keys (Arrow keys and A/D)', () => {
+ it('should pan left on ArrowLeft', () => {
+ dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX, deltaY] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBeLessThan(0); // Pan left = negative deltaX
+ expect(deltaY).toBe(0);
+ });
+
+ it('should pan right on ArrowRight', () => {
+ dispatchKeyEvent('keydown', 'ArrowRight');
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX, deltaY] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBeGreaterThan(0); // Pan right = positive deltaX
+ expect(deltaY).toBe(0);
+ });
+
+ it('should pan up on ArrowUp', () => {
+ dispatchKeyEvent('keydown', 'ArrowUp');
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX, deltaY] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBe(0);
+ expect(deltaY).toBeLessThan(0); // Pan up = negative deltaY
+ });
+
+ it('should pan down on ArrowDown', () => {
+ dispatchKeyEvent('keydown', 'ArrowDown');
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX, deltaY] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBe(0);
+ expect(deltaY).toBeGreaterThan(0); // Pan down = positive deltaY
+ });
+
+ it('should pan left on A key', () => {
+ dispatchKeyEvent('keydown', 'a');
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX, deltaY] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBeLessThan(0);
+ expect(deltaY).toBe(0);
+ });
+
+ it('should pan right on D key', () => {
+ dispatchKeyEvent('keydown', 'd');
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX, deltaY] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBeGreaterThan(0);
+ expect(deltaY).toBe(0);
+ });
+
+ it('should pan with Shift + Arrow keys (always pan even when frame selected)', () => {
+ dispatchKeyEvent('keydown', 'ArrowLeft', { shiftKey: true });
+
+ expect(callbacks.onPan).toHaveBeenCalledTimes(1);
+ const [deltaX] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBeLessThan(0);
+ });
+
+ it('should pan by correct percentage of viewport', () => {
+ dispatchKeyEvent('keydown', 'ArrowRight');
+
+ const expectedStepX = DISPLAY_WIDTH * KEYBOARD_CONSTANTS.panStepPercent;
+ const [deltaX] = (callbacks.onPan as jest.Mock).mock.calls[0];
+ expect(deltaX).toBeCloseTo(expectedStepX, 5);
+ });
+
+ it('should prevent default on handled pan keys', () => {
+ const event = dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('zoom keys (W / S / + / - / =)', () => {
+ it('should zoom in on W key', () => {
+ dispatchKeyEvent('keydown', 'w');
+
+ expect(callbacks.onZoom).toHaveBeenCalledWith('in');
+ });
+
+ it('should zoom out on S key', () => {
+ dispatchKeyEvent('keydown', 's');
+
+ expect(callbacks.onZoom).toHaveBeenCalledWith('out');
+ });
+
+ it('should zoom in on + key', () => {
+ dispatchKeyEvent('keydown', '+');
+
+ expect(callbacks.onZoom).toHaveBeenCalledWith('in');
+ });
+
+ it('should zoom in on = key', () => {
+ dispatchKeyEvent('keydown', '=');
+
+ expect(callbacks.onZoom).toHaveBeenCalledWith('in');
+ });
+
+ it('should zoom out on - key', () => {
+ dispatchKeyEvent('keydown', '-');
+
+ expect(callbacks.onZoom).toHaveBeenCalledWith('out');
+ });
+
+ it('should zoom with + and - even when Shift is pressed', () => {
+ // Note: Shift+w triggers vertical pan (not zoom) per design
+ dispatchKeyEvent('keydown', '+', { shiftKey: true });
+ dispatchKeyEvent('keydown', '-', { shiftKey: true });
+
+ expect(callbacks.onZoom).toHaveBeenCalledTimes(2);
+ expect(callbacks.onZoom).toHaveBeenCalledWith('in');
+ expect(callbacks.onZoom).toHaveBeenCalledWith('out');
+ });
+
+ it('should pan vertically with Shift+w/s instead of zoom', () => {
+ dispatchKeyEvent('keydown', 'w', { shiftKey: true });
+ dispatchKeyEvent('keydown', 's', { shiftKey: true });
+
+ expect(callbacks.onZoom).not.toHaveBeenCalled();
+ expect(callbacks.onPan).toHaveBeenCalledTimes(2);
+ });
+
+ it('should prevent default on handled zoom keys', () => {
+ const event = dispatchKeyEvent('keydown', 'w');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('reset keys (Home / 0)', () => {
+ it('should reset zoom on Home key', () => {
+ dispatchKeyEvent('keydown', 'Home');
+
+ expect(callbacks.onResetZoom).toHaveBeenCalled();
+ });
+
+ it('should reset zoom on 0 key', () => {
+ dispatchKeyEvent('keydown', '0');
+
+ expect(callbacks.onResetZoom).toHaveBeenCalled();
+ });
+
+ it('should reset when Shift is pressed (Shift does not block)', () => {
+ dispatchKeyEvent('keydown', 'Home', { shiftKey: true });
+
+ expect(callbacks.onResetZoom).toHaveBeenCalled();
+ });
+
+ it('should not reset when Ctrl/Alt/Meta is pressed', () => {
+ dispatchKeyEvent('keydown', '0', { ctrlKey: true });
+ dispatchKeyEvent('keydown', 'Home', { altKey: true });
+ dispatchKeyEvent('keydown', '0', { metaKey: true });
+
+ expect(callbacks.onResetZoom).not.toHaveBeenCalled();
+ });
+
+ it('should prevent default on handled reset keys', () => {
+ const event = dispatchKeyEvent('keydown', 'Home');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('escape key', () => {
+ it('should call onEscape callback on Escape key', () => {
+ dispatchKeyEvent('keydown', 'Escape');
+
+ expect(callbacks.onEscape).toHaveBeenCalled();
+ });
+
+ it('should prevent default on Escape', () => {
+ const event = dispatchKeyEvent('keydown', 'Escape');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('frame navigation (onFrameNav)', () => {
+ it('should call onFrameNav with "up" on ArrowUp when handler returns true', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowUp');
+
+ expect(callbacks.onFrameNav).toHaveBeenCalledWith('up');
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should call onFrameNav with "down" on ArrowDown when handler returns true', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowDown');
+
+ expect(callbacks.onFrameNav).toHaveBeenCalledWith('down');
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should call onFrameNav with "left" on ArrowLeft when handler returns true', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(callbacks.onFrameNav).toHaveBeenCalledWith('left');
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should call onFrameNav with "right" on ArrowRight when handler returns true', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowRight');
+
+ expect(callbacks.onFrameNav).toHaveBeenCalledWith('right');
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should fall through to pan when onFrameNav returns false', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(false);
+ dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(callbacks.onFrameNav).toHaveBeenCalledWith('left');
+ expect(callbacks.onPan).toHaveBeenCalled();
+ });
+
+ it('should skip onFrameNav and pan directly when Shift is held', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowLeft', { shiftKey: true });
+
+ expect(callbacks.onFrameNav).not.toHaveBeenCalled();
+ expect(callbacks.onPan).toHaveBeenCalled();
+ });
+
+ it('should not call onFrameNav for A/D keys', () => {
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'a');
+ dispatchKeyEvent('keydown', 'd');
+
+ expect(callbacks.onFrameNav).not.toHaveBeenCalled();
+ expect(callbacks.onPan).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('marker navigation (onMarkerNav)', () => {
+ it('should call onMarkerNav with "left" on ArrowLeft when handler returns true', () => {
+ (callbacks.onMarkerNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(callbacks.onMarkerNav).toHaveBeenCalledWith('left');
+ expect(callbacks.onFrameNav).not.toHaveBeenCalled();
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should call onMarkerNav with "right" on ArrowRight when handler returns true', () => {
+ (callbacks.onMarkerNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowRight');
+
+ expect(callbacks.onMarkerNav).toHaveBeenCalledWith('right');
+ expect(callbacks.onFrameNav).not.toHaveBeenCalled();
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should fall through to frame nav when onMarkerNav returns false', () => {
+ (callbacks.onMarkerNav as jest.Mock).mockReturnValue(false);
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(callbacks.onMarkerNav).toHaveBeenCalledWith('left');
+ expect(callbacks.onFrameNav).toHaveBeenCalledWith('left');
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ });
+
+ it('should fall through to pan when both marker and frame nav return false', () => {
+ (callbacks.onMarkerNav as jest.Mock).mockReturnValue(false);
+ (callbacks.onFrameNav as jest.Mock).mockReturnValue(false);
+ dispatchKeyEvent('keydown', 'ArrowLeft');
+
+ expect(callbacks.onMarkerNav).toHaveBeenCalled();
+ expect(callbacks.onFrameNav).toHaveBeenCalled();
+ expect(callbacks.onPan).toHaveBeenCalled();
+ });
+
+ it('should skip marker nav and go directly to pan when Shift is held', () => {
+ (callbacks.onMarkerNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowLeft', { shiftKey: true });
+
+ expect(callbacks.onMarkerNav).not.toHaveBeenCalled();
+ expect(callbacks.onFrameNav).not.toHaveBeenCalled();
+ expect(callbacks.onPan).toHaveBeenCalled();
+ });
+
+ it('should not call onMarkerNav for ArrowUp/ArrowDown', () => {
+ (callbacks.onMarkerNav as jest.Mock).mockReturnValue(true);
+ dispatchKeyEvent('keydown', 'ArrowUp');
+ dispatchKeyEvent('keydown', 'ArrowDown');
+
+ expect(callbacks.onMarkerNav).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('attach/detach', () => {
+ it('should handle events when attached', () => {
+ dispatchKeyEvent('keydown', 'w');
+
+ expect(callbacks.onZoom).toHaveBeenCalled();
+ });
+
+ it('should not handle events after detach', () => {
+ handler.detach();
+ dispatchKeyEvent('keydown', 'w');
+
+ expect(callbacks.onZoom).not.toHaveBeenCalled();
+ });
+
+ it('should handle events after re-attach', () => {
+ handler.detach();
+ handler.attach();
+ dispatchKeyEvent('keydown', 'w');
+
+ expect(callbacks.onZoom).toHaveBeenCalled();
+ });
+
+ it('should not attach twice', () => {
+ handler.attach(); // Already attached in beforeEach
+ dispatchKeyEvent('keydown', 'w');
+
+ // Should still only fire once
+ expect(callbacks.onZoom).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle multiple detach calls gracefully', () => {
+ handler.detach();
+ handler.detach();
+
+ // Should not throw
+ expect(true).toBe(true);
+ });
+ });
+
+ describe('destroy', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should detach event listeners on destroy', () => {
+ handler.destroy();
+ dispatchKeyEvent('keydown', 'w');
+
+ expect(callbacks.onZoom).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('jump to call tree (J key)', () => {
+ it('should call onJumpToCallTree on J key', () => {
+ dispatchKeyEvent('keydown', 'j');
+
+ expect(callbacks.onJumpToCallTree).toHaveBeenCalled();
+ });
+
+ it('should call onJumpToCallTree on uppercase J key', () => {
+ dispatchKeyEvent('keydown', 'J');
+
+ expect(callbacks.onJumpToCallTree).toHaveBeenCalled();
+ });
+
+ it('should not call onJumpToCallTree when Ctrl is pressed', () => {
+ dispatchKeyEvent('keydown', 'j', { ctrlKey: true });
+
+ expect(callbacks.onJumpToCallTree).not.toHaveBeenCalled();
+ });
+
+ it('should not call onJumpToCallTree when Alt is pressed', () => {
+ dispatchKeyEvent('keydown', 'j', { altKey: true });
+
+ expect(callbacks.onJumpToCallTree).not.toHaveBeenCalled();
+ });
+
+ it('should not call onJumpToCallTree when Meta is pressed', () => {
+ dispatchKeyEvent('keydown', 'j', { metaKey: true });
+
+ expect(callbacks.onJumpToCallTree).not.toHaveBeenCalled();
+ });
+
+ it('should prevent default on J key', () => {
+ const event = dispatchKeyEvent('keydown', 'j');
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('focus keys (Enter / Z)', () => {
+ it('should call onFocus on Enter key', () => {
+ dispatchKeyEvent('keydown', 'Enter');
+
+ expect(callbacks.onFocus).toHaveBeenCalled();
+ });
+
+ it('should call onFocus on z key', () => {
+ dispatchKeyEvent('keydown', 'z');
+
+ expect(callbacks.onFocus).toHaveBeenCalled();
+ });
+
+ it('should call onFocus on Z key', () => {
+ dispatchKeyEvent('keydown', 'Z');
+
+ expect(callbacks.onFocus).toHaveBeenCalled();
+ });
+
+ it('should not call onFocus when Ctrl is pressed', () => {
+ dispatchKeyEvent('keydown', 'Enter', { ctrlKey: true });
+ dispatchKeyEvent('keydown', 'z', { ctrlKey: true });
+
+ expect(callbacks.onFocus).not.toHaveBeenCalled();
+ });
+
+ it('should not call onFocus when Alt is pressed', () => {
+ dispatchKeyEvent('keydown', 'Enter', { altKey: true });
+ dispatchKeyEvent('keydown', 'z', { altKey: true });
+
+ expect(callbacks.onFocus).not.toHaveBeenCalled();
+ });
+
+ it('should not call onFocus when Meta is pressed', () => {
+ dispatchKeyEvent('keydown', 'Enter', { metaKey: true });
+ dispatchKeyEvent('keydown', 'z', { metaKey: true });
+
+ expect(callbacks.onFocus).not.toHaveBeenCalled();
+ });
+
+ it('should prevent default on Enter/Z keys', () => {
+ const enterEvent = dispatchKeyEvent('keydown', 'Enter');
+ const zEvent = dispatchKeyEvent('keydown', 'z');
+
+ expect(enterEvent.defaultPrevented).toBe(true);
+ expect(zEvent.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('Copy (Ctrl/Cmd+C)', () => {
+ it('should call onCopy on Ctrl+C', () => {
+ dispatchKeyEvent('keydown', 'c', { ctrlKey: true });
+
+ expect(callbacks.onCopy).toHaveBeenCalled();
+ });
+
+ it('should call onCopy on Cmd+C (Mac)', () => {
+ dispatchKeyEvent('keydown', 'c', { metaKey: true });
+
+ expect(callbacks.onCopy).toHaveBeenCalled();
+ });
+
+ it('should call onCopy on uppercase C', () => {
+ dispatchKeyEvent('keydown', 'C', { ctrlKey: true });
+
+ expect(callbacks.onCopy).toHaveBeenCalled();
+ });
+
+ it('should not call onCopy without modifier', () => {
+ dispatchKeyEvent('keydown', 'c');
+
+ expect(callbacks.onCopy).not.toHaveBeenCalled();
+ });
+
+ it('should not call onCopy with Alt modifier', () => {
+ dispatchKeyEvent('keydown', 'c', { ctrlKey: true, altKey: true });
+
+ expect(callbacks.onCopy).not.toHaveBeenCalled();
+ });
+
+ it('should prevent default on Ctrl/Cmd+C', () => {
+ const event = dispatchKeyEvent('keydown', 'c', { ctrlKey: true });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe('unhandled keys', () => {
+ it('should not prevent default on unhandled keys', () => {
+ const event = dispatchKeyEvent('keydown', 'x');
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('should not call callbacks on unhandled keys', () => {
+ dispatchKeyEvent('keydown', 'x');
+ dispatchKeyEvent('keydown', 'Tab');
+
+ expect(callbacks.onPan).not.toHaveBeenCalled();
+ expect(callbacks.onZoom).not.toHaveBeenCalled();
+ expect(callbacks.onResetZoom).not.toHaveBeenCalled();
+ expect(callbacks.onEscape).not.toHaveBeenCalled();
+ expect(callbacks.onFocus).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('optional callbacks', () => {
+ it('should work without callbacks', () => {
+ const handlerWithoutCallbacks = new KeyboardHandler(container, viewport);
+ handlerWithoutCallbacks.attach();
+
+ // Should not throw
+ dispatchKeyEvent('keydown', 'w');
+ dispatchKeyEvent('keydown', 'a');
+ dispatchKeyEvent('keydown', 'Home');
+ dispatchKeyEvent('keydown', 'Escape');
+
+ handlerWithoutCallbacks.destroy();
+ });
+ });
+});
diff --git a/log-viewer/src/features/timeline/__tests__/markers.test.ts b/log-viewer/src/features/timeline/__tests__/markers.test.ts
index 45d7f5be..e9def0d2 100644
--- a/log-viewer/src/features/timeline/__tests__/markers.test.ts
+++ b/log-viewer/src/features/timeline/__tests__/markers.test.ts
@@ -127,7 +127,7 @@ describe('TimelineMarkerRenderer', () => {
describe('T013: Color Accuracy Verification', () => {
it('should render error markers with pre-blended color via sprite tint', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 100_000, summary: 'Test error' },
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test error' },
];
renderer = new TimelineMarkerRenderer(
@@ -146,7 +146,7 @@ describe('TimelineMarkerRenderer', () => {
it('should render skip markers with pre-blended color via sprite tint', () => {
const markers: TimelineMarker[] = [
- { type: 'skip', startTime: 100_000, summary: 'Test skip' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'Test skip' },
];
renderer = new TimelineMarkerRenderer(
@@ -165,7 +165,12 @@ describe('TimelineMarkerRenderer', () => {
it('should render unexpected markers with pre-blended color via sprite tint', () => {
const markers: TimelineMarker[] = [
- { type: 'unexpected', startTime: 100_000, summary: 'Test unexpected' },
+ {
+ id: 'marker-unexpected',
+ type: 'unexpected',
+ startTime: 100_000,
+ summary: 'Test unexpected',
+ },
];
renderer = new TimelineMarkerRenderer(
@@ -186,8 +191,8 @@ describe('TimelineMarkerRenderer', () => {
describe('T008: End Time Resolution Algorithm', () => {
it('should resolve endTime to next marker startTime when null', () => {
const markers: TimelineMarker[] = [
- { type: 'skip', startTime: 100_000, summary: 'First' },
- { type: 'error', startTime: 500_000, summary: 'Second' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'First' },
+ { id: 'marker-error', type: 'error', startTime: 500_000, summary: 'Second' },
];
renderer = new TimelineMarkerRenderer(
@@ -209,7 +214,7 @@ describe('TimelineMarkerRenderer', () => {
it('should resolve endTime to timeline end for last marker', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 500_000, summary: 'Only marker' },
+ { id: 'marker-error', type: 'error', startTime: 500_000, summary: 'Only marker' },
];
renderer = new TimelineMarkerRenderer(
@@ -227,9 +232,9 @@ describe('TimelineMarkerRenderer', () => {
it('should handle multiple markers in sequence', () => {
const markers: TimelineMarker[] = [
- { type: 'skip', startTime: 100_000, summary: 'First' },
- { type: 'unexpected', startTime: 300_000, summary: 'Second' },
- { type: 'error', startTime: 600_000, summary: 'Third' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'First' },
+ { id: 'marker-unexpected', type: 'unexpected', startTime: 300_000, summary: 'Second' },
+ { id: 'marker-error', type: 'error', startTime: 600_000, summary: 'Third' },
];
renderer = new TimelineMarkerRenderer(
@@ -249,7 +254,7 @@ describe('TimelineMarkerRenderer', () => {
describe('T009: Viewport Culling Behavior', () => {
it('should render only markers within viewport time range', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 100_000, summary: 'In viewport' },
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'In viewport' },
];
renderer = new TimelineMarkerRenderer(
@@ -268,8 +273,8 @@ describe('TimelineMarkerRenderer', () => {
it('should cull markers entirely before viewport', () => {
// Markers that end before the viewport starts should be culled
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 100_000, summary: 'First marker' },
- { type: 'skip', startTime: 200_000, summary: 'Second marker' },
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'First marker' },
+ { id: 'marker-skip', type: 'skip', startTime: 200_000, summary: 'Second marker' },
];
// Zoom in 10x first (so we can actually pan)
@@ -310,7 +315,7 @@ describe('TimelineMarkerRenderer', () => {
zoomedViewport.setZoom(0.1, 0);
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 900_000, summary: 'After viewport' },
+ { id: 'marker-error', type: 'error', startTime: 900_000, summary: 'After viewport' },
];
renderer = new TimelineMarkerRenderer(
@@ -330,8 +335,8 @@ describe('TimelineMarkerRenderer', () => {
it('should render partially visible markers', () => {
const markers: TimelineMarker[] = [
- { type: 'skip', startTime: 100_000, summary: 'Extends into viewport' },
- { type: 'error', startTime: 900_000, summary: 'Starts before end' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'Extends into viewport' },
+ { id: 'marker-error', type: 'error', startTime: 900_000, summary: 'Starts before end' },
];
renderer = new TimelineMarkerRenderer(
@@ -350,8 +355,8 @@ describe('TimelineMarkerRenderer', () => {
it('should not render markers with width < 1px', () => {
// Create viewport with very small zoom so markers appear very narrow
const markers: TimelineMarker[] = [
- { type: 'skip', startTime: 100_000, summary: 'First' },
- { type: 'error', startTime: 100_001, summary: 'Second (1ns later)' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'First' },
+ { id: 'marker-error', type: 'error', startTime: 100_001, summary: 'Second (1ns later)' },
];
renderer = new TimelineMarkerRenderer(
@@ -385,9 +390,9 @@ describe('TimelineMarkerRenderer', () => {
it('should sort markers by startTime on construction', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 300_000, summary: 'Third' },
- { type: 'skip', startTime: 100_000, summary: 'First' },
- { type: 'unexpected', startTime: 200_000, summary: 'Second' },
+ { id: 'marker-error', type: 'error', startTime: 300_000, summary: 'Third' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'First' },
+ { id: 'marker-unexpected', type: 'unexpected', startTime: 200_000, summary: 'Second' },
];
renderer = new TimelineMarkerRenderer(
@@ -417,7 +422,9 @@ describe('TimelineMarkerRenderer', () => {
describe('T014: Hit Testing', () => {
it('should return null when no indicators are hit', () => {
- const markers: TimelineMarker[] = [{ type: 'error', startTime: 100_000, summary: 'Test' }];
+ const markers: TimelineMarker[] = [
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test' },
+ ];
renderer = new TimelineMarkerRenderer(
mockContainer as unknown as PIXI.Container,
@@ -433,7 +440,7 @@ describe('TimelineMarkerRenderer', () => {
it('should return marker when hit', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 100_000, summary: 'Test error' },
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test error' },
];
renderer = new TimelineMarkerRenderer(
@@ -459,8 +466,8 @@ describe('TimelineMarkerRenderer', () => {
// Error: 200_000 to timeline end
// At time 250_000, both markers overlap
const markers: TimelineMarker[] = [
- { type: 'skip', startTime: 100_000, summary: 'Skip marker' },
- { type: 'error', startTime: 200_000, summary: 'Error marker' },
+ { id: 'marker-skip', type: 'skip', startTime: 100_000, summary: 'Skip marker' },
+ { id: 'marker-error', type: 'error', startTime: 200_000, summary: 'Error marker' },
];
renderer = new TimelineMarkerRenderer(
@@ -493,7 +500,7 @@ describe('TimelineMarkerRenderer', () => {
it('should work correctly with panned viewport', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 500_000, summary: 'Test error' },
+ { id: 'marker-error', type: 'error', startTime: 500_000, summary: 'Test error' },
];
// Pan viewport to show the marker area
@@ -517,7 +524,7 @@ describe('TimelineMarkerRenderer', () => {
it('should ignore Y coordinate for full-height indicators', () => {
const markers: TimelineMarker[] = [
- { type: 'error', startTime: 100_000, summary: 'Test error' },
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test error' },
];
renderer = new TimelineMarkerRenderer(
@@ -546,7 +553,7 @@ describe('TimelineMarkerRenderer', () => {
describe('updateMarkers', () => {
it('should update markers array', () => {
const initialMarkers: TimelineMarker[] = [
- { type: 'error', startTime: 100_000, summary: 'Initial' },
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Initial' },
];
renderer = new TimelineMarkerRenderer(
@@ -561,8 +568,8 @@ describe('TimelineMarkerRenderer', () => {
expect(visibleSprites.length).toBe(1);
const newMarkers: TimelineMarker[] = [
- { type: 'skip', startTime: 50_000, summary: 'New first' },
- { type: 'error', startTime: 200_000, summary: 'New second' },
+ { id: 'marker-skip', type: 'skip', startTime: 50_000, summary: 'New first' },
+ { id: 'marker-error', type: 'error', startTime: 200_000, summary: 'New second' },
];
renderer.updateMarkers(newMarkers);
@@ -582,7 +589,9 @@ describe('TimelineMarkerRenderer', () => {
describe('destroy', () => {
it('should clean up sprite pool', () => {
- const markers: TimelineMarker[] = [{ type: 'error', startTime: 100_000, summary: 'Test' }];
+ const markers: TimelineMarker[] = [
+ { id: 'marker-error', type: 'error', startTime: 100_000, summary: 'Test' },
+ ];
renderer = new TimelineMarkerRenderer(
mockContainer as unknown as PIXI.Container,
diff --git a/log-viewer/src/features/timeline/__tests__/viewport.test.ts b/log-viewer/src/features/timeline/__tests__/viewport.test.ts
index 5710968f..e8140886 100644
--- a/log-viewer/src/features/timeline/__tests__/viewport.test.ts
+++ b/log-viewer/src/features/timeline/__tests__/viewport.test.ts
@@ -395,4 +395,85 @@ describe('TimelineViewport', () => {
expect(bounds.depthEnd).toBeGreaterThanOrEqual(0);
});
});
+
+ describe('focusOnEvent', () => {
+ it('should zoom to fit event with 10% padding', () => {
+ const eventTimestamp = 200_000; // 0.2ms
+ const eventDuration = 100_000; // 0.1ms
+ const eventDepth = 3;
+
+ viewport.focusOnEvent(eventTimestamp, eventDuration, eventDepth);
+
+ const state = viewport.getState();
+
+ // Expected zoom: displayWidth / (duration * 1.2)
+ // 10% padding on each side means total width is 1.2x the event duration
+ const expectedZoom = DISPLAY_WIDTH / (eventDuration * 1.2);
+ expect(state.zoom).toBeCloseTo(expectedZoom, 5);
+ });
+
+ it('should center horizontally on event', () => {
+ const eventTimestamp = 200_000;
+ const eventDuration = 100_000;
+ const eventDepth = 3;
+
+ viewport.focusOnEvent(eventTimestamp, eventDuration, eventDepth);
+
+ const state = viewport.getState();
+
+ // Event center in pixels = (timestamp + duration/2) * zoom
+ const eventCenterX = (eventTimestamp + eventDuration / 2) * state.zoom;
+
+ // Viewport center should be near event center
+ const viewportCenterX = state.offsetX + DISPLAY_WIDTH / 2;
+ expect(viewportCenterX).toBeCloseTo(eventCenterX, 0);
+ });
+
+ it('should center vertically on event depth', () => {
+ const eventTimestamp = 200_000;
+ const eventDuration = 100_000;
+ const eventDepth = 5;
+
+ viewport.focusOnEvent(eventTimestamp, eventDuration, eventDepth);
+
+ // After focus, the event depth should be centered in the viewport
+ const bounds = viewport.getBounds();
+ const visibleDepths = bounds.depthEnd - bounds.depthStart;
+
+ // The event depth should be roughly in the middle of visible range
+ const expectedCenter = eventDepth;
+ const actualCenter = (bounds.depthStart + bounds.depthEnd) / 2;
+
+ // Allow some margin because of rounding and clamping
+ expect(Math.abs(actualCenter - expectedCenter)).toBeLessThan(visibleDepths / 2 + 1);
+ });
+
+ it('should clamp zoom to maximum when event is very small', () => {
+ const eventTimestamp = 200_000;
+ const eventDuration = 10; // Very small duration (10 nanoseconds)
+ const eventDepth = 3;
+
+ viewport.focusOnEvent(eventTimestamp, eventDuration, eventDepth);
+
+ const state = viewport.getState();
+ const maxZoom = DISPLAY_WIDTH / TIMELINE_CONSTANTS.MAX_ZOOM_NS;
+
+ // Zoom should be clamped to maximum
+ expect(state.zoom).toBeCloseTo(maxZoom, 5);
+ });
+
+ it('should clamp zoom to minimum when event spans entire timeline', () => {
+ const eventTimestamp = 0;
+ const eventDuration = TOTAL_DURATION * 2; // Event larger than timeline
+ const eventDepth = 3;
+
+ viewport.focusOnEvent(eventTimestamp, eventDuration, eventDepth);
+
+ const state = viewport.getState();
+ const minZoom = DISPLAY_WIDTH / TOTAL_DURATION;
+
+ // Zoom should be clamped to minimum
+ expect(state.zoom).toBeCloseTo(minZoom, 5);
+ });
+ });
});
diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
index 0cc7a8f8..bfa5665e 100644
--- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
+++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
@@ -17,19 +17,24 @@
* LogEvent should only be referenced here in ApexLogTimeline to convert to generic EventNode for FlameChart and not in FlameChart or its dependencies.
*/
+import { ContextMenu, type ContextMenuItem } from '../../../components/ContextMenu.js';
import type { ApexLog, LogEvent } from '../../../core/log-parser/LogEvents.js';
+import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js';
+import { formatDuration } from '../../../core/utility/Util.js';
import { goToRow } from '../../call-tree/components/CalltreeView.js';
import { getTheme } from '../themes/ThemeSelector.js';
import type {
EventNode,
FindEventDetail,
FindResultsEventDetail,
+ ModifierKeys,
TimelineMarker,
TimelineOptions,
ViewportState,
} from '../types/flamechart.types.js';
import type { SearchCursor } from '../types/search.types.js';
import { extractMarkers } from '../utils/marker-utils.js';
+import { logEventToTreeNode } from '../utils/tree-converter.js';
import { FlameChart } from './FlameChart.js';
import { TimelineTooltipManager } from './TimelineTooltipManager.js';
@@ -40,11 +45,14 @@ interface ApexTimelineOptions extends TimelineOptions {
export class ApexLogTimeline {
private flamechart: FlameChart;
private tooltipManager: TimelineTooltipManager | null = null;
+ private contextMenu: ContextMenu | null = null;
private apexLog: ApexLog | null = null;
private options: TimelineOptions = {};
private container: HTMLElement | null = null;
private events: LogEvent[] = [];
private searchCursor: SearchCursor | null = null;
+ private selectedEventForContextMenu: EventNode | null = null;
+ private selectedMarkerForContextMenu: TimelineMarker | null = null;
constructor() {
this.flamechart = new FlameChart();
@@ -76,18 +84,25 @@ export class ApexLogTimeline {
const markers = extractMarkers(this.apexLog);
this.events = this.extractEvents();
+ // Convert LogEvent to TreeNode structure for search and navigation
+ // This is Apex-specific: filters out 0-duration events that are invisible
+ // Also builds navigation maps during traversal to avoid duplicate O(n) work
+ const { treeNodes, maps } = logEventToTreeNode(this.events);
+
// Initialize FlameChart with Apex-specific callbacks
await this.flamechart.init(
container,
this.events,
+ treeNodes,
+ maps,
markers,
{ ...options, enableSearch: true }, // Enable search via options
{
onMouseMove: (screenX, screenY, event, marker) => {
this.handleMouseMove(screenX, screenY, event, marker);
},
- onClick: (screenX, screenY, event, marker) => {
- this.handleClick(screenX, screenY, event, marker);
+ onClick: (screenX, screenY, event, marker, modifiers) => {
+ this.handleClick(screenX, screenY, event, marker, modifiers);
},
onViewportChange: (viewport: ViewportState) => {
if (options.onViewportChange) {
@@ -97,9 +112,49 @@ export class ApexLogTimeline {
onSearchNavigate: (event, screenX, screenY, depth) => {
this.handleSearchNavigate(event, screenX, screenY, depth);
},
+ onFrameNavigate: (event, screenX, screenY, _depth) => {
+ this.handleFrameNavigate(event, screenX, screenY);
+ },
+ onMarkerNavigate: (marker, screenX, screenY) => {
+ this.handleMarkerNavigate(marker, screenX, screenY);
+ },
+ onSelect: (eventNode) => {
+ this.handleSelect(eventNode);
+ },
+ onMarkerSelect: (marker) => {
+ this.handleMarkerSelect(marker);
+ },
+ onJumpToCallTree: (eventNode) => {
+ this.handleJumpToCallTree(eventNode);
+ },
+ onJumpToCallTreeForMarker: (marker) => {
+ this.handleJumpToCallTreeForMarker(marker);
+ },
+ onContextMenu: (target, screenX, screenY, clientX, clientY) => {
+ this.handleContextMenu(target, screenX, screenY, clientX, clientY);
+ },
+ onCopy: (eventNode) => {
+ this.copyToClipboard(eventNode.text);
+ },
+ onCopyMarker: (marker) => {
+ this.copyToClipboard(marker.summary);
+ },
},
);
+ // Create context menu Lit element (using constructor ensures custom element is registered)
+ this.contextMenu = new ContextMenu();
+ container.appendChild(this.contextMenu);
+
+ // Listen for context menu events
+ this.contextMenu.addEventListener('menu-select', ((e: CustomEvent) => {
+ this.handleContextMenuSelect(e.detail.itemId);
+ }) as EventListener);
+ this.contextMenu.addEventListener('menu-close', () => {
+ this.selectedEventForContextMenu = null;
+ this.selectedMarkerForContextMenu = null;
+ });
+
// Wire up search event listeners
this.enableSearch();
}
@@ -118,6 +173,10 @@ export class ApexLogTimeline {
this.tooltipManager.destroy();
this.tooltipManager = null;
}
+ if (this.contextMenu) {
+ this.contextMenu.remove();
+ this.contextMenu = null;
+ }
}
/**
@@ -190,6 +249,11 @@ export class ApexLogTimeline {
return;
}
+ // Don't update tooltip while context menu is open
+ if (this.contextMenu?.isVisible()) {
+ return;
+ }
+
// Priority: Events take precedence over truncation markers
if (event) {
this.tooltipManager.show(event, screenX, screenY);
@@ -211,24 +275,453 @@ export class ApexLogTimeline {
}
/**
- * Handle click - navigate to Apex log event or marker.
+ * Handle click - select frame or marker (but don't navigate).
+ * Click on frame/marker selects it only. Use J key to navigate to call tree.
+ * Cmd/Ctrl+Click on frame navigates directly to call tree.
*/
private handleClick(
- screenX: number,
- screenY: number,
+ _screenX: number,
+ _screenY: number,
event: LogEvent | null,
marker: TimelineMarker | null,
+ modifiers?: ModifierKeys,
): void {
- // Navigate to truncation marker if clicked
- if (marker) {
+ // Cmd/Ctrl+Click on a frame navigates directly to call tree
+ // Note: Only works on individual frames, not buckets (buckets are aggregated)
+ if (event && (modifiers?.metaKey || modifiers?.ctrlKey)) {
+ goToRow(event.timestamp);
+ return;
+ }
+
+ // Cmd/Ctrl+Click on a marker navigates directly to call tree
+ if (marker && (modifiers?.metaKey || modifiers?.ctrlKey)) {
goToRow(marker.startTime);
return;
}
- // Navigate to event if clicked
- if (event) {
- goToRow(event.timestamp);
+ // Frame and marker clicks are handled by FlameChart's selection system
+ // (via onSelect and onMarkerSelect callbacks)
+ // No longer auto-navigate to call tree on click - use J key for explicit navigation
+ }
+
+ /**
+ * Handle selection change from FlameChart.
+ * Selection only updates visual state, does not navigate call tree.
+ * Use J key for explicit "jump to call tree" action.
+ */
+ private handleSelect(eventNode: EventNode | null): void {
+ if (!eventNode) {
+ // Selection cleared - hide tooltip
+ if (this.tooltipManager) {
+ this.tooltipManager.hide();
+ }
+ return;
+ }
+
+ // Selection only - no auto-navigation to call tree
+ // User can press J to explicitly jump to call tree
+ }
+
+ /**
+ * Handle J key "Jump to Call Tree" action.
+ * Navigates call tree to the selected frame.
+ */
+ private handleJumpToCallTree(eventNode: EventNode): void {
+ goToRow(eventNode.timestamp);
+ }
+
+ /**
+ * Handle J key "Jump to Call Tree" action for markers.
+ * Navigates call tree to the marker's start time.
+ */
+ private handleJumpToCallTreeForMarker(marker: TimelineMarker): void {
+ goToRow(marker.startTime);
+ }
+
+ /**
+ * Handle marker selection change from FlameChart.
+ */
+ private handleMarkerSelect(marker: TimelineMarker | null): void {
+ if (!marker) {
+ // Marker selection cleared - hide tooltip
+ if (this.tooltipManager) {
+ this.tooltipManager.hide();
+ }
+ return;
+ }
+
+ // Marker selection only - no auto-navigation to call tree
+ // User can press J to explicitly jump to call tree
+ }
+
+ /**
+ * Handle keyboard navigation to a frame.
+ * Shows tooltip for the navigated-to frame.
+ */
+ private handleFrameNavigate(event: EventNode, screenX: number, screenY: number): void {
+ if (!this.tooltipManager) {
+ return;
+ }
+
+ const eventWithOriginal = event as EventNode & { original?: LogEvent };
+ const logEvent = eventWithOriginal.original;
+ if (logEvent) {
+ this.tooltipManager.show(logEvent, screenX, screenY);
+ }
+ }
+
+ /**
+ * Handle keyboard navigation to a marker.
+ * Shows tooltip for the navigated-to marker.
+ */
+ private handleMarkerNavigate(marker: TimelineMarker, screenX: number, screenY: number): void {
+ if (!this.tooltipManager) {
+ return;
+ }
+ this.tooltipManager.showTruncation(marker, screenX, screenY);
+ }
+
+ /**
+ * Type guard to check if target is a TimelineMarker.
+ */
+ private isTimelineMarker(target: EventNode | TimelineMarker): target is TimelineMarker {
+ // TimelineMarker has 'type' as 'error' | 'skip' | 'unexpected'
+ // EventNode has 'type' as a string like 'METHOD_ENTRY', etc.
+ // TimelineMarker has 'summary', EventNode has 'text'
+ return 'summary' in target && 'startTime' in target && !('duration' in target);
+ }
+
+ /**
+ * Handle right-click context menu request.
+ *
+ * @param target - The event node or marker that was right-clicked, or null for empty space
+ * @param screenX - Canvas-relative X coordinate (for tooltip positioning, same as hover)
+ * @param screenY - Canvas-relative Y coordinate (for tooltip positioning, same as hover)
+ * @param clientX - Window X coordinate (for context menu positioning)
+ * @param clientY - Window Y coordinate (for context menu positioning)
+ */
+ private handleContextMenu(
+ target: EventNode | TimelineMarker | null,
+ screenX: number,
+ screenY: number,
+ clientX: number,
+ clientY: number,
+ ): void {
+ if (!this.contextMenu) {
+ return;
+ }
+
+ if (!target) {
+ // Empty space context menu
+ this.showEmptySpaceContextMenu(clientX, clientY);
+ return;
+ }
+
+ if (this.isTimelineMarker(target)) {
+ // Marker context menu
+ this.showMarkerContextMenu(target, screenX, screenY, clientX, clientY);
+ } else {
+ // Frame context menu
+ this.showFrameContextMenu(target, screenX, screenY, clientX, clientY);
+ }
+ }
+
+ /**
+ * Show context menu for a frame (event node).
+ */
+ private showFrameContextMenu(
+ eventNode: EventNode,
+ screenX: number,
+ screenY: number,
+ clientX: number,
+ clientY: number,
+ ): void {
+ if (!this.contextMenu) {
+ return;
+ }
+
+ // Store selected event for menu actions
+ this.selectedEventForContextMenu = eventNode;
+
+ // Show tooltip for the right-clicked frame using screen coords (same as hover)
+ const eventWithOriginal = eventNode as EventNode & { original?: LogEvent };
+ const logEvent = eventWithOriginal.original;
+ if (this.tooltipManager && logEvent) {
+ this.tooltipManager.show(logEvent, screenX, screenY, { keepPosition: true });
+ }
+
+ // Build menu items
+ const items: ContextMenuItem[] = [
+ { id: 'show-in-call-tree', label: 'Show in Call Tree', shortcut: 'J' },
+ ];
+
+ // Add "Go to Source" only when hasValidSymbols is true
+ if (logEvent?.hasValidSymbols) {
+ items.push({ id: 'go-to-source', label: 'Go to Source' });
}
+
+ items.push(
+ { id: 'zoom-to-frame', label: 'Zoom to Frame', shortcut: 'Z' },
+ { id: 'separator-1', label: '', separator: true },
+ { id: 'copy-name', label: 'Copy Name', shortcut: this.getCopyShortcut() },
+ { id: 'copy-details', label: 'Copy Details' },
+ { id: 'copy-call-stack', label: 'Copy Call Stack' },
+ );
+
+ // Use client coords for context menu (positioned in viewport)
+ this.contextMenu.show(items, clientX, clientY);
+ }
+
+ /**
+ * Show context menu for a marker.
+ */
+ private showMarkerContextMenu(
+ marker: TimelineMarker,
+ screenX: number,
+ screenY: number,
+ clientX: number,
+ clientY: number,
+ ): void {
+ if (!this.contextMenu) {
+ return;
+ }
+
+ // Store selected marker for menu actions
+ this.selectedMarkerForContextMenu = marker;
+ this.selectedEventForContextMenu = null;
+
+ // Show tooltip for the right-clicked marker using screen coords
+ if (this.tooltipManager) {
+ this.tooltipManager.showTruncation(marker, screenX, screenY);
+ }
+
+ // Build menu items for markers
+ const items: ContextMenuItem[] = [
+ { id: 'show-in-call-tree', label: 'Show in Call Tree', shortcut: 'J' },
+ { id: 'zoom-to-marker', label: 'Zoom to Marker', shortcut: 'Z' },
+ { id: 'separator-1', label: '', separator: true },
+ { id: 'copy-summary', label: 'Copy Summary', shortcut: this.getCopyShortcut() },
+ { id: 'copy-marker-details', label: 'Copy Details' },
+ ];
+
+ // Use client coords for context menu (positioned in viewport)
+ this.contextMenu.show(items, clientX, clientY);
+ }
+
+ /**
+ * Show context menu for empty space (viewport actions).
+ */
+ private showEmptySpaceContextMenu(clientX: number, clientY: number): void {
+ if (!this.contextMenu) {
+ return;
+ }
+
+ // Clear any stored references
+ this.selectedEventForContextMenu = null;
+ this.selectedMarkerForContextMenu = null;
+
+ // Hide tooltip since we're not over a frame or marker
+ if (this.tooltipManager) {
+ this.tooltipManager.hide();
+ }
+
+ // Build menu items for empty space
+ const items: ContextMenuItem[] = [{ id: 'reset-zoom', label: 'Reset Zoom', shortcut: '0' }];
+
+ // Use client coords for context menu (positioned in viewport)
+ this.contextMenu.show(items, clientX, clientY);
+ }
+
+ /**
+ * Handle context menu item selection.
+ */
+ private handleContextMenuSelect(itemId: string): void {
+ // Handle viewport-level actions (don't require a selected event or marker)
+ if (itemId === 'reset-zoom') {
+ this.flamechart.resetZoom();
+ return;
+ }
+
+ // Handle marker-level actions (require a selected marker)
+ const marker = this.selectedMarkerForContextMenu;
+ if (marker) {
+ switch (itemId) {
+ case 'show-in-call-tree':
+ this.handleJumpToCallTreeForMarker(marker);
+ break;
+ case 'zoom-to-marker':
+ this.flamechart.focusOnSelectedMarker();
+ break;
+ case 'copy-summary':
+ this.copyToClipboard(marker.summary);
+ break;
+ case 'copy-marker-details':
+ this.copyToClipboard(this.formatMarkerDetails(marker));
+ break;
+ }
+ return;
+ }
+
+ // Handle frame-level actions (require a selected event)
+ const event = this.selectedEventForContextMenu;
+ if (!event) {
+ return;
+ }
+
+ switch (itemId) {
+ case 'show-in-call-tree':
+ this.handleJumpToCallTree(event);
+ break;
+ case 'go-to-source':
+ this.handleGoToSource(event);
+ break;
+ case 'zoom-to-frame':
+ this.flamechart.focusOnSelectedFrame();
+ break;
+ case 'copy-name':
+ this.copyToClipboard(event.text);
+ break;
+ case 'copy-details':
+ this.copyToClipboard(this.formatEventDetails(event));
+ break;
+ case 'copy-call-stack':
+ this.copyToClipboard(this.formatCallStack(event));
+ break;
+ }
+ }
+
+ /**
+ * Handle "Go to Source Code" action.
+ * Opens the source file in VS Code for methods with valid symbols.
+ */
+ private handleGoToSource(eventNode: EventNode): void {
+ const eventWithOriginal = eventNode as EventNode & { original?: LogEvent };
+ const logEvent = eventWithOriginal.original;
+ if (logEvent?.hasValidSymbols) {
+ vscodeMessenger.send('openType', logEvent.text);
+ }
+ }
+
+ /**
+ * Copy text to clipboard.
+ */
+ private copyToClipboard(text: string): void {
+ navigator.clipboard.writeText(text).catch(() => {
+ // Silently fail - clipboard API may not be available in all contexts
+ });
+ }
+
+ /**
+ * Format event details for clipboard (similar to tooltip content).
+ */
+ private formatEventDetails(eventNode: EventNode): string {
+ // Access original LogEvent for full details
+ const logEvent = (eventNode as EventNode & { original?: LogEvent }).original;
+ if (!logEvent) {
+ // Fallback for nodes without original
+ return `Name: ${eventNode.text}\nType: ${eventNode.type}`;
+ }
+
+ const lines: string[] = [];
+ lines.push(`Name: ${logEvent.text}${logEvent.suffix ?? ''}`);
+
+ if (logEvent.type) {
+ lines.push(`Type: ${logEvent.type}`);
+ }
+
+ if (logEvent.exitStamp && logEvent.duration.total) {
+ let durationStr = formatDuration(logEvent.duration.total);
+ if (logEvent.cpuType === 'free') {
+ durationStr += ' (free)';
+ } else if (logEvent.duration.self) {
+ durationStr += ` (self ${formatDuration(logEvent.duration.self)})`;
+ }
+ lines.push(`Duration: ${durationStr}`);
+ }
+
+ // Add metrics (only if non-zero)
+ const govLimits = this.apexLog?.governorLimits;
+
+ if (logEvent.dmlCount.total) {
+ lines.push(`DML: ${this.formatLimit(logEvent.dmlCount, govLimits?.dmlStatements.limit)}`);
+ }
+ if (logEvent.dmlRowCount.total) {
+ lines.push(`DML Rows: ${this.formatLimit(logEvent.dmlRowCount, govLimits?.dmlRows.limit)}`);
+ }
+ if (logEvent.soqlCount.total) {
+ lines.push(`SOQL: ${this.formatLimit(logEvent.soqlCount, govLimits?.soqlQueries.limit)}`);
+ }
+ if (logEvent.soqlRowCount.total) {
+ lines.push(
+ `SOQL Rows: ${this.formatLimit(logEvent.soqlRowCount, govLimits?.queryRows.limit)}`,
+ );
+ }
+ if (logEvent.soslCount.total) {
+ lines.push(`SOSL: ${this.formatLimit(logEvent.soslCount, govLimits?.soslQueries.limit)}`);
+ }
+ if (logEvent.soslRowCount.total) {
+ lines.push(
+ `SOSL Rows: ${this.formatLimit(logEvent.soslRowCount, govLimits?.soslQueries.limit)}`,
+ );
+ }
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Format a metric with limit for clipboard.
+ */
+ private formatLimit(metric: { total: number; self: number }, limit?: number): string {
+ const outOf = limit ? `/${limit}` : '';
+ return `${metric.total}${outOf} (self ${metric.self})`;
+ }
+
+ /**
+ * Format call stack for clipboard.
+ * Builds the parent chain from root to the selected event.
+ */
+ private formatCallStack(eventNode: EventNode): string {
+ const logEvent = (eventNode as EventNode & { original?: LogEvent }).original;
+ if (!logEvent) {
+ return eventNode.text;
+ }
+
+ // Build call stack by traversing up parent chain
+ const stack: LogEvent[] = [];
+ let current: LogEvent | null = logEvent;
+ while (current?.type) {
+ stack.unshift(current); // Prepend to get root-first order
+ current = current.parent;
+ }
+
+ // Format as call stack (one entry per line)
+ return stack.map((event) => event.text + (event.suffix ?? '')).join('\n');
+ }
+
+ /**
+ * Format marker details for clipboard.
+ * Includes summary, type, and optional metadata.
+ */
+ private formatMarkerDetails(marker: TimelineMarker): string {
+ const lines: string[] = [];
+
+ lines.push(`Summary: ${marker.summary}`);
+ lines.push(`Type: ${marker.type}`);
+
+ if (marker.metadata) {
+ lines.push(`Details: ${marker.metadata}`);
+ }
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Get platform-specific copy shortcut.
+ */
+ private getCopyShortcut(): string {
+ // Use userAgent as fallback since navigator.platform is deprecated
+ const isMac = /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent);
+ return isMac ? '\u2318C' : 'Ctrl+C';
}
/**
diff --git a/log-viewer/src/features/timeline/optimised/CLAUDE.md b/log-viewer/src/features/timeline/optimised/CLAUDE.md
new file mode 100644
index 00000000..44816830
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/CLAUDE.md
@@ -0,0 +1,200 @@
+# Timeline Optimised Module
+
+This module implements the high-performance flame chart visualization for Apex debug logs.
+
+## Architecture Overview
+
+The module follows a **pure orchestrator** pattern where FlameChart is a generic coordinator that delegates all feature-specific logic to dedicated classes.
+
+## Class Responsibilities
+
+### FlameChart (Orchestrator)
+
+- **Pure orchestrator** - delegates all logic to feature classes
+- Wires up renderers, handlers, managers
+- Coordinates render loop
+- **NO business logic** - only delegation and composition
+
+### ApexLogTimeline (Apex Adapter)
+
+- Apex-specific translation layer
+- Converts ApexLog to generic EventNode data
+- Handles themes, tooltips, markers
+- Delegates rendering to FlameChart
+
+### SelectionManager
+
+- Owns selection state (`selectedNode`)
+- Tree navigation logic (up/down/left/right)
+- Selection lifecycle (select, clear, navigate)
+- Maps hit test results to tree nodes
+
+### SearchManager
+
+- Owns search state and cursor
+- Tree traversal with predicates
+- Match collection and navigation
+
+### Renderers (\*Renderer classes)
+
+- Pure rendering, no business logic
+- Receive data, output visuals
+- Examples: `SelectionHighlightRenderer`, `SearchHighlightRenderer`, `TextLabelRenderer`
+
+### Handlers (\*Handler classes)
+
+- Input processing only
+- Invoke callbacks, don't manage state
+- Examples: `KeyboardHandler`, `TimelineInteractionHandler`
+
+### TimelineViewport
+
+- Owns viewport state (zoom, pan, bounds)
+- All coordinate transformations and calculations
+- Screen-to-world and world-to-screen conversions
+- Tooltip positioning calculations (e.g., `calculateVisibleCenterX`)
+- FlameChart delegates coordinate math here, never implements it inline
+
+## Key Design Patterns
+
+### Computed State Over Tracked State
+
+Prefer computed methods over manual state tracking:
+
+```typescript
+// GOOD: Computed from actual state
+private getHighlightMode(): 'none' | 'search' | 'selection' {
+ if (this.selectionManager?.hasSelection()) return 'selection';
+ if (this.searchManager?.getCursor()?.total > 0) return 'search';
+ return 'none';
+}
+
+// AVOID: Manual tracking that can get out of sync
+private highlightMode: 'none' | 'search' | 'selection' = 'none';
+```
+
+### Callback Consolidation
+
+Extract repeated callback patterns into helpers:
+
+```typescript
+// Helper method
+private notifyViewportChange(): void {
+ this.requestRender();
+ if (this.callbacks.onViewportChange && this.viewport) {
+ this.callbacks.onViewportChange(this.viewport.getState());
+ }
+}
+```
+
+## Adding New Features
+
+When adding a new feature:
+
+1. **Create a dedicated Manager class** (e.g., `NewFeatureManager`)
+ - Own the feature's state
+ - Encapsulate the feature's logic
+ - Expose a clean API
+
+2. **Add to FlameChart as a member**
+ - Initialize in `init()`
+ - Clean up in `destroy()`
+
+3. **FlameChart delegates, doesn't implement**
+ - FlameChart calls manager/viewport methods
+ - FlameChart doesn't contain feature logic or calculations
+
+ ```typescript
+ // GOOD: Delegate to TimelineViewport
+ const screenX = this.viewport.calculateVisibleCenterX(timestamp, duration);
+
+ // AVOID: Inline calculations in FlameChart
+ const frameCenterX = timestamp * zoom - offsetX;
+ const screenX = Math.max(50, Math.min(width - 50, frameCenterX));
+ ```
+
+## File Structure
+
+```
+optimised/
+βββ FlameChart.ts # Main orchestrator
+βββ CLAUDE.md # This documentation
+βββ interaction/
+β βββ KeyboardHandler.ts # Keyboard input processing
+β βββ TimelineInteractionHandler.ts # Mouse/touch input
+β βββ HitTestManager.ts # Hit testing for mouse events
+βββ selection/
+β βββ SelectionManager.ts # Selection state and navigation
+β βββ SelectionHighlightRenderer.ts # Selection visuals
+β βββ TreeNavigator.ts # Tree traversal (internal)
+βββ search/
+β βββ SearchManager.ts # Search state and matching
+β βββ SearchHighlightRenderer.ts # Search visuals
+β βββ ...
+βββ rendering/
+β βββ HighlightRenderer.ts # Shared highlight rendering
+β βββ ...
+βββ ...
+```
+
+## Testing
+
+Each manager class should have its own test file:
+
+- `SelectionManager.test.ts` - Selection logic tests
+- `TreeNavigator.test.ts` - Tree navigation tests
+- `SearchManager.test.ts` - Search logic tests
+
+Tests should focus on the manager's API, not internal implementation.
+
+## Performance Requirements
+
+**This module handles 50MB+ logs with 500k+ lines. Performance is non-negotiable.**
+
+### Critical Performance Guidelines
+
+1. **Avoid allocations in render loop**
+ - Pre-allocate arrays and objects
+ - Reuse geometry buffers
+ - Never create closures in hot paths
+
+2. **Use spatial data structures**
+ - TemporalSegmentTree for time-based queries
+ - Pre-computed rectangle maps for O(1) lookup
+ - Viewport culling before any iteration
+
+3. **Batch operations**
+ - Group draw calls by category/color
+ - Use instanced rendering where possible
+ - Minimize state changes
+
+4. **Profile before optimizing**
+ - Use Chrome DevTools Performance tab
+ - Measure frame time, not just "feels fast"
+ - Target 60fps (16.6ms frame budget)
+
+5. **Cache computed values**
+ - Viewport transforms
+ - Color conversions
+ - Text measurements
+
+## Naming Conventions
+
+### Class Suffixes
+
+- `*Renderer` - Classes that produce visuals (TextLabelRenderer, SearchHighlightRenderer)
+- `*Handler` - Classes that process input (KeyboardHandler, TimelineInteractionHandler)
+- `*Cache` / `*Index` - Classes for data lookup and pre-computation
+- `*Detector` - Classes that detect or compute values from input
+
+### Avoid
+
+- Generic `*Manager` suffix - too vague, prefer specific role names
+- God classes with mixed responsibilities
+- Tiny classes that could be functions
+
+### Class Size Guidelines
+
+- Target 100-300 lines per class
+- If > 400 lines, consider splitting by concern
+- If < 50 lines, consider if it should be a function or merged
diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts
index f9ff5ea7..a20179ea 100644
--- a/log-viewer/src/features/timeline/optimised/FlameChart.ts
+++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts
@@ -14,6 +14,7 @@ import * as PIXI from 'pixi.js';
import type { LogEvent } from '../../../core/log-parser/LogEvents.js';
import type {
EventNode,
+ ModifierKeys,
TimelineMarker,
TimelineOptions,
TimelineState,
@@ -22,6 +23,7 @@ import type {
} from '../types/flamechart.types.js';
import { TIMELINE_CONSTANTS, TimelineError, TimelineErrorCode } from '../types/flamechart.types.js';
import type { SearchCursor, SearchMatch, SearchOptions } from '../types/search.types.js';
+import type { NavigationMaps } from '../utils/tree-converter.js';
import { MeshMarkerRenderer } from './markers/MeshMarkerRenderer.js';
import { MeshRectangleRenderer } from './MeshRectangleRenderer.js';
@@ -36,17 +38,25 @@ import { SearchTextLabelRenderer } from './search/SearchTextLabelRenderer.js';
import { TextLabelRenderer } from './TextLabelRenderer.js';
import { AxisRenderer } from './time-axis/AxisRenderer.js';
-import { logEventToTreeNode } from '../utils/tree-converter.js';
import { cssColorToPixi } from './BucketColorResolver.js';
import { HitTestManager } from './interaction/HitTestManager.js';
+import {
+ KEYBOARD_CONSTANTS,
+ KeyboardHandler,
+ type FrameNavDirection,
+ type MarkerNavDirection,
+} from './interaction/KeyboardHandler.js';
import { TimelineInteractionHandler } from './interaction/TimelineInteractionHandler.js';
import { TimelineResizeHandler } from './interaction/TimelineResizeHandler.js';
import type { PrecomputedRect } from './RectangleManager.js';
import { RectangleManager } from './RectangleManager.js';
import { FlameChartCursor } from './search/FlameChartCursor.js';
import { SearchManager } from './search/SearchManager.js';
+import { SelectionHighlightRenderer } from './selection/SelectionHighlightRenderer.js';
+import { SelectionManager } from './selection/SelectionManager.js';
import { TimelineEventIndex } from './TimelineEventIndex.js';
import { TimelineViewport } from './TimelineViewport.js';
+import { ViewportAnimator } from './ViewportAnimator.js';
export interface FlameChartCallbacks {
onMouseMove?: (
@@ -60,9 +70,38 @@ export interface FlameChartCallbacks {
screenY: number,
event: LogEvent | null,
marker: TimelineMarker | null,
+ modifiers?: ModifierKeys,
) => void;
onViewportChange?: (viewport: ViewportState) => void;
onSearchNavigate?: (event: EventNode, screenX: number, screenY: number, depth: number) => void;
+ /** Called when keyboard navigates to a frame (includes screen coords for tooltip). */
+ onFrameNavigate?: (event: EventNode, screenX: number, screenY: number, depth: number) => void;
+ /** Called when keyboard navigates to a marker (includes screen coords for tooltip). */
+ onMarkerNavigate?: (marker: TimelineMarker, screenX: number, screenY: number) => void;
+ /** Called when frame selection changes (click to select, arrow keys to navigate). */
+ onSelect?: (event: EventNode | null) => void;
+ /** Called when marker selection changes (click to select, arrow keys to navigate). */
+ onMarkerSelect?: (marker: TimelineMarker | null) => void;
+ /** Called when J key is pressed to jump to call tree for selected frame or marker. */
+ onJumpToCallTree?: (event: EventNode) => void;
+ /** Called when J key is pressed to jump to call tree for selected marker. */
+ onJumpToCallTreeForMarker?: (marker: TimelineMarker) => void;
+ /**
+ * Called when right-click occurs on the timeline.
+ * Passes screen coords for tooltip and client coords for menu positioning.
+ * target is the clicked item: EventNode, TimelineMarker, or null for empty space.
+ */
+ onContextMenu?: (
+ target: EventNode | TimelineMarker | null,
+ screenX: number,
+ screenY: number,
+ clientX: number,
+ clientY: number,
+ ) => void;
+ /** Called when Ctrl/Cmd+C is pressed to copy selected frame or marker. */
+ onCopy?: (event: EventNode) => void;
+ /** Called when Ctrl/Cmd+C is pressed to copy selected marker. */
+ onCopyMarker?: (marker: TimelineMarker) => void;
}
export class FlameChart {
@@ -95,17 +134,33 @@ export class FlameChart {
private uiContainer: PIXI.Container | null = null;
private renderLoopId: number | null = null;
private interactionHandler: TimelineInteractionHandler | null = null;
+ private keyboardHandler: KeyboardHandler | null = null;
private readonly markers: TimelineMarker[] = [];
private hitTestManager: HitTestManager | null = null;
+ // Selection system
+ private selectionManager: SelectionManager | null = null;
+ private selectionRenderer: SelectionHighlightRenderer | null = null;
+ private viewportAnimator: ViewportAnimator | null = null;
+
/**
* Initialize the flamechart renderer.
+ *
+ * @param container - HTML element to render into
+ * @param events - Array of LogEvent objects for rendering
+ * @param treeNodes - Pre-converted TreeNode structure for navigation/search (from logEventToTreeNode)
+ * @param maps - Pre-built navigation maps from tree conversion
+ * @param markers - Timeline markers (truncation regions, etc.)
+ * @param options - Rendering options
+ * @param callbacks - Event callbacks
*/
public async init(
container: HTMLElement,
events: LogEvent[],
+ treeNodes: TreeNode[],
+ maps: NavigationMaps,
markers: TimelineMarker[] = [],
options: TimelineOptions = {},
callbacks: FlameChartCallbacks = {},
@@ -166,8 +221,18 @@ export class FlameChart {
// Initialize state
this.initializeState(events);
- // Convert LogEvent to TreeNode structure for generic search
- this.treeNodes = logEventToTreeNode(events) as unknown as TreeNode[];
+ // Store pre-converted TreeNode structure for search and navigation
+ this.treeNodes = treeNodes;
+
+ // Initialize selection manager for frame selection and traversal
+ // Pass pre-built maps to avoid duplicate O(n) traversal
+ this.selectionManager = new SelectionManager(this.treeNodes, maps);
+
+ // Set markers in selection manager for marker navigation
+ this.selectionManager.setMarkers(this.markers);
+
+ // Initialize viewport animator for smooth transitions
+ this.viewportAnimator = new ViewportAnimator();
// Determine renderer type: mesh is default for testing
const useMeshRenderer = options.renderer !== 'sprite';
@@ -275,9 +340,23 @@ export class FlameChart {
markerRenderer: this.markerRenderer,
});
+ // Create selection highlight renderer
+ if (this.worldContainer) {
+ this.selectionRenderer = new SelectionHighlightRenderer(this.worldContainer);
+ // Set marker context for marker selection highlighting
+ this.selectionRenderer.setMarkerContext(
+ this.markers,
+ this.index.totalDuration,
+ this.index.maxDepth,
+ );
+ }
+
// Setup interaction handler
this.setupInteractionHandler();
+ // Setup keyboard handler
+ this.setupKeyboardHandler();
+
this.resizeHandler = new TimelineResizeHandler(container, this);
this.resizeHandler.setupResizeObserver();
@@ -306,6 +385,25 @@ export class FlameChart {
this.interactionHandler = null;
}
+ // Clean up keyboard handler
+ if (this.keyboardHandler) {
+ this.keyboardHandler.destroy();
+ this.keyboardHandler = null;
+ }
+
+ // Clean up selection components
+ if (this.selectionRenderer) {
+ this.selectionRenderer.destroy();
+ this.selectionRenderer = null;
+ }
+ this.selectionManager = null;
+
+ // Clean up viewport animator
+ if (this.viewportAnimator) {
+ this.viewportAnimator.cancel();
+ this.viewportAnimator = null;
+ }
+
// Clean up search components
if (this.searchRenderer) {
this.searchRenderer.destroy();
@@ -414,6 +512,12 @@ export class FlameChart {
return null;
}
+ // Clear selection when starting a new search to show search highlights
+ this.selectionManager?.clear();
+ if (this.selectionRenderer) {
+ this.selectionRenderer.clear();
+ }
+
const innerCursor = this.newSearchManager.search(predicate, options);
// Wrap with FlameChartCursor to add automatic side effects
@@ -430,6 +534,7 @@ export class FlameChart {
/**
* Clear current search and reset cursor.
+ * If a frame is selected, selection highlight will automatically show via getHighlightMode().
*/
public clearSearch(): void {
this.newSearchManager?.clear();
@@ -438,6 +543,7 @@ export class FlameChart {
/**
* Handle search navigation side effects:
+ * - Clear selection so search highlight shows via getHighlightMode()
* - Center viewport on match
* - Call onSearchNavigate callback for application-specific logic (e.g., tooltips)
* - Request render
@@ -447,13 +553,21 @@ export class FlameChart {
return;
}
+ // Clear selection so search highlight shows
+ this.selectionManager?.clear();
+ if (this.selectionRenderer) {
+ this.selectionRenderer.clear();
+ }
+
// Center viewport on the match
this.viewport.centerOnEvent(match.event.timestamp, match.event.duration, match.depth);
// Call application-specific callback (e.g., for showing tooltips)
if (this.callbacks.onSearchNavigate) {
- const viewportState = this.viewport.getState();
- const screenX = match.event.timestamp * viewportState.zoom - viewportState.offsetX;
+ const screenX = this.viewport.calculateVisibleCenterX(
+ match.event.timestamp,
+ match.event.duration,
+ );
const screenY = this.viewport.depthToScreenY(match.depth);
this.callbacks.onSearchNavigate(match.event, screenX, screenY, match.depth);
}
@@ -632,18 +746,134 @@ export class FlameChart {
onMouseMove: (x: number, y: number) => {
this.handleMouseMove(x, y);
},
- onClick: (x: number, y: number) => {
- this.handleClick(x, y);
+ onClick: (x: number, y: number, modifiers?: ModifierKeys) => {
+ this.handleClick(x, y, modifiers);
+ },
+ onDoubleClick: (x: number, y: number) => {
+ this.handleDoubleClick(x, y);
},
onMouseLeave: () => {
if (this.callbacks.onMouseMove) {
this.callbacks.onMouseMove(0, 0, null, null);
}
},
+ onDragStart: () => {
+ // Cancel any keyboard pan animation when user starts dragging
+ this.viewportAnimator?.cancel();
+ },
+ onContextMenu: (screenX: number, screenY: number, clientX: number, clientY: number) => {
+ this.handleContextMenu(screenX, screenY, clientX, clientY);
+ },
},
);
}
+ private setupKeyboardHandler(): void {
+ if (!this.container || !this.viewport || !this.app?.canvas) {
+ return;
+ }
+
+ const canvas = this.app.canvas as HTMLCanvasElement;
+
+ // Make container focusable for keyboard events
+ this.container.setAttribute('tabindex', '0');
+ this.container.style.outline = 'none'; // Remove focus outline
+
+ // Auto-focus on click
+ canvas.addEventListener('mousedown', () => {
+ this.container?.focus();
+ });
+
+ this.keyboardHandler = new KeyboardHandler(this.container, this.viewport, {
+ onPan: (deltaX: number, deltaY: number) => {
+ if (!this.viewport || !this.viewportAnimator) {
+ return;
+ }
+
+ // Use animated pan via chase animation for smooth keyboard panning
+ this.viewportAnimator.addToTarget(this.viewport, deltaX, deltaY, () =>
+ this.notifyViewportChange(),
+ );
+ },
+ onZoom: (direction: 'in' | 'out') => {
+ if (!this.viewport || !this.viewportAnimator) {
+ return;
+ }
+
+ const factor =
+ direction === 'in' ? KEYBOARD_CONSTANTS.zoomFactor : 1 / KEYBOARD_CONSTANTS.zoomFactor;
+
+ // Use animated zoom via chase animation for smooth keyboard zooming
+ this.viewportAnimator.multiplyZoomTarget(this.viewport, factor, () =>
+ this.notifyViewportChange(),
+ );
+ },
+ onResetZoom: () => {
+ if (!this.viewport) {
+ return;
+ }
+
+ // Cancel any keyboard pan animation since reset changes the entire viewport
+ this.viewportAnimator?.cancel();
+
+ this.viewport.resetZoom();
+ this.notifyViewportChange();
+ },
+ onEscape: () => {
+ // Clear selection first (frame or marker), then search
+ if (this.selectionManager?.hasAnySelection()) {
+ this.clearSelection();
+ } else {
+ this.clearSearch();
+ }
+ },
+ onMarkerNav: (direction: MarkerNavDirection) => {
+ return this.navigateMarker(direction);
+ },
+ onFrameNav: (direction: FrameNavDirection) => {
+ return this.navigateFrame(direction);
+ },
+ onJumpToCallTree: () => {
+ // Jump to call tree for selected frame or marker
+ const selectedNode = this.selectionManager?.getSelected();
+ if (selectedNode && this.callbacks.onJumpToCallTree) {
+ this.callbacks.onJumpToCallTree(selectedNode.data);
+ return;
+ }
+
+ // Jump to call tree for selected marker
+ const selectedMarker = this.selectionManager?.getSelectedMarker();
+ if (selectedMarker && this.callbacks.onJumpToCallTreeForMarker) {
+ this.callbacks.onJumpToCallTreeForMarker(selectedMarker);
+ }
+ },
+ onFocus: () => {
+ // Focus (zoom to fit) on selected frame or marker
+ if (this.selectionManager?.hasSelection()) {
+ this.focusOnSelectedFrame();
+ } else if (this.selectionManager?.hasMarkerSelection()) {
+ this.focusOnSelectedMarker();
+ }
+ },
+ onCopy: () => {
+ // Copy selected frame name
+ const selectedNode = this.selectionManager?.getSelected();
+ if (selectedNode && this.callbacks.onCopy) {
+ this.callbacks.onCopy(selectedNode.data);
+ return;
+ }
+
+ // Copy selected marker summary
+ const selectedMarker = this.selectionManager?.getSelectedMarker();
+ if (selectedMarker && this.callbacks.onCopyMarker) {
+ this.callbacks.onCopyMarker(selectedMarker);
+ }
+ },
+ });
+
+ this.keyboardHandler.attach();
+ }
+
private handleMouseMove(screenX: number, screenY: number): void {
if (!this.viewport || !this.index || !this.hitTestManager) {
return;
@@ -672,7 +902,7 @@ export class FlameChart {
}
}
- private handleClick(screenX: number, screenY: number): void {
+ private handleClick(screenX: number, screenY: number, modifiers?: ModifierKeys): void {
if (!this.viewport || !this.index || !this.hitTestManager) {
return;
}
@@ -689,12 +919,518 @@ export class FlameChart {
maxDepth,
);
+ // Update selection based on click target
+ if (event) {
+ // Find the TreeNode for this LogEvent using original reference
+ const treeNode = this.selectionManager?.findByOriginal(event);
+ if (treeNode) {
+ this.selectFrame(treeNode);
+ }
+ } else if (marker) {
+ // Clicked on a marker - select it
+ this.selectMarker(marker);
+ } else {
+ // Clicked on empty space - clear selection
+ this.clearSelection();
+ }
+
// Notify callback
if (this.callbacks.onClick) {
- this.callbacks.onClick(screenX, screenY, event, marker);
+ this.callbacks.onClick(screenX, screenY, event, marker, modifiers);
+ }
+ }
+
+ /**
+ * Handle double-click - focus (zoom to fit) on the clicked event or marker.
+ * When clicking on a bucket, focuses on the same "best event" that the tooltip displays.
+ */
+ private handleDoubleClick(screenX: number, screenY: number): void {
+ if (!this.viewport || !this.index || !this.hitTestManager) {
+ return;
+ }
+
+ const viewportState = this.viewport.getState();
+ const depth = this.viewport.screenYToDepth(screenY);
+ const maxDepth = this.index.maxDepth;
+
+ const { event, marker } = this.hitTestManager.hitTest(
+ screenX,
+ screenY,
+ depth,
+ viewportState,
+ maxDepth,
+ );
+
+ // Handle event double-click
+ if (event) {
+ // Find the TreeNode for this LogEvent
+ const treeNode = this.selectionManager?.findByOriginal(event);
+ if (!treeNode) {
+ return;
+ }
+
+ // Select the frame first (so it's highlighted after focus)
+ this.selectFrame(treeNode);
+
+ // Focus on the individual event (zoom to fit)
+ this.focusOnSelectedFrame();
+ return;
+ }
+
+ // Handle marker double-click
+ if (marker) {
+ // Select the marker first (so it's highlighted after focus)
+ this.selectMarker(marker);
+
+ // Focus on the marker (zoom to fit)
+ this.focusOnSelectedMarker();
+ }
+ }
+
+ /**
+ * Handle right-click (context menu) on the timeline.
+ * If clicking on an event, selects it and notifies callback.
+ * If clicking on a marker, selects it and notifies callback.
+ * If clicking on empty space, notifies callback with null.
+ *
+ * @param screenX - Canvas-relative X coordinate (for hit testing)
+ * @param screenY - Canvas-relative Y coordinate (for hit testing)
+ * @param clientX - Client X coordinate (for menu positioning, from original event)
+ * @param clientY - Client Y coordinate (for menu positioning, from original event)
+ */
+ private handleContextMenu(
+ screenX: number,
+ screenY: number,
+ clientX: number,
+ clientY: number,
+ ): void {
+ if (!this.viewport || !this.index || !this.hitTestManager) {
+ return;
+ }
+
+ const viewportState = this.viewport.getState();
+ const depth = this.viewport.screenYToDepth(screenY);
+ const maxDepth = this.index.maxDepth;
+
+ // Use screenX/screenY for hit testing
+ const { event, marker } = this.hitTestManager.hitTest(
+ screenX,
+ screenY,
+ depth,
+ viewportState,
+ maxDepth,
+ );
+
+ // Handle event context menu
+ if (event) {
+ // Find the TreeNode for this LogEvent
+ const treeNode = this.selectionManager?.findByOriginal(event);
+ if (!treeNode) {
+ return;
+ }
+
+ // Select the frame (so it's highlighted when menu appears)
+ this.selectFrame(treeNode);
+
+ // Notify callback with selected event data
+ // - screenX/screenY: canvas-relative coordinates for tooltip positioning (same as hover)
+ // - clientX/clientY: window coordinates for context menu positioning
+ if (this.callbacks.onContextMenu) {
+ this.callbacks.onContextMenu(treeNode.data, screenX, screenY, clientX, clientY);
+ }
+ return;
+ }
+
+ // Handle marker context menu
+ if (marker) {
+ // Select the marker (so it's highlighted when menu appears)
+ this.selectMarker(marker);
+
+ // Notify callback with selected marker data
+ if (this.callbacks.onContextMenu) {
+ this.callbacks.onContextMenu(marker, screenX, screenY, clientX, clientY);
+ }
+ return;
+ }
+
+ // Empty space click - notify callback with null
+ if (this.callbacks.onContextMenu) {
+ this.callbacks.onContextMenu(null, screenX, screenY, clientX, clientY);
+ }
+ }
+
+ // ============================================================================
+ // PRIVATE HELPERS
+ // ============================================================================
+
+ /**
+ * Compute the current highlight mode based on actual state.
+ * Selection (frame or marker) takes priority over search.
+ *
+ * @returns Current highlight mode
+ */
+ private getHighlightMode(): 'none' | 'search' | 'selection' {
+ if (this.selectionManager?.hasAnySelection()) {
+ return 'selection';
+ }
+ const cursor = this.newSearchManager?.getCursor();
+ if (cursor && cursor.total > 0) {
+ return 'search';
+ }
+ return 'none';
+ }
+
+ /**
+ * Notify viewport change and request render.
+ * Consolidates duplicated callback pattern.
+ */
+ private notifyViewportChange(): void {
+ this.requestRender();
+ if (this.callbacks.onViewportChange && this.viewport) {
+ this.callbacks.onViewportChange(this.viewport.getState());
+ }
+ }
+
+ // ============================================================================
+ // SELECTION METHODS
+ // ============================================================================
+
+ /**
+ * Select a frame (TreeNode).
+ * Updates visual highlight and notifies callback.
+ * Selection takes priority over search highlight (via getHighlightMode()).
+ *
+ * @param node - TreeNode to select
+ */
+ private selectFrame(node: TreeNode): void {
+ this.selectionManager?.select(node);
+
+ // Update selection renderer
+ if (this.selectionRenderer) {
+ this.selectionRenderer.setSelection(node as TreeNode);
+ }
+
+ // Notify callback
+ if (this.callbacks.onSelect) {
+ this.callbacks.onSelect(node.data);
+ }
+
+ this.requestRender();
+ }
+
+ /**
+ * Clear the current selection (frame or marker).
+ * If search has matches, search highlight will automatically show via getHighlightMode().
+ */
+ private clearSelection(): void {
+ const hadFrameSelection = this.selectionManager?.hasSelection();
+ const hadMarkerSelection = this.selectionManager?.hasMarkerSelection();
+
+ if (!hadFrameSelection && !hadMarkerSelection) {
+ return;
+ }
+
+ this.selectionManager?.clear();
+
+ // Clear selection renderer
+ if (this.selectionRenderer) {
+ this.selectionRenderer.clear();
+ }
+
+ // Notify callbacks
+ if (hadFrameSelection && this.callbacks.onSelect) {
+ this.callbacks.onSelect(null);
+ }
+ if (hadMarkerSelection && this.callbacks.onMarkerSelect) {
+ this.callbacks.onMarkerSelect(null);
+ }
+
+ this.requestRender();
+ }
+
+ /**
+ * Select a marker.
+ * Updates visual highlight and notifies callback.
+ * Selection takes priority over search highlight (via getHighlightMode()).
+ *
+ * @param marker - TimelineMarker to select
+ */
+ private selectMarker(marker: TimelineMarker): void {
+ this.selectionManager?.selectMarker(marker);
+
+ // Update selection renderer
+ if (this.selectionRenderer) {
+ this.selectionRenderer.setMarkerSelection(marker);
+ }
+
+ // Notify callback
+ if (this.callbacks.onMarkerSelect) {
+ this.callbacks.onMarkerSelect(marker);
+ }
+
+ this.requestRender();
+ }
+
+ /**
+ * Navigate marker selection.
+ * Called by keyboard handler for arrow key navigation on markers.
+ *
+ * @param direction - Navigation direction ('left' for previous, 'right' for next)
+ * @returns true if navigation was handled (marker is selected)
+ */
+ private navigateMarker(direction: MarkerNavDirection): boolean {
+ // No navigation if no marker is selected
+ if (!this.selectionManager?.hasMarkerSelection()) {
+ return false;
+ }
+
+ // Navigate and get the new marker (or null if at boundary)
+ const nextMarker = this.selectionManager.navigateMarker(direction);
+
+ // If navigation found a valid marker, update renderer and center viewport
+ if (nextMarker) {
+ // Update selection renderer
+ if (this.selectionRenderer) {
+ this.selectionRenderer.setMarkerSelection(nextMarker);
+ }
+
+ // Notify callback
+ if (this.callbacks.onMarkerSelect) {
+ this.callbacks.onMarkerSelect(nextMarker);
+ }
+
+ // Always request render to show updated selection highlight
+ this.requestRender();
+
+ // Auto-center viewport on the newly selected marker
+ this.centerOnSelectedMarker();
+
+ // Notify navigation callback for tooltip
+ if (this.callbacks.onMarkerNavigate && this.viewport) {
+ const screenX = this.viewport.calculateVisibleCenterX(nextMarker.startTime, 0);
+ // Markers span full height - position tooltip near top of visible area
+ const screenY = 50;
+ this.callbacks.onMarkerNavigate(nextMarker, screenX, screenY);
+ }
+ }
+
+ // Return true if we have a marker selection (even if we couldn't navigate further)
+ // This prevents falling through to frame navigation or pan when at a boundary
+ return true;
+ }
+
+ /**
+ * Navigate frame selection using tree navigation.
+ * Called by keyboard handler for arrow key navigation.
+ *
+ * @param direction - Navigation direction
+ * @returns true if navigation was handled (selection changed or stayed at boundary)
+ */
+ private navigateFrame(direction: FrameNavDirection): boolean {
+ // No navigation if nothing is selected
+ if (!this.selectionManager?.hasSelection()) {
+ return false;
+ }
+
+ // Navigate and get the new node (or null if at boundary)
+ const nextNode = this.selectionManager.navigate(direction);
+
+ // If navigation found a valid node, update renderer and center viewport
+ if (nextNode) {
+ // Update selection renderer
+ if (this.selectionRenderer) {
+ this.selectionRenderer.setSelection(nextNode as TreeNode);
+ }
+
+ // Notify callback
+ if (this.callbacks.onSelect) {
+ this.callbacks.onSelect(nextNode.data);
+ }
+
+ // Always request render to show updated selection highlight
+ // (centerOnSelectedFrame only renders if viewport moves)
+ this.requestRender();
+
+ // Auto-center viewport on the newly selected frame
+ this.centerOnSelectedFrame();
+
+ // Notify navigation callback for tooltip (similar to search navigation)
+ if (this.callbacks.onFrameNavigate && this.viewport) {
+ const depth = nextNode.depth ?? 0;
+ const screenX = this.viewport.calculateVisibleCenterX(
+ nextNode.data.timestamp,
+ nextNode.data.duration,
+ );
+ const screenY = this.viewport.depthToScreenY(depth);
+ this.callbacks.onFrameNavigate(nextNode.data, screenX, screenY, depth);
+ }
+ }
+
+ // Return true if we have a selection (even if we couldn't navigate further)
+ // This prevents falling through to pan when at a boundary (e.g., at root)
+ return true;
+ }
+
+ /**
+ * Center viewport on the currently selected frame.
+ * Uses smooth animation when navigating to off-screen frames.
+ */
+ private centerOnSelectedFrame(): void {
+ const selectedNode = this.selectionManager?.getSelected();
+ if (!selectedNode || !this.viewport) {
+ return;
+ }
+
+ const event = selectedNode.data;
+ const depth = selectedNode.depth ?? 0;
+
+ // Calculate target offset (without applying it)
+ const targetOffset = this.viewport.calculateCenterOffset(
+ event.timestamp,
+ event.duration,
+ depth,
+ );
+
+ // Check if viewport needs to move
+ const currentState = this.viewport.getState();
+ const needsAnimation =
+ Math.abs(targetOffset.x - currentState.offsetX) > 1 ||
+ Math.abs(targetOffset.y - currentState.offsetY) > 1;
+
+ if (needsAnimation && this.viewportAnimator) {
+ // Animate to target position (300ms)
+ this.viewportAnimator.animate(this.viewport, targetOffset.x, targetOffset.y, 300, () =>
+ this.notifyViewportChange(),
+ );
+ } else if (needsAnimation) {
+ // Fallback: instant move if no animator
+ this.viewport.centerOnEvent(event.timestamp, event.duration, depth);
+ this.notifyViewportChange();
+ }
+ }
+
+ /**
+ * Focus viewport on the currently selected frame (zoom to fit).
+ * Calculates optimal zoom to fit the frame with padding.
+ */
+ public focusOnSelectedFrame(): void {
+ const selectedNode = this.selectionManager?.getSelected();
+ if (!selectedNode || !this.viewport) {
+ return;
+ }
+
+ const event = selectedNode.data;
+ const depth = selectedNode.depth ?? 0;
+
+ // Focus on the event (zoom to fit with padding)
+ this.viewport.focusOnEvent(event.timestamp, event.duration, depth);
+ this.notifyViewportChange();
+ }
+
+ /**
+ * Center viewport on the currently selected marker.
+ * Uses smooth animation when navigating to off-screen markers.
+ */
+ private centerOnSelectedMarker(): void {
+ const selectedMarker = this.selectionManager?.getSelectedMarker();
+ if (!selectedMarker || !this.viewport || !this.index) {
+ return;
+ }
+
+ // Calculate marker duration (extends to next marker or timeline end)
+ const markers = this.selectionManager?.getMarkers() ?? [];
+ const markerIndex = markers.findIndex((m) => m.id === selectedMarker.id);
+ const nextMarker = markers[markerIndex + 1];
+ const markerEnd = nextMarker?.startTime ?? this.index.totalDuration;
+ const duration = markerEnd - selectedMarker.startTime;
+
+ // Use middle depth for centering (markers span all depths)
+ const middleDepth = Math.floor(this.index.maxDepth / 2);
+
+ // Calculate target offset (without applying it)
+ const targetOffset = this.viewport.calculateCenterOffset(
+ selectedMarker.startTime,
+ duration,
+ middleDepth,
+ );
+
+ // Check if viewport needs to move
+ const currentState = this.viewport.getState();
+ const needsAnimation =
+ Math.abs(targetOffset.x - currentState.offsetX) > 1 ||
+ Math.abs(targetOffset.y - currentState.offsetY) > 1;
+
+ if (needsAnimation && this.viewportAnimator) {
+ // Animate to target position (300ms)
+ this.viewportAnimator.animate(this.viewport, targetOffset.x, targetOffset.y, 300, () =>
+ this.notifyViewportChange(),
+ );
+ } else if (needsAnimation) {
+ // Fallback: instant move if no animator
+ this.viewport.centerOnEvent(selectedMarker.startTime, duration, middleDepth);
+ this.notifyViewportChange();
}
}
+ /**
+ * Focus viewport on the currently selected marker (zoom to fit).
+ * Calculates optimal zoom to fit the marker with padding.
+ */
+ public focusOnSelectedMarker(): void {
+ const selectedMarker = this.selectionManager?.getSelectedMarker();
+ if (!selectedMarker || !this.viewport || !this.index) {
+ return;
+ }
+
+ // Calculate marker duration (extends to next marker or timeline end)
+ const markers = this.selectionManager?.getMarkers() ?? [];
+ const markerIndex = markers.findIndex((m) => m.id === selectedMarker.id);
+ const nextMarker = markers[markerIndex + 1];
+ const markerEnd = nextMarker?.startTime ?? this.index.totalDuration;
+ const duration = markerEnd - selectedMarker.startTime;
+
+ // Use middle depth for focusing (markers span all depths)
+ const middleDepth = Math.floor(this.index.maxDepth / 2);
+
+ // Focus on the marker (zoom to fit with padding)
+ this.viewport.focusOnEvent(selectedMarker.startTime, duration, middleDepth);
+ this.notifyViewportChange();
+ }
+
+ /**
+ * Get the currently selected node (frame).
+ *
+ * @returns Currently selected TreeNode, or null if none
+ */
+ public getSelectedNode(): TreeNode | null {
+ return this.selectionManager?.getSelected() ?? null;
+ }
+
+ /**
+ * Get the currently selected marker.
+ *
+ * @returns Currently selected TimelineMarker, or null if none
+ */
+ public getSelectedMarker(): TimelineMarker | null {
+ return this.selectionManager?.getSelectedMarker() ?? null;
+ }
+
+ /**
+ * Reset viewport to show entire timeline.
+ * Cancels any active animations.
+ */
+ public resetZoom(): void {
+ if (!this.viewport) {
+ return;
+ }
+
+ // Cancel any active animation since reset changes the entire viewport
+ this.viewportAnimator?.cancel();
+
+ this.viewport.resetZoom();
+ this.notifyViewportChange();
+ }
+
private initializeState(events: LogEvent[]): void {
if (!this.viewport) {
return;
@@ -813,9 +1549,6 @@ export class FlameChart {
const matchedEventIds = cursor.getMatchedEventIds();
this.searchStyleRenderer!.render(visibleRects, matchedEventIds, buckets, viewportState);
- // Render highlight border for current match
- this.searchRenderer!.render(cursor, viewportState);
-
// Clear normal renderer when in search mode
if (this.batchRenderer) {
this.batchRenderer.clear();
@@ -828,9 +1561,6 @@ export class FlameChart {
if (this.searchStyleRenderer) {
this.searchStyleRenderer.clear();
}
- if (this.searchRenderer) {
- this.searchRenderer.clear();
- }
}
// Render text labels (with or without search styling)
@@ -852,6 +1582,22 @@ export class FlameChart {
}
}
+ // Render only ONE highlight based on computed mode (selection takes priority)
+ const highlightMode = this.getHighlightMode();
+ if (highlightMode === 'selection') {
+ // Selection highlight mode: show selection highlight
+ this.selectionRenderer?.render(viewportState);
+ this.searchRenderer?.clear();
+ } else if (highlightMode === 'search') {
+ // Search highlight mode: show search match highlight
+ this.searchRenderer!.render(cursor!, viewportState);
+ this.selectionRenderer?.clear();
+ } else {
+ // No active highlight mode: clear both
+ this.searchRenderer?.clear();
+ this.selectionRenderer?.clear();
+ }
+
this.app.render();
}
}
diff --git a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts
index 92fa150e..b3520dfe 100644
--- a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts
+++ b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts
@@ -70,8 +70,15 @@ export class TimelineTooltipManager {
* @param event - Event to display tooltip for
* @param mouseX - Mouse X position relative to container
* @param mouseY - Mouse Y position relative to container
+ * @param options - Optional settings
+ * @param options.keepPosition - If true and tooltip is already visible for this event, don't reposition
*/
- public show(event: LogEvent, mouseX: number, mouseY: number): void {
+ public show(
+ event: LogEvent,
+ mouseX: number,
+ mouseY: number,
+ options?: { keepPosition?: boolean },
+ ): void {
// If tooltip is already visible, update immediately (no delay between events)
const wasVisible = this.tooltipElement?.style.display === 'block';
@@ -83,8 +90,11 @@ export class TimelineTooltipManager {
return;
}
- // If same event and visible, just update position
+ // If same event and visible, optionally keep position unchanged
if (this.currentEvent === event && wasVisible) {
+ if (options?.keepPosition) {
+ return;
+ }
this.positionTooltip(mouseX, mouseY);
return;
}
diff --git a/log-viewer/src/features/timeline/optimised/TimelineViewport.ts b/log-viewer/src/features/timeline/optimised/TimelineViewport.ts
index 1aea7b73..0d45a3e4 100644
--- a/log-viewer/src/features/timeline/optimised/TimelineViewport.ts
+++ b/log-viewer/src/features/timeline/optimised/TimelineViewport.ts
@@ -205,6 +205,63 @@ export class TimelineViewport {
this.state.offsetY = 0;
}
+ /**
+ * Alias for reset() - resets zoom to show all content.
+ * Used by keyboard handler for Home/0 key.
+ */
+ public resetZoom(): void {
+ this.reset();
+ }
+
+ /**
+ * Zoom by a factor with optional anchor point.
+ * @param factor - Multiplier for current zoom (>1 zooms in, <1 zooms out)
+ * @param anchorX - Screen X coordinate to keep stable (optional, defaults to center)
+ * @returns true if zoom changed
+ */
+ public zoomByFactor(factor: number, anchorX?: number): boolean {
+ const newZoom = this.state.zoom * factor;
+ return this.setZoom(newZoom, anchorX);
+ }
+
+ /**
+ * Focus viewport on a specific event by zooming to fit it with padding.
+ * Calculates optimal zoom level to fit the frame with 10% padding on each side,
+ * then centers the viewport on the event.
+ *
+ * @param eventTimestamp - Event start time in nanoseconds
+ * @param eventDuration - Event duration in nanoseconds
+ * @param eventDepth - Event depth in call tree (0-indexed)
+ */
+ public focusOnEvent(eventTimestamp: number, eventDuration: number, eventDepth: number): void {
+ // Calculate zoom to fit frame with 10% padding on each side (20% total)
+ const padding = 0.1;
+ const targetTimeWidth = eventDuration * (1 + padding * 2);
+
+ // Calculate new zoom level (pixels per nanosecond)
+ const newZoom = this.state.displayWidth / targetTimeWidth;
+
+ // Clamp to valid zoom range
+ const clampedZoom = Math.max(this.getMinZoom(), Math.min(this.getMaxZoom(), newZoom));
+
+ // Apply new zoom
+ this.state.zoom = clampedZoom;
+
+ // Calculate target offsets to center the event
+ const eventX = eventTimestamp * this.state.zoom;
+ const eventWidth = eventDuration * this.state.zoom;
+ const eventMidpoint = eventX + eventWidth / 2;
+
+ // Center event midpoint at screen center
+ const newOffsetX = eventMidpoint - this.state.displayWidth / 2;
+ this.state.offsetX = this.clampOffsetX(newOffsetX);
+
+ // Center vertically on the event depth
+ const eventY = eventDepth * TIMELINE_CONSTANTS.EVENT_HEIGHT;
+ const newWorldYBottom = eventY - this.state.displayHeight / 2;
+ this.state.offsetY = this.clampOffsetY(-newWorldYBottom);
+ }
+
/**
* Center viewport on a specific event.
* Scrolls horizontally and vertically to center the event in the viewport.
@@ -262,6 +319,68 @@ export class TimelineViewport {
}
}
+ /**
+ * Set viewport offsets directly.
+ * Used by ViewportAnimator for smooth transitions.
+ *
+ * @param offsetX - Horizontal offset in pixels
+ * @param offsetY - Vertical offset in pixels
+ */
+ public setOffset(offsetX: number, offsetY: number): void {
+ this.state.offsetX = this.clampOffsetX(offsetX);
+ this.state.offsetY = this.clampOffsetY(offsetY);
+ }
+
+ /**
+ * Calculate target offsets to center on an event without applying them.
+ * Used by ViewportAnimator to determine animation target.
+ *
+ * @param eventTimestamp - Event start time in nanoseconds
+ * @param eventDuration - Event duration in nanoseconds
+ * @param eventDepth - Event depth in call tree (0-indexed)
+ * @returns Target offsets (clamped to valid range)
+ */
+ public calculateCenterOffset(
+ eventTimestamp: number,
+ eventDuration: number,
+ eventDepth: number,
+ ): { x: number; y: number } {
+ // ========== Horizontal Centering ==========
+ const eventX = eventTimestamp * this.state.zoom;
+ const eventWidth = eventDuration * this.state.zoom;
+ const eventMidpoint = eventX + eventWidth / 2;
+
+ // Check if off-screen (left or right)
+ const screenX = eventX - this.state.offsetX;
+ const isOffScreenHorizontal = screenX > this.state.displayWidth || screenX + eventWidth < 0;
+
+ let targetOffsetX = this.state.offsetX;
+ if (isOffScreenHorizontal) {
+ // Center event midpoint at screen center
+ targetOffsetX = this.clampOffsetX(eventMidpoint - this.state.displayWidth / 2);
+ }
+
+ // ========== Vertical Centering ==========
+ const eventY = eventDepth * TIMELINE_CONSTANTS.EVENT_HEIGHT;
+
+ // Calculate screen Y position of event
+ const worldYBottom = -this.state.offsetY;
+ const screenY = this.state.displayHeight - (eventY - worldYBottom);
+
+ // Check if off-screen (top or bottom)
+ const isOffScreenVertical = screenY < 0 || screenY > this.state.displayHeight;
+
+ let targetOffsetY = this.state.offsetY;
+ if (isOffScreenVertical) {
+ // Center event at vertical center
+ const targetWorldY = eventY;
+ const newWorldYBottom = targetWorldY - this.state.displayHeight / 2;
+ targetOffsetY = this.clampOffsetY(-newWorldYBottom);
+ }
+
+ return { x: targetOffsetX, y: targetOffsetY };
+ }
+
// ============================================================================
// PRIVATE HELPERS
// ============================================================================
@@ -337,6 +456,34 @@ export class TimelineViewport {
return Math.max(minOffset, Math.min(maxOffset, offsetY));
}
+ /**
+ * Clamp offset values to valid range.
+ * Used by ViewportAnimator to ensure targets are within bounds.
+ *
+ * @param offsetX - Horizontal offset to clamp
+ * @param offsetY - Vertical offset to clamp
+ * @returns Clamped offset values
+ */
+ public clampOffset(offsetX: number, offsetY: number): { x: number; y: number } {
+ return {
+ x: this.clampOffsetX(offsetX),
+ y: this.clampOffsetY(offsetY),
+ };
+ }
+
+ /**
+ * Clamp zoom value to valid range.
+ * Used by ViewportAnimator to ensure target zoom is within bounds.
+ *
+ * @param zoom - Zoom level to clamp
+ * @returns Clamped zoom value
+ */
+ public clampZoom(zoom: number): number {
+ const minZoom = this.getMinZoom();
+ const maxZoom = this.getMaxZoom();
+ return Math.max(minZoom, Math.min(maxZoom, zoom));
+ }
+
/**
* Convert screen Y coordinate to depth level.
*
@@ -365,4 +512,39 @@ export class TimelineViewport {
// Screen Y distance from bottom
return this.state.displayHeight - (worldY - worldYBottom);
}
+
+ /**
+ * Calculate X position at the center of the visible portion of a frame.
+ * When zoomed in on a wide frame, only part of the frame may be visible.
+ * This returns the center of that visible portion, clamped with padding.
+ *
+ * Used for tooltip positioning during navigation.
+ *
+ * @param timestamp - Frame/marker start time in nanoseconds
+ * @param duration - Frame duration in nanoseconds (0 for markers)
+ * @param padding - Padding from viewport edges (default 50px)
+ * @returns Screen X coordinate at center of visible frame portion
+ */
+ public calculateVisibleCenterX(
+ timestamp: number,
+ duration: number,
+ padding: number = 50,
+ ): number {
+ // Calculate frame bounds in screen coords
+ const frameStartX = timestamp * this.state.zoom - this.state.offsetX;
+ const frameEndX = (timestamp + duration) * this.state.zoom - this.state.offsetX;
+
+ // Calculate visible portion of frame (intersection with viewport)
+ const visibleStartX = Math.max(frameStartX, 0);
+ const visibleEndX = Math.min(frameEndX, this.state.displayWidth);
+
+ // Center of visible portion
+ const centerX = (visibleStartX + visibleEndX) / 2;
+
+ // Clamp with padding for tooltip visibility
+ const minX = padding;
+ const maxX = this.state.displayWidth - padding;
+
+ return Math.max(minX, Math.min(maxX, centerX));
+ }
}
diff --git a/log-viewer/src/features/timeline/optimised/ViewportAnimator.ts b/log-viewer/src/features/timeline/optimised/ViewportAnimator.ts
new file mode 100644
index 00000000..fc9430a2
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/ViewportAnimator.ts
@@ -0,0 +1,254 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * ViewportAnimator
+ *
+ * Provides smooth animation for viewport transitions.
+ * Uses requestAnimationFrame for 60fps animation with easing.
+ */
+
+import type { TimelineViewport } from './TimelineViewport.js';
+
+/**
+ * Easing functions for smooth viewport transitions.
+ */
+export const easing = {
+ /** Fast deceleration - good for snapping to position */
+ easeOutQuint: (t: number): number => 1 - Math.pow(1 - t, 5),
+ /** Medium deceleration - balanced feel */
+ easeOutCubic: (t: number): number => 1 - Math.pow(1 - t, 3),
+};
+
+/**
+ * Animate viewport from current state to target.
+ */
+export class ViewportAnimator {
+ private animationId: number | null = null;
+
+ // Chase animation state (for keyboard pan)
+ private targetOffsetX: number = 0;
+ private targetOffsetY: number = 0;
+ private isChasingPan: boolean = false;
+
+ // Chase animation state (for keyboard zoom)
+ private targetZoom: number = 0;
+ private isChasingZoom: boolean = false;
+
+ /** How quickly to approach target (0-1). Higher = faster approach. */
+ private readonly lerpFactor: number = 0.15;
+
+ /**
+ * Animate viewport offset to target position.
+ *
+ * @param viewport - TimelineViewport to animate
+ * @param targetOffsetX - Target horizontal offset
+ * @param targetOffsetY - Target vertical offset
+ * @param duration - Animation duration in milliseconds (default: 300)
+ * @param onFrame - Callback invoked each animation frame for re-rendering
+ */
+ public animate(
+ viewport: TimelineViewport,
+ targetOffsetX: number,
+ targetOffsetY: number,
+ duration: number = 300,
+ onFrame: () => void,
+ ): void {
+ // Cancel any existing animation (including chase)
+ this.cancel();
+
+ const startState = viewport.getState();
+ const startOffsetX = startState.offsetX;
+ const startOffsetY = startState.offsetY;
+ const startTime = performance.now();
+
+ const tick = (now: number): void => {
+ const elapsed = now - startTime;
+ const t = Math.min(elapsed / duration, 1);
+ const easedT = easing.easeOutCubic(t);
+
+ // Interpolate offsets
+ const currentX = startOffsetX + (targetOffsetX - startOffsetX) * easedT;
+ const currentY = startOffsetY + (targetOffsetY - startOffsetY) * easedT;
+
+ viewport.setOffset(currentX, currentY);
+ onFrame();
+
+ if (t < 1) {
+ this.animationId = requestAnimationFrame(tick);
+ } else {
+ this.animationId = null;
+ }
+ };
+
+ this.animationId = requestAnimationFrame(tick);
+ }
+
+ /**
+ * Add to current target offset for continuous chase animation.
+ * Used for keyboard pan where rapid keypresses update the target.
+ *
+ * When not already chasing, starts from the current viewport position.
+ * When already chasing, adds to the existing target (accumulates input).
+ *
+ * @param viewport - TimelineViewport to animate
+ * @param deltaX - Horizontal offset to add
+ * @param deltaY - Vertical offset to add
+ * @param onFrame - Callback invoked each animation frame for re-rendering
+ */
+ public addToTarget(
+ viewport: TimelineViewport,
+ deltaX: number,
+ deltaY: number,
+ onFrame: () => void,
+ ): void {
+ if (this.isChasingPan) {
+ // Add to existing target
+ this.targetOffsetX += deltaX;
+ this.targetOffsetY += deltaY;
+
+ // Clamp target to viewport bounds
+ const clamped = viewport.clampOffset(this.targetOffsetX, this.targetOffsetY);
+ this.targetOffsetX = clamped.x;
+ this.targetOffsetY = clamped.y;
+ } else {
+ // Start fresh from current position
+ const current = viewport.getState();
+ this.targetOffsetX = current.offsetX + deltaX;
+ this.targetOffsetY = current.offsetY + deltaY;
+
+ // Clamp target to viewport bounds
+ const clamped = viewport.clampOffset(this.targetOffsetX, this.targetOffsetY);
+ this.targetOffsetX = clamped.x;
+ this.targetOffsetY = clamped.y;
+
+ // Cancel any timed animation and start pan chase
+ this.cancel();
+ this.startPanChase(viewport, onFrame);
+ }
+ }
+
+ /**
+ * Multiply zoom by factor for continuous chase animation.
+ * Used for keyboard zoom where rapid keypresses accumulate.
+ *
+ * When not already chasing, starts from the current zoom level.
+ * When already chasing, multiplies the existing target (accumulates input).
+ *
+ * @param viewport - TimelineViewport to animate
+ * @param factor - Zoom factor to multiply (>1 zooms in, <1 zooms out)
+ * @param onFrame - Callback invoked each animation frame for re-rendering
+ */
+ public multiplyZoomTarget(viewport: TimelineViewport, factor: number, onFrame: () => void): void {
+ if (this.isChasingZoom) {
+ // Multiply existing target
+ this.targetZoom *= factor;
+
+ // Clamp to valid zoom range
+ this.targetZoom = viewport.clampZoom(this.targetZoom);
+ } else {
+ // Start fresh from current zoom
+ const current = viewport.getState();
+ this.targetZoom = current.zoom * factor;
+
+ // Clamp to valid zoom range
+ this.targetZoom = viewport.clampZoom(this.targetZoom);
+
+ // Cancel any timed animation and start zoom chase
+ this.cancel();
+ this.startZoomChase(viewport, onFrame);
+ }
+ }
+
+ /**
+ * Start the pan chase animation loop.
+ * Continuously lerps toward target offset until close enough.
+ */
+ private startPanChase(viewport: TimelineViewport, onFrame: () => void): void {
+ this.isChasingPan = true;
+
+ const tick = (): void => {
+ const current = viewport.getState();
+ const dx = this.targetOffsetX - current.offsetX;
+ const dy = this.targetOffsetY - current.offsetY;
+
+ // Stop if close enough (sub-pixel threshold)
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
+ viewport.setOffset(this.targetOffsetX, this.targetOffsetY);
+ onFrame();
+ this.isChasingPan = false;
+ this.animationId = null;
+ return;
+ }
+
+ // Lerp toward target
+ viewport.setOffset(
+ current.offsetX + dx * this.lerpFactor,
+ current.offsetY + dy * this.lerpFactor,
+ );
+ onFrame();
+
+ this.animationId = requestAnimationFrame(tick);
+ };
+
+ this.animationId = requestAnimationFrame(tick);
+ }
+
+ /**
+ * Start the zoom chase animation loop.
+ * Continuously lerps toward target zoom until close enough.
+ * Uses log-space interpolation for perceptually uniform zoom speed.
+ */
+ private startZoomChase(viewport: TimelineViewport, onFrame: () => void): void {
+ this.isChasingZoom = true;
+
+ const tick = (): void => {
+ const current = viewport.getState();
+
+ // Use log-space for perceptually uniform zoom
+ const logCurrent = Math.log(current.zoom);
+ const logTarget = Math.log(this.targetZoom);
+ const logDiff = logTarget - logCurrent;
+
+ // Stop if close enough (less than 0.5% zoom difference)
+ if (Math.abs(logDiff) < 0.005) {
+ viewport.setZoom(this.targetZoom);
+ onFrame();
+ this.isChasingZoom = false;
+ this.animationId = null;
+ return;
+ }
+
+ // Lerp in log space and convert back
+ const newLogZoom = logCurrent + logDiff * this.lerpFactor;
+ const newZoom = Math.exp(newLogZoom);
+
+ viewport.setZoom(newZoom);
+ onFrame();
+
+ this.animationId = requestAnimationFrame(tick);
+ };
+
+ this.animationId = requestAnimationFrame(tick);
+ }
+
+ /**
+ * Cancel any in-progress animation (timed, pan chase, or zoom chase).
+ */
+ public cancel(): void {
+ if (this.animationId !== null) {
+ cancelAnimationFrame(this.animationId);
+ this.animationId = null;
+ }
+ this.isChasingPan = false;
+ this.isChasingZoom = false;
+ }
+
+ /**
+ * Check if an animation is currently running.
+ */
+ public isAnimating(): boolean {
+ return this.animationId !== null;
+ }
+}
diff --git a/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts b/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts
index ea5a09ef..1fc88df9 100644
--- a/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts
+++ b/log-viewer/src/features/timeline/optimised/interaction/HitTestManager.ts
@@ -16,7 +16,13 @@
*/
import type { LogEvent } from '../../../../core/log-parser/LogEvents.js';
-import type { PixelBucket, TimelineMarker, ViewportState } from '../../types/flamechart.types.js';
+import {
+ BUCKET_CONSTANTS,
+ type PixelBucket,
+ type TimelineMarker,
+ type ViewportBounds,
+ type ViewportState,
+} from '../../types/flamechart.types.js';
import type { PrecomputedRect } from '../RectangleManager.js';
import type { TimelineEventIndex } from '../TimelineEventIndex.js';
@@ -126,8 +132,8 @@ export class HitTestManager {
const bucketResult = this.findBucketAtPosition(screenX, depth, viewport);
if (bucketResult) {
isOverEventArea = true;
- // Find nearest event from bucket's eventRefs (X direction only, same depth)
- event = this.findNearestEventInBucket(bucketResult.bucket, screenX, viewport);
+ // Find best event from bucket using priority and duration
+ event = this.findBestEventInBucket(bucketResult.bucket);
}
}
}
@@ -175,6 +181,7 @@ export class HitTestManager {
/**
* Find bucket at screen position, if any.
+ * Uses the bucket's pre-computed x position (same as rendering) for accurate hit detection.
* @returns Bucket and its screen bounds, or null if not over a bucket
*/
private findBucketAtPosition(
@@ -189,16 +196,16 @@ export class HitTestManager {
continue;
}
- // Calculate bucket screen position
- const bucketScreenX = bucket.timeStart * viewport.zoom - viewport.offsetX;
- const bucketScreenEnd = bucket.timeEnd * viewport.zoom - viewport.offsetX;
+ // Use pre-computed x position (grid-aligned, matches rendering)
+ const bucketScreenX = bucket.x - viewport.offsetX;
+ const bucketScreenEnd = bucketScreenX + BUCKET_CONSTANTS.BUCKET_WIDTH;
// Check if mouse X is within bucket bounds
if (screenX >= bucketScreenX && screenX <= bucketScreenEnd) {
return {
bucket,
screenX: bucketScreenX,
- screenWidth: bucketScreenEnd - bucketScreenX,
+ screenWidth: BUCKET_CONSTANTS.BUCKET_WIDTH,
};
}
}
@@ -207,40 +214,62 @@ export class HitTestManager {
}
/**
- * Find the nearest event in a bucket based on X distance.
- * Only considers events at the same depth (bucket.depth).
+ * Find the best event in a bucket using priority and duration.
+ *
+ * Selection strategy:
+ * 1. Highest priority category wins (DML > SOQL > Method > etc.)
+ * 2. For same priority, longest duration wins
*
* @param bucket - The bucket containing aggregated events
- * @param screenX - Mouse X position in screen coordinates
- * @param viewport - Current viewport state
- * @returns Nearest event, or null if bucket is empty
+ * @returns Best event based on priority/duration, or null if bucket is empty
*/
- private findNearestEventInBucket(
- bucket: PixelBucket,
- screenX: number,
- viewport: ViewportState,
- ): LogEvent | null {
- if (bucket.eventRefs.length === 0) {
+ private findBestEventInBucket(bucket: PixelBucket): LogEvent | null {
+ let events = bucket.eventRefs;
+
+ // If eventRefs is empty (TemporalSegmentTree optimization), query the index
+ if (events.length === 0) {
+ const bounds: ViewportBounds = {
+ timeStart: bucket.timeStart,
+ timeEnd: bucket.timeEnd,
+ depthStart: bucket.depth,
+ depthEnd: bucket.depth,
+ };
+ events = this.index.findEventsInRegion(bounds);
+ }
+
+ if (events.length === 0) {
return null;
}
- // Convert screen X to time
- const mouseTime = (screenX + viewport.offsetX) / viewport.zoom;
+ // Single event - return it directly
+ if (events.length === 1) {
+ return events[0] ?? null;
+ }
+
+ // Build priority map for O(1) lookup
+ const priorityMap = new Map();
+ BUCKET_CONSTANTS.CATEGORY_PRIORITY.forEach((cat, i) => priorityMap.set(cat, i));
- let nearestEvent: LogEvent | null = null;
- let nearestDistance = Infinity;
+ let bestEvent: LogEvent | null = null;
+ let bestPriority = Infinity;
+ let bestDuration = -1;
- for (const event of bucket.eventRefs) {
- // Calculate event center time
- const eventCenterTime = event.timestamp + (event.duration?.total ?? 0) / 2;
- const distance = Math.abs(eventCenterTime - mouseTime);
+ for (const event of events) {
+ const priority = priorityMap.get(event.subCategory) ?? Infinity;
+ const duration = event.duration?.total ?? 0;
- if (distance < nearestDistance) {
- nearestDistance = distance;
- nearestEvent = event;
+ // Priority wins first (lower index = higher priority)
+ if (priority < bestPriority) {
+ bestEvent = event;
+ bestPriority = priority;
+ bestDuration = duration;
+ } else if (priority === bestPriority && duration > bestDuration) {
+ // Same priority: longest duration wins
+ bestEvent = event;
+ bestDuration = duration;
}
}
- return nearestEvent;
+ return bestEvent;
}
}
diff --git a/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts
new file mode 100644
index 00000000..b9fb1c32
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts
@@ -0,0 +1,421 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * KeyboardHandler
+ *
+ * Handles keyboard input for flame chart viewport controls.
+ * Manages pan, zoom, and reset operations via keyboard shortcuts.
+ */
+
+import type { TimelineViewport } from '../TimelineViewport.js';
+
+/**
+ * Configuration constants for keyboard interactions.
+ */
+export const KEYBOARD_CONSTANTS = {
+ /** Percentage of viewport to pan per keypress (reduced for smoother feel) */
+ panStepPercent: 0.05,
+ /** Zoom multiplier per keypress (reduced for smoother feel) */
+ zoomFactor: 1.2,
+} as const;
+
+/**
+ * Navigation direction for frame traversal.
+ */
+export type FrameNavDirection = 'up' | 'down' | 'left' | 'right';
+
+/**
+ * Navigation direction for marker traversal (horizontal only).
+ */
+export type MarkerNavDirection = 'left' | 'right';
+
+/**
+ * Callbacks for keyboard events.
+ */
+export interface KeyboardCallbacks {
+ /** Called when viewport should pan by delta pixels. */
+ onPan?: (deltaX: number, deltaY: number) => void;
+
+ /** Called when viewport should zoom in or out. */
+ onZoom?: (direction: 'in' | 'out') => void;
+
+ /** Called when viewport should reset to show all content. */
+ onResetZoom?: () => void;
+
+ /** Called when Escape key is pressed (cancel/deselect). */
+ onEscape?: () => void;
+
+ /**
+ * Called when arrow key is pressed for frame navigation.
+ * In a flame chart (depth 0 at bottom, children visually above):
+ * - up: Navigate to child frame (visually up, deeper in call stack)
+ * - down: Navigate to parent frame (visually down, shallower in call stack)
+ * - left: Navigate to previous sibling frame
+ * - right: Navigate to next sibling frame
+ *
+ * Returns true if navigation was handled (frame was selected),
+ * false to fall through to pan behavior.
+ */
+ onFrameNav?: (direction: FrameNavDirection) => boolean;
+
+ /**
+ * Called when arrow key is pressed for marker navigation.
+ * Markers only support horizontal navigation (left/right).
+ * - left: Navigate to previous marker (by time)
+ * - right: Navigate to next marker (by time)
+ *
+ * Returns true if navigation was handled (marker was selected),
+ * false to fall through to frame navigation or pan behavior.
+ */
+ onMarkerNav?: (direction: MarkerNavDirection) => boolean;
+
+ /**
+ * Called when J key is pressed for "Jump to Call Tree".
+ * Navigates the call tree to the currently selected frame or marker.
+ */
+ onJumpToCallTree?: () => void;
+
+ /**
+ * Called when Enter or Z key is pressed for "Focus" (zoom to fit).
+ * Zooms the viewport to fit the currently selected frame with padding.
+ */
+ onFocus?: () => void;
+
+ /**
+ * Called when Ctrl/Cmd+C is pressed to copy the selected frame name.
+ */
+ onCopy?: () => void;
+}
+
+/**
+ * Handles keyboard input for flame chart navigation.
+ *
+ * Key Mappings:
+ * - W / + / = : Zoom in
+ * - S / - : Zoom out
+ * - Shift + W : Pan up (through stack depth)
+ * - Shift + S : Pan down (through stack depth)
+ * - A : Pan left
+ * - D : Pan right
+ * - Arrow keys (no modifier): Frame navigation (when frame selected), otherwise pan
+ * - Shift + Arrow keys: Pan in direction (always, even with frame selected)
+ * - Home / 0 : Reset zoom
+ * - Escape : Cancel/deselect
+ */
+export class KeyboardHandler {
+ private container: HTMLElement;
+ private viewport: TimelineViewport;
+ private callbacks: KeyboardCallbacks;
+
+ private isAttached = false;
+
+ // Bound handlers for cleanup
+ private boundKeyDown: (e: KeyboardEvent) => void;
+ private boundKeyUp: (e: KeyboardEvent) => void;
+
+ constructor(
+ container: HTMLElement,
+ viewport: TimelineViewport,
+ callbacks: KeyboardCallbacks = {},
+ ) {
+ this.container = container;
+ this.viewport = viewport;
+ this.callbacks = callbacks;
+
+ // Bind handlers
+ this.boundKeyDown = this.handleKeyDown.bind(this);
+ this.boundKeyUp = this.handleKeyUp.bind(this);
+ }
+
+ /**
+ * Attach keyboard event listeners to the container.
+ */
+ public attach(): void {
+ if (this.isAttached) {
+ return;
+ }
+
+ this.container.addEventListener('keydown', this.boundKeyDown);
+ this.container.addEventListener('keyup', this.boundKeyUp);
+ this.isAttached = true;
+ }
+
+ /**
+ * Detach keyboard event listeners from the container.
+ */
+ public detach(): void {
+ if (!this.isAttached) {
+ return;
+ }
+
+ this.container.removeEventListener('keydown', this.boundKeyDown);
+ this.container.removeEventListener('keyup', this.boundKeyUp);
+ this.isAttached = false;
+ }
+
+ /**
+ * Clean up all resources.
+ */
+ public destroy(): void {
+ this.detach();
+ }
+
+ /**
+ * Handle keydown events.
+ */
+ private handleKeyDown(event: KeyboardEvent): void {
+ // Determine action based on key combination
+ if (this.handlePanKeys(event)) {
+ event.preventDefault();
+ return;
+ }
+
+ if (this.handleZoomKeys(event)) {
+ event.preventDefault();
+ return;
+ }
+
+ if (this.handleResetKeys(event)) {
+ event.preventDefault();
+ return;
+ }
+
+ if (this.handleEscapeKey(event)) {
+ event.preventDefault();
+ return;
+ }
+
+ if (this.handleJumpKey(event)) {
+ event.preventDefault();
+ return;
+ }
+
+ if (this.handleFocusKeys(event)) {
+ event.preventDefault();
+ return;
+ }
+
+ if (this.handleCopyKey(event)) {
+ event.preventDefault();
+ return;
+ }
+ }
+
+ /**
+ * Handle keyup events.
+ */
+ private handleKeyUp(_event: KeyboardEvent): void {
+ // No-op - reserved for future use
+ }
+
+ /**
+ * Handle pan keys.
+ * - Shift + Arrow keys: Always pan (even when frame/marker selected)
+ * - Arrow keys without Shift: Marker navigation first (if marker selected),
+ * then frame navigation, otherwise falls through to pan
+ * - A/D: Horizontal pan (left/right)
+ * @returns true if event was handled
+ */
+ private handlePanKeys(event: KeyboardEvent): boolean {
+ const viewportState = this.viewport.getState();
+ const stepX = viewportState.displayWidth * KEYBOARD_CONSTANTS.panStepPercent;
+ const stepY = viewportState.displayHeight * KEYBOARD_CONSTANTS.panStepPercent;
+
+ // A/D keys for horizontal pan (always work, regardless of Shift)
+ if (event.key === 'a' || event.key === 'A') {
+ this.callbacks.onPan?.(-stepX, 0);
+ return true;
+ }
+ if (event.key === 'd' || event.key === 'D') {
+ this.callbacks.onPan?.(stepX, 0);
+ return true;
+ }
+
+ // Arrow keys: Try marker navigation first (unless Shift is held)
+ // Markers only support left/right navigation
+ if (!event.shiftKey && this.callbacks.onMarkerNav) {
+ let markerDirection: MarkerNavDirection | null = null;
+ switch (event.key) {
+ case 'ArrowLeft':
+ markerDirection = 'left';
+ break;
+ case 'ArrowRight':
+ markerDirection = 'right';
+ break;
+ }
+
+ if (markerDirection !== null) {
+ const handled = this.callbacks.onMarkerNav(markerDirection);
+ if (handled) {
+ return true;
+ }
+ // Fall through to frame nav or pan if marker nav didn't handle it
+ }
+ }
+
+ // Arrow keys: Try frame navigation (unless Shift is held)
+ // If onFrameNav returns true, navigation was handled; otherwise fall through to pan
+ if (!event.shiftKey && this.callbacks.onFrameNav) {
+ let direction: FrameNavDirection | null = null;
+ switch (event.key) {
+ case 'ArrowUp':
+ direction = 'up';
+ break;
+ case 'ArrowDown':
+ direction = 'down';
+ break;
+ case 'ArrowLeft':
+ direction = 'left';
+ break;
+ case 'ArrowRight':
+ direction = 'right';
+ break;
+ }
+
+ if (direction !== null) {
+ const handled = this.callbacks.onFrameNav(direction);
+ if (handled) {
+ return true;
+ }
+ // Fall through to pan if frame nav didn't handle it
+ }
+ }
+
+ // Arrow keys: pan when Shift is held OR when frame nav didn't handle it
+ switch (event.key) {
+ case 'ArrowLeft':
+ this.callbacks.onPan?.(-stepX, 0);
+ return true;
+ case 'ArrowRight':
+ this.callbacks.onPan?.(stepX, 0);
+ return true;
+ case 'ArrowUp':
+ this.callbacks.onPan?.(0, -stepY);
+ return true;
+ case 'ArrowDown':
+ this.callbacks.onPan?.(0, stepY);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Handle zoom keys (W / S / + / - / =).
+ * Shift + W/S = vertical pan (consistent with Shift + wheel behavior).
+ * @returns true if event was handled
+ */
+ private handleZoomKeys(event: KeyboardEvent): boolean {
+ const key = event.key.toLowerCase();
+
+ // Shift + W/S = vertical pan (up/down through stack depth)
+ if (event.shiftKey && (key === 'w' || key === 's')) {
+ const viewportState = this.viewport.getState();
+ const stepY = viewportState.displayHeight * KEYBOARD_CONSTANTS.panStepPercent;
+ const deltaY = key === 'w' ? -stepY : stepY;
+ this.callbacks.onPan?.(0, deltaY);
+ return true;
+ }
+
+ switch (key) {
+ case 'w':
+ case '+':
+ case '=':
+ this.callbacks.onZoom?.('in');
+ return true;
+ case 's':
+ case '-':
+ this.callbacks.onZoom?.('out');
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Handle reset zoom keys (Home / 0).
+ * @returns true if event was handled
+ */
+ private handleResetKeys(event: KeyboardEvent): boolean {
+ // Don't handle if Ctrl/Alt/Meta is pressed (allow browser shortcuts)
+ if (event.ctrlKey || event.altKey || event.metaKey) {
+ return false;
+ }
+
+ switch (event.key) {
+ case 'Home':
+ case '0':
+ this.callbacks.onResetZoom?.();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Handle Escape key (cancel/deselect).
+ * @returns true if event was handled
+ */
+ private handleEscapeKey(event: KeyboardEvent): boolean {
+ if (event.key === 'Escape') {
+ this.callbacks.onEscape?.();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handle J key (Jump to Call Tree).
+ * Navigates call tree to currently selected frame.
+ * @returns true if event was handled
+ */
+ private handleJumpKey(event: KeyboardEvent): boolean {
+ // Don't handle if modifier keys are pressed (allow browser shortcuts)
+ if (event.ctrlKey || event.altKey || event.metaKey) {
+ return false;
+ }
+
+ if (event.key === 'j' || event.key === 'J') {
+ this.callbacks.onJumpToCallTree?.();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handle Enter/Z keys (Focus - zoom to fit selected frame).
+ * @returns true if event was handled
+ */
+ private handleFocusKeys(event: KeyboardEvent): boolean {
+ // Don't handle if modifier keys are pressed (allow browser shortcuts)
+ if (event.ctrlKey || event.altKey || event.metaKey) {
+ return false;
+ }
+
+ if (event.key === 'Enter' || event.key === 'z' || event.key === 'Z') {
+ this.callbacks.onFocus?.();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handle Ctrl/Cmd+C key (Copy selected frame name).
+ * @returns true if event was handled
+ */
+ private handleCopyKey(event: KeyboardEvent): boolean {
+ // Only handle Ctrl+C (Windows/Linux) or Cmd+C (Mac)
+ const isCtrlOrCmd = event.ctrlKey || event.metaKey;
+ if (!isCtrlOrCmd || event.altKey) {
+ return false;
+ }
+
+ if (event.key === 'c' || event.key === 'C') {
+ this.callbacks.onCopy?.();
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts
index c8b02917..4b3ef4ec 100644
--- a/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts
+++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts
@@ -9,6 +9,7 @@
* Manages zoom (wheel), pan (drag), and event selection.
*/
+import type { ModifierKeys } from '../../types/flamechart.types.js';
import type { TimelineViewport } from '../TimelineViewport.js';
/**
@@ -39,13 +40,27 @@ export interface InteractionCallbacks {
onMouseMove?: (x: number, y: number) => void;
/** Called when mouse clicks on timeline. */
- onClick?: (x: number, y: number) => void;
+ onClick?: (x: number, y: number, modifiers?: ModifierKeys) => void;
+
+ /** Called when mouse double-clicks on timeline. */
+ onDoubleClick?: (x: number, y: number) => void;
/** Called when hover state over event changes. Returns true if over an event. */
onHoverChange?: (isOverEvent: boolean) => void;
/** Called when mouse leaves the canvas. */
onMouseLeave?: () => void;
+
+ /** Called when drag operation starts (mouse or touch). */
+ onDragStart?: () => void;
+
+ /** Called when right-click (context menu) occurs on timeline.
+ * @param screenX - Canvas-relative X coordinate (for hit testing)
+ * @param screenY - Canvas-relative Y coordinate (for hit testing)
+ * @param clientX - Client X coordinate (for menu positioning)
+ * @param clientY - Client Y coordinate (for menu positioning)
+ */
+ onContextMenu?: (screenX: number, screenY: number, clientX: number, clientY: number) => void;
}
export class TimelineInteractionHandler {
@@ -61,7 +76,19 @@ export class TimelineInteractionHandler {
private lastTouchX = 0;
private lastTouchY = 0;
private isOverEvent = false;
- private isMouseDown = false;
+
+ // Track if actual panning occurred during mousedown-to-mouseup (to distinguish click from drag)
+ private didPanDuringClick = false;
+ private mouseDownX = 0;
+ private mouseDownY = 0;
+ private static readonly DRAG_THRESHOLD = 3; // px - movement required to count as drag
+
+ // Double-click detection state
+ private lastClickTime = 0;
+ private lastClickX = 0;
+ private lastClickY = 0;
+ private static readonly DOUBLE_CLICK_THRESHOLD = 300; // ms
+ private static readonly DOUBLE_CLICK_DISTANCE = 5; // px
// Event listener references for cleanup
@@ -156,6 +183,11 @@ export class TimelineInteractionHandler {
const mouseLeaveHandler = this.handleMouseLeave.bind(this);
this.canvas.addEventListener('mouseleave', mouseLeaveHandler);
this.registerBoundHandler('mouseleave', mouseLeaveHandler);
+
+ // Context menu (right-click)
+ const contextMenuHandler = this.handleContextMenu.bind(this);
+ this.canvas.addEventListener('contextmenu', contextMenuHandler);
+ this.registerBoundHandler('contextmenu', contextMenuHandler);
}
/**
@@ -210,6 +242,11 @@ export class TimelineInteractionHandler {
this.canvas.removeEventListener('mouseleave', mouseLeaveHandler);
}
+ const contextMenuHandler = this.boundHandlers.get('contextmenu');
+ if (contextMenuHandler) {
+ this.canvas.removeEventListener('contextmenu', contextMenuHandler);
+ }
+
this.boundHandlers.clear();
}
@@ -218,17 +255,46 @@ export class TimelineInteractionHandler {
// ============================================================================
/**
- * Handle wheel event for zoom and horizontal pan.
+ * Handle wheel event for zoom and pan.
*
* Implements:
- * - Vertical wheel (deltaY): Mouse-anchored zoom
+ * - Shift + wheel: Vertical pan (scroll up/down in stack depth) - respects natural scrolling
+ * - Alt/Option + wheel: Horizontal pan (scroll left/right in time)
* - Horizontal wheel (deltaX): Pan (trackpad swipe left/right)
+ * - Vertical wheel (deltaY): Mouse-anchored zoom
* - Mouse cursor position remains over the same timeline point during zoom
* - Prevents page scroll
*/
private handleWheel(event: WheelEvent): void {
event.preventDefault();
+ // Shift + wheel/swipe = Force pan (skip zoom, useful when frame is selected)
+ // Choose horizontal or vertical pan based on dominant delta direction
+ if (event.shiftKey && this.options.enablePan) {
+ let changed: boolean;
+ if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
+ // Horizontal swipe with Shift = horizontal pan
+ changed = this.viewport.panBy(event.deltaX, 0);
+ } else {
+ // Vertical wheel with Shift = vertical pan (stack depth)
+ changed = this.viewport.panBy(0, event.deltaY);
+ }
+ if (changed && this.callbacks.onViewportChange) {
+ this.callbacks.onViewportChange();
+ }
+ return;
+ }
+
+ // Alt/Option + wheel = Horizontal pan (time axis)
+ // Uses deltaY as horizontal movement (since wheel primarily produces deltaY)
+ if (event.altKey && this.options.enablePan) {
+ const changed = this.viewport.panBy(-event.deltaY, 0);
+ if (changed && this.callbacks.onViewportChange) {
+ this.callbacks.onViewportChange();
+ }
+ return;
+ }
+
// Handle horizontal pan (trackpad swipe left/right)
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) && this.options.enablePan) {
// Horizontal wheel event detected - treat as pan
@@ -282,6 +348,12 @@ export class TimelineInteractionHandler {
// Apply zoom with mouse position as anchor
const changed = this.viewport.setZoom(newZoom, mouseX);
+ // Mark as panning during zoom to prevent accidental selection
+ // (trackpad gestures can sometimes trigger concurrent click events)
+ if (changed) {
+ this.didPanDuringClick = true;
+ }
+
// Notify callback if viewport changed
if (changed && this.callbacks.onViewportChange) {
this.callbacks.onViewportChange();
@@ -302,9 +374,11 @@ export class TimelineInteractionHandler {
}
this.isDragging = true;
- this.isMouseDown = true;
+ this.didPanDuringClick = false; // Reset - will be set true if we actually pan
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
+ this.mouseDownX = event.clientX;
+ this.mouseDownY = event.clientY;
// Change cursor to grabbing
this.canvas.style.cursor = 'grabbing';
@@ -315,8 +389,23 @@ export class TimelineInteractionHandler {
*/
private handleMouseMove(event: MouseEvent): void {
if (this.isDragging && this.options.enablePan) {
- // Clear click flag since we're dragging
- this.isMouseDown = false;
+ // Check if we've moved enough to start actual panning (vs just a click with slight movement)
+ if (!this.didPanDuringClick) {
+ const distanceX = Math.abs(event.clientX - this.mouseDownX);
+ const distanceY = Math.abs(event.clientY - this.mouseDownY);
+ const distance = Math.max(distanceX, distanceY);
+
+ if (distance >= TimelineInteractionHandler.DRAG_THRESHOLD) {
+ // We've exceeded the drag threshold - this is a real drag, not a click
+ this.didPanDuringClick = true;
+
+ // Notify callback that drag started (used to cancel keyboard animations)
+ this.callbacks.onDragStart?.();
+ } else {
+ // Haven't moved enough yet - don't start panning
+ return;
+ }
+ }
// Calculate delta from last position
const deltaX = event.clientX - this.lastMouseX;
@@ -354,6 +443,7 @@ export class TimelineInteractionHandler {
}
this.isDragging = false;
+ // Note: didPanDuringClick is NOT reset here - it's read by handleClick which fires after mouseup
// Restore cursor based on whether we're over an event
this.canvas.style.cursor = this.isOverEvent ? 'pointer' : 'grab';
@@ -370,25 +460,78 @@ export class TimelineInteractionHandler {
}
/**
- * Handle click - event selection.
+ * Handle click - event selection and double-click detection.
*/
private handleClick(event: MouseEvent): void {
- // Only fire click if mousedown occurred without dragging
- if (!this.isMouseDown) {
+ // Skip click processing if we panned during this mousedown-click sequence
+ // (The click event fires after mouseup, so didPanDuringClick is still valid)
+ if (this.didPanDuringClick) {
return;
}
- this.isMouseDown = false;
-
const rect = this.canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
- if (this.callbacks.onClick) {
- this.callbacks.onClick(mouseX, mouseY);
+ const currentTime = Date.now();
+
+ // Check for double-click
+ const timeSinceLastClick = currentTime - this.lastClickTime;
+ const distanceX = Math.abs(mouseX - this.lastClickX);
+ const distanceY = Math.abs(mouseY - this.lastClickY);
+ const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
+
+ const isDoubleClick =
+ timeSinceLastClick < TimelineInteractionHandler.DOUBLE_CLICK_THRESHOLD &&
+ distance < TimelineInteractionHandler.DOUBLE_CLICK_DISTANCE;
+
+ // Extract modifier keys from event
+ const modifiers: ModifierKeys = {
+ metaKey: event.metaKey,
+ ctrlKey: event.ctrlKey,
+ shiftKey: event.shiftKey,
+ altKey: event.altKey,
+ };
+
+ if (isDoubleClick) {
+ // Reset click state to prevent triple-click being detected as double
+ this.lastClickTime = 0;
+
+ if (this.callbacks.onDoubleClick) {
+ this.callbacks.onDoubleClick(mouseX, mouseY);
+ }
+ } else {
+ // Single click - update tracking state
+ this.lastClickTime = currentTime;
+ this.lastClickX = mouseX;
+ this.lastClickY = mouseY;
+
+ if (this.callbacks.onClick) {
+ this.callbacks.onClick(mouseX, mouseY, modifiers);
+ }
}
}
+ /**
+ * Handle context menu (right-click).
+ * Passes both canvas-relative (for hit testing) and client coordinates (for menu positioning).
+ */
+ private handleContextMenu(event: MouseEvent): void {
+ // Prevent default browser context menu
+ event.preventDefault();
+
+ if (!this.callbacks.onContextMenu) {
+ return;
+ }
+
+ const rect = this.canvas.getBoundingClientRect();
+ const screenX = event.clientX - rect.left; // Canvas-relative for hit test
+ const screenY = event.clientY - rect.top;
+
+ // Pass both coordinate systems: screen coords for hit test, client coords for menu positioning
+ this.callbacks.onContextMenu(screenX, screenY, event.clientX, event.clientY);
+ }
+
/**
* Handle mouse leave - end drag and notify when cursor exits canvas.
*/
@@ -399,8 +542,8 @@ export class TimelineInteractionHandler {
this.canvas.style.cursor = 'grab';
}
- // Reset mouse down state
- this.isMouseDown = false;
+ // Mark as panned to prevent click from firing when mouse returns
+ this.didPanDuringClick = true;
if (this.callbacks.onMouseLeave) {
this.callbacks.onMouseLeave();
@@ -432,6 +575,9 @@ export class TimelineInteractionHandler {
this.lastTouchX = touch.clientX;
this.lastTouchY = touch.clientY;
+ // Notify callback that drag started (used to cancel keyboard animations)
+ this.callbacks.onDragStart?.();
+
// Change cursor to grabbing
this.canvas.style.cursor = 'grabbing';
}
diff --git a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts
new file mode 100644
index 00000000..2c1079b9
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * HighlightRenderer - Shared highlight drawing utility
+ *
+ * Used by both SearchHighlightRenderer and SelectionHighlightRenderer
+ * to ensure consistent visual appearance. Uses true alpha transparency
+ * to create a "yellow glass" tint effect where frame colors show through.
+ */
+
+import * as PIXI from 'pixi.js';
+import { TIMELINE_CONSTANTS, type ViewportState } from '../../types/flamechart.types.js';
+
+/**
+ * Highlight colors with alpha values for true transparency.
+ */
+export interface HighlightColors {
+ /** Source color (0xRRGGBB) - extracted from CSS variables */
+ sourceColor: number;
+}
+
+/**
+ * Minimum visible highlight width in pixels.
+ * Small events are expanded to this width for visibility.
+ */
+export const MIN_HIGHLIGHT_WIDTH = 6;
+
+/**
+ * Render a highlight rectangle with true alpha transparency.
+ * Creates a "yellow glass" tint effect where the frame color shows through.
+ *
+ * For small events (< MIN_HIGHLIGHT_WIDTH):
+ * - More opaque fill for visibility (0.6 alpha)
+ *
+ * For normal events:
+ * - Semi-transparent overlay (0.3 alpha) + border (0.9 alpha)
+ *
+ * @param graphics - PixiJS Graphics to draw to
+ * @param timestamp - Event start time in nanoseconds
+ * @param duration - Event duration in nanoseconds
+ * @param depth - Event depth (0-indexed)
+ * @param viewport - Current viewport state
+ * @param colors - Highlight colors (source color only, alpha applied during render)
+ */
+export function renderHighlight(
+ graphics: PIXI.Graphics,
+ timestamp: number,
+ duration: number,
+ depth: number,
+ viewport: ViewportState,
+ colors: HighlightColors,
+): void {
+ // Calculate screen position from event data and current viewport
+ const screenX = timestamp * viewport.zoom;
+ const screenWidth = duration * viewport.zoom;
+ const screenY = depth * TIMELINE_CONSTANTS.EVENT_HEIGHT;
+ const screenHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT;
+
+ // Pre-calculate gap values (must match rectangle rendering in EventBatchRenderer)
+ const halfGap = TIMELINE_CONSTANTS.RECT_GAP / 2;
+ const gappedHeight = screenHeight - TIMELINE_CONSTANTS.RECT_GAP;
+
+ // Calculate event center point (always accurate regardless of zoom)
+ const eventCenterX = screenX + screenWidth / 2;
+
+ // Enforce minimum visible size for highlight
+ const visibleWidth = Math.max(screenWidth, MIN_HIGHLIGHT_WIDTH);
+
+ // Center the minimum-size highlight on the actual event position
+ const centeredX = eventCenterX - visibleWidth / 2;
+
+ // Calculate gapped dimensions to match rectangle rendering exactly
+ // Rectangle renderer uses: x + halfGap, y + halfGap, width - gap, height - gap
+ const gappedWidth = Math.max(2, screenWidth - TIMELINE_CONSTANTS.RECT_GAP);
+ const rectX = screenX + halfGap;
+ const rectY = screenY + halfGap;
+
+ if (screenWidth < MIN_HIGHLIGHT_WIDTH) {
+ // Small event: use minimum width, centered on event, more opaque for visibility
+ graphics.rect(centeredX, rectY, visibleWidth, gappedHeight);
+ graphics.fill({ color: colors.sourceColor, alpha: 0.6 });
+ } else {
+ // Normal event: overlay + border
+ // Overlay fill with true alpha transparency (frame color shows through)
+ // Uses gapped bounds to match rectangle rendering exactly
+ graphics.rect(rectX, rectY, gappedWidth, gappedHeight);
+ graphics.fill({ color: colors.sourceColor, alpha: 0.3 });
+
+ // Border at FULL bounds (before gap adjustment) so stroke extends outside
+ // Canvas strokes are center-aligned: half inside, half outside the path
+ // With 2px stroke at full bounds, the border extends 1px outside the rectangle
+ // This matches Chrome DevTools selection highlight behavior
+ graphics.rect(screenX, screenY, screenWidth, screenHeight);
+ graphics.stroke({
+ width: 2,
+ color: colors.sourceColor,
+ alpha: 0.9,
+ });
+ }
+}
+
+/**
+ * Extract highlight colors from CSS variables.
+ * Uses --vscode-editor-findMatchBackground as the source color.
+ * Alpha values are applied during rendering for true transparency.
+ *
+ * @returns Highlight colors (source color only)
+ */
+export function extractHighlightColors(): HighlightColors {
+ const computedStyle = getComputedStyle(document.documentElement);
+
+ const colorStr =
+ computedStyle.getPropertyValue('--vscode-editor-findMatchBackground').trim() || '#ff9632';
+
+ return {
+ sourceColor: parseColorToHex(colorStr),
+ };
+}
+
+/**
+ * Parse CSS color string to numeric hex color (RGB only, ignoring alpha).
+ *
+ * @param cssColor - CSS color string
+ * @returns Numeric color (0xRRGGBB)
+ */
+function parseColorToHex(cssColor: string): number {
+ if (!cssColor) {
+ return 0xea5c00; // Default orange
+ }
+
+ if (cssColor.startsWith('#')) {
+ const hex = cssColor.slice(1);
+ if (hex.length === 8) {
+ // #RRGGBBAA - extract RGB, ignore alpha
+ return parseInt(hex.slice(0, 6), 16);
+ }
+ if (hex.length === 6) {
+ return parseInt(hex, 16);
+ }
+ if (hex.length === 4) {
+ // #RGBA - extract RGB, ignore alpha
+ const r = hex[0]!;
+ const g = hex[1]!;
+ const b = hex[2]!;
+ return parseInt(r + r + g + g + b + b, 16);
+ }
+ if (hex.length === 3) {
+ const r = hex[0]!;
+ const g = hex[1]!;
+ const b = hex[2]!;
+ return parseInt(r + r + g + g + b + b, 16);
+ }
+ }
+
+ // rgba() fallback - ignore alpha
+ const rgba = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d*(?:\.\d+)?))?\)/);
+ if (rgba) {
+ const r = parseInt(rgba[1]!, 10);
+ const g = parseInt(rgba[2]!, 10);
+ const b = parseInt(rgba[3]!, 10);
+ return (r << 16) | (g << 8) | b;
+ }
+
+ return 0xea5c00; // Default orange
+}
diff --git a/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts
index d484eeda..dcd90c36 100644
--- a/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts
+++ b/log-viewer/src/features/timeline/optimised/search/SearchHighlightRenderer.ts
@@ -7,14 +7,19 @@
*
* Rendering layer for search highlights using PixiJS Graphics.
* Draws borders and overlays for matched events with viewport culling.
+ *
+ * Uses shared HighlightRenderer for consistent styling with SelectionHighlightRenderer.
*/
import * as PIXI from 'pixi.js';
import type { EventNode, ViewportState } from '../../types/flamechart.types.js';
-import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js';
import type { SearchCursor, SearchMatch } from '../../types/search.types.js';
-import { blendWithBackground } from '../BucketColorResolver.js';
import type { PrecomputedRect } from '../RectangleManager.js';
+import {
+ extractHighlightColors,
+ renderHighlight,
+ type HighlightColors,
+} from '../rendering/HighlightRenderer.js';
/**
* Culling bounds derived from viewport.
@@ -33,20 +38,6 @@ interface CullingBounds {
depthEnd: number;
}
-/**
- * Highlight colors from VS Code theme (pre-blended opaque).
- */
-interface HighlightColors {
- /** Source color for current match (0xRRGGBB) - used for border. */
- currentMatchColor: number;
- /** Pre-blended opaque color for small event solid fill (0.6 opacity). */
- smallEventFillColor: number;
- /** Pre-blended opaque color for overlay fill (0.3 opacity). */
- overlayFillColor: number;
- /** Pre-blended opaque color for border stroke (0.9 opacity). */
- borderStrokeColor: number;
-}
-
/**
* SearchHighlightRenderer
*
@@ -77,8 +68,8 @@ export class SearchHighlightRenderer {
container.addChild(this.allMatchGraphics);
container.addChild(this.currentMatchGraphics);
- // Extract colors from CSS variables
- this.colors = this.extractColors();
+ // Extract colors from shared utility
+ this.colors = extractHighlightColors();
}
/**
@@ -103,61 +94,15 @@ export class SearchHighlightRenderer {
return;
}
- // Minimum visible highlight width in pixels
- const MIN_HIGHLIGHT_WIDTH = 6;
-
- // Calculate screen position from event data and current viewport (not from stale rect)
- const event = currentMatch.event;
- const screenX = event.timestamp * viewport.zoom;
- const screenWidth = event.duration * viewport.zoom;
- const screenY = currentMatch.depth * TIMELINE_CONSTANTS.EVENT_HEIGHT;
- const screenHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT;
-
- // Calculate event center point (always accurate regardless of zoom)
- const eventCenterX = screenX + screenWidth / 2;
-
- // Enforce minimum visible size for highlight
- const visibleWidth = Math.max(screenWidth, MIN_HIGHLIGHT_WIDTH);
-
- // Center the minimum-size highlight on the actual event position
- const centeredX = eventCenterX - visibleWidth / 2;
-
- // Use different rendering based on whether minimum width is applied
- if (screenWidth < MIN_HIGHLIGHT_WIDTH) {
- // Small event: solid fill with pre-blended color (simulates 0.6 opacity)
- // This makes the expanded highlight more prominent and avoids visual complexity
- this.currentMatchGraphics.rect(centeredX, screenY, visibleWidth, screenHeight);
- this.currentMatchGraphics.fill({ color: this.colors.smallEventFillColor });
- } else {
- // Normal event: overlay with border (pre-blended opaque colors)
- const halfGap = TIMELINE_CONSTANTS.RECT_GAP / 2;
- const gappedWidth = Math.max(2, visibleWidth - TIMELINE_CONSTANTS.RECT_GAP);
- const gappedHeight = screenHeight - TIMELINE_CONSTANTS.RECT_GAP;
-
- // Overlay fill with pre-blended color (simulates 0.3 opacity)
- this.currentMatchGraphics.rect(
- centeredX + halfGap,
- screenY + halfGap,
- gappedWidth,
- gappedHeight,
- );
- this.currentMatchGraphics.fill({ color: this.colors.overlayFillColor });
-
- // Internal border with pre-blended color (simulates 0.9 opacity)
- const borderInset = 1;
- const borderX = centeredX + halfGap + borderInset;
- const borderY = screenY + halfGap + borderInset;
- const borderWidth = Math.max(0, gappedWidth - borderInset * 2);
- const borderHeight = Math.max(0, gappedHeight - borderInset * 2);
-
- if (borderWidth > 0 && borderHeight > 0) {
- this.currentMatchGraphics.rect(borderX, borderY, borderWidth, borderHeight);
- this.currentMatchGraphics.stroke({
- width: 1,
- color: this.colors.borderStrokeColor,
- });
- }
- }
+ // Use shared highlight rendering logic
+ renderHighlight(
+ this.currentMatchGraphics,
+ currentMatch.event.timestamp,
+ currentMatch.event.duration,
+ currentMatch.depth,
+ viewport,
+ this.colors,
+ );
}
/**
@@ -236,75 +181,4 @@ export class SearchHighlightRenderer {
// (don't check rect.width > 0 because EventBatchRenderer might cull small rects)
return true;
}
-
- /**
- * Extract highlight colors from CSS variables and pre-blend for opaque rendering.
- * Falls back to defaults if CSS variables not available.
- *
- * @returns Highlight colors as pre-blended opaque PixiJS numeric values
- */
- private extractColors(): HighlightColors {
- const computedStyle = getComputedStyle(document.documentElement);
-
- const currentMatchColorStr =
- computedStyle.getPropertyValue('--vscode-editor-findMatchBackground').trim() || '#ff9632';
-
- const c = this.parseColor(currentMatchColorStr);
- const sourceColor = c.color;
-
- // Pre-blend colors with background for opaque rendering
- return {
- currentMatchColor: sourceColor,
- smallEventFillColor: blendWithBackground(sourceColor, 0.6),
- overlayFillColor: blendWithBackground(sourceColor, 0.3),
- borderStrokeColor: blendWithBackground(sourceColor, 0.9),
- };
- }
-
- /**
- * Parse CSS color string to PixiJS numeric color (RGB only, ignoring alpha).
- * Handles hex format (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) and rgb(a) formats.
- * Alpha is intentionally ignored - highlight opacities are fixed for visibility.
- *
- * @param cssColor - CSS color string
- * @returns PixiJS numeric color (0xRRGGBB)
- */
- private parseColor(cssColor: string): { color: number } {
- if (!cssColor) {
- return { color: 0xea5c00 };
- }
- if (cssColor.startsWith('#')) {
- const hex = cssColor.slice(1);
- if (hex.length === 8) {
- // #RRGGBBAA - extract RGB, ignore alpha
- const rgb = hex.slice(0, 6);
- return { color: parseInt(rgb, 16) };
- }
- if (hex.length === 6) {
- return { color: parseInt(hex, 16) };
- }
- if (hex.length === 4) {
- // #RGBA - extract RGB, ignore alpha
- const r = hex[0]!;
- const g = hex[1]!;
- const b = hex[2]!;
- return { color: parseInt(r + r + g + g + b + b, 16) };
- }
- if (hex.length === 3) {
- const r = hex[0]!;
- const g = hex[1]!;
- const b = hex[2]!;
- return { color: parseInt(r + r + g + g + b + b, 16) };
- }
- }
- // rgba() fallback - ignore alpha
- const rgba = cssColor.match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*(?:\.\d+)?))?\)/);
- if (rgba) {
- const r = parseInt(rgba[1]!, 10);
- const g = parseInt(rgba[2]!, 10);
- const b = parseInt(rgba[3]!, 10);
- return { color: (r << 16) | (g << 8) | b };
- }
- return { color: 0xea5c00 };
- }
}
diff --git a/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts
new file mode 100644
index 00000000..75e0fe28
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/selection/SelectionHighlightRenderer.ts
@@ -0,0 +1,311 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * SelectionHighlightRenderer
+ *
+ * Rendering layer for selection highlight using PixiJS Graphics.
+ * Draws an orange highlight around the selected frame or marker with viewport culling.
+ *
+ * Uses shared HighlightRenderer for consistent styling with SearchHighlightRenderer.
+ * Selection highlight looks identical to search match highlight.
+ *
+ * Supports two selection types:
+ * - Frame selection: highlight around a specific event rectangle
+ * - Marker selection: full-height highlight for timeline markers
+ */
+
+import * as PIXI from 'pixi.js';
+import type {
+ EventNode,
+ TimelineMarker,
+ TreeNode,
+ ViewportState,
+} from '../../types/flamechart.types.js';
+import { TIMELINE_CONSTANTS } from '../../types/flamechart.types.js';
+import {
+ extractHighlightColors,
+ MIN_HIGHLIGHT_WIDTH,
+ renderHighlight,
+ type HighlightColors,
+} from '../rendering/HighlightRenderer.js';
+
+/**
+ * Culling bounds derived from viewport.
+ */
+interface CullingBounds {
+ /** Start time in nanoseconds */
+ timeStart: number;
+ /** End time in nanoseconds */
+ timeEnd: number;
+ /** Start depth (0-indexed) */
+ depthStart: number;
+ /** End depth (0-indexed) */
+ depthEnd: number;
+}
+
+/**
+ * SelectionHighlightRenderer
+ *
+ * Renders selection highlight as an orange overlay + border around the selected frame or marker.
+ * Uses two Graphics layers:
+ * - Frame highlight: zIndex 3 (renders on top of frames)
+ * - Marker highlight: zIndex -1 (renders behind frames/buckets)
+ * Styling is identical to search match highlight via shared HighlightRenderer.
+ */
+export class SelectionHighlightRenderer {
+ /** Graphics for frame selection highlight (renders on top of frames) */
+ private frameGraphics: PIXI.Graphics;
+
+ /** Graphics for marker selection highlight (renders behind frames) */
+ private markerGraphics: PIXI.Graphics;
+
+ /** Currently selected node (frame) */
+ private selectedNode: TreeNode | null = null;
+
+ /** Currently selected marker */
+ private selectedMarker: TimelineMarker | null = null;
+
+ /** All markers for duration calculation */
+ private markers: TimelineMarker[] = [];
+
+ /** Timeline end time for last marker duration calculation */
+ private timelineEnd = 0;
+
+ /** Max depth in timeline for marker height calculation */
+ private maxDepth = 0;
+
+ /** Highlight colors extracted from CSS variables (same as search) */
+ private colors: HighlightColors;
+
+ /**
+ * @param container - PixiJS container to add graphics to (worldContainer)
+ */
+ constructor(container: PIXI.Container) {
+ // Frame highlight graphics - renders on top of frames
+ this.frameGraphics = new PIXI.Graphics();
+ this.frameGraphics.zIndex = 3;
+ container.addChild(this.frameGraphics);
+
+ // Marker highlight graphics - renders behind frames/buckets
+ this.markerGraphics = new PIXI.Graphics();
+ this.markerGraphics.zIndex = -1;
+ container.addChild(this.markerGraphics);
+
+ // Extract colors from shared utility (same colors as search highlight)
+ this.colors = extractHighlightColors();
+ }
+
+ /**
+ * Set the currently selected node (frame).
+ * Clears marker selection (mutually exclusive).
+ *
+ * @param node - TreeNode to select, or null to clear selection
+ */
+ public setSelection(node: TreeNode | null): void {
+ this.selectedNode = node;
+ this.selectedMarker = null; // Clear marker selection
+ }
+
+ /**
+ * Set the currently selected marker.
+ * Clears frame selection (mutually exclusive).
+ *
+ * @param marker - TimelineMarker to select, or null to clear selection
+ */
+ public setMarkerSelection(marker: TimelineMarker | null): void {
+ this.selectedMarker = marker;
+ this.selectedNode = null; // Clear frame selection
+ }
+
+ /**
+ * Set markers array and timeline parameters for marker selection.
+ * Required for calculating marker duration.
+ *
+ * @param markers - Array of timeline markers (sorted by startTime)
+ * @param timelineEnd - End time of timeline in nanoseconds
+ * @param maxDepth - Maximum depth in timeline
+ */
+ public setMarkerContext(markers: TimelineMarker[], timelineEnd: number, maxDepth: number): void {
+ this.markers = markers;
+ this.timelineEnd = timelineEnd;
+ this.maxDepth = maxDepth;
+ }
+
+ /**
+ * Get the currently selected node (frame).
+ *
+ * @returns Currently selected TreeNode, or null if none
+ */
+ public getSelection(): TreeNode | null {
+ return this.selectedNode;
+ }
+
+ /**
+ * Get the currently selected marker.
+ *
+ * @returns Currently selected TimelineMarker, or null if none
+ */
+ public getMarkerSelection(): TimelineMarker | null {
+ return this.selectedMarker;
+ }
+
+ /**
+ * Render the selection highlight (frame or marker).
+ *
+ * @param viewport - Viewport state for culling and transforms
+ */
+ public render(viewport: ViewportState): void {
+ // Clear both graphics
+ this.frameGraphics.clear();
+ this.markerGraphics.clear();
+
+ // Render marker selection if present (uses markerGraphics - behind frames)
+ if (this.selectedMarker) {
+ this.renderMarkerHighlight(viewport);
+ return;
+ }
+
+ // Render frame selection if present (uses frameGraphics - on top of frames)
+ if (!this.selectedNode) {
+ return;
+ }
+
+ const bounds = this.calculateBounds(viewport);
+ const event = this.selectedNode.data;
+ const depth = this.selectedNode.depth ?? 0;
+
+ // Check visibility
+ if (!this.isVisible(event, depth, bounds)) {
+ return;
+ }
+
+ // Use shared highlight rendering logic (same styling as search highlight)
+ renderHighlight(
+ this.frameGraphics,
+ event.timestamp,
+ event.duration,
+ depth,
+ viewport,
+ this.colors,
+ );
+ }
+
+ /**
+ * Render marker selection highlight.
+ * Markers render as full-height vertical bands that cover the entire viewport height.
+ *
+ * @param viewport - Viewport state for transforms
+ */
+ private renderMarkerHighlight(viewport: ViewportState): void {
+ if (!this.selectedMarker) {
+ return;
+ }
+
+ // Calculate marker duration (extends to next marker or timeline end)
+ const markerIndex = this.markers.findIndex((m) => m.id === this.selectedMarker!.id);
+ const nextMarker = this.markers[markerIndex + 1];
+ const markerEnd = nextMarker?.startTime ?? this.timelineEnd;
+ const duration = markerEnd - this.selectedMarker.startTime;
+
+ // Calculate screen position
+ const screenX = this.selectedMarker.startTime * viewport.zoom;
+ const screenWidth = duration * viewport.zoom;
+
+ // Full height to cover entire visible viewport regardless of pan position
+ // Use a large height that extends beyond any reasonable viewport
+ // Start from well above visible area and extend well below
+ const screenY = -viewport.displayHeight;
+ const screenHeight = viewport.displayHeight * 3;
+
+ // Enforce minimum visible width for narrow markers
+ const visibleWidth = Math.max(screenWidth, MIN_HIGHLIGHT_WIDTH);
+
+ // Calculate event center and center the highlight
+ const eventCenterX = screenX + screenWidth / 2;
+ const centeredX = screenWidth < MIN_HIGHLIGHT_WIDTH ? eventCenterX - visibleWidth / 2 : screenX;
+ const finalWidth = screenWidth < MIN_HIGHLIGHT_WIDTH ? visibleWidth : screenWidth;
+
+ // Draw full-height highlight (uses markerGraphics - renders behind frames)
+ this.markerGraphics.rect(centeredX, screenY, finalWidth, screenHeight);
+
+ if (screenWidth < MIN_HIGHLIGHT_WIDTH) {
+ // Narrow marker: more opaque fill for visibility
+ this.markerGraphics.fill({ color: this.colors.sourceColor, alpha: 0.6 });
+ } else {
+ // Normal marker: semi-transparent fill + border
+ this.markerGraphics.fill({ color: this.colors.sourceColor, alpha: 0.3 });
+
+ // Border at full bounds
+ this.markerGraphics.rect(screenX, screenY, screenWidth, screenHeight);
+ this.markerGraphics.stroke({
+ width: 2,
+ color: this.colors.sourceColor,
+ alpha: 0.9,
+ });
+ }
+ }
+
+ /**
+ * Clear the selection highlight from display.
+ */
+ public clear(): void {
+ this.frameGraphics.clear();
+ this.markerGraphics.clear();
+ this.selectedNode = null;
+ this.selectedMarker = null;
+ }
+
+ /**
+ * Destroy renderer and cleanup resources.
+ */
+ public destroy(): void {
+ this.frameGraphics.destroy();
+ this.markerGraphics.destroy();
+ }
+
+ /**
+ * Calculate culling bounds from viewport state.
+ *
+ * @param viewport - Viewport state
+ * @returns Culling bounds in timeline coordinates
+ */
+ private calculateBounds(viewport: ViewportState): CullingBounds {
+ const timeStart = viewport.offsetX / viewport.zoom;
+ const timeEnd = (viewport.offsetX + viewport.displayWidth) / viewport.zoom;
+
+ // Viewport culling for vertical (depth-based)
+ const worldYBottom = -viewport.offsetY;
+ const worldYTop = -viewport.offsetY + viewport.displayHeight;
+
+ const EVENT_HEIGHT = TIMELINE_CONSTANTS.EVENT_HEIGHT;
+ const depthStart = Math.floor(worldYBottom / EVENT_HEIGHT);
+ const depthEnd = Math.floor(worldYTop / EVENT_HEIGHT);
+
+ return { timeStart, timeEnd, depthStart, depthEnd };
+ }
+
+ /**
+ * Check if event is visible within culling bounds.
+ *
+ * @param event - Event to test
+ * @param depth - Depth of event in tree
+ * @param bounds - Culling bounds
+ * @returns true if event is visible
+ */
+ private isVisible(event: EventNode, depth: number, bounds: CullingBounds): boolean {
+ const rectTimeStart = event.timestamp;
+ const rectTimeEnd = rectTimeStart + event.duration;
+
+ if (rectTimeEnd <= bounds.timeStart || rectTimeStart >= bounds.timeEnd) {
+ return false;
+ }
+
+ if (depth < bounds.depthStart || depth > bounds.depthEnd) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts b/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts
new file mode 100644
index 00000000..450a5b1f
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/selection/SelectionManager.ts
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * SelectionManager
+ *
+ * Owns selection state and logic for flame chart selection (frames and markers).
+ * Encapsulates TreeNavigator and provides a clean API for selection operations.
+ *
+ * Responsibilities:
+ * - Owns selectedNode state (frame selection)
+ * - Owns selectedMarker state (marker selection)
+ * - Owns TreeNavigator instance (internal)
+ * - Provides selection lifecycle (select, clear, navigate)
+ * - Maps LogEvent to TreeNode for hit test integration
+ *
+ * Selection is mutually exclusive: selecting a frame clears marker selection and vice versa.
+ */
+
+import type { LogEvent } from '../../../../core/log-parser/LogEvents.js';
+import type { EventNode, TimelineMarker, TreeNode } from '../../types/flamechart.types.js';
+import type { NavigationMaps } from '../../utils/tree-converter.js';
+import type { FrameNavDirection } from '../interaction/KeyboardHandler.js';
+import { TreeNavigator } from './TreeNavigator.js';
+
+/**
+ * Direction for marker navigation.
+ */
+export type MarkerNavDirection = 'left' | 'right';
+
+/**
+ * Selection type discriminator.
+ */
+export type SelectionType = 'none' | 'frame' | 'marker';
+
+export class SelectionManager {
+ /** Currently selected node (frame) */
+ private selectedNode: TreeNode | null = null;
+
+ /** Currently selected marker */
+ private selectedMarker: TimelineMarker | null = null;
+
+ /** All markers for navigation (sorted by startTime) */
+ private markers: TimelineMarker[] = [];
+
+ /** Tree navigator for traversal operations */
+ private navigator: TreeNavigator;
+
+ /**
+ * Create a SelectionManager from tree nodes and pre-built maps.
+ *
+ * @param treeNodes - Root-level TreeNodes to navigate
+ * @param maps - Pre-built navigation maps from tree conversion
+ */
+ constructor(treeNodes: TreeNode[], maps: NavigationMaps) {
+ this.navigator = new TreeNavigator(treeNodes as TreeNode[], maps);
+ }
+
+ /**
+ * Set the markers array for marker navigation.
+ * Markers should be sorted by startTime.
+ *
+ * @param markers - Array of timeline markers
+ */
+ public setMarkers(markers: TimelineMarker[]): void {
+ this.markers = markers;
+ }
+
+ /**
+ * Get all markers.
+ *
+ * @returns Array of timeline markers
+ */
+ public getMarkers(): TimelineMarker[] {
+ return this.markers;
+ }
+
+ /**
+ * Select a tree node (frame).
+ * Clears any marker selection (mutually exclusive).
+ *
+ * @param node - TreeNode to select
+ */
+ public select(node: TreeNode): void {
+ this.selectedNode = node;
+ this.selectedMarker = null; // Clear marker selection
+ }
+
+ /**
+ * Select a marker.
+ * Clears any frame selection (mutually exclusive).
+ *
+ * @param marker - TimelineMarker to select
+ */
+ public selectMarker(marker: TimelineMarker): void {
+ this.selectedMarker = marker;
+ this.selectedNode = null; // Clear frame selection
+ }
+
+ /**
+ * Clear the current selection (frame or marker).
+ */
+ public clear(): void {
+ this.selectedNode = null;
+ this.selectedMarker = null;
+ }
+
+ /**
+ * Clear only the frame selection.
+ */
+ public clearFrame(): void {
+ this.selectedNode = null;
+ }
+
+ /**
+ * Clear only the marker selection.
+ */
+ public clearMarker(): void {
+ this.selectedMarker = null;
+ }
+
+ /**
+ * Navigate from current selection in the specified direction.
+ * Returns the new node if navigation was successful, null if at boundary.
+ *
+ * For left/right navigation: tries siblings first, then falls back to
+ * cross-parent navigation at the same depth (Chrome DevTools behavior).
+ *
+ * @param direction - Navigation direction ('up', 'down', 'left', 'right')
+ * @returns New selected node, or null if at boundary or no selection
+ */
+ public navigate(direction: FrameNavDirection): TreeNode | null {
+ if (!this.selectedNode) {
+ return null;
+ }
+
+ const currentNode = this.selectedNode as TreeNode;
+ let nextNode: TreeNode | null = null;
+
+ switch (direction) {
+ case 'up':
+ // Visual up = into children (flame charts render depth 0 at bottom)
+ nextNode = this.navigator.getChildAtCenter(currentNode);
+ break;
+ case 'down':
+ // Visual down = to parent
+ nextNode = this.navigator.getParent(currentNode);
+ break;
+ case 'left':
+ // Try sibling first, then cross-parent at same depth
+ nextNode = this.navigator.getPrevSibling(currentNode);
+ if (!nextNode) {
+ nextNode = this.navigator.getPrevAtDepth(currentNode);
+ }
+ break;
+ case 'right':
+ // Try sibling first, then cross-parent at same depth
+ nextNode = this.navigator.getNextSibling(currentNode);
+ if (!nextNode) {
+ nextNode = this.navigator.getNextAtDepth(currentNode);
+ }
+ break;
+ }
+
+ if (nextNode) {
+ this.selectedNode = nextNode as TreeNode;
+ return this.selectedNode;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the currently selected node (frame).
+ *
+ * @returns Currently selected TreeNode, or null if none
+ */
+ public getSelected(): TreeNode | null {
+ return this.selectedNode;
+ }
+
+ /**
+ * Get the currently selected marker.
+ *
+ * @returns Currently selected TimelineMarker, or null if none
+ */
+ public getSelectedMarker(): TimelineMarker | null {
+ return this.selectedMarker;
+ }
+
+ /**
+ * Check if there is an active frame selection.
+ *
+ * @returns true if a frame node is selected
+ */
+ public hasSelection(): boolean {
+ return this.selectedNode !== null;
+ }
+
+ /**
+ * Check if there is an active marker selection.
+ *
+ * @returns true if a marker is selected
+ */
+ public hasMarkerSelection(): boolean {
+ return this.selectedMarker !== null;
+ }
+
+ /**
+ * Check if there is any selection (frame or marker).
+ *
+ * @returns true if anything is selected
+ */
+ public hasAnySelection(): boolean {
+ return this.selectedNode !== null || this.selectedMarker !== null;
+ }
+
+ /**
+ * Get the current selection type.
+ *
+ * @returns 'none' | 'frame' | 'marker'
+ */
+ public getSelectionType(): SelectionType {
+ if (this.selectedNode !== null) {
+ return 'frame';
+ }
+ if (this.selectedMarker !== null) {
+ return 'marker';
+ }
+ return 'none';
+ }
+
+ /**
+ * Navigate between markers in the specified direction.
+ * Returns the new marker if navigation was successful, null if at boundary.
+ *
+ * @param direction - Navigation direction ('left' for previous, 'right' for next)
+ * @returns New selected marker, or null if at boundary or no marker selection
+ */
+ public navigateMarker(direction: MarkerNavDirection): TimelineMarker | null {
+ if (!this.selectedMarker || this.markers.length === 0) {
+ return null;
+ }
+
+ const currentIndex = this.markers.findIndex((m) => m.id === this.selectedMarker!.id);
+ if (currentIndex === -1) {
+ return null;
+ }
+
+ const nextIndex = direction === 'right' ? currentIndex + 1 : currentIndex - 1;
+
+ // Check boundaries (no wrapping)
+ if (nextIndex < 0 || nextIndex >= this.markers.length) {
+ return null;
+ }
+
+ const nextMarker = this.markers[nextIndex];
+ if (!nextMarker) {
+ return null;
+ }
+
+ this.selectedMarker = nextMarker;
+ return this.selectedMarker;
+ }
+
+ /**
+ * Find a TreeNode by its original LogEvent reference.
+ * Used to map hit test results back to tree nodes for selection.
+ *
+ * @param logEvent - Original LogEvent from hit test
+ * @returns The TreeNode, or null if not found
+ */
+ public findByOriginal(logEvent: LogEvent): TreeNode | null {
+ return this.navigator.findByOriginal(logEvent) as TreeNode | null;
+ }
+
+ /**
+ * Find a TreeNode by its event ID.
+ *
+ * @param id - Event ID to search for
+ * @returns The TreeNode, or null if not found
+ */
+ public findById(id: string): TreeNode | null {
+ return this.navigator.findById(id) as TreeNode | null;
+ }
+}
diff --git a/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts b/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts
new file mode 100644
index 00000000..56919e90
--- /dev/null
+++ b/log-viewer/src/features/timeline/optimised/selection/TreeNavigator.ts
@@ -0,0 +1,302 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+/**
+ * TreeNavigator
+ *
+ * Provides tree traversal for flame chart frame selection.
+ * Uses pre-built navigation maps for O(1) lookup operations.
+ *
+ * Maps are built during tree conversion (logEventToTreeNode) to avoid
+ * duplicate O(n) traversal work.
+ */
+
+import type { LogEvent } from '../../../../core/log-parser/LogEvents.js';
+import type { EventNode, TreeNode } from '../../types/flamechart.types.js';
+import type { NavigationMaps, SiblingInfo } from '../../utils/tree-converter.js';
+
+/**
+ * TreeNavigator enables parent/child/sibling traversal of TreeNode structures.
+ *
+ * Usage:
+ * ```typescript
+ * const { treeNodes, maps } = logEventToTreeNode(events);
+ * const navigator = new TreeNavigator(treeNodes, maps);
+ *
+ * // Find a node by its event ID
+ * const node = navigator.findById('event-123');
+ *
+ * // Get parent (for flame chart: Arrow Down = visually down to parent)
+ * const parent = navigator.getParent(node);
+ *
+ * // Get child (for flame chart: Arrow Up = visually up to children)
+ * const child = navigator.getChildAtCenter(node);
+ *
+ * // Navigate left/right (Arrow Left/Right)
+ * const next = navigator.getNextSibling(node);
+ * const prev = navigator.getPrevSibling(node);
+ * ```
+ */
+export class TreeNavigator {
+ /** Maps event ID to its TreeNode */
+ private nodeMap: Map>;
+
+ /** Maps event ID to its parent TreeNode (null for root nodes) */
+ private parentMap: Map | null>;
+
+ /** Maps event ID to sibling info for efficient sibling navigation */
+ private siblingMap: Map;
+
+ /** Maps original LogEvent to TreeNode for hit test lookup */
+ private originalMap: Map>;
+
+ /** Maps depth to nodes at that depth, sorted by timestamp for cross-parent navigation */
+ private depthMap: Map[]>;
+
+ /** Maps event ID to its depth for quick lookup */
+ private depthLookup: Map;
+
+ /**
+ * Construct a TreeNavigator from pre-built navigation maps.
+ * Maps are built during tree conversion (logEventToTreeNode).
+ *
+ * @param rootNodes - Array of root-level TreeNodes (unused, kept for API compatibility)
+ * @param maps - Pre-built navigation maps from tree conversion
+ */
+ constructor(_rootNodes: TreeNode[], maps: NavigationMaps) {
+ this.originalMap = maps.originalMap;
+ this.nodeMap = maps.nodeMap;
+ this.parentMap = maps.parentMap;
+ this.siblingMap = maps.siblingMap;
+ this.depthMap = maps.depthMap;
+ this.depthLookup = maps.depthLookup;
+
+ // Sort each depth array by timestamp for efficient binary search
+ for (const nodesAtDepth of this.depthMap.values()) {
+ nodesAtDepth.sort((a, b) => a.data.timestamp - b.data.timestamp);
+ }
+ }
+
+ /**
+ * Find a TreeNode by its event ID.
+ *
+ * @param id - Event ID to search for
+ * @returns The TreeNode, or null if not found
+ */
+ public findById(id: string): TreeNode | null {
+ return this.nodeMap.get(id) ?? null;
+ }
+
+ /**
+ * Find a TreeNode by its original LogEvent reference.
+ * Useful for mapping hit test results back to tree nodes.
+ *
+ * @param logEvent - Original LogEvent from hit test
+ * @returns The TreeNode, or null if not found
+ */
+ public findByOriginal(logEvent: LogEvent): TreeNode | null {
+ return this.originalMap.get(logEvent) ?? null;
+ }
+
+ /**
+ * Get the parent of a node (Arrow Up navigation).
+ *
+ * @param node - Current node
+ * @returns Parent node, or null if node is a root
+ */
+ public getParent(node: TreeNode): TreeNode | null {
+ return this.parentMap.get(node.data.id) ?? null;
+ }
+
+ /**
+ * Get the first child of a node (Arrow Down navigation).
+ *
+ * @param node - Current node
+ * @returns First child node, or null if node is a leaf
+ */
+ public getFirstChild(node: TreeNode): TreeNode | null {
+ if (!node.children || node.children.length === 0) {
+ return null;
+ }
+ return node.children[0] ?? null;
+ }
+
+ /**
+ * Get the child whose time range contains the center of the parent's time range.
+ * Falls back to closest child if no exact overlap.
+ *
+ * Chrome DevTools behavior: selects child that overlaps with the center of current frame,
+ * rather than always selecting the leftmost child.
+ *
+ * @param node - Current node
+ * @returns Child node at center, or null if node is a leaf
+ */
+ public getChildAtCenter(node: TreeNode): TreeNode | null {
+ if (!node.children || node.children.length === 0) {
+ return null;
+ }
+
+ const parentCenter = node.data.timestamp + node.data.duration / 2;
+
+ // Find child containing the center point
+ for (const child of node.children) {
+ const childStart = child.data.timestamp;
+ const childEnd = childStart + child.data.duration;
+ if (parentCenter >= childStart && parentCenter < childEnd) {
+ return child;
+ }
+ }
+
+ // Fallback: find closest child to center
+ let closest = node.children[0]!;
+ let minDistance = Infinity;
+ for (const child of node.children) {
+ const childCenter = child.data.timestamp + child.data.duration / 2;
+ const distance = Math.abs(childCenter - parentCenter);
+ if (distance < minDistance) {
+ minDistance = distance;
+ closest = child;
+ }
+ }
+ return closest;
+ }
+
+ /**
+ * Get the next sibling of a node (Arrow Right navigation).
+ *
+ * @param node - Current node
+ * @returns Next sibling, or null if node is last sibling
+ */
+ public getNextSibling(node: TreeNode): TreeNode | null {
+ const siblingInfo = this.siblingMap.get(node.data.id);
+ if (!siblingInfo) {
+ return null;
+ }
+
+ const nextIndex = siblingInfo.index + 1;
+ if (nextIndex >= siblingInfo.siblings.length) {
+ return null;
+ }
+
+ return siblingInfo.siblings[nextIndex] ?? null;
+ }
+
+ /**
+ * Get the previous sibling of a node (Arrow Left navigation).
+ *
+ * @param node - Current node
+ * @returns Previous sibling, or null if node is first sibling
+ */
+ public getPrevSibling(node: TreeNode): TreeNode | null {
+ const siblingInfo = this.siblingMap.get(node.data.id);
+ if (!siblingInfo) {
+ return null;
+ }
+
+ const prevIndex = siblingInfo.index - 1;
+ if (prevIndex < 0) {
+ return null;
+ }
+
+ return siblingInfo.siblings[prevIndex] ?? null;
+ }
+
+ /**
+ * Get the next node at the same depth (cross-parent navigation).
+ * Used when getNextSibling() returns null to continue navigation
+ * to frames with different parents.
+ *
+ * @param node - Current node
+ * @returns Next node at same depth, or null if at end
+ */
+ public getNextAtDepth(node: TreeNode): TreeNode | null {
+ const depth = this.depthLookup.get(node.data.id);
+ if (depth === undefined) {
+ return null;
+ }
+
+ const nodesAtDepth = this.depthMap.get(depth);
+ if (!nodesAtDepth || nodesAtDepth.length === 0) {
+ return null;
+ }
+
+ // Binary search for first node that starts at or after current node ends
+ // Using < (not <=) to include adjacent frames where one ends exactly where next starts
+ const nodeEnd = node.data.timestamp + node.data.duration;
+ let left = 0;
+ let right = nodesAtDepth.length;
+
+ while (left < right) {
+ const mid = Math.floor((left + right) / 2);
+ const midNode = nodesAtDepth[mid]!;
+ if (midNode.data.timestamp < nodeEnd) {
+ left = mid + 1;
+ } else {
+ right = mid;
+ }
+ }
+
+ // left is now the index of the first node starting at or after nodeEnd
+ if (left >= nodesAtDepth.length) {
+ return null;
+ }
+
+ const candidate = nodesAtDepth[left];
+ // Make sure we don't return the same node
+ if (candidate && candidate.data.id !== node.data.id) {
+ return candidate;
+ }
+ return null;
+ }
+
+ /**
+ * Get the previous node at the same depth (cross-parent navigation).
+ * Used when getPrevSibling() returns null to continue navigation
+ * to frames with different parents.
+ *
+ * @param node - Current node
+ * @returns Previous node at same depth, or null if at start
+ */
+ public getPrevAtDepth(node: TreeNode): TreeNode | null {
+ const depth = this.depthLookup.get(node.data.id);
+ if (depth === undefined) {
+ return null;
+ }
+
+ const nodesAtDepth = this.depthMap.get(depth);
+ if (!nodesAtDepth || nodesAtDepth.length === 0) {
+ return null;
+ }
+
+ // Binary search for last node that ends at or before current node starts
+ // Using <= to include adjacent frames where one ends exactly where next starts
+ const nodeStart = node.data.timestamp;
+ let left = 0;
+ let right = nodesAtDepth.length;
+
+ while (left < right) {
+ const mid = Math.floor((left + right) / 2);
+ const midNode = nodesAtDepth[mid]!;
+ const midEnd = midNode.data.timestamp + midNode.data.duration;
+ if (midEnd <= nodeStart) {
+ left = mid + 1;
+ } else {
+ right = mid;
+ }
+ }
+
+ // left-1 is the index of the last node ending at or before nodeStart
+ const prevIndex = left - 1;
+ if (prevIndex < 0) {
+ return null;
+ }
+
+ const candidate = nodesAtDepth[prevIndex];
+ // Make sure we don't return the same node
+ if (candidate && candidate.data.id !== node.data.id) {
+ return candidate;
+ }
+ return null;
+ }
+}
diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts
index 09d46278..fe7ac2eb 100644
--- a/log-viewer/src/features/timeline/types/flamechart.types.ts
+++ b/log-viewer/src/features/timeline/types/flamechart.types.ts
@@ -56,6 +56,24 @@ export interface ViewportBounds {
depthEnd: number;
}
+/**
+ * Modifier keys state from mouse/keyboard events.
+ * Used for Cmd/Ctrl+Click navigation.
+ */
+export interface ModifierKeys {
+ /** Meta key (Cmd on Mac). */
+ metaKey: boolean;
+
+ /** Ctrl key. */
+ ctrlKey: boolean;
+
+ /** Shift key. */
+ shiftKey: boolean;
+
+ /** Alt/Option key. */
+ altKey: boolean;
+}
+
// ============================================================================
// GENERIC EVENT TYPES
// ============================================================================
@@ -431,6 +449,12 @@ export type MarkerType = 'error' | 'skip' | 'unexpected';
* Extracted from ApexLog.logIssues during timeline initialization.
*/
export interface TimelineMarker {
+ /**
+ * Unique identifier for this marker.
+ * Used for selection tracking and navigation.
+ */
+ id: string;
+
/**
* Type of marker
* - 'error': Critical system error causing marker (highest severity)
diff --git a/log-viewer/src/features/timeline/utils/marker-utils.ts b/log-viewer/src/features/timeline/utils/marker-utils.ts
index df1ceb80..f73b7d3a 100644
--- a/log-viewer/src/features/timeline/utils/marker-utils.ts
+++ b/log-viewer/src/features/timeline/utils/marker-utils.ts
@@ -33,6 +33,7 @@ export function extractMarkers(log: ApexLog): TimelineMarker[] {
const markers: TimelineMarker[] = [];
+ let markerIndex = 0;
for (const issue of log.logIssues) {
// Validate type using type guard
if (!isMarkerType(issue.type)) {
@@ -45,6 +46,7 @@ export function extractMarkers(log: ApexLog): TimelineMarker[] {
}
const marker: TimelineMarker = {
+ id: `marker-${markerIndex++}`,
type: issue.type,
startTime: issue.startTime,
summary: issue.summary,
@@ -68,6 +70,10 @@ export function extractMarkers(log: ApexLog): TimelineMarker[] {
* @returns True if marker is valid, false otherwise
*/
export function validateMarker(marker: TimelineMarker): boolean {
+ if (!marker.id) {
+ return false;
+ }
+
if (!isMarkerType(marker.type)) {
return false;
}
diff --git a/log-viewer/src/features/timeline/utils/tree-converter.ts b/log-viewer/src/features/timeline/utils/tree-converter.ts
index d9c244f8..0a189da6 100644
--- a/log-viewer/src/features/timeline/utils/tree-converter.ts
+++ b/log-viewer/src/features/timeline/utils/tree-converter.ts
@@ -8,50 +8,155 @@
* Converts LogEvent hierarchies to generic TreeNode structures.
* Enables FlameChart to work with generic event types while maintaining
* backwards compatibility with existing LogEvent-based code.
+ *
+ * Also builds navigation maps during traversal to avoid duplicate O(n) work.
*/
import type { LogEvent } from '../../../core/log-parser/LogEvents.js';
import type { EventNode, TreeNode } from '../types/flamechart.types.js';
/**
- * Converts LogEvent array to TreeNode array.
+ * Sibling information for a node.
+ */
+export interface SiblingInfo {
+ /** Index in parent's children array (or root array) */
+ index: number;
+ /** Reference to siblings array (parent.children or root array) */
+ siblings: TreeNode[];
+}
+
+/**
+ * Navigation maps built during tree conversion.
+ * Used by TreeNavigator for O(1) lookups.
+ */
+export interface NavigationMaps {
+ /** Maps original LogEvent to TreeNode for hit test lookup */
+ originalMap: Map>;
+ /** Maps event ID to its TreeNode */
+ nodeMap: Map>;
+ /** Maps event ID to its parent TreeNode (null for root nodes) */
+ parentMap: Map | null>;
+ /** Maps event ID to sibling info for efficient sibling navigation */
+ siblingMap: Map;
+ /** Maps depth to nodes at that depth (unsorted, will be sorted by TreeNavigator) */
+ depthMap: Map[]>;
+ /** Maps event ID to its depth for quick lookup */
+ depthLookup: Map;
+}
+
+/**
+ * Result of tree conversion including nodes and navigation maps.
+ */
+export interface TreeConversionResult {
+ treeNodes: TreeNode[];
+ maps: NavigationMaps;
+}
+
+/**
+ * Converts LogEvent array to TreeNode array with navigation maps.
*
* Recursively traverses event.children to build tree structure.
* Generates synthetic IDs using timestamp-depth-childIndex to match RectangleManager.
+ * Builds all navigation maps during traversal to avoid duplicate O(n) work.
+ *
+ * **Important:** Events with zero duration are filtered out as they are invisible
+ * in the flame chart and would cause navigation issues. Children of zero-duration
+ * events are also excluded (branch is truncated).
*
* @param events - Array of LogEvent objects
- * @param depth - Current depth in tree (0-indexed)
- * @returns TreeNode array with EventNode data
+ * @returns TreeConversionResult with tree nodes and navigation maps
*/
-export function logEventToTreeNode(
- events: LogEvent[],
- depth = 0,
-): TreeNode[] {
- return events.map((event, index) => ({
- data: {
- id: `${event.timestamp}-${depth}-${index}`,
- timestamp: event.timestamp,
- duration: event.duration.total,
- type: event.type ?? event.subCategory ?? 'UNKNOWN',
- text: event.text,
- original: event, // Keep reference for backwards compatibility
- },
- children: event.children ? logEventToTreeNode(event.children, depth + 1) : undefined,
- depth,
- }));
+export function logEventToTreeNode(events: LogEvent[]): TreeConversionResult {
+ const maps: NavigationMaps = {
+ originalMap: new Map(),
+ nodeMap: new Map(),
+ parentMap: new Map(),
+ siblingMap: new Map(),
+ depthMap: new Map(),
+ depthLookup: new Map(),
+ };
+
+ const treeNodes = convertEventsRecursive(events, 0, maps, null);
+
+ return { treeNodes, maps };
}
/**
- * Generates unique ID for event using timestamp and a counter.
- * Used for looking up events in rectMap.
- *
- * Note: This uses a global counter to ensure uniqueness when called multiple times
- * for the same event during rectMap building.
+ * Internal recursive function that converts events and populates maps.
*
- * @param event - LogEvent to generate ID for
- * @returns Unique string ID
+ * @param events - Array of LogEvent objects
+ * @param depth - Current depth in tree (0-indexed)
+ * @param maps - Navigation maps to populate
+ * @param parent - Parent TreeNode (null for root nodes)
+ * @returns TreeNode array with EventNode data (excluding zero-duration events)
*/
-let idCounter = 0;
-export function generateEventId(event: LogEvent): string {
- return `${event.timestamp}-${idCounter++}`;
+function convertEventsRecursive(
+ events: LogEvent[],
+ depth: number,
+ maps: NavigationMaps,
+ parent: TreeNode | null,
+): TreeNode[] {
+ const result: TreeNode[] = [];
+
+ const len = events.length;
+ for (let index = 0; index < len; index++) {
+ const event = events[index]!;
+
+ // Skip events with zero duration - they are invisible and cause navigation issues
+ // Also skip their children (truncate branch) since the parent won't be navigable
+ const duration = event.duration.total;
+ if (duration <= 0) {
+ continue;
+ }
+
+ const id = event.timestamp + '-' + depth + '-' + index;
+ const children = event.children;
+
+ // Create node first (children will be set after recursive call)
+ const node: TreeNode = {
+ data: {
+ id,
+ timestamp: event.timestamp,
+ duration: duration,
+ type: event.type ?? event.subCategory ?? 'UNKNOWN',
+ text: event.text,
+ original: event,
+ },
+ children: undefined,
+ depth,
+ };
+
+ // Recursively process children (passing current node as parent)
+ if (children) {
+ node.children = convertEventsRecursive(children, depth + 1, maps, node);
+ }
+
+ result.push(node);
+
+ // Populate maps for this node
+ maps.originalMap.set(event, node);
+ maps.nodeMap.set(id, node);
+ maps.parentMap.set(id, parent);
+ maps.depthLookup.set(id, depth);
+
+ // Add to depth map
+ let nodesAtDepth = maps.depthMap.get(depth);
+ if (!nodesAtDepth) {
+ nodesAtDepth = [];
+ maps.depthMap.set(depth, nodesAtDepth);
+ }
+ nodesAtDepth.push(node);
+ }
+
+ // Populate sibling map for all nodes in this result array
+ // This must happen after the loop so we have the complete siblings array
+ for (let i = 0; i < result.length; i++) {
+ const node = result[i]!;
+ maps.siblingMap.set(node.data.id, {
+ index: i,
+ siblings: result,
+ });
+ }
+
+ return result;
}