diff --git a/src/lib/canvas-2d-batch-renderers.ts b/src/lib/canvas-2d-batch-renderers.ts new file mode 100644 index 000000000..a217e4f34 --- /dev/null +++ b/src/lib/canvas-2d-batch-renderers.ts @@ -0,0 +1,69 @@ +// This file contains a collection of classes which make it easier to perform +// batch rendering of Canvas2D primitives. The advantage of this over just doing +// ctx.beginPath() ... ctx.rect(...) ... ctx.endPath() is that you can construct +// several different batch renderers are the same time, then decide on their +// paint order at the end. +// +// See FlamechartPanZoomView.renderOverlays for an example of how this is used. + +export interface TextArgs { + text: string + x: number + y: number +} + +export class BatchCanvasTextRenderer { + private argsBatch: TextArgs[] = [] + + text(args: TextArgs) { + this.argsBatch.push(args) + } + + fill(ctx: CanvasRenderingContext2D, color: string) { + if (this.argsBatch.length === 0) return + ctx.fillStyle = color + for (let args of this.argsBatch) { + ctx.fillText(args.text, args.x, args.y) + } + this.argsBatch = [] + } +} + +export interface RectArgs { + x: number + y: number + w: number + h: number +} + +export class BatchCanvasRectRenderer { + private argsBatch: RectArgs[] = [] + + rect(args: RectArgs) { + this.argsBatch.push(args) + } + + private drawPath(ctx: CanvasRenderingContext2D) { + ctx.beginPath() + for (let args of this.argsBatch) { + ctx.rect(args.x, args.y, args.w, args.h) + } + ctx.closePath() + this.argsBatch = [] + } + + fill(ctx: CanvasRenderingContext2D, color: string) { + if (this.argsBatch.length === 0) return + ctx.fillStyle = color + this.drawPath(ctx) + ctx.fill() + } + + stroke(ctx: CanvasRenderingContext2D, color: string, lineWidth: number) { + if (this.argsBatch.length === 0) return + ctx.strokeStyle = color + ctx.lineWidth = lineWidth + this.drawPath(ctx) + ctx.stroke() + } +} diff --git a/src/lib/flamechart.ts b/src/lib/flamechart.ts index 313a11fd5..eeacdfbf7 100644 --- a/src/lib/flamechart.ts +++ b/src/lib/flamechart.ts @@ -1,7 +1,7 @@ import {Frame, CallTreeNode} from './profile' import {lastOf} from './utils' -import {clamp} from './math' +import {clamp, Rect, Vec2} from './math' export interface FlamechartFrame { node: CallTreeNode @@ -90,6 +90,27 @@ export class Flamechart { return clamp(viewportWidth, minWidth, maxWidth) } + // Given a desired config-space viewport rectangle, clamp the rectangle so + // that it fits within the given flamechart. This prevents the viewport from + // extending past the bounds of the flamechart or zooming in too far. + getClampedConfigSpaceViewportRect({ + configSpaceViewportRect, + renderInverted, + }: { + configSpaceViewportRect: Rect + renderInverted?: boolean + }) { + const configSpaceSize = new Vec2(this.getTotalWeight(), this.getLayers().length) + const width = this.getClampedViewportWidth(configSpaceViewportRect.size.x) + const size = configSpaceViewportRect.size.withX(width) + const origin = Vec2.clamp( + configSpaceViewportRect.origin, + new Vec2(0, renderInverted ? 0 : -1), + Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))), + ) + return new Rect(origin, configSpaceViewportRect.size.withX(width)) + } + constructor(private source: FlamechartDataSource) { const stack: FlamechartFrame[] = [] const openFrame = (node: CallTreeNode, value: number) => { diff --git a/src/lib/profile-search.ts b/src/lib/profile-search.ts new file mode 100644 index 000000000..528612915 --- /dev/null +++ b/src/lib/profile-search.ts @@ -0,0 +1,90 @@ +import {Profile, Frame, CallTreeNode} from './profile' +import {FuzzyMatch, fuzzyMatchStrings} from './fuzzy-find' +import {Flamechart, FlamechartFrame} from './flamechart' +import {Rect, Vec2} from './math' + +export enum FlamechartType { + CHRONO_FLAME_CHART, + LEFT_HEAVY_FLAME_GRAPH, +} + +// A utility class for storing cached search results to avoid recomputation when +// the search results & profile did not change. +export class ProfileSearchResults { + constructor(readonly profile: Profile, readonly searchQuery: string) {} + + private matches: Map | null = null + getMatchForFrame(frame: Frame): FuzzyMatch | null { + if (!this.matches) { + this.matches = new Map() + this.profile.forEachFrame(frame => { + const match = fuzzyMatchStrings(frame.name, this.searchQuery) + if (match == null) return + this.matches!.set(frame, match) + }) + } + return this.matches.get(frame) || null + } +} + +export interface FlamechartSearchMatch { + configSpaceBounds: Rect + node: CallTreeNode +} + +interface CachedFlamechartResult { + matches: FlamechartSearchMatch[] + indexForNode: Map +} + +export class FlamechartSearchResults { + constructor(readonly flamechart: Flamechart, readonly profileResults: ProfileSearchResults) {} + + private matches: CachedFlamechartResult | null = null + private getResults(): CachedFlamechartResult { + if (this.matches == null) { + const matches: FlamechartSearchMatch[] = [] + const indexForNode = new Map() + const visit = (frame: FlamechartFrame, depth: number) => { + const {node} = frame + if (this.profileResults.getMatchForFrame(node.frame)) { + const configSpaceBounds = new Rect( + new Vec2(frame.start, depth), + new Vec2(frame.end - frame.start, 1), + ) + indexForNode.set(node, matches.length) + matches.push({configSpaceBounds, node}) + } + + frame.children.forEach(child => { + visit(child, depth + 1) + }) + } + + const layers = this.flamechart.getLayers() + if (layers.length > 0) { + layers[0].forEach(frame => visit(frame, 0)) + } + + this.matches = {matches, indexForNode} + } + return this.matches + } + + count(): number { + return this.getResults().matches.length + } + + indexOf(node: CallTreeNode): number | null { + const result = this.getResults().indexForNode.get(node) + return result === undefined ? null : result + } + + at(index: number): FlamechartSearchMatch { + const matches = this.getResults().matches + if (index < 0 || index >= matches.length) { + throw new Error(`Index ${index} out of bounds in list of ${matches.length} matches.`) + } + return matches[index] + } +} diff --git a/src/lib/profile.ts b/src/lib/profile.ts index a7a88fb13..a37fc3e69 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -116,6 +116,13 @@ export class Profile { protected frames = new KeyedSet() + // Profiles store two call-trees. + // + // The "append order" call tree is the one in which nodes are ordered in + // whatever order they were appended to their parent. + // + // The "grouped" call tree is one in which each node has at most one child per + // frame. Nodes are ordered in decreasing order of weight protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null) protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null) @@ -169,6 +176,17 @@ export class Profile { return this.totalNonIdleWeight } + // This is private because it should only be called in the ProfileBuilder + // classes. Once a Profile instance has been constructed, it should be treated + // as immutable. + protected sortGroupedCallTree() { + function visit(node: CallTreeNode) { + node.children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight())) + node.children.forEach(visit) + } + visit(this.groupedCalltreeRoot) + } + forEachCallGrouped( openFrame: (node: CallTreeNode, value: number) => void, closeFrame: (node: CallTreeNode, value: number) => void, @@ -180,10 +198,7 @@ export class Profile { let childTime = 0 - const children = [...node.children] - children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight())) - - children.forEach(function (child) { + node.children.forEach(function (child) { visit(child, start + childTime) childTime += child.getTotalWeight() }) @@ -250,12 +265,6 @@ export class Profile { this.frames.forEach(fn) } - forEachSample(fn: (sample: CallTreeNode, weight: number) => void) { - for (let i = 0; i < this.samples.length; i++) { - fn(this.samples[i], this.weights[i]) - } - } - getProfileWithRecursionFlattened(): Profile { const builder = new CallTreeProfileBuilder() @@ -511,6 +520,7 @@ export class StackListProfileBuilder extends Profile { this.totalWeight, this.weights.reduce((a, b) => a + b, 0), ) + this.sortGroupedCallTree() return this } } @@ -651,6 +661,7 @@ export class CallTreeProfileBuilder extends Profile { if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) { throw new Error('Tried to complete profile construction with a non-empty stack') } + this.sortGroupedCallTree() return this } } diff --git a/src/store/index.ts b/src/store/index.ts index aca59f459..12bb37b19 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,6 +11,10 @@ import {HashParams, getHashParams} from '../lib/hash-params' import {ProfileGroupState, profileGroup} from './profiles-state' import {SortMethod, SortField, SortDirection} from '../views/profile-table-view' import {useSelector} from '../lib/preact-redux' +import {Profile} from '../lib/profile' +import {FlamechartViewState} from './flamechart-view-state' +import {SandwichViewState} from './sandwich-view-state' +import {getProfileToView} from './getters' export const enum ViewMode { CHRONO_FLAME_CHART, @@ -101,3 +105,30 @@ export function useAppSelector(selector: (t: ApplicationState) => T, cacheArg /* eslint-disable react-hooks/exhaustive-deps */ return useSelector(selector, cacheArgs) } + +export interface ActiveProfileState { + profile: Profile + index: number + chronoViewState: FlamechartViewState + leftHeavyViewState: FlamechartViewState + sandwichViewState: SandwichViewState +} + +export function useActiveProfileState(): ActiveProfileState | null { + return useAppSelector(state => { + const {profileGroup} = state + if (!profileGroup) return null + if (profileGroup.indexToView >= profileGroup.profiles.length) return null + + const index = profileGroup.indexToView + const profileState = profileGroup.profiles[index] + return { + ...profileGroup.profiles[profileGroup.indexToView], + profile: getProfileToView({ + profile: profileState.profile, + flattenRecursion: state.flattenRecursion, + }), + index: profileGroup.indexToView, + } + }, []) +} diff --git a/src/views/application-container.tsx b/src/views/application-container.tsx index 20fc5c109..0236a383f 100644 --- a/src/views/application-container.tsx +++ b/src/views/application-container.tsx @@ -1,10 +1,11 @@ import {h} from 'preact' -import {Application, ActiveProfileState} from './application' -import {getProfileToView, getCanvasContext} from '../store/getters' +import {Application} from './application' +import {getCanvasContext} from '../store/getters' import {actions} from '../store/actions' import {useActionCreator} from '../lib/preact-redux' import {memo} from 'preact/compat' -import {useAppSelector} from '../store' +import {useAppSelector, useActiveProfileState} from '../store' +import {ProfileSearchContextProvider} from './search-view' const { setLoading, @@ -24,36 +25,21 @@ export const ApplicationContainer = memo(() => { [], ) - const activeProfileState: ActiveProfileState | null = useAppSelector(state => { - const {profileGroup} = state - if (!profileGroup) return null - if (profileGroup.indexToView >= profileGroup.profiles.length) return null - - const index = profileGroup.indexToView - const profileState = profileGroup.profiles[index] - return { - ...profileGroup.profiles[profileGroup.indexToView], - profile: getProfileToView({ - profile: profileState.profile, - flattenRecursion: state.flattenRecursion, - }), - index: profileGroup.indexToView, - } - }, []) - return ( - + + + ) }) diff --git a/src/views/application.tsx b/src/views/application.tsx index 31482fb54..5a7aa84ed 100644 --- a/src/views/application.tsx +++ b/src/views/application.tsx @@ -2,16 +2,14 @@ import {h} from 'preact' import {StyleSheet, css} from 'aphrodite' import {FileSystemDirectoryEntry} from '../import/file-system-entry' -import {Profile, ProfileGroup} from '../lib/profile' +import {ProfileGroup} from '../lib/profile' import {FontFamily, FontSize, Colors, Duration} from './style' import {importEmscriptenSymbolMap} from '../lib/emscripten' import {SandwichViewContainer} from './sandwich-view' import {saveToFile} from '../lib/file-format' -import {ApplicationState, ViewMode, canUseXHR} from '../store' +import {ApplicationState, ViewMode, canUseXHR, ActiveProfileState} from '../store' import {StatelessComponent} from '../lib/typed-redux' import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container' -import {SandwichViewState} from '../store/sandwich-view-state' -import {FlamechartViewState} from '../store/flamechart-view-state' import {CanvasContext} from '../gl/canvas-context' import {Graphics} from '../gl/graphics' import {Toolbar} from './toolbar' @@ -131,14 +129,6 @@ export class GLCanvas extends StatelessComponent { } } -export interface ActiveProfileState { - profile: Profile - index: number - chronoViewState: FlamechartViewState - leftHeavyViewState: FlamechartViewState - sandwichViewState: SandwichViewState -} - export type ApplicationProps = ApplicationState & { setGLCanvas: (canvas: HTMLCanvasElement | null) => void setLoading: (loading: boolean) => void diff --git a/src/views/callee-flamegraph-view.tsx b/src/views/callee-flamegraph-view.tsx index 7af82b8eb..dfc18000e 100644 --- a/src/views/callee-flamegraph-view.tsx +++ b/src/views/callee-flamegraph-view.tsx @@ -13,7 +13,7 @@ import { getFrameToColorBucket, } from '../store/getters' import {FlamechartID} from '../store/flamechart-view-state' -import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper' +import {FlamechartWrapper} from './flamechart-wrapper' import {useAppSelector} from '../store' import {h} from 'preact' import {memo} from 'preact/compat' @@ -78,14 +78,9 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps canvasContext={canvasContext} getCSSColorForFrame={getCSSColorForFrame} {...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)} + {...callerCallee.calleeFlamegraph} // This overrides the setSelectedNode specified in useFlamechartSettesr setSelectedNode={noop} - {...callerCallee.calleeFlamegraph} - /* - * TODO(jlfwong): When implementing search for the sandwich views, - * change these flags - * */ - {...useDummySearchProps()} /> ) }) diff --git a/src/views/flamechart-pan-zoom-view.tsx b/src/views/flamechart-pan-zoom-view.tsx index 288bfb9c2..3e30e41f3 100644 --- a/src/views/flamechart-pan-zoom-view.tsx +++ b/src/views/flamechart-pan-zoom-view.tsx @@ -1,5 +1,5 @@ import {Rect, AffineTransform, Vec2, clamp} from '../lib/math' -import {CallTreeNode, Frame} from '../lib/profile' +import {CallTreeNode} from '../lib/profile' import {Flamechart, FlamechartFrame} from '../lib/flamechart' import {CanvasContext} from '../gl/canvas-context' import {FlamechartRenderer} from '../gl/flamechart-renderer' @@ -13,8 +13,8 @@ import { import {style} from './flamechart-style' import {h, Component} from 'preact' import {css} from 'aphrodite' -import {memoizeByReference} from '../lib/utils' -import {FuzzyMatch, fuzzyMatchStrings} from '../lib/fuzzy-find' +import {ProfileSearchResults} from '../lib/profile-search' +import {BatchCanvasTextRenderer, BatchCanvasRectRenderer} from '../lib/canvas-2d-batch-renderers' interface FlamechartFrameLabel { configSpaceBounds: Rect @@ -56,8 +56,7 @@ export interface FlamechartPanZoomViewProps { logicalSpaceViewportSize: Vec2 setLogicalSpaceViewportSize: (size: Vec2) => void - searchIsActive: boolean - searchQuery: string + searchResults: ProfileSearchResults | null } export class FlamechartPanZoomView extends Component { @@ -173,24 +172,6 @@ export class FlamechartPanZoomView extends Component { - return fuzzyMatchStrings(frame.name, this.props.searchQuery) - }) - const frameMatchesSearchQuery = (frame: Frame): FuzzyMatch | null => { - if (!this.props.searchIsActive) return null - if (this.props.searchQuery.length === 0) return null - return memoizedFuzzyMatch(frame) - } + const labelBatch = new BatchCanvasTextRenderer() + const fadedLabelBatch = new BatchCanvasTextRenderer() + const matchedTextHighlightBatch = new BatchCanvasRectRenderer() + const directlySelectedOutlineBatch = new BatchCanvasRectRenderer() + const indirectlySelectedOutlineBatch = new BatchCanvasRectRenderer() + const matchedFrameBatch = new BatchCanvasRectRenderer() const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => { const width = frame.end - frame.start @@ -244,7 +223,7 @@ export class FlamechartPanZoomView extends Component minWidthToRender) { - const match = frameMatchesSearchQuery(frame.node.frame) + const match = this.props.searchResults?.getMatchForFrame(frame.node.frame) const trimmedText = trimTextMid( ctx, @@ -262,44 +241,40 @@ export class FlamechartPanZoomView extends Component 0 && !match) { - ctx.fillStyle = Colors.LIGHT_GRAY - } else { - ctx.fillStyle = Colors.DARK_GRAY - } - ctx.fillText( - trimmedText.trimmedString, - physicalLabelBounds.left() + LABEL_PADDING_PX, - Math.round( + const batch = this.props.searchResults != null && !match ? fadedLabelBatch : labelBatch + batch.text({ + text: trimmedText.trimmedString, + + // This is specifying the position of the starting text baseline. + x: physicalLabelBounds.left() + LABEL_PADDING_PX, + y: Math.round( physicalLabelBounds.bottom() - (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2, ), - ) + }) } } for (let child of frame.children) { @@ -307,19 +282,14 @@ export class FlamechartPanZoomView extends Component { - if (!this.props.selectedNode && !this.props.searchIsActive) return + if (!this.props.selectedNode && this.props.searchResults == null) return const width = frame.end - frame.start const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1)) @@ -330,49 +300,68 @@ export class FlamechartPanZoomView extends Component this.props.configSpaceViewportRect.bottom()) return if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) { - let outlineColor: string | null = null + if (this.props.searchResults?.getMatchForFrame(frame.node.frame)) { + const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds) + matchedFrameBatch.rect({ + x: Math.round(physicalRectBounds.left() + frameOutlineWidth / 2), + y: Math.round(physicalRectBounds.top() + frameOutlineWidth / 2), + w: Math.round(Math.max(0, physicalRectBounds.width() - frameOutlineWidth)), + h: Math.round(Math.max(0, physicalRectBounds.height() - frameOutlineWidth)), + }) + } if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) { - if (frame.node === this.props.selectedNode) { - outlineColor = Colors.DARK_BLUE - } else if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) { - outlineColor = Colors.PALE_DARK_BLUE - } - } else { - if (frameMatchesSearchQuery(frame.node.frame)) { - outlineColor = Colors.YELLOW - } - } + let batch = + frame.node === this.props.selectedNode + ? directlySelectedOutlineBatch + : indirectlySelectedOutlineBatch - if (outlineColor != null) { - if (ctx.strokeStyle !== outlineColor) { - // If the outline color changed, stroke the existing path - // constructed by previous ctx.rect calls, then update the stroke - // style before drawing the next one. - ctx.stroke() - ctx.beginPath() - ctx.strokeStyle = outlineColor - } const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds) - ctx.rect( - Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2), - Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2), - Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)), - Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)), - ) + batch.rect({ + x: Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2), + y: Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2), + w: Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)), + h: Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)), + }) } } - for (let child of frame.children) { renderSpecialFrameOutlines(child, depth + 1) } } - ctx.beginPath() for (let frame of this.props.flamechart.getLayers()[0] || []) { renderSpecialFrameOutlines(frame) } - ctx.stroke() + + for (let frame of this.props.flamechart.getLayers()[0] || []) { + renderFrameLabelAndChildren(frame) + } + + matchedFrameBatch.fill(ctx, Colors.ORANGE) + matchedTextHighlightBatch.fill(ctx, Colors.YELLOW) + fadedLabelBatch.fill(ctx, Colors.LIGHT_GRAY) + labelBatch.fill(ctx, Colors.BLACK) + indirectlySelectedOutlineBatch.stroke(ctx, Colors.PALE_DARK_BLUE, frameOutlineWidth) + directlySelectedOutlineBatch.stroke(ctx, Colors.DARK_BLUE, frameOutlineWidth) + + if (this.hoveredLabel) { + let color = Colors.DARK_GRAY + if (this.props.selectedNode === this.hoveredLabel.node) { + color = Colors.DARK_BLUE + } + + ctx.lineWidth = 2 * devicePixelRatio + ctx.strokeStyle = color + + const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds) + ctx.strokeRect( + Math.round(physicalViewBounds.left()), + Math.round(physicalViewBounds.top()), + Math.round(Math.max(0, physicalViewBounds.width())), + Math.round(Math.max(0, physicalViewBounds.height())), + ) + } this.renderTimeIndicators() } @@ -754,10 +743,7 @@ export class FlamechartPanZoomView extends Component(null) + +export interface FlamechartSearchProps { + flamechart: Flamechart + selectedNode: CallTreeNode | null + setSelectedNode: (node: CallTreeNode | null) => void + configSpaceViewportRect: Rect + setConfigSpaceViewportRect: (rect: Rect) => void + children: ComponentChildren +} + +interface FlamechartSearchData { + results: FlamechartSearchResults | null + flamechart: Flamechart + selectedNode: CallTreeNode | null + setSelectedNode: (node: CallTreeNode | null) => void + configSpaceViewportRect: Rect + setConfigSpaceViewportRect: (rect: Rect) => void +} + +export const FlamechartSearchContextProvider = ({ + flamechart, + selectedNode, + setSelectedNode, + configSpaceViewportRect, + setConfigSpaceViewportRect, + children, +}: FlamechartSearchProps) => { + const profileSearchResults: ProfileSearchResults | null = useContext(ProfileSearchContext) + const flamechartSearchResults: FlamechartSearchResults | null = useMemo(() => { + if (profileSearchResults == null) { + return null + } + return new FlamechartSearchResults(flamechart, profileSearchResults) + }, [flamechart, profileSearchResults]) + + return ( + + {children} + + ) +} + +export const FlamechartSearchView = memo(() => { + const flamechartData = useContext(FlamechartSearchContext) + + // TODO(jlfwong): This pattern is pretty gross, but I really don't want values + // that can be undefined or null. + const searchResults = flamechartData == null ? null : flamechartData.results + const selectedNode = flamechartData == null ? null : flamechartData.selectedNode + const setSelectedNode = flamechartData == null ? null : flamechartData.setSelectedNode + const configSpaceViewportRect = + flamechartData == null ? null : flamechartData.configSpaceViewportRect + const setConfigSpaceViewportRect = + flamechartData == null ? null : flamechartData.setConfigSpaceViewportRect + const flamechart = flamechartData == null ? null : flamechartData.flamechart + + const numResults = searchResults == null ? null : searchResults.count() + const resultIndex: number | null = useMemo(() => { + if (searchResults == null) return null + if (selectedNode == null) return null + return searchResults.indexOf(selectedNode) + }, [searchResults, selectedNode]) + + const selectAndZoomToMatch = useCallback( + (match: FlamechartSearchMatch) => { + if (!setSelectedNode) return + if (!flamechart) return + if (!configSpaceViewportRect) return + if (!setConfigSpaceViewportRect) return + + // After the node is selected, we want to set the viewport so that the new + // node can be seen clearly. + // + // TODO(jlfwong): The lack of animation here can be kind of jarring. It + // would be nice to have some easier way for people to orient themselves + // after the viewport shifted. + const configSpaceResultBounds = match.configSpaceBounds + + const viewportRect = new Rect( + configSpaceResultBounds.origin.minus(new Vec2(0, 1)), + configSpaceResultBounds.size.withY(configSpaceViewportRect.height()), + ) + + setSelectedNode(match.node) + setConfigSpaceViewportRect( + flamechart.getClampedConfigSpaceViewportRect({configSpaceViewportRect: viewportRect}), + ) + }, + [configSpaceViewportRect, setConfigSpaceViewportRect, setSelectedNode, flamechart], + ) + + const {selectPrev, selectNext} = useMemo(() => { + if (numResults == null || numResults === 0 || searchResults == null) { + return {selectPrev: () => {}, selectNext: () => {}} + } + + return { + selectPrev: () => { + if (!searchResults?.at) return + if (numResults == null || numResults === 0) return + + let index = resultIndex == null ? numResults - 1 : resultIndex - 1 + if (index < 0) index = numResults - 1 + const result = searchResults.at(index) + selectAndZoomToMatch(result) + }, + + selectNext: () => { + if (!searchResults?.at) return + if (numResults == null || numResults === 0) return + + let index = resultIndex == null ? 0 : resultIndex + 1 + if (index >= numResults) index = 0 + const result = searchResults.at(index) + selectAndZoomToMatch(result) + }, + } + }, [numResults, resultIndex, searchResults, searchResults?.at, selectAndZoomToMatch]) + + return ( + + ) +}) diff --git a/src/views/flamechart-view-container.tsx b/src/views/flamechart-view-container.tsx index 58acaa8dd..00fe7684a 100644 --- a/src/views/flamechart-view-container.tsx +++ b/src/views/flamechart-view-container.tsx @@ -14,12 +14,11 @@ import { createGetCSSColorForFrame, getFrameToColorBucket, } from '../store/getters' -import {ActiveProfileState} from './application' import {Vec2, Rect} from '../lib/math' import {actions} from '../store/actions' import {memo} from 'preact/compat' -import {useAppSelector} from '../store' -import {SearchViewProps} from './search-view' +import {ActiveProfileState} from '../store' +import {FlamechartSearchContextProvider} from './flamechart-search-view' interface FlamechartSetters { setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void @@ -66,24 +65,9 @@ export type FlamechartViewProps = { flamechartRenderer: FlamechartRenderer renderInverted: boolean getCSSColorForFrame: (frame: Frame) => string - searchIsActive: boolean - searchQuery: string - setSearchQuery: (query: string) => void - setSearchIsActive: (active: boolean) => void } & FlamechartSetters & FlamechartViewState -const {setSearchQuery, setSearchIsActive} = actions - -function useSearchViewProps(): SearchViewProps { - return { - searchIsActive: useAppSelector(state => state.searchIsActive, []), - setSearchQuery: useActionCreator(setSearchQuery, []), - searchQuery: useAppSelector(state => state.searchQuery, []), - setSearchIsActive: useActionCreator(setSearchIsActive, []), - } -} - export const getChronoViewFlamechart = memoizeByShallowEquality( ({ profile, @@ -143,17 +127,26 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) = flamechart, }) + const setters = useFlamechartSetters(FlamechartID.CHRONO, index) + return ( - + selectedNode={chronoViewState.selectedNode} + setSelectedNode={setters.setSelectedNode} + configSpaceViewportRect={chronoViewState.configSpaceViewportRect} + setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect} + > + + ) }) @@ -195,16 +188,25 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr flamechart, }) + const setters = useFlamechartSetters(FlamechartID.LEFT_HEAVY, index) + return ( - + selectedNode={leftHeavyViewState.selectedNode} + setSelectedNode={setters.setSelectedNode} + configSpaceViewportRect={leftHeavyViewState.configSpaceViewportRect} + setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect} + > + + ) }) diff --git a/src/views/flamechart-view.tsx b/src/views/flamechart-view.tsx index b14a5eab6..5d6e7a14d 100644 --- a/src/views/flamechart-view.tsx +++ b/src/views/flamechart-view.tsx @@ -1,4 +1,4 @@ -import {h} from 'preact' +import {h, Fragment} from 'preact' import {css} from 'aphrodite' import {CallTreeNode} from '../lib/profile' @@ -14,7 +14,8 @@ import {FlamechartPanZoomView} from './flamechart-pan-zoom-view' import {Hovertip} from './hovertip' import {FlamechartViewProps} from './flamechart-view-container' import {StatelessComponent} from '../lib/typed-redux' -import {SearchView} from './search-view' +import {ProfileSearchContext} from './search-view' +import {FlamechartSearchView} from './flamechart-search-view' export class FlamechartView extends StatelessComponent { private configSpaceSize() { @@ -102,28 +103,28 @@ export class FlamechartView extends StatelessComponent { canvasContext={this.props.canvasContext} setConfigSpaceViewportRect={this.setConfigSpaceViewportRect} /> - - + + {searchResults => ( + + + + + )} + {this.renderTooltip()} {this.props.selectedNode && ( {}, []), - setSearchIsActive: useCallback((v: boolean) => {}, []), - } -} export class FlamechartWrapper extends StatelessComponent { private clampViewportToFlamegraph(viewportRect: Rect) { const {flamechart, renderInverted} = this.props - const configSpaceSize = new Vec2(flamechart.getTotalWeight(), flamechart.getLayers().length) - const width = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x) - const size = viewportRect.size.withX(width) - const origin = Vec2.clamp( - viewportRect.origin, - new Vec2(0, renderInverted ? 0 : -1), - Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))), - ) - return new Rect(origin, viewportRect.size.withX(width)) + return flamechart.getClampedConfigSpaceViewportRect({ + configSpaceViewportRect: viewportRect, + renderInverted, + }) } private setConfigSpaceViewportRect = (configSpaceViewportRect: Rect) => { this.props.setConfigSpaceViewportRect(this.clampViewportToFlamegraph(configSpaceViewportRect)) @@ -95,8 +79,7 @@ export class FlamechartWrapper extends StatelessComponent { renderInverted={this.props.renderInverted} logicalSpaceViewportSize={this.props.logicalSpaceViewportSize} setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize} - searchIsActive={this.props.searchIsActive} - searchQuery={this.props.searchQuery} + searchResults={null} /> {this.renderTooltip()} diff --git a/src/views/inverted-caller-flamegraph-view.tsx b/src/views/inverted-caller-flamegraph-view.tsx index d372bf38a..9985ca845 100644 --- a/src/views/inverted-caller-flamegraph-view.tsx +++ b/src/views/inverted-caller-flamegraph-view.tsx @@ -14,7 +14,7 @@ import { } from '../store/getters' import {FlamechartID} from '../store/flamechart-view-state' import {useAppSelector} from '../store' -import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper' +import {FlamechartWrapper} from './flamechart-wrapper' import {h} from 'preact' import {memo} from 'preact/compat' @@ -87,14 +87,9 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai canvasContext={canvasContext} getCSSColorForFrame={getCSSColorForFrame} {...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)} + {...callerCallee.invertedCallerFlamegraph} // This overrides the setSelectedNode specified in useFlamechartSettesr setSelectedNode={noop} - {...callerCallee.invertedCallerFlamegraph} - /* - * TODO(jlfwong): When implementing search for the sandwich views, - * change these flags - * */ - {...useDummySearchProps()} /> ) }) diff --git a/src/views/profile-table-view.tsx b/src/views/profile-table-view.tsx index 3ed78fb0b..7a5774108 100644 --- a/src/views/profile-table-view.tsx +++ b/src/views/profile-table-view.tsx @@ -1,18 +1,17 @@ import {h, Component, JSX, ComponentChild} from 'preact' import {StyleSheet, css} from 'aphrodite' import {Profile, Frame} from '../lib/profile' -import {sortBy, formatPercent} from '../lib/utils' +import {formatPercent} from '../lib/utils' import {FontSize, Colors, Sizes, commonStyle} from './style' import {ColorChit} from './color-chit' import {ListItem, ScrollableListView} from './scrollable-list-view' import {actions} from '../store/actions' import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters' -import {ActiveProfileState} from './application' import {useActionCreator} from '../lib/preact-redux' -import {useAppSelector} from '../store' +import {useAppSelector, ActiveProfileState} from '../store' import {memo} from 'preact/compat' -import {useCallback, useMemo} from 'preact/hooks' -import {fuzzyMatchStrings} from '../lib/fuzzy-find' +import {useCallback, useMemo, useContext} from 'preact/hooks' +import {SandwichViewContext} from './sandwich-view' export enum SortField { SYMBOL_NAME, @@ -68,13 +67,9 @@ class SortIcon extends Component { } } -interface ProfileTableRowInfo { +interface ProfileTableRowViewProps { frame: Frame matchedRanges: [number, number][] | null -} - -interface ProfileTableRowViewProps { - info: ProfileTableRowInfo index: number profile: Profile selectedFrame: Frame | null @@ -100,15 +95,14 @@ function highlightRanges( } const ProfileTableRowView = ({ - info, + frame, + matchedRanges, profile, index, selectedFrame, setSelectedFrame, getCSSColorForFrame, }: ProfileTableRowViewProps) => { - const {frame, matchedRanges} = info - const totalWeight = frame.getTotalWeight() const selfWeight = frame.getSelfWeight() const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight() @@ -206,48 +200,21 @@ export const ProfileTableView = memo( [sortMethod, setSortMethod], ) - const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => { - const rowList: ProfileTableRowInfo[] = [] - - profile.forEachFrame(frame => { - let matchedRanges: [number, number][] | null = null - if (searchIsActive) { - const match = fuzzyMatchStrings(frame.name, searchQuery) - if (match == null) return - matchedRanges = match.matchedRanges - } - rowList.push({frame, matchedRanges}) - }) - - switch (sortMethod.field) { - case SortField.SYMBOL_NAME: { - sortBy(rowList, f => f.frame.name.toLowerCase()) - break - } - case SortField.SELF: { - sortBy(rowList, f => f.frame.getSelfWeight()) - break - } - case SortField.TOTAL: { - sortBy(rowList, f => f.frame.getTotalWeight()) - break - } - } - if (sortMethod.direction === SortDirection.DESCENDING) { - rowList.reverse() - } - - return rowList - }, [profile, sortMethod, searchQuery, searchIsActive]) + const sandwichContext = useContext(SandwichViewContext) const renderItems = useCallback( (firstIndex: number, lastIndex: number) => { + if (!sandwichContext) return null + const rows: JSX.Element[] = [] for (let i = firstIndex; i <= lastIndex; i++) { + const frame = sandwichContext.rowList[i] + const match = sandwichContext.getSearchMatchForFrame(frame) rows.push( ProfileTableRowView({ - info: rowList[i], + frame, + matchedRanges: match == null ? null : match.matchedRanges, index: i, profile: profile, selectedFrame: selectedFrame, @@ -278,7 +245,7 @@ export const ProfileTableView = memo( return {rows}
}, [ - rowList, + sandwichContext, profile, selectedFrame, setSelectedFrame, @@ -288,9 +255,13 @@ export const ProfileTableView = memo( ], ) - const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [ - rowList, - ]) + const listItems: ListItem[] = useMemo( + () => + sandwichContext == null + ? [] + : sandwichContext.rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), + [sandwichContext], + ) const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [ onSortClick, @@ -341,7 +312,7 @@ export const ProfileTableView = memo( className={css(style.scrollView)} renderItems={renderItems} initialIndexInView={ - selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame) + selectedFrame == null ? null : sandwichContext?.getIndexForFrame(selectedFrame) } /> diff --git a/src/views/sandwich-search-view.tsx b/src/views/sandwich-search-view.tsx new file mode 100644 index 000000000..b36a77174 --- /dev/null +++ b/src/views/sandwich-search-view.tsx @@ -0,0 +1,44 @@ +import {memo} from 'preact/compat' +import {useContext, useMemo} from 'preact/hooks' +import {SearchView} from './search-view' +import {h} from 'preact' +import {SandwichViewContext} from './sandwich-view' + +export const SandwichSearchView = memo(() => { + const sandwichViewContext = useContext(SandwichViewContext) + + const rowList = sandwichViewContext != null ? sandwichViewContext.rowList : null + const resultIndex = + sandwichViewContext?.selectedFrame != null + ? sandwichViewContext.getIndexForFrame(sandwichViewContext.selectedFrame) + : null + const numResults = rowList != null ? rowList.length : null + + const {selectPrev, selectNext} = useMemo(() => { + if (rowList == null || numResults == null || numResults === 0 || sandwichViewContext == null) { + return {selectPrev: () => {}, selectNext: () => {}} + } + + return { + selectPrev: () => { + let index = resultIndex == null ? numResults - 1 : resultIndex - 1 + if (index < 0) index = numResults - 1 + sandwichViewContext.setSelectedFrame(rowList[index]) + }, + selectNext: () => { + let index = resultIndex == null ? 0 : resultIndex + 1 + if (index >= numResults) index = 0 + sandwichViewContext.setSelectedFrame(rowList[index]) + }, + } + }, [resultIndex, rowList, numResults, sandwichViewContext]) + + return ( + + ) +}) diff --git a/src/views/sandwich-view.tsx b/src/views/sandwich-view.tsx index c0ca0c757..45a5ea359 100644 --- a/src/views/sandwich-view.tsx +++ b/src/views/sandwich-view.tsx @@ -1,18 +1,20 @@ import {Frame} from '../lib/profile' import {StyleSheet, css} from 'aphrodite' -import {ProfileTableViewContainer} from './profile-table-view' -import {h, JSX} from 'preact' +import {ProfileTableViewContainer, SortField, SortDirection} from './profile-table-view' +import {h, JSX, createContext} from 'preact' import {memo} from 'preact/compat' -import {useCallback} from 'preact/hooks' +import {useCallback, useMemo, useContext} from 'preact/hooks' import {commonStyle, Sizes, Colors, FontSize} from './style' import {actions} from '../store/actions' import {StatelessComponent} from '../lib/typed-redux' import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view' import {CalleeFlamegraphView} from './callee-flamegraph-view' -import {ActiveProfileState} from './application' -import {useDispatch, useActionCreator} from '../lib/preact-redux' -import {SearchView} from './search-view' -import {useAppSelector} from '../store' +import {useDispatch} from '../lib/preact-redux' +import {SandwichSearchView} from './sandwich-search-view' +import {useAppSelector, ActiveProfileState} from '../store' +import {sortBy} from '../lib/utils' +import {ProfileSearchContext} from './search-view' +import {FuzzyMatch} from '../lib/fuzzy-find' interface SandwichViewProps { selectedFrame: Frame | null @@ -20,10 +22,6 @@ interface SandwichViewProps { activeProfileState: ActiveProfileState setSelectedFrame: (selectedFrame: Frame | null) => void glCanvas: HTMLCanvasElement - searchQuery: string - searchIsActive: boolean - setSearchQuery: (query: string | null) => void - setSearchIsActive: (active: boolean) => void } class SandwichView extends StatelessComponent { @@ -45,13 +43,7 @@ class SandwichView extends StatelessComponent { } render() { - const { - selectedFrame, - searchIsActive, - setSearchIsActive, - searchQuery, - setSearchQuery, - } = this.props + const {selectedFrame} = this.props let flamegraphViews: JSX.Element | null = null if (selectedFrame) { @@ -84,12 +76,7 @@ class SandwichView extends StatelessComponent {
- +
{flamegraphViews}
@@ -143,7 +130,15 @@ interface SandwichViewContainerProps { glCanvas: HTMLCanvasElement } -const {setSearchQuery, setSearchIsActive} = actions +interface SandwichViewContextData { + rowList: Frame[] + selectedFrame: Frame | null + setSelectedFrame: (frame: Frame | null) => void + getIndexForFrame: (frame: Frame) => number | null + getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null +} + +export const SandwichViewContext = createContext(null) export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => { const {activeProfileState, glCanvas} = ownProps @@ -163,17 +158,78 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) [dispatch, index], ) + const profile = activeProfileState.profile + const tableSortMethod = useAppSelector(state => state.tableSortMethod, []) + const profileSearchResults = useContext(ProfileSearchContext) + + const selectedFrame = callerCallee ? callerCallee.selectedFrame : null + + const rowList: Frame[] = useMemo(() => { + const rowList: Frame[] = [] + + profile.forEachFrame(frame => { + if (profileSearchResults && !profileSearchResults.getMatchForFrame(frame)) { + return + } + rowList.push(frame) + }) + + switch (tableSortMethod.field) { + case SortField.SYMBOL_NAME: { + sortBy(rowList, f => f.name.toLowerCase()) + break + } + case SortField.SELF: { + sortBy(rowList, f => f.getSelfWeight()) + break + } + case SortField.TOTAL: { + sortBy(rowList, f => f.getTotalWeight()) + break + } + } + if (tableSortMethod.direction === SortDirection.DESCENDING) { + rowList.reverse() + } + + return rowList + }, [profile, profileSearchResults, tableSortMethod]) + + const getIndexForFrame: (frame: Frame) => number | null = useMemo(() => { + const indexByFrame = new Map() + for (let i = 0; i < rowList.length; i++) { + indexByFrame.set(rowList[i], i) + } + return (frame: Frame) => { + const index = indexByFrame.get(frame) + return index == null ? null : index + } + }, [rowList]) + + const getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null = useMemo(() => { + return (frame: Frame) => { + if (profileSearchResults == null) return null + return profileSearchResults.getMatchForFrame(frame) + } + }, [profileSearchResults]) + + const contextData: SandwichViewContextData = { + rowList, + selectedFrame, + setSelectedFrame, + getIndexForFrame, + getSearchMatchForFrame, + } + return ( - state.searchQuery, [])} - setSearchQuery={useActionCreator(setSearchQuery, [])} - searchIsActive={useAppSelector(state => state.searchIsActive, [])} - setSearchIsActive={useActionCreator(setSearchIsActive, [])} - /> + + + ) }) diff --git a/src/views/scrollable-list-view.tsx b/src/views/scrollable-list-view.tsx index 5cecec27e..5832a7857 100644 --- a/src/views/scrollable-list-view.tsx +++ b/src/views/scrollable-list-view.tsx @@ -17,7 +17,10 @@ interface RangeResult { interface ScrollableListViewProps { items: ListItem[] axis: 'x' | 'y' - renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[] + renderItems: ( + firstVisibleIndex: number, + lastVisibleIndex: number, + ) => JSX.Element | JSX.Element[] | null className?: string initialIndexInView?: number | null } diff --git a/src/views/search-view.tsx b/src/views/search-view.tsx index e66776de2..667e9ba25 100644 --- a/src/views/search-view.tsx +++ b/src/views/search-view.tsx @@ -1,23 +1,54 @@ import {StyleSheet, css} from 'aphrodite' -import {h} from 'preact' -import {useCallback, useRef, useEffect} from 'preact/hooks' +import {h, createContext, ComponentChildren, Fragment} from 'preact' +import {useCallback, useRef, useEffect, useMemo} from 'preact/hooks' import {memo} from 'preact/compat' import {Sizes, Colors, FontSize} from './style' +import {ProfileSearchResults} from '../lib/profile-search' +import {Profile} from '../lib/profile' +import {useActiveProfileState, useAppSelector} from '../store' +import {useActionCreator} from '../lib/preact-redux' +import {actions} from '../store/actions' function stopPropagation(ev: Event) { ev.stopPropagation() } -export interface SearchViewProps { - searchQuery: string - searchIsActive: boolean +export const ProfileSearchContext = createContext(null) - setSearchQuery: (query: string) => void - setSearchIsActive: (active: boolean) => void +export const ProfileSearchContextProvider = ({children}: {children: ComponentChildren}) => { + const activeProfileState = useActiveProfileState() + const profile: Profile | null = activeProfileState ? activeProfileState.profile : null + const searchIsActive = useAppSelector(state => state.searchIsActive, []) + const searchQuery = useAppSelector(state => state.searchQuery, []) + + const searchResults = useMemo(() => { + if (!profile || !searchIsActive || searchQuery.length === 0) { + return null + } + return new ProfileSearchResults(profile, searchQuery) + }, [searchIsActive, searchQuery, profile]) + + return ( + {children} + ) +} + +const {setSearchQuery: setSearchQueryAction, setSearchIsActive: setSearchIsActiveAction} = actions + +interface SearchViewProps { + resultIndex: number | null + numResults: number | null + selectNext: () => void + selectPrev: () => void } export const SearchView = memo( - ({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => { + ({numResults, resultIndex, selectNext, selectPrev}: SearchViewProps) => { + const searchQuery = useAppSelector(state => state.searchQuery, []) + const searchIsActive = useAppSelector(state => state.searchIsActive, []) + const setSearchQuery = useActionCreator(setSearchQueryAction, []) + const setSearchIsActive = useActionCreator(setSearchIsActiveAction, []) + const onInput = useCallback( (ev: Event) => { const value = (ev.target as HTMLInputElement).value @@ -28,6 +59,19 @@ export const SearchView = memo( const inputRef = useRef(null) + const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive]) + + const selectPrevOrNextResult = useCallback( + (ev: KeyboardEvent) => { + if (ev.shiftKey) { + selectPrev() + } else { + selectNext() + } + }, + [selectPrev, selectNext], + ) + const onKeyDown = useCallback( (ev: KeyboardEvent) => { ev.stopPropagation() @@ -37,6 +81,10 @@ export const SearchView = memo( setSearchIsActive(false) } + if (ev.key === 'Enter') { + selectPrevOrNextResult(ev) + } + if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) { if (inputRef.current) { // If the input is already focused, select all @@ -49,7 +97,7 @@ export const SearchView = memo( ev.preventDefault() } }, - [setSearchIsActive], + [setSearchIsActive, selectPrevOrNextResult], ) useEffect(() => { @@ -81,24 +129,37 @@ export const SearchView = memo( } }, [setSearchIsActive]) - const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive]) - if (!searchIsActive) return null return (
🔍 - - + + + + {numResults != null && ( + + + {resultIndex == null ? '?' : resultIndex + 1}/{numResults} + + + + + )}