Skip to content

Commit 26bd1b4

Browse files
Merge pull request #701 from lukecotter/feat-timeline-method-text
feat: Add text label rendering for timeline events with search-aware styles
2 parents d874e53 + 5cc47ec commit 26bd1b4

6 files changed

Lines changed: 698 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
-**Timeline**: A brand new **experimental** timeline Flame Chart that is up to **7X faster**. ([#446] [#251] [#92])
1313
- Enable it via **Settings -> Apex Log Analyzer -> Timeline -> Experimental -> Timeline**.
1414
- Generally Improved performance, especially for large logs.
15+
- Text labels on Timeline events.
1516
- Zoom and pan are now **7X faster**.
1617
- The Time axis scales more naturally when zooming, with larger gaps between the markers on longer logs.
1718
- Search + highlight will grey out non matches to find matches more easily.

log-viewer/src/features/timeline/optimised/FlameChart.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { RectangleManager } from './RectangleManager.js';
3030
import { SearchHighlightRenderer } from './SearchHighlightRenderer.js';
3131
import { SearchManager } from './SearchManager.js';
3232
import { SearchStyleRenderer } from './SearchStyleRenderer.js';
33+
import { SearchTextLabelRenderer } from './SearchTextLabelRenderer.js';
34+
import { TextLabelRenderer } from './TextLabelRenderer.js';
3335
import { TimelineEventIndex } from './TimelineEventIndex.js';
3436
import { TimelineInteractionHandler } from './TimelineInteractionHandler.js';
3537
import { TimelineMarkerRenderer } from './TimelineMarkerRenderer.js';
@@ -155,6 +157,8 @@ export class FlameChart<E extends EventNode = EventNode> {
155157

156158
private searchStyleRenderer: SearchStyleRenderer | null = null;
157159
private searchRenderer: SearchHighlightRenderer | null = null;
160+
private textLabelRenderer: TextLabelRenderer | null = null;
161+
private searchTextLabelRenderer: SearchTextLabelRenderer | null = null;
158162

159163
private worldContainer: PIXI.Container | null = null;
160164
private axisContainer: PIXI.Container | null = null;
@@ -271,6 +275,21 @@ export class FlameChart<E extends EventNode = EventNode> {
271275
this.searchStyleRenderer = new SearchStyleRenderer(this.worldContainer, this.state.batches);
272276
}
273277

278+
// Create text label renderer (renders method names on rectangles)
279+
if (this.worldContainer) {
280+
this.textLabelRenderer = new TextLabelRenderer(this.worldContainer);
281+
await this.textLabelRenderer.loadFont();
282+
283+
// SearchTextLabelRenderer uses composition - delegates matched labels to TextLabelRenderer
284+
this.searchTextLabelRenderer = new SearchTextLabelRenderer(
285+
this.worldContainer,
286+
this.textLabelRenderer,
287+
);
288+
289+
// Enable zIndex sorting for proper layering
290+
this.worldContainer.sortableChildren = true;
291+
}
292+
274293
// Setup interaction handler
275294
this.setupInteractionHandler();
276295

@@ -313,6 +332,17 @@ export class FlameChart<E extends EventNode = EventNode> {
313332
this.searchStyleRenderer = null;
314333
}
315334

335+
// Clean up text label renderer
336+
if (this.searchTextLabelRenderer) {
337+
this.searchTextLabelRenderer.destroy();
338+
this.searchTextLabelRenderer = null;
339+
}
340+
341+
if (this.textLabelRenderer) {
342+
this.textLabelRenderer.destroy();
343+
this.textLabelRenderer = null;
344+
}
345+
316346
// Clean up renderers
317347
if (this.batchRenderer) {
318348
this.batchRenderer.destroy();
@@ -771,6 +801,25 @@ export class FlameChart<E extends EventNode = EventNode> {
771801
}
772802
}
773803

804+
// Render text labels (with or without search styling)
805+
if (cursor && cursor.total > 0) {
806+
// Search mode: SearchTextLabelRenderer coordinates both matched and unmatched labels
807+
const matchedEventIds = cursor.getMatchedEventIds();
808+
if (this.searchTextLabelRenderer) {
809+
this.searchTextLabelRenderer.render(culledRects, matchedEventIds, viewportState);
810+
}
811+
} else {
812+
// Normal mode: render all visible text
813+
if (this.textLabelRenderer) {
814+
this.textLabelRenderer.render(culledRects, viewportState);
815+
}
816+
817+
// Clear search text renderer when not in search mode
818+
if (this.searchTextLabelRenderer) {
819+
this.searchTextLabelRenderer.clear();
820+
}
821+
}
822+
774823
this.app.render();
775824
}
776825
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright (c) 2025 Certinia Inc. All rights reserved.
3+
*/
4+
5+
/**
6+
* SearchTextLabelRenderer
7+
*
8+
* Renders text labels with search-aware styling using composition.
9+
* Delegates matched event labels to TextLabelRenderer (full opacity).
10+
* Renders non-matched event labels itself (dimmed at 0.4 alpha).
11+
*
12+
* Architecture:
13+
* - Uses TextLabelRenderer for matched text (full opacity 1.0)
14+
* - Manages own container/labels for unmatched text (dimmed 0.4)
15+
* - No font loading needed (shares font with TextLabelRenderer)
16+
*/
17+
18+
import { BitmapText, Container } from 'pixi.js';
19+
import type { ViewportState } from '../types/flamechart.types.js';
20+
import { TEXT_LABEL_CONSTANTS, TIMELINE_CONSTANTS } from '../types/flamechart.types.js';
21+
import type { PrecomputedRect } from './RectangleManager.js';
22+
import type { TextLabelRenderer } from './TextLabelRenderer.js';
23+
24+
/** Alpha value for dimmed (non-matched) labels */
25+
const DIMMED_ALPHA = 0.4;
26+
27+
/**
28+
* SearchTextLabelRenderer
29+
*
30+
* Manages BitmapText labels for timeline events in search mode.
31+
* Renders matched events via TextLabelRenderer, non-matched events dimmed.
32+
*/
33+
export class SearchTextLabelRenderer {
34+
/** Container for unmatched (dimmed) text labels */
35+
private container: Container;
36+
37+
/** Labels for unmatched events keyed by rectangle ID */
38+
private labels: Map<string, BitmapText> = new Map();
39+
40+
/**
41+
* Create a new SearchTextLabelRenderer.
42+
*
43+
* @param parentContainer - The worldContainer to add labels to
44+
* @param textLabelRenderer - TextLabelRenderer instance for rendering matched labels
45+
*/
46+
constructor(
47+
parentContainer: Container,
48+
private textLabelRenderer: TextLabelRenderer,
49+
) {
50+
this.container = new Container();
51+
this.container.zIndex = TEXT_LABEL_CONSTANTS.Z_INDEX;
52+
this.container.label = 'SearchTextLabelRenderer';
53+
parentContainer.addChild(this.container);
54+
}
55+
56+
/**
57+
* Render text labels with search-aware styling.
58+
* Matched events are rendered by TextLabelRenderer (full opacity).
59+
* Non-matched events are rendered here (dimmed).
60+
*
61+
* @param culledRects - Rectangles grouped by category (from RectangleManager)
62+
* @param matchedEventIds - Set of event IDs that match search
63+
* @param viewport - Current viewport state for sticky label positioning
64+
*/
65+
public render(
66+
culledRects: Map<string, PrecomputedRect[]>,
67+
matchedEventIds: ReadonlySet<string>,
68+
viewport: ViewportState,
69+
): void {
70+
// Reset visibility for unmatched labels (managed by this renderer)
71+
for (const label of this.labels.values()) {
72+
label.visible = false;
73+
}
74+
75+
// Render unmatched events (dimmed) - managed by this renderer
76+
this.renderUnmatchedLabels(culledRects, matchedEventIds, viewport);
77+
78+
// Filter to matched rects only, then delegate to TextLabelRenderer
79+
const matchedRects = this.filterMatchedRects(culledRects, matchedEventIds);
80+
this.textLabelRenderer.render(matchedRects, viewport);
81+
}
82+
83+
/**
84+
* Filter culledRects to only include rectangles that match the search.
85+
*
86+
* @param culledRects - All visible rectangles grouped by category
87+
* @param matchedEventIds - Set of event IDs that match search
88+
* @returns Filtered map containing only matched rectangles
89+
*/
90+
private filterMatchedRects(
91+
culledRects: Map<string, PrecomputedRect[]>,
92+
matchedEventIds: ReadonlySet<string>,
93+
): Map<string, PrecomputedRect[]> {
94+
const result = new Map<string, PrecomputedRect[]>();
95+
for (const [category, rects] of culledRects) {
96+
const matched = rects.filter((r) => matchedEventIds.has(r.id));
97+
if (matched.length > 0) {
98+
result.set(category, matched);
99+
}
100+
}
101+
return result;
102+
}
103+
104+
/**
105+
* Render labels for non-matched events with dimmed opacity.
106+
*
107+
* @param culledRects - Rectangles grouped by category
108+
* @param matchedEventIds - Set of matched event IDs
109+
* @param viewport - Current viewport state
110+
*/
111+
private renderUnmatchedLabels(
112+
culledRects: Map<string, PrecomputedRect[]>,
113+
matchedEventIds: ReadonlySet<string>,
114+
viewport: ViewportState,
115+
): void {
116+
const viewportLeftEdge = viewport.offsetX;
117+
const stickyLeftX = viewportLeftEdge + TEXT_LABEL_CONSTANTS.PADDING_LEFT;
118+
119+
const fontHeightAdjustment = (TIMELINE_CONSTANTS.EVENT_HEIGHT - 4) / 2;
120+
const fontSize = TIMELINE_CONSTANTS.EVENT_HEIGHT - fontHeightAdjustment;
121+
const fontYPositionOffset = TIMELINE_CONSTANTS.EVENT_HEIGHT - fontHeightAdjustment / 2;
122+
123+
for (const rects of culledRects.values()) {
124+
for (const rect of rects) {
125+
// Only process non-matched events
126+
if (matchedEventIds.has(rect.id)) {
127+
continue;
128+
}
129+
130+
// LOD: Skip small rectangles
131+
if (rect.width < TEXT_LABEL_CONSTANTS.MIN_VISIBLE_WIDTH) {
132+
continue;
133+
}
134+
135+
const text = rect.eventRef.text;
136+
if (!text) {
137+
continue;
138+
}
139+
140+
// Calculate sticky label position
141+
const rectLeftX = rect.x + TEXT_LABEL_CONSTANTS.PADDING_LEFT;
142+
const rectRightX = rect.x + rect.width - TEXT_LABEL_CONSTANTS.PADDING_RIGHT;
143+
const labelX = Math.max(rectLeftX, stickyLeftX);
144+
145+
// Calculate available width
146+
const availableWidth = rectRightX - labelX;
147+
if (availableWidth < TEXT_LABEL_CONSTANTS.MIN_VISIBLE_WIDTH) {
148+
continue;
149+
}
150+
151+
// Calculate truncated text
152+
const truncated = this.truncateText(text, availableWidth);
153+
if (!truncated) {
154+
continue;
155+
}
156+
157+
// Lazy create or reuse label
158+
let label = this.labels.get(rect.id);
159+
if (!label) {
160+
label = new BitmapText({
161+
text: '',
162+
style: {
163+
fontFamily: TEXT_LABEL_CONSTANTS.FONT.FAMILY,
164+
fontSize: fontSize,
165+
},
166+
});
167+
label.scale.y = -1; // Compensate for worldContainer Y-axis inversion
168+
this.container.addChild(label);
169+
this.labels.set(rect.id, label);
170+
}
171+
172+
// Update label
173+
label.text = truncated;
174+
label.x = labelX;
175+
label.y = rect.y + fontYPositionOffset;
176+
label.alpha = DIMMED_ALPHA;
177+
label.visible = true;
178+
}
179+
}
180+
}
181+
182+
/**
183+
* Hide all unmatched labels (set visible = false).
184+
* Called when switching to normal mode.
185+
* Note: Does NOT clear TextLabelRenderer - FlameChart manages that separately.
186+
*/
187+
public clear(): void {
188+
for (const label of this.labels.values()) {
189+
label.visible = false;
190+
}
191+
}
192+
193+
/**
194+
* Clean up all labels and remove from container.
195+
* Called when FlameChart is destroyed.
196+
* Note: Does NOT destroy TextLabelRenderer (managed by FlameChart).
197+
*/
198+
public destroy(): void {
199+
for (const label of this.labels.values()) {
200+
label.destroy();
201+
}
202+
this.labels.clear();
203+
this.container.destroy();
204+
}
205+
206+
/**
207+
* Truncate text to fit within available width using middle truncation.
208+
* Preserves both the beginning and end of the text for better context.
209+
*
210+
* @param text - The text to truncate
211+
* @param availableWidth - Available width in pixels for the text
212+
* @returns Truncated text with ellipsis, or null if too narrow
213+
*/
214+
private truncateText(text: string, availableWidth: number): string | null {
215+
const maxChars = Math.floor(availableWidth / TEXT_LABEL_CONSTANTS.CHAR_WIDTH);
216+
217+
if (maxChars < TEXT_LABEL_CONSTANTS.MIN_CHARS_WITH_ELLIPSIS) {
218+
return null;
219+
}
220+
221+
if (text.length <= maxChars) {
222+
return text;
223+
}
224+
225+
if (maxChars <= 3) {
226+
return text.slice(0, maxChars - 1) + TEXT_LABEL_CONSTANTS.ELLIPSIS;
227+
}
228+
229+
const charsAvailable = maxChars - 1;
230+
const startChars = Math.ceil(charsAvailable / 2);
231+
const endChars = Math.floor(charsAvailable / 2);
232+
233+
return (
234+
text.slice(0, startChars) + TEXT_LABEL_CONSTANTS.ELLIPSIS + text.slice(text.length - endChars)
235+
);
236+
}
237+
}

0 commit comments

Comments
 (0)