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. ![Flame Chart](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/v1.18/lana-timeline.png) 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 + * + * ``` + * + * ```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; }