diff --git a/lana-docs-site/docs/docs/features/timeline.md b/lana-docs-site/docs/docs/features/timeline.md index 8caeb752..1132ddf7 100644 --- a/lana-docs-site/docs/docs/features/timeline.md +++ b/lana-docs-site/docs/docs/features/timeline.md @@ -31,6 +31,133 @@ The new experimental timeline is up to **7X faster** than the legacy timeline, w To revert to the legacy timeline, navigate to **Settings → Apex Log Analyzer → Timeline → Legacy** and enable it. ::: +## Navigation + +### Zoom + Pan + +| 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 + +| Action | Mouse | Keyboard | +| ----------------- | ------------------ | -------- | +| Show in 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 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` | + +### Measurement & Zoom Tools + +#### Measure Range + +Use `Shift+Drag` to measure the duration between any two points on the timeline. This is useful for precisely measuring the time span of specific operations or groups of events. + +| Action | Mouse/Keyboard | +| ------------------- | --------------------------------------------------- | +| Create Measurement | `Shift+Drag` on timeline | +| Resize Measurement | Drag the left or right edge of the measurement | +| Zoom to Measurement | Double-click inside measurement, or click zoom icon | +| Clear Measurement | `Escape` or click outside the measurement area | + +The measurement overlay displays: + +- The time duration of the selected range +- A zoom icon to quickly zoom to fit the measured area + +:::tip Resize Handles +Hover near the edges of an existing measurement to see the resize cursor. Drag to adjust the measurement boundaries — edges can be dragged past each other to swap positions. +::: + +#### Area Zoom + +Use `Alt/Option+Drag` to select a time range and instantly zoom to fit it. This provides a quick way to focus on a specific portion of the timeline. + +| Action | Mouse/Keyboard | +| --------- | ----------------------------- | +| Area Zoom | `Alt/Option+Drag` on timeline | + +Release the mouse button to zoom the viewport to fit the selected area exactly. + +## Tooltip + + + +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: + +- **Event Name** - e.g., `METHOD_ENTRY`, `EXECUTION_STARTED`, `SOQL_EXECUTION_BEGIN` +- **Event Description** - Additional information about the event such as method name or SOQL query executed +- **Timestamp** - The start and end timestamp for the given event which can be cross-referenced in the log file +- **Time** - Wall-clock time of day for the event (e.g., `14:30:05.122 → 14:30:06.625`). Only shown when wall-clock data is available in the log. +- **Duration** - Made up of **Total Time** (time spent in that event and its children) and **Self Time** (time directly spent in that event) +- **Rows** - Shows **Total Rows** (rows from that event and its children) and **Self Rows** (rows directly from that event) + ## Minimap The minimap gives you instant context of your entire log. Spot hotspots at a glance, jump anywhere with a click, and always know exactly where you are—all without scrolling. @@ -152,112 +279,6 @@ Hovering over the metric strip displays a detailed breakdown showing: Each row displays: color swatch, metric name, percentage, and used/limit values. -## Navigation - -### Zoom + Pan - -| 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 - -| Action | Mouse | Keyboard | -| ----------------- | ------------------ | -------- | -| Show in 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 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` | - -### Measurement & Zoom Tools - -#### Measure Range - -Use `Shift+Drag` to measure the duration between any two points on the timeline. This is useful for precisely measuring the time span of specific operations or groups of events. - -| Action | Mouse/Keyboard | -| ------------------- | --------------------------------------------------- | -| Create Measurement | `Shift+Drag` on timeline | -| Resize Measurement | Drag the left or right edge of the measurement | -| Zoom to Measurement | Double-click inside measurement, or click zoom icon | -| Clear Measurement | `Escape` or click outside the measurement area | - -The measurement overlay displays: - -- The time duration of the selected range -- A zoom icon to quickly zoom to fit the measured area - -:::tip Resize Handles -Hover near the edges of an existing measurement to see the resize cursor. Drag to adjust the measurement boundaries — edges can be dragged past each other to swap positions. -::: - -#### Area Zoom - -Use `Alt/Option+Drag` to select a time range and instantly zoom to fit it. This provides a quick way to focus on a specific portion of the timeline. - -| Action | Mouse/Keyboard | -| --------- | ----------------------------- | -| Area Zoom | `Alt/Option+Drag` on timeline | - -Release the mouse button to zoom the viewport to fit the selected area exactly. - ## Wall-Clock Time The timeline supports switching between two time display modes: @@ -267,27 +288,6 @@ The timeline supports switching between two time display modes: Click the clock icon button in the top-right corner of the timeline toolbar to toggle between modes. -## Tooltip - - - -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: - -- **Event Name** - e.g., `METHOD_ENTRY`, `EXECUTION_STARTED`, `SOQL_EXECUTION_BEGIN` -- **Event Description** - Additional information about the event such as method name or SOQL query executed -- **Timestamp** - The start and end timestamp for the given event which can be cross-referenced in the log file -- **Time** - Wall-clock time of day for the event (e.g., `14:30:05.122 → 14:30:06.625`). Only shown when wall-clock data is available in the log. -- **Duration** - Made up of **Total Time** (time spent in that event and its children) and **Self Time** (time directly spent in that event) -- **Rows** - Shows **Total Rows** (rows from that event and its children) and **Self Rows** (rows directly from that event) - ## Themes The timeline supports multiple color themes for better visual clarity and personalization. The extension includes 19 built-in themes with improved contrast and readability. diff --git a/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts b/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts index fa5d4ec1..757aaa87 100644 --- a/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/time-axis/AxisRenderer.ts @@ -23,11 +23,7 @@ import { Container, Text } from 'pixi.js'; import type { ViewportState } from '../../types/flamechart.types.js'; import { SpritePool } from '../SpritePool.js'; - -/** - * Nanoseconds per millisecond conversion constant. - */ -const NS_PER_MS = 1_000_000; +import { NS_PER_MS, formatMilliseconds, selectInterval } from './timeAxisConstants.js'; /** * Axis rendering configuration. @@ -56,7 +52,6 @@ interface TickInterval { } export class AxisRenderer { - private container: Container; private spritePool: SpritePool; private labelsContainer: Container; private screenSpaceContainer: Container | null = null; @@ -66,8 +61,6 @@ export class AxisRenderer { private gridLineColor: number; constructor(container: Container, config?: Partial) { - this.container = container; - // Default configuration this.config = { height: 30, @@ -157,7 +150,7 @@ export class AxisRenderer { const targetIntervalMs = targetIntervalNs / NS_PER_MS; // Find appropriate interval using 1-2-5 sequence - const { interval, skipFactor } = this.selectInterval(targetIntervalMs); + const { interval, skipFactor } = selectInterval(targetIntervalMs); return { interval: interval * NS_PER_MS, // Convert back to nanoseconds @@ -165,70 +158,6 @@ export class AxisRenderer { }; } - /** - * Select appropriate interval using 1-2-5 sequence. - * Returns interval in milliseconds and skip factor for label density. - */ - private selectInterval(targetMs: number): { interval: number; skipFactor: number } { - // Base intervals using 1-2-5 sequence - // Extended to support 0.001ms (1 microsecond) precision when zoomed way in - const baseIntervals = [ - // Sub-millisecond (microseconds in ms) - 0.001, // 1 microsecond - 0.002, // 2 microseconds - 0.005, // 5 microseconds - // Tens of microseconds - 0.01, // 10 microseconds - 0.02, // 20 microseconds - 0.05, // 50 microseconds - // Hundreds of microseconds - 0.1, // 100 microseconds - 0.2, // 200 microseconds - 0.5, // 500 microseconds - // Milliseconds - 1, - 2, - 5, - 10, - 20, - 50, - 100, - 200, - 500, - // Seconds - 1000, - 2000, - 5000, - 10000, - ]; - - // Find smallest interval >= targetMs - let interval = baseIntervals[baseIntervals.length - 1] ?? 1000; - for (const candidate of baseIntervals) { - if (candidate >= targetMs) { - interval = candidate; - break; - } - } - - // Default skip factor of 1 (show all labels) - let skipFactor = 1; - - // If labels are still too close, increase skip factor - // This happens when zoomed way out - if (interval >= 1000) { - // For large intervals (1s+), potentially skip every 2nd or 5th - if (targetMs > interval * 1.5) { - skipFactor = 2; - } - if (targetMs > interval * 3) { - skipFactor = 5; - } - } - - return { interval, skipFactor }; - } - // ============================================================================ // PRIVATE: RENDERING // ============================================================================ @@ -298,7 +227,7 @@ export class AxisRenderer { // Add label at top if requested if (showLabel && this.screenSpaceContainer) { const timeMs = timeNs / NS_PER_MS; - const labelText = this.formatMilliseconds(timeMs); + const labelText = formatMilliseconds(timeMs); // Only show label if not empty (skip zero) if (labelText) { @@ -355,33 +284,4 @@ export class AxisRenderer { label.visible = false; } } - - // ============================================================================ - // PRIVATE: FORMATTING - // ============================================================================ - - /** - * Format time with appropriate units and precision. - * - Whole seconds: "1 s", "2 s" (not "1000 ms") - * - Milliseconds: up to 3 decimal places: "18800.345 ms" - * - Omit zero: don't show "0 s" or "0 ms", just start from first non-zero - */ - private formatMilliseconds(timeMs: number): string { - // Omit zero - if (timeMs === 0) { - return ''; - } - - // Convert to seconds if >= 1000ms and whole seconds - if (timeMs >= 1000 && timeMs % 1000 === 0) { - const seconds = timeMs / 1000; - return `${seconds} s`; - } - - // Format as milliseconds with up to 3 decimal places - // Remove trailing zeros after decimal point - const formatted = timeMs.toFixed(3); - const trimmed = formatted.replace(/\.?0+$/, ''); - return `${trimmed} ms`; - } } diff --git a/log-viewer/src/features/timeline/optimised/time-axis/ClockTimeAxisRenderer.ts b/log-viewer/src/features/timeline/optimised/time-axis/ClockTimeAxisRenderer.ts new file mode 100644 index 00000000..14856504 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/time-axis/ClockTimeAxisRenderer.ts @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { Container, Graphics, Text } from 'pixi.js'; + +import { formatWallClockTime } from '../../../../core/utility/Util.js'; +import type { TickInterval, TickLabelResult, TimeAxisLabelStrategy } from './MeshAxisRenderer.js'; +import { NS_PER_MS, parseColorToHex } from './timeAxisConstants.js'; + +const STICKY_PADDING_X = 4; +const STICKY_PADDING_Y = 2; +const STICKY_LEFT_X = 4; +const STICKY_TOP_Y = 5; + +/** + * Wall-clock time label strategy for the time axis. + * Shows wall-clock time (HH:MM:SS.mmm) for anchor ticks and relative offsets + * (+N ms) for sub-millisecond ticks between anchors. + */ +export class ClockTimeAxisRenderer implements TimeAxisLabelStrategy { + private startTimeMs: number; + private firstTimestampNs: number; + private fontSize: number; + + /** Sticky label: persistent text pinned to left edge showing last off-screen anchor */ + private stickyText: Text | null = null; + private stickyBackground: Graphics | null = null; + + /** Per-frame state */ + private previousWallClockMsInt = -1; + private previousAnchorTime = 0; + private lastOffscreenAnchorTime: number | null = null; + private preComputedAnchorIdx = -1; + private visibleLabels: { label: Text; isAnchor: boolean }[] = []; + private textColor: string; + + constructor(startTimeMs: number, firstTimestampNs: number, fontSize: number, textColor: string) { + this.startTimeMs = startTimeMs; + this.firstTimestampNs = firstTimestampNs; + this.fontSize = fontSize; + this.textColor = textColor; + } + + adjustTickInterval(interval: TickInterval): TickInterval { + return { ...interval, skipFactor: 1 }; + } + + beginFrame(tickInterval: TickInterval, firstTickIndex: number, firstTimestampNs: number): void { + this.previousWallClockMsInt = -1; + this.previousAnchorTime = 0; + this.lastOffscreenAnchorTime = null; + this.preComputedAnchorIdx = -1; + this.visibleLabels = []; + + // Pre-compute anchor state so the first tick in the loop is correctly + // classified as wall-clock vs relative. + const firstTime = firstTickIndex * tickInterval.interval; + const firstMs = Math.round(this.startTimeMs + (firstTime - firstTimestampNs) / NS_PER_MS); + + // The ms boundary is where wallClockMs = firstMs - 0.5 (Math.round rounds .5 up). + const msBoundaryNs = (firstMs - 0.5 - this.startTimeMs) * NS_PER_MS + firstTimestampNs; + const anchorTickIndex = Math.ceil(msBoundaryNs / tickInterval.interval); + + this.previousWallClockMsInt = firstMs; + this.previousAnchorTime = anchorTickIndex * tickInterval.interval; + this.preComputedAnchorIdx = anchorTickIndex; + + if (anchorTickIndex < firstTickIndex) { + this.lastOffscreenAnchorTime = this.previousAnchorTime; + } + } + + renderTickLabel( + time: number, + screenSpaceX: number, + getOrCreateLabel: (text: string) => Text, + tickIndex?: number, + ): TickLabelResult | null { + const wallClockMs = this.startTimeMs + (time - this.firstTimestampNs) / NS_PER_MS; + const wallClockMsInt = Math.round(wallClockMs); + + let labelText: string; + let isAnchorLabel = false; + + if (wallClockMsInt !== this.previousWallClockMsInt || tickIndex === this.preComputedAnchorIdx) { + this.previousWallClockMsInt = wallClockMsInt; + this.previousAnchorTime = time; + labelText = formatWallClockTimeTrimmed(wallClockMs); + isAnchorLabel = true; + } else { + labelText = formatRelativeOffset(time - this.previousAnchorTime); + } + + if (!labelText) { + return null; + } + + const label = getOrCreateLabel(labelText); + label.x = screenSpaceX - 3; + label.y = 5; + label.anchor.set(1, 0); + + if (isAnchorLabel) { + const labelLeftEdge = label.x - estimateMonospaceWidth(labelText, this.fontSize); + if (labelLeftEdge < 0) { + this.lastOffscreenAnchorTime = time; + label.visible = false; + } else { + this.visibleLabels.push({ label, isAnchor: true }); + } + } else { + this.visibleLabels.push({ label, isAnchor: false }); + } + + return { label, isAnchor: isAnchorLabel }; + } + + endFrame(screenSpaceContainer: Container | null, hasSubMsTicks: boolean): void { + this.updateStickyLabel( + this.lastOffscreenAnchorTime, + this.visibleLabels, + hasSubMsTicks, + screenSpaceContainer, + ); + } + + refreshColors(textColor: string): void { + this.textColor = textColor; + if (this.stickyText) { + this.stickyText.style.fill = textColor; + } + this.updateStickyBackground(); + } + + destroy(): void { + if (this.stickyText) { + this.stickyText.destroy(); + this.stickyText = null; + } + if (this.stickyBackground) { + this.stickyBackground.destroy(); + this.stickyBackground = null; + } + } + + // ============================================================================ + // PRIVATE: STICKY LABEL + // ============================================================================ + + private updateStickyLabel( + lastOffscreenAnchorTime: number | null, + visibleLabels: { label: Text; isAnchor: boolean }[], + hasSubMsTicks: boolean, + screenSpaceContainer: Container | null, + ): void { + if (!lastOffscreenAnchorTime || !screenSpaceContainer || !hasSubMsTicks) { + this.hideStickyLabel(); + return; + } + + const stickyTimeText = formatWallClockTimeTrimmed( + this.startTimeMs + (lastOffscreenAnchorTime - this.firstTimestampNs) / NS_PER_MS, + ); + + if (!this.stickyText) { + this.stickyText = new Text({ + text: stickyTimeText, + style: { + fontFamily: 'monospace', + fontSize: this.fontSize, + fill: this.textColor, + }, + }); + this.stickyText.anchor.set(0, 0); + screenSpaceContainer.addChild(this.stickyText); + } else { + this.stickyText.text = stickyTimeText; + } + + this.stickyText.x = STICKY_LEFT_X + STICKY_PADDING_X; + this.stickyText.y = STICKY_TOP_Y; + this.stickyText.visible = true; + + if (!this.stickyBackground) { + this.stickyBackground = new Graphics(); + const textIndex = screenSpaceContainer.getChildIndex(this.stickyText); + screenSpaceContainer.addChildAt(this.stickyBackground, textIndex); + } + this.updateStickyBackground(); + this.stickyBackground.visible = true; + + const stickyRightEdge = STICKY_LEFT_X + this.stickyText.width + STICKY_PADDING_X * 2 + 4; + + for (const { label, isAnchor } of visibleLabels) { + const labelLeftEdge = label.x - label.width; + if (labelLeftEdge < stickyRightEdge) { + if (isAnchor) { + this.hideStickyLabel(); + break; + } + label.visible = false; + } + } + } + + private updateStickyBackground(): void { + if (!this.stickyBackground || !this.stickyText || !this.stickyText.visible) { + return; + } + + this.stickyBackground.clear(); + + const bgColor = getStickyBackgroundColor(); + const width = this.stickyText.width + STICKY_PADDING_X * 2; + const height = this.stickyText.height + STICKY_PADDING_Y * 2; + + this.stickyBackground.roundRect( + STICKY_LEFT_X, + STICKY_TOP_Y - STICKY_PADDING_Y, + width, + height, + 2, + ); + this.stickyBackground.fill({ color: bgColor, alpha: 0.85 }); + } + + private hideStickyLabel(): void { + if (this.stickyText) { + this.stickyText.visible = false; + } + if (this.stickyBackground) { + this.stickyBackground.visible = false; + } + } +} + +// ============================================================================ +// PRIVATE: FORMATTING UTILITIES +// ============================================================================ + +function formatWallClockTimeTrimmed(ms: number): string { + const raw = formatWallClockTime(ms); + return raw.replace(/\.?0+$/, ''); +} + +function formatRelativeOffset(offsetNs: number): string { + if (offsetNs === 0) { + return ''; + } + + const offsetMs = offsetNs / NS_PER_MS; + + if (offsetMs >= 1000) { + const seconds = offsetMs / 1000; + const formatted = seconds.toFixed(3).replace(/\.?0+$/, ''); + return `+${formatted} s`; + } + + const formatted = offsetMs.toFixed(3).replace(/\.?0+$/, ''); + return `+${formatted} ms`; +} + +function estimateMonospaceWidth(text: string, fontSize: number): number { + return text.length * fontSize * 0.6; +} + +function getStickyBackgroundColor(): number { + const computedStyle = getComputedStyle(document.documentElement); + const bgStr = computedStyle.getPropertyValue('--vscode-editor-background').trim() || '#1e1e1e'; + return parseColorToHex(bgStr); +} diff --git a/log-viewer/src/features/timeline/optimised/time-axis/ElapsedTimeAxisRenderer.ts b/log-viewer/src/features/timeline/optimised/time-axis/ElapsedTimeAxisRenderer.ts new file mode 100644 index 00000000..0ae88ba9 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/time-axis/ElapsedTimeAxisRenderer.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import type { Text } from 'pixi.js'; + +import type { TickInterval, TickLabelResult, TimeAxisLabelStrategy } from './MeshAxisRenderer.js'; +import { NS_PER_MS, formatMilliseconds } from './timeAxisConstants.js'; + +/** + * Elapsed-time label strategy for the time axis. + * Formats labels as elapsed time (e.g., "1 s", "500 ms", "1.234 ms"). + */ +export class ElapsedTimeAxisRenderer implements TimeAxisLabelStrategy { + adjustTickInterval(interval: TickInterval): TickInterval { + return interval; + } + + beginFrame(): void { + // No per-frame state needed for elapsed time + } + + renderTickLabel( + time: number, + screenSpaceX: number, + getOrCreateLabel: (text: string) => Text, + ): TickLabelResult | null { + const timeMs = time / NS_PER_MS; + const labelText = formatMilliseconds(timeMs); + if (!labelText) { + return null; + } + + const label = getOrCreateLabel(labelText); + label.x = screenSpaceX - 3; + label.y = 5; + label.anchor.set(1, 0); + + return { label, isAnchor: false }; + } + + endFrame(): void { + // No post-frame work for elapsed time + } + + refreshColors(_textColor: string): void { + // No owned resources to update + } + + destroy(): void { + // No owned resources to clean up + } +} diff --git a/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts b/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts index d9fa2b79..7a007781 100644 --- a/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts @@ -7,12 +7,14 @@ * * Renders time scale axis with dynamic labels and tick marks using PixiJS Mesh. * Adapts tick density and label precision based on zoom level. + * Delegates label formatting to strategy renderers (elapsed vs wall-clock). * * Performance optimizations: * - Single Mesh draw call for all grid lines * - Direct buffer updates (no scene graph overhead) * - Clip-space coordinates (no uniform binding overhead) * - Labels use PIXI.Text (optimal for dynamic text with caching) + * - Cached per-frame allocations (Set, closure, viewport object) * * Tick levels (zoom-dependent): * - Seconds: Major ticks at 1s, 2s, 5s, 10s intervals @@ -22,15 +24,18 @@ */ import { Container, Geometry, Mesh, Shader, Text } from 'pixi.js'; -import { formatWallClockTime } from '../../../../core/utility/Util.js'; + import type { ViewportState } from '../../types/flamechart.types.js'; import { RectangleGeometry, type ViewportTransform } from '../RectangleGeometry.js'; import { createRectangleShader } from '../RectangleShader.js'; - -/** - * Nanoseconds per millisecond conversion constant. - */ -const NS_PER_MS = 1_000_000; +import { ClockTimeAxisRenderer } from './ClockTimeAxisRenderer.js'; +import { ElapsedTimeAxisRenderer } from './ElapsedTimeAxisRenderer.js'; +import { + NS_PER_MS, + applyAlphaToColor, + parseColorToHex, + selectInterval, +} from './timeAxisConstants.js'; /** * Axis rendering configuration. @@ -55,46 +60,92 @@ interface AxisConfig { /** * Time interval for tick marks. */ -interface TickInterval { +export interface TickInterval { /** Interval duration in nanoseconds */ interval: number; /** Skip factor (1 = show all, 2 = show every 2nd, 5 = show every 5th) */ skipFactor: number; } +/** + * Result from rendering a single tick label. + */ +export interface TickLabelResult { + label: Text; + isAnchor: boolean; +} + +/** + * Strategy interface for time axis label rendering. + */ +export interface TimeAxisLabelStrategy { + /** Adjust tick interval (e.g., wall-clock sets skipFactor to 1) */ + adjustTickInterval(interval: TickInterval): TickInterval; + /** Called before the tick loop to set up per-frame state */ + beginFrame(tickInterval: TickInterval, firstTickIndex: number, firstTimestampNs: number): void; + /** Format and position a label for a single tick. Returns the created label or null. */ + renderTickLabel( + time: number, + screenSpaceX: number, + getOrCreateLabel: (text: string) => Text, + tickIndex?: number, + ): TickLabelResult | null; + /** Called after the tick loop (e.g., wall-clock updates sticky label) */ + endFrame(screenSpaceContainer: Container | null, hasSubMsTicks: boolean): void; + /** Refresh colors after theme change */ + refreshColors(textColor: string): void; + /** Clean up resources */ + destroy(): void; +} + export class MeshAxisRenderer { - private parentContainer: Container; private geometry: RectangleGeometry; private shader: Shader; private mesh: Mesh; private labelsContainer: Container; private screenSpaceContainer: Container | null = null; private config: AxisConfig; - private labelCache: Map = new Map(); + /** Pool of reusable Text labels (index-based to support duplicate text) */ + private labelPool: Text[] = []; + /** Number of active labels in current frame */ + private activeLabelCount = 0; /** Grid line color */ private gridLineColor: number; - - /** Time display mode: 'elapsed' (default) or 'wallClock' */ - private displayMode: 'elapsed' | 'wallClock' = 'elapsed'; - /** Wall-clock time of the first event in ms since midnight (for wallClock mode) */ - private startTimeMs = 0; - /** Nanosecond timestamp of the first event (for wallClock mode) */ - private firstTimestampNs = 0; + /** Cached grid line color with alpha pre-applied (ABGR format) */ + private gridLineColorWithAlpha: number; + + /** Active label strategy */ + private strategy: TimeAxisLabelStrategy; + + /** Cached Set reused across frames to track rendered pixel positions */ + private renderedPixels = new Set(); + /** Cached bound method for getOrCreateLabel to avoid closure allocation per frame */ + private boundGetOrCreateLabel: (text: string) => Text; + /** Cached viewport transform object reused across frames */ + private cachedViewportTransform: ViewportTransform = { + offsetX: 0, + offsetY: 0, + displayWidth: 0, + displayHeight: 0, + canvasYOffset: 0, + }; constructor(container: Container, config?: Partial) { - this.parentContainer = container; - // Default configuration this.config = { height: 30, - lineColor: 0x808080, // Medium gray that works in light and dark themes - textColor: '#808080', // Medium gray that works in light and dark themes + lineColor: 0x808080, + textColor: '#808080', fontSize: 11, minLabelSpacing: 80, ...config, }; this.gridLineColor = this.config.lineColor; + this.gridLineColorWithAlpha = applyAlphaToColor( + this.gridLineColor, + this.config.gridAlpha ?? 1.0, + ); // Create geometry and shader for grid lines this.geometry = new RectangleGeometry(); @@ -110,6 +161,12 @@ export class MeshAxisRenderer { // Labels container - will be added to screen space container when provided this.labelsContainer = new Container(); + + // Default to elapsed time strategy + this.strategy = new ElapsedTimeAxisRenderer(); + + // Bind once in constructor instead of creating a closure each frame + this.boundGetOrCreateLabel = this.getOrCreateLabel.bind(this); } /** @@ -157,8 +214,11 @@ export class MeshAxisRenderer { // Calculate appropriate tick interval based on zoom const tickInterval = this.calculateTickInterval(viewport); + // Let strategy adjust the tick interval (e.g., wall-clock sets skipFactor to 1) + const adjustedInterval = this.strategy.adjustTickInterval(tickInterval); + // Render tick marks (vertical lines from top to bottom, behind rectangles) - this.renderTicks(viewport, timeStart, timeEnd, tickInterval, gridHeight); + this.renderTicks(viewport, timeStart, timeEnd, adjustedInterval, gridHeight); } /** @@ -172,17 +232,26 @@ export class MeshAxisRenderer { // Update grid line color const lineColorStr = computedStyle.getPropertyValue('--vscode-editorLineNumber-foreground').trim() || '#808080'; - this.gridLineColor = this.parseColorToHex(lineColorStr); + this.gridLineColor = parseColorToHex(lineColorStr); this.config.lineColor = this.gridLineColor; + // Update cached color with alpha + this.gridLineColorWithAlpha = applyAlphaToColor( + this.gridLineColor, + this.config.gridAlpha ?? 1.0, + ); + // Update text color this.config.textColor = computedStyle.getPropertyValue('--vscode-editorLineNumber-foreground').trim() || '#808080'; // Update existing labels with new color - for (const label of this.labelCache.values()) { + for (const label of this.labelPool) { label.style.fill = this.config.textColor; } + + // Update strategy colors + this.strategy.refreshColors(this.config.textColor); } /** @@ -194,62 +263,19 @@ export class MeshAxisRenderer { startTimeMs: number, firstTimestampNs: number, ): void { - this.displayMode = mode; - this.startTimeMs = startTimeMs; - this.firstTimestampNs = firstTimestampNs; - } - - /** - * Apply alpha to a color by pre-multiplying into ABGR format for the shader. - * The shader expects colors in ABGR format with alpha in the high byte. - */ - private applyAlphaToColor(color: number, alpha: number): number { - if (alpha >= 1.0) { - // Full alpha - pack as opaque ABGR - const r = (color >> 16) & 0xff; - const g = (color >> 8) & 0xff; - const b = color & 0xff; - return (0xff << 24) | (b << 16) | (g << 8) | r; - } - // Pre-multiply alpha into ABGR format - const r = (color >> 16) & 0xff; - const g = (color >> 8) & 0xff; - const b = color & 0xff; - const a = Math.round(alpha * 255); - return (a << 24) | (b << 16) | (g << 8) | r; - } - - /** - * Parse CSS color string to numeric hex. - */ - private parseColorToHex(cssColor: string): number { - if (!cssColor) { - return 0x808080; - } - - if (cssColor.startsWith('#')) { - const hex = cssColor.slice(1); - if (hex.length === 6) { - return parseInt(hex, 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 - const rgba = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\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; + // Destroy the old strategy to clean up resources + this.strategy.destroy(); + + if (mode === 'wallClock') { + this.strategy = new ClockTimeAxisRenderer( + startTimeMs, + firstTimestampNs, + this.config.fontSize, + this.config.textColor, + ); + } else { + this.strategy = new ElapsedTimeAxisRenderer(); } - - return 0x808080; } /** @@ -259,7 +285,9 @@ export class MeshAxisRenderer { this.geometry.destroy(); this.mesh.destroy(); this.labelsContainer.destroy(); - this.labelCache.clear(); + this.labelPool.length = 0; + this.activeLabelCount = 0; + this.strategy.destroy(); } // ============================================================================ @@ -283,7 +311,7 @@ export class MeshAxisRenderer { const targetIntervalMs = targetIntervalNs / NS_PER_MS; // Find appropriate interval using 1-2-5 sequence - const { interval, skipFactor } = this.selectInterval(targetIntervalMs); + const { interval, skipFactor } = selectInterval(targetIntervalMs); return { interval: interval * NS_PER_MS, // Convert back to nanoseconds @@ -291,70 +319,6 @@ export class MeshAxisRenderer { }; } - /** - * Select appropriate interval using 1-2-5 sequence. - * Returns interval in milliseconds and skip factor for label density. - */ - private selectInterval(targetMs: number): { interval: number; skipFactor: number } { - // Base intervals using 1-2-5 sequence - // Extended to support 0.001ms (1 microsecond) precision when zoomed way in - const baseIntervals = [ - // Sub-millisecond (microseconds in ms) - 0.001, // 1 microsecond - 0.002, // 2 microseconds - 0.005, // 5 microseconds - // Tens of microseconds - 0.01, // 10 microseconds - 0.02, // 20 microseconds - 0.05, // 50 microseconds - // Hundreds of microseconds - 0.1, // 100 microseconds - 0.2, // 200 microseconds - 0.5, // 500 microseconds - // Milliseconds - 1, - 2, - 5, - 10, - 20, - 50, - 100, - 200, - 500, - // Seconds - 1000, - 2000, - 5000, - 10000, - ]; - - // Find smallest interval >= targetMs - let interval = baseIntervals[baseIntervals.length - 1] ?? 1000; - for (const candidate of baseIntervals) { - if (candidate >= targetMs) { - interval = candidate; - break; - } - } - - // Default skip factor of 1 (show all labels) - let skipFactor = 1; - - // If labels are still too close, increase skip factor - // This happens when zoomed way out - if (interval >= 1000) { - // For large intervals (1s+), potentially skip every 2nd or 5th - if (targetMs > interval * 1.5) { - skipFactor = 2; - } - if (targetMs > interval * 3) { - skipFactor = 5; - } - } - - return { interval, skipFactor }; - } - // ============================================================================ // PRIVATE: RENDERING // ============================================================================ @@ -371,7 +335,7 @@ export class MeshAxisRenderer { ): void { const effectiveHeight = gridHeight ?? viewport.displayHeight; const showLabels = this.config.showLabels !== false; - const gridAlpha = this.config.gridAlpha ?? 1.0; + // Calculate first tick position (snap to interval boundary) // Go back one extra tick to ensure we cover the left edge const firstTickIndex = Math.floor(timeStart / tickInterval.interval) - 1; @@ -380,32 +344,26 @@ export class MeshAxisRenderer { // Go forward one extra tick to ensure we cover the right edge const lastTickIndex = Math.ceil(timeEnd / tickInterval.interval) + 1; - // Track rendered pixel positions to prevent duplicates - const renderedPixels = new Set(); + // Reuse cached Set instead of allocating new one each frame + this.renderedPixels.clear(); // Count ticks for buffer allocation const maxTicks = lastTickIndex - firstTickIndex + 1; this.geometry.ensureCapacity(maxTicks); - // Create viewport transform for coordinate conversion - // Note: offsetY is 0 because axis grid lines should span full screen height - // regardless of vertical panning - // No canvasYOffset needed - main timeline has its own canvas - const viewportTransform: ViewportTransform = { - offsetX: viewport.offsetX, - offsetY: 0, // Full-height elements ignore Y pan - displayWidth: viewport.displayWidth, - displayHeight: effectiveHeight, - canvasYOffset: 0, - }; - - // Pre-multiply color with alpha for grid lines - const gridLineColorWithAlpha = this.applyAlphaToColor(this.gridLineColor, gridAlpha); + // Update cached viewport transform in-place + const viewportTransform = this.cachedViewportTransform; + viewportTransform.offsetX = viewport.offsetX; + viewportTransform.offsetY = 0; // Full-height elements ignore Y pan + viewportTransform.displayWidth = viewport.displayWidth; + viewportTransform.displayHeight = effectiveHeight; + viewportTransform.canvasYOffset = 0; let rectIndex = 0; + const hasSubMsTicks = tickInterval.interval < NS_PER_MS; - const isWallClockDisplay = this.displayMode === 'wallClock'; - const isIntervalMsOrMore = tickInterval.interval >= NS_PER_MS; + // Notify strategy of frame start + this.strategy.beginFrame(tickInterval, firstTickIndex, firstTickIndex * tickInterval.interval); // Render all ticks in range for (let i = firstTickIndex; i <= lastTickIndex; i++) { @@ -416,10 +374,10 @@ export class MeshAxisRenderer { const pixelX = Math.round(screenX); // Skip if we already rendered a line at this pixel position - if (renderedPixels.has(pixelX)) { + if (this.renderedPixels.has(pixelX)) { continue; } - renderedPixels.add(pixelX); + this.renderedPixels.add(pixelX); // Calculate if this tick should show a label based on global position // This ensures labels stay consistent when panning @@ -432,39 +390,24 @@ export class MeshAxisRenderer { 0, 1, effectiveHeight, - gridLineColorWithAlpha, + this.gridLineColorWithAlpha, viewportTransform, ); rectIndex++; // Add label at top if requested (only when showLabels is enabled) if (showLabels && shouldShowLabel && this.screenSpaceContainer) { - const timeMs = time / NS_PER_MS; - const useWallClock = isWallClockDisplay && (isIntervalMsOrMore || time % NS_PER_MS === 0); - const labelText = useWallClock - ? formatWallClockTime(this.startTimeMs + (time - this.firstTimestampNs) / NS_PER_MS) - : this.formatMilliseconds(timeMs); - - // Only show label if not empty (skip zero) - if (labelText) { - const label = this.getOrCreateLabel(labelText); - - // Calculate screen-space X position (accounting for stage pan) - // screenX is in world space, need to convert to screen space - const screenSpaceX = screenX - viewport.offsetX; - - // Position label in screen space (top-left origin, Y pointing down) - // No minimap offset needed - main timeline has its own canvas - label.x = screenSpaceX - 3; // 3px to the left of line - label.y = 5; // 5px from top - label.anchor.set(1, 0); // Right-align to line, align top - } + const screenSpaceX = screenX - viewport.offsetX; + this.strategy.renderTickLabel(time, screenSpaceX, this.boundGetOrCreateLabel, i); } } // Set draw count and make visible this.geometry.setDrawCount(rectIndex); this.mesh.visible = true; + + // Notify strategy of frame end + this.strategy.endFrame(this.screenSpaceContainer, hasSubMsTicks); } // ============================================================================ @@ -472,11 +415,12 @@ export class MeshAxisRenderer { // ============================================================================ /** - * Get or create a PIXI.Text label from cache. - * Reuses labels to avoid constant object creation. + * Get or create a PIXI.Text label from the pool. + * Uses index-based pooling so the same text can appear at multiple positions. */ private getOrCreateLabel(text: string): Text { - let label = this.labelCache.get(text); + const index = this.activeLabelCount++; + let label = this.labelPool[index]; if (!label) { label = new Text({ @@ -487,13 +431,13 @@ export class MeshAxisRenderer { fill: this.config.textColor, }, }); - this.labelCache.set(text, label); + this.labelPool.push(label); this.labelsContainer.addChild(label); + } else { + label.text = text; } - // Make label visible label.visible = true; - return label; } @@ -501,37 +445,10 @@ export class MeshAxisRenderer { * Hide all labels (for next render pass). */ private clearLabels(): void { - for (const label of this.labelCache.values()) { - label.visible = false; - } - } - - // ============================================================================ - // PRIVATE: FORMATTING - // ============================================================================ - - /** - * Format time with appropriate units and precision. - * - Whole seconds: "1 s", "2 s" (not "1000 ms") - * - Milliseconds: up to 3 decimal places: "18800.345 ms" - * - Omit zero: don't show "0 s" or "0 ms", just start from first non-zero - */ - private formatMilliseconds(timeMs: number): string { - // Omit zero - if (timeMs === 0) { - return ''; - } - - // Convert to seconds if >= 1000ms and whole seconds - if (timeMs >= 1000 && timeMs % 1000 === 0) { - const seconds = timeMs / 1000; - return `${seconds} s`; + for (let i = 0; i < this.activeLabelCount; i++) { + this.labelPool[i]!.visible = false; } - - // Format as milliseconds with up to 3 decimal places - // Remove trailing zeros after decimal point - const formatted = timeMs.toFixed(3); - const trimmed = formatted.replace(/\.?0+$/, ''); - return `${trimmed} ms`; + this.activeLabelCount = 0; + // Strategy handles its own label cleanup (e.g., sticky label) in beginFrame } } diff --git a/log-viewer/src/features/timeline/optimised/time-axis/TimeGridCalculator.ts b/log-viewer/src/features/timeline/optimised/time-axis/TimeGridCalculator.ts index 668d9c84..5c0e205f 100644 --- a/log-viewer/src/features/timeline/optimised/time-axis/TimeGridCalculator.ts +++ b/log-viewer/src/features/timeline/optimised/time-axis/TimeGridCalculator.ts @@ -12,6 +12,8 @@ * human-readable time units (microseconds, milliseconds, seconds). */ +import { NS_PER_MS, selectInterval } from './timeAxisConstants.js'; + /** * 1-2-5 sequence intervals in nanoseconds for time grid lines. * Covers from 1 microsecond to 50 seconds. @@ -106,46 +108,7 @@ export function calculateLastGridLineTime(timeEndNs: number, intervalNs: number) } /** - * Base intervals in milliseconds using 1-2-5 sequence. - * Matches AxisRenderer's baseIntervals for consistent grid alignment. - */ -const BASE_INTERVALS_MS: readonly number[] = [ - // Sub-millisecond (microseconds in ms) - 0.001, // 1 microsecond - 0.002, // 2 microseconds - 0.005, // 5 microseconds - // Tens of microseconds - 0.01, // 10 microseconds - 0.02, // 20 microseconds - 0.05, // 50 microseconds - // Hundreds of microseconds - 0.1, // 100 microseconds - 0.2, // 200 microseconds - 0.5, // 500 microseconds - // Milliseconds - 1, - 2, - 5, - 10, - 20, - 50, - 100, - 200, - 500, - // Seconds - 1000, - 2000, - 5000, - 10000, -] as const; - -/** - * Nanoseconds per millisecond conversion constant. - */ -const NS_PER_MS = 1_000_000; - -/** - * Calculate grid interval in nanoseconds using AxisRenderer's exact logic. + * Calculate grid interval in nanoseconds using the shared 1-2-5 sequence logic. * Ensures metric strip and main timeline use identical intervals. * * @param zoom - Pixels per nanosecond @@ -158,15 +121,6 @@ export function calculateGridIntervalNs( ): number { const targetIntervalNs = minSpacingPx / zoom; const targetIntervalMs = targetIntervalNs / NS_PER_MS; - - // Find smallest interval >= targetMs (same logic as AxisRenderer.selectInterval) - let intervalMs = BASE_INTERVALS_MS[BASE_INTERVALS_MS.length - 1]!; - for (const candidate of BASE_INTERVALS_MS) { - if (candidate >= targetIntervalMs) { - intervalMs = candidate; - break; - } - } - - return intervalMs * NS_PER_MS; + const { interval } = selectInterval(targetIntervalMs); + return interval * NS_PER_MS; } diff --git a/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts b/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts new file mode 100644 index 00000000..53c29ab2 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/time-axis/timeAxisConstants.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Shared constants and utilities for time-axis rendering. + * + * Consolidates duplicated logic from MeshAxisRenderer, AxisRenderer, + * ClockTimeAxisRenderer, ElapsedTimeAxisRenderer, and TimeGridCalculator. + */ + +/** + * Nanoseconds per millisecond conversion constant. + */ +export const NS_PER_MS = 1_000_000; + +/** + * Label positioning offsets in pixels. + */ +export const LABEL_OFFSET_X = 3; +export const LABEL_OFFSET_Y = 5; + +/** + * 1-2-5 sequence intervals in milliseconds for tick selection. + * Covers from 1 microsecond (0.001 ms) to 10 seconds. + */ +const BASE_INTERVALS_MS: readonly number[] = [ + // Sub-millisecond (microseconds in ms) + 0.001, 0.002, 0.005, + // Tens of microseconds + 0.01, 0.02, 0.05, + // Hundreds of microseconds + 0.1, 0.2, 0.5, + // Milliseconds + 1, 2, 5, 10, 20, 50, 100, 200, 500, + // Seconds + 1000, 2000, 5000, 10000, +] as const; + +/** + * Select appropriate interval using 1-2-5 sequence. + * Returns interval in milliseconds and skip factor for label density. + */ +export function selectInterval(targetMs: number): { interval: number; skipFactor: number } { + // Find smallest interval >= targetMs + let interval = BASE_INTERVALS_MS[BASE_INTERVALS_MS.length - 1] ?? 1000; + for (const candidate of BASE_INTERVALS_MS) { + if (candidate >= targetMs) { + interval = candidate; + break; + } + } + + // Default skip factor of 1 (show all labels) + let skipFactor = 1; + + // If labels are still too close, increase skip factor + // This happens when zoomed way out + if (interval >= 1000) { + if (targetMs > interval * 1.5) { + skipFactor = 2; + } + if (targetMs > interval * 3) { + skipFactor = 5; + } + } + + return { interval, skipFactor }; +} + +/** + * Parse CSS color string to numeric hex. + */ +export function parseColorToHex(cssColor: string): number { + if (!cssColor) { + return 0x808080; + } + + if (cssColor.startsWith('#')) { + const hex = cssColor.slice(1); + if (hex.length === 6) { + return parseInt(hex, 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 + const rgba = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\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 0x808080; +} + +/** + * Apply alpha to a color by pre-multiplying into ABGR format for the shader. + * The shader expects colors in ABGR format with alpha in the high byte. + */ +export function applyAlphaToColor(color: number, alpha: number): number { + const r = (color >> 16) & 0xff; + const g = (color >> 8) & 0xff; + const b = color & 0xff; + if (alpha >= 1.0) { + return (0xff << 24) | (b << 16) | (g << 8) | r; + } + const a = Math.round(alpha * 255); + return (a << 24) | (b << 16) | (g << 8) | r; +} + +/** + * Format time with appropriate units and precision. + * - Whole seconds: "1 s", "2 s" (not "1000 ms") + * - Milliseconds: up to 3 decimal places: "18800.345 ms" + * - Omit zero: don't show "0 s" or "0 ms" + */ +export function formatMilliseconds(timeMs: number): string { + if (timeMs === 0) { + return ''; + } + + if (timeMs >= 1000 && timeMs % 1000 === 0) { + const seconds = timeMs / 1000; + return `${seconds} s`; + } + + const formatted = timeMs.toFixed(3); + const trimmed = formatted.replace(/\.?0+$/, ''); + return `${trimmed} ms`; +}