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`;
+}