Skip to content
Open
29 changes: 28 additions & 1 deletion src/app-logic/url-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
NativeSymbolInfo,
Transform,
IndexIntoFrameTable,
MarkerIndex,
SelectedMarkersPerThread,
} from 'firefox-profiler/types';
import {
decodeUintArrayFromUrlComponent,
Expand All @@ -49,7 +51,7 @@ import {
import { tabSlugs } from '../app-logic/tabs-handling';
import { StringTable } from 'firefox-profiler/utils/string-table';

export const CURRENT_URL_VERSION = 13;
export const CURRENT_URL_VERSION = 14;

/**
* This static piece of state might look like an anti-pattern, but it's a relatively
Expand Down Expand Up @@ -184,6 +186,7 @@ type CallTreeQuery = BaseQuery & {

type MarkersQuery = BaseQuery & {
markerSearch: string; // "DOMEvent"
marker?: MarkerIndex; // Selected marker index for the current thread, e.g. 42
};

type NetworkQuery = BaseQuery & {
Expand Down Expand Up @@ -218,6 +221,7 @@ type Query = BaseQuery & {

// Markers specific
markerSearch?: string;
marker?: MarkerIndex;

// Network specific
networkSearch?: string;
Expand Down Expand Up @@ -367,6 +371,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
query = baseQuery as MarkersQueryShape;
query.markerSearch =
urlState.profileSpecific.markersSearchString || undefined;
query.marker =
selectedThreadsKey !== null &&
urlState.profileSpecific.selectedMarkers[selectedThreadsKey] !== null
? urlState.profileSpecific.selectedMarkers[selectedThreadsKey]
: undefined;
break;
case 'network-chart':
query = baseQuery as NetworkQueryShape;
Expand Down Expand Up @@ -499,6 +508,19 @@ export function stateFromLocation(
transforms[selectedThreadsKey] = parseTransforms(query.transforms);
}

// Parse the selected marker for the current thread
const selectedMarkers: SelectedMarkersPerThread = {};
if (
selectedThreadsKey !== null &&
query.marker !== undefined &&
query.marker !== null
) {
const markerIndex = Number(query.marker);
if (!isNaN(markerIndex)) {
selectedMarkers[selectedThreadsKey] = markerIndex;
}
}

// tabID is used for the tab selector that we have in our full view.
let tabID = null;
if (query.tabID && Number.isInteger(Number(query.tabID))) {
Expand Down Expand Up @@ -587,6 +609,7 @@ export function stateFromLocation(
legacyHiddenThreads: query.hiddenThreads
? query.hiddenThreads.split('-').map((index) => Number(index))
: null,
selectedMarkers,
},
};
}
Expand Down Expand Up @@ -1196,6 +1219,10 @@ const _upgraders: {
[13]: (_) => {
// just added the focus-self transform
},
[14]: (_) => {
// Added marker parameter for persisting highlighted markers in URLs.
// This is backward compatible as the marker parameter is optional.
},
};

for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) {
Expand Down
125 changes: 124 additions & 1 deletion src/components/marker-chart/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,42 @@ function getDefaultMarkerColors(isHighlighted: boolean) {
class MarkerChartCanvasImpl extends React.PureComponent<Props> {
_textMeasurement: TextMeasurement | null = null;

override componentDidUpdate(prevProps: Props) {
// When the viewport finishes sizing itself, or when the selected marker changes,
// scroll to bring the selected marker into view (e.g. on initial load from URL).
const viewportDidMount =
!prevProps.viewport.isSizeSet && this.props.viewport.isSizeSet;
const selectedMarkerChanged =
this.props.selectedMarkerIndex !== prevProps.selectedMarkerIndex;

if (viewportDidMount || selectedMarkerChanged) {
this._scrollSelectionIntoView();
}
}

_scrollSelectionIntoView = () => {
const { selectedMarkerIndex, markerTimingAndBuckets, rowHeight, viewport } =
this.props;

if (selectedMarkerIndex === null) {
return;
}

const markerIndexToTimingRow = this._getMarkerIndexToTimingRow(
markerTimingAndBuckets
);
const rowIndex = markerIndexToTimingRow[selectedMarkerIndex];
const y: CssPixels = rowIndex * rowHeight;
const { viewportTop, viewportBottom } = viewport;

if (y < viewportTop || y + rowHeight > viewportBottom) {
// Scroll the marker to the vertical center of the viewport.
const viewportHeight = viewportBottom - viewportTop;
const targetViewportTop = y + rowHeight / 2 - viewportHeight / 2;
viewport.moveViewport(0, viewportTop - targetViewportTop);
}
};

/**
* Get the fill, stroke, and text colors for a marker based on its schema and data.
* If the marker schema has a colorField, use that field's value.
Expand Down Expand Up @@ -957,8 +993,92 @@ class MarkerChartCanvasImpl extends React.PureComponent<Props> {
);
};

/**
* Calculate the canvas position for a marker's tooltip based on the marker's index.
* The method is used when the selected marker comes from the URL and does NOT
* necessarily correspond to what's being hovered at the moment.
*
* @param markerIndex - The marker's index in the original thread's marker table
* @returns Canvas-relative coordinates {offsetX, offsetY} or null if marker not visible
*/
getTooltipPosition = (
markerIndex: MarkerIndex
): { offsetX: CssPixels; offsetY: CssPixels } | null => {
const {
rangeStart,
rangeEnd,
markerTimingAndBuckets,
rowHeight,
marginLeft,
marginRight,
viewport: { containerWidth, viewportLeft, viewportRight, viewportTop },
} = this.props;

// Step 1: Find which row this marker is displayed in
const markerIndexToTimingRow = this._getMarkerIndexToTimingRow(
markerTimingAndBuckets
);
const rowIndex = markerIndexToTimingRow[markerIndex];

// Step 2: Get the timing data for all markers in this row
const markerTiming = markerTimingAndBuckets[rowIndex];
if (!markerTiming || typeof markerTiming === 'string') {
// Row is empty or is a bucket label (string), not actual marker data
return null;
}

// Step 3: Find the position of our specific marker within this row's data
let markerTimingIndex = -1;
for (let i = 0; i < markerTiming.length; i++) {
if (markerTiming.index[i] === markerIndex) {
markerTimingIndex = i;
break;
}
}

if (markerTimingIndex === -1) {
// Marker not found in this row's data (shouldn't happen, but handle gracefully)
return null;
}

// Step 4: Calculate horizontal (X) position
const startTimestamp = markerTiming.start[markerTimingIndex];
const endTimestamp = markerTiming.end[markerTimingIndex];

// Convert absolute timestamps to relative positions (0.0 to 1.0 of the full range)
const markerContainerWidth = containerWidth - marginLeft - marginRight;
const rangeLength: Milliseconds = rangeEnd - rangeStart;
const viewportLength: UnitIntervalOfProfileRange =
viewportRight - viewportLeft;
const startTime: UnitIntervalOfProfileRange =
(startTimestamp - rangeStart) / rangeLength;
const endTime: UnitIntervalOfProfileRange =
(endTimestamp - rangeStart) / rangeLength;

// Calculate pixel position: map the time range to the visible viewport
const x: CssPixels =
((startTime - viewportLeft) * markerContainerWidth) / viewportLength +
marginLeft;
const w: CssPixels =
((endTime - startTime) * markerContainerWidth) / viewportLength;

// For instant markers (start === end), use the center point
// For interval markers, use a point 1/3 into the marker (or 30px, whichever is smaller)
const isInstantMarker = startTimestamp === endTimestamp;
const offsetX = isInstantMarker ? x : x + Math.min(w / 3, 30);

// Step 5: Calculate vertical (Y) position
// Place the tooltip at the top of the marker's row, with a small offset
const offsetY: CssPixels = rowIndex * rowHeight - viewportTop + 5;

// Return canvas-relative coordinates (should be converted to page coordinates by caller)
return { offsetX, offsetY };
};

override render() {
const { containerWidth, containerHeight, isDragging } = this.props.viewport;
const { containerWidth, containerHeight, isDragging, viewportTop } =
this.props.viewport;
const { selectedMarkerIndex } = this.props;

return (
<ChartCanvas
Expand All @@ -976,6 +1096,9 @@ class MarkerChartCanvasImpl extends React.PureComponent<Props> {
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
stickyTooltips={true}
selectedItem={selectedMarkerIndex}
getTooltipPosition={this.getTooltipPosition}
viewportTop={viewportTop}
/>
);
}
Expand Down
87 changes: 87 additions & 0 deletions src/components/shared/chart/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ type Props<Item> = {
readonly onMouseLeave?: (e: { nativeEvent: MouseEvent }) => unknown;
// Defaults to false. Set to true if the chart should persist the tooltips on click.
readonly stickyTooltips?: boolean;
// Selected item coming from URL or other non-click mechanism.
readonly selectedItem?: Item | null;
// Method to compute tooltip position for the selected item above.
readonly getTooltipPosition?: (
item: Item
) => { offsetX: CssPixels; offsetY: CssPixels } | null;
// Current vertical scroll offset of the viewport.
readonly viewportTop?: CssPixels;
};

// The naming of the X and Y coordinates here correspond to the ones
Expand Down Expand Up @@ -359,6 +367,54 @@ export class ChartCanvas<Item> extends React.Component<
this._canvas = canvas;
};

_syncSelectedItemFromProp = (selectedItem: Item | null) => {
if (selectedItem === null) {
this.setState({ selectedItem: null });
return;
}

if (!this.props.getTooltipPosition || !this._canvas) {
return;
}

const tooltipPosition = this.props.getTooltipPosition(selectedItem);
if (!tooltipPosition) {
// Can't get position, but still set the selectedItem
this.setState({ selectedItem });
return;
}

const { offsetX, offsetY } = tooltipPosition;

// If the item is outside the visible canvas area, skip setting the tooltip
// position for now. The viewport will scroll to bring it into view, and the
// viewportTop change will trigger a re-sync with the correct position.
if (offsetY < 0 || offsetY > this.props.containerHeight) {
return;
}

const canvasRect = this._canvas.getBoundingClientRect();
const pageX = canvasRect.left + window.scrollX + offsetX;
const pageY = canvasRect.top + window.scrollY + offsetY;
this.setState({
selectedItem,
pageX,
pageY,
});
};

override componentDidMount() {
// Initialize selectedItem from props on mount if provided and the canvas
// already has dimensions. If containerWidth is still 0, the viewport hasn't
// been laid out yet - componentDidUpdate will pick it up once it becomes non-zero.
if (
this.props.selectedItem !== undefined &&
this.props.containerWidth !== 0
) {
this._syncSelectedItemFromProp(this.props.selectedItem);
}
}

override UNSAFE_componentWillReceiveProps() {
// It is possible that the data backing the chart has been
// changed, for instance after symbolication. Clear the
Expand All @@ -377,6 +433,37 @@ export class ChartCanvas<Item> extends React.Component<

override componentDidUpdate(prevProps: Props<Item>, prevState: State<Item>) {
if (prevProps !== this.props) {
// Sync selectedItem state with selectedItem prop if it changed
// and the state doesn't already have this item selected.
if (
this.props.selectedItem !== undefined &&
this.props.selectedItem !== prevProps.selectedItem &&
!hoveredItemsAreEqual(this.state.selectedItem, this.props.selectedItem)
) {
this._syncSelectedItemFromProp(this.props.selectedItem);
}

// The canvas just received its dimensions for the first time (containerWidth
// went from 0 to non-zero). If a selectedItem was provided but couldn't be
// synced in componentDidMount, do it now.
if (
prevProps.containerWidth === 0 &&
this.props.containerWidth !== 0 &&
this.props.selectedItem !== undefined
) {
this._syncSelectedItemFromProp(this.props.selectedItem);
}

// The viewport scrolled (e.g. to bring the selected item into view on
// load). Re-sync the tooltip position so it stays on the selected item.
if (
this.props.viewportTop !== undefined &&
this.props.viewportTop !== prevProps.viewportTop &&
this.props.selectedItem !== undefined
) {
this._syncSelectedItemFromProp(this.props.selectedItem);
}

if (
this.state.selectedItem !== null &&
prevState.selectedItem === this.state.selectedItem
Expand Down
5 changes: 0 additions & 5 deletions src/reducers/profile-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ export const defaultThreadViewOptions: ThreadViewOptions = {
selectedInvertedCallNodePath: [],
expandedNonInvertedCallNodePaths: new PathSet(),
expandedInvertedCallNodePaths: new PathSet(),
selectedMarker: null,
selectedNetworkMarker: null,
lastSeenTransformCount: 0,
};
Expand Down Expand Up @@ -328,10 +327,6 @@ const viewOptionsPerThread: Reducer<ThreadViewOptionsPerThreads> = (
: { expandedNonInvertedCallNodePaths: expandedCallNodePaths }
);
}
case 'CHANGE_SELECTED_MARKER': {
const { threadsKey, selectedMarker } = action;
return _updateThreadViewOptions(state, threadsKey, { selectedMarker });
}
case 'CHANGE_SELECTED_NETWORK_MARKER': {
const { threadsKey, selectedNetworkMarker } = action;
return _updateThreadViewOptions(state, threadsKey, {
Expand Down
20 changes: 20 additions & 0 deletions src/reducers/url-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
AssemblyViewState,
IsOpenPerPanelState,
TabID,
SelectedMarkersPerThread,
} from 'firefox-profiler/types';

import type { TabSlug } from '../app-logic/tabs-handling';
Expand Down Expand Up @@ -677,6 +678,24 @@ const symbolServerUrl: Reducer<string | null> = (state = null) => {
return state;
};

const selectedMarkers: Reducer<SelectedMarkersPerThread> = (
state = {},
action
): SelectedMarkersPerThread => {
switch (action.type) {
case 'CHANGE_SELECTED_MARKER': {
const { threadsKey, selectedMarker } = action;
// Store the selected marker for this specific threadsKey
return {
...state,
[threadsKey]: selectedMarker,
};
}
default:
return state;
}
};

/**
* These values are specific to an individual profile.
*/
Expand All @@ -703,6 +722,7 @@ const profileSpecific = combineReducers({
localTrackOrderChangedPids,
showJsTracerSummary,
tabFilter,
selectedMarkers,
// The timeline tracks used to be hidden and sorted by thread indexes, rather than
// track indexes. The only way to migrate this information to tracks-based data is to
// first retrieve the profile, so they can't be upgraded by the normal url upgrading
Expand Down
Loading