Switch to redux for global state management (#100)

This should allow state to be more easily retained globally when switching views, and should make implementation of cross-view features like search easier too.

It also removes the need for `ReloadableComponent`

Fixes #78

Test Plan:
This changes a lot of how the app works, and a lot of stuff that isn't currently covered by tests, so here's a rough manual test plan:

1. Loaded up http://localhost:1234/, click to load the example profile
2. Switch between views, see that viewport & selection position is now retained when switching views
3. See that clicking on nodes selects them in Time Order & Left Heavy views
4. See that hitting Cmd+S saves a profile
5. See that dropping a profile in works
6. See that dropping a profile + a symbol map works
7. See that visiting localhost:1234/#profileURL=https://raw.githubusercontent.com/jlfwong/speedscope/master/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json works
8. See that hitting "r" to toggle recursion flattening works
This commit is contained in:
Jamie Wong
2018-07-31 23:53:56 -07:00
committed by GitHub
parent 2f95f77fcf
commit 11a3a67dce
27 changed files with 1132 additions and 687 deletions
+70
View File
@@ -0,0 +1,70 @@
import {actionCreator} from './typed-redux'
import {Profile, CallTreeNode, Frame} from '../profile'
import {SortMethod} from '../profile-table-view'
import {ViewMode} from '.'
import {FlamechartID} from './flamechart-view-state'
import {Rect, Vec2} from '../math'
import {HashParams} from '../hash-params'
export namespace actions {
// Set the top-level profile from which other data will be derived
export const setProfile = actionCreator<Profile>('setProfile')
// Set the profile currently being viewed
export const setActiveProfile = actionCreator<Profile>('setActiveProfile')
export const setFrameToColorBucket = actionCreator<Map<string | number, number>>(
'setFrameToColorBucket',
)
export const setGLCanvas = actionCreator<HTMLCanvasElement | null>('setGLCanvas')
// Set which top-level view should be displayed
export const setViewMode = actionCreator<ViewMode>('setViewMode')
// Set whether or not recursion should be flattened when viewing flamegraphs
export const setFlattenRecursion = actionCreator<boolean>('setFlattenRecursion')
// Set whether a file drag is currently active. Used to indicate that the
// application is a valid drop target.
export const setDragActive = actionCreator<boolean>('setDragActive')
// Set whether the application is currently in a loading state. Used to
// display a loading progress bar.
export const setLoading = actionCreator<boolean>('setLoading')
// Set whether the application is in an errored state.
export const setError = actionCreator<boolean>('setError')
// Set whether parameters defined by the URL encoded k=v pairs after the # in the URL
export const setHashParams = actionCreator<HashParams>('setHashParams')
export namespace sandwichView {
// Set the table sorting method used for the sandwich view.
export const setTableSortMethod = actionCreator<SortMethod>('sandwichView.setTableSortMethod')
export const setSelectedFrame = actionCreator<Frame | null>('sandwichView.setSelectedFarmr')
}
export namespace flamechart {
export const setHoveredNode = actionCreator<{
id: FlamechartID
hover: {node: CallTreeNode; event: MouseEvent} | null
}>('flamechart.setHoveredNode')
export const setSelectedNode = actionCreator<{
id: FlamechartID
selectedNode: CallTreeNode | null
}>('flamechart.setSelectedNode')
export const setConfigSpaceViewportRect = actionCreator<{
id: FlamechartID
configSpaceViewportRect: Rect
}>('flamechart.setConfigSpaceViewportRect')
export const setLogicalSpaceViewportSize = actionCreator<{
id: FlamechartID
logicalSpaceViewportSize: Vec2
}>('flamechart.setLogicalSpaceViewportSpace')
}
}
+61
View File
@@ -0,0 +1,61 @@
import {CallTreeNode} from '../profile'
import {Rect, Vec2} from '../math'
import {Reducer} from './typed-redux'
import {actions} from './actions'
export enum FlamechartID {
LEFT_HEAVY = 'LEFT_HEAVY',
CHRONO = 'CHRONO',
SANDWICH_INVERTED_CALLERS = 'SANDWICH_INVERTED_CALLERS',
SANDWICH_CALLEES = 'SANDWICH_CALLEES',
}
export interface FlamechartViewState {
hover: {
node: CallTreeNode
event: MouseEvent
} | null
selectedNode: CallTreeNode | null
logicalSpaceViewportSize: Vec2
configSpaceViewportRect: Rect
}
export function createFlamechartViewStateReducer(id: FlamechartID): Reducer<FlamechartViewState> {
let initialState: FlamechartViewState = {
hover: null,
selectedNode: null,
configSpaceViewportRect: Rect.empty,
logicalSpaceViewportSize: Vec2.zero,
}
return (state = initialState, action) => {
if (actions.flamechart.setHoveredNode.matches(action) && action.payload.id === id) {
const {hover} = action.payload
return {...state, hover}
}
if (actions.flamechart.setSelectedNode.matches(action) && action.payload.id === id) {
const {selectedNode} = action.payload
return {...state, selectedNode}
}
if (actions.flamechart.setConfigSpaceViewportRect.matches(action) && action.payload.id === id) {
const {configSpaceViewportRect} = action.payload
return {...state, configSpaceViewportRect}
}
if (
actions.flamechart.setLogicalSpaceViewportSize.matches(action) &&
action.payload.id === id
) {
const {logicalSpaceViewportSize} = action.payload
return {...state, logicalSpaceViewportSize}
}
if (actions.setProfile.matches(action)) {
// If the profile changes, we should invalidate all of our state, since none of it still applies
return initialState
}
if (actions.setViewMode.matches(action)) {
// If we switch views, the hover information is no longer relevant
return {...state, hover: null}
}
return state
}
}
+47
View File
@@ -0,0 +1,47 @@
import {Frame, Profile} from '../profile'
import {triangle, memoizeByReference, memoizeByShallowEquality} from '../utils'
import {RowAtlas} from '../row-atlas'
import {CanvasContext} from '../canvas-context'
import {Color} from '../color'
import {FlamechartRowAtlasKey} from '../flamechart-renderer'
export const createGetColorBucketForFrame = memoizeByReference(
(frameToColorBucket: Map<number | string, number>) => {
return (frame: Frame): number => {
return frameToColorBucket.get(frame.key) || 0
}
},
)
export const createGetCSSColorForFrame = memoizeByReference(
(frameToColorBucket: Map<number | string, number>) => {
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
return (frame: Frame): string => {
const t = getColorBucketForFrame(frame) / 255
const x = triangle(30.0 * t)
const H = 360.0 * (0.9 * t)
const C = 0.25 + 0.2 * x
const L = 0.8 - 0.15 * x
return Color.fromLumaChromaHue(L, C, H).toCSS()
}
},
)
export const getCanvasContext = memoizeByReference((canvas: HTMLCanvasElement) => {
return new CanvasContext(canvas)
})
export const getRowAtlas = memoizeByReference((canvasContext: CanvasContext) => {
return new RowAtlas<FlamechartRowAtlasKey>(canvasContext)
})
export const getProfileWithRecursionFlattened = memoizeByReference((profile: Profile) =>
profile.getProfileWithRecursionFlattened(),
)
export const getProfileToView = memoizeByShallowEquality(
({profile, flattenRecursion}: {profile: Profile; flattenRecursion: boolean}): Profile => {
return flattenRecursion ? profile.getProfileWithRecursionFlattened() : profile
},
)
+85
View File
@@ -0,0 +1,85 @@
import {actions} from './actions'
/**
* The root node of application state. We use redux (https://redux.js.org/)
* as our state management solution.
*/
import * as redux from 'redux'
import {setter, Reducer} from './typed-redux'
import {Profile} from '../profile'
import {
createFlamechartViewStateReducer,
FlamechartID,
FlamechartViewState,
} from './flamechart-view-state'
import {SandwichViewState, sandwichView} from './sandwich-view-state'
import {HashParams, getHashParams} from '../hash-params'
export const enum ViewMode {
CHRONO_FLAME_CHART,
LEFT_HEAVY_FLAME_GRAPH,
SANDWICH_VIEW,
}
export interface ApplicationState {
profile: Profile | null
frameToColorBucket: Map<string | number, number>
hashParams: HashParams
glCanvas: HTMLCanvasElement | null
flattenRecursion: boolean
viewMode: ViewMode
dragActive: boolean
loading: boolean
error: boolean
chronoView: FlamechartViewState
leftHeavyView: FlamechartViewState
sandwichView: SandwichViewState
}
const protocol = window.location.protocol
// Speedscope is usable both from a local HTML file being served
// from a file:// URL, and via websites. In the case of file:// URLs,
// however, XHR will be unavailable to fetching files in adjacent directories.
export const canUseXHR = protocol === 'http:' || protocol === 'https:'
export function createApplicationStore(
initialState: Partial<ApplicationState>,
): redux.Store<ApplicationState> {
const hashParams = getHashParams()
const loading = canUseXHR && hashParams.profileURL != null
const reducer: Reducer<ApplicationState> = redux.combineReducers({
profile: setter<Profile | null>(actions.setProfile, null),
frameToColorBucket: setter<Map<string | number, number>>(
actions.setFrameToColorBucket,
new Map(),
),
hashParams: setter<HashParams>(actions.setHashParams, hashParams),
flattenRecursion: setter<boolean>(actions.setFlattenRecursion, false),
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
dragActive: setter<boolean>(actions.setDragActive, false),
loading: setter<boolean>(actions.setLoading, loading),
error: setter<boolean>(actions.setError, false),
chronoView: createFlamechartViewStateReducer(FlamechartID.CHRONO),
leftHeavyView: createFlamechartViewStateReducer(FlamechartID.LEFT_HEAVY),
sandwichView,
})
return redux.createStore(reducer, initialState)
}
+86
View File
@@ -0,0 +1,86 @@
import {SortMethod, SortField, SortDirection} from '../profile-table-view'
import {Frame} from '../profile'
import {
FlamechartViewState,
FlamechartID,
createFlamechartViewStateReducer,
} from './flamechart-view-state'
import {Reducer} from './typed-redux'
import {actions} from './actions'
export interface SandwichViewState {
tableSortMethod: SortMethod
callerCallee: CallerCalleeState | null
}
export interface CallerCalleeState {
selectedFrame: Frame
invertedCallerFlamegraph: FlamechartViewState
calleeFlamegraph: FlamechartViewState
}
const defaultSortMethod = {
field: SortField.SELF,
direction: SortDirection.DESCENDING,
}
const calleesReducer = createFlamechartViewStateReducer(FlamechartID.SANDWICH_CALLEES)
const invertedCallersReducer = createFlamechartViewStateReducer(
FlamechartID.SANDWICH_INVERTED_CALLERS,
)
export const sandwichView: Reducer<SandwichViewState> = (
state = {tableSortMethod: defaultSortMethod, callerCallee: null},
action,
) => {
if (actions.setProfile.matches(action)) {
// When a new profile is dropped in, none of the selection state is going to make
// sense any more.
return {...state, callerCallee: null}
}
const {callerCallee} = state
if (callerCallee) {
const {calleeFlamegraph, invertedCallerFlamegraph} = callerCallee
const nextCalleeFlamegraph = calleesReducer(calleeFlamegraph, action)
const nextInvertedCallerFlamegraph = invertedCallersReducer(invertedCallerFlamegraph, action)
if (
nextCalleeFlamegraph !== calleeFlamegraph ||
nextInvertedCallerFlamegraph !== invertedCallerFlamegraph
) {
return {
...state,
callerCallee: {
...callerCallee,
calleeFlamegraph: nextCalleeFlamegraph,
invertedCallerFlamegraph: nextInvertedCallerFlamegraph,
},
}
}
}
if (actions.sandwichView.setTableSortMethod.matches(action)) {
return {...state, tableSortMethod: action.payload}
}
if (actions.sandwichView.setSelectedFrame.matches(action)) {
if (action.payload == null) {
return {
...state,
callerCallee: null,
}
} else {
return {
...state,
callerCallee: {
selectedFrame: action.payload,
calleeFlamegraph: calleesReducer(undefined, action),
invertedCallerFlamegraph: invertedCallersReducer(undefined, action),
},
}
}
}
return state
}
+72
View File
@@ -0,0 +1,72 @@
import {connect} from 'preact-redux'
import * as redux from 'redux'
import {ComponentConstructor, Component} from 'preact'
export interface Action<TPayload> extends redux.Action<string> {
payload: TPayload
}
export interface ActionCreator<TPayload> {
// Returns an action with a non-empty payload
(payload: TPayload): Action<TPayload>
// Returns an action with an empty payload ({})
(): Action<TPayload>
matches(action: Action<any>): action is Action<TPayload>
}
const usedActionTypes = new Set<string>()
export function actionCreator(type: string): ActionCreator<void>
export function actionCreator<TPayload>(type: string): ActionCreator<TPayload>
export function actionCreator(type: string) {
if (usedActionTypes.has(type)) {
throw new Error(`Cannot re-use action type name: ${type}`)
}
const creator: any = (payload = {}) => {
return {type, payload}
}
creator.matches = (action: Action<any>) => {
return action.type === type
}
return creator
}
export type Reducer<T> = (state: T | undefined, action: Action<any>) => T
export function setter<T>(
setterAction: ActionCreator<T>,
defaultVal: T,
): (state: T | undefined, action: Action<any>) => T {
return (state = defaultVal, action) => {
if (setterAction.matches(action)) {
return action.payload
}
return state
}
}
export type Dispatch = redux.Dispatch<Action<any>>
export type WithDispatch<T> = T & {dispatch: Dispatch}
export type WithoutDispatch<T> = Pick<T, Exclude<keyof T, 'dispatch'>>
// We make this into a single function invocation instead of the connect(map, map)(Component)
// syntax to make better use of type inference.
export function createContainer<OwnProps, State, PropsFromState, ComponentType>(
component: {
new (props: OwnProps & PropsFromState & {dispatch: Dispatch}): ComponentType
},
mapStateToProps: (state: State, ownProps: OwnProps) => PropsFromState,
): ComponentConstructor<OwnProps, {}> {
return connect(mapStateToProps, (dispatch: Dispatch) => ({dispatch}))(component)
}
export type VoidState = {
__dummyField: void
}
export abstract class StatelessComponent<P> extends Component<P, VoidState> {}
+57 -281
View File
@@ -1,24 +1,17 @@
import {h} from 'preact'
import {h, Component} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {ReloadableComponent, SerializedComponent} from './reloadable'
import {FileSystemDirectoryEntry} from './import/file-system-entry'
import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer'
import {CanvasContext} from './canvas-context'
import {Profile, Frame} from './profile'
import {Flamechart} from './flamechart'
import {FlamechartView} from './flamechart-view'
import {FontFamily, FontSize, Colors, Sizes, Duration} from './style'
import {getHashParams, HashParams} from './hash-params'
import {SortMethod, SortField, SortDirection} from './profile-table-view'
import {triangle} from './utils'
import {Color} from './color'
import {RowAtlas} from './row-atlas'
import {importEmscriptenSymbolMap} from './emscripten'
import {SandwichView} from './sandwich-view'
import {SandwichViewContainer} from './sandwich-view'
import {saveToFile} from './file-format'
import {ApplicationState, ViewMode, canUseXHR} from './app-state'
import {actions} from './app-state/actions'
import {Dispatch, StatelessComponent, WithDispatch} from './app-state/typed-redux'
import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container'
import {getProfileToView} from './app-state/getters'
const importModule = import('./import')
// Force eager loading of the module
@@ -30,44 +23,16 @@ async function importFromFileSystemDirectoryEntry(entry: FileSystemDirectoryEntr
return (await importModule).importFromFileSystemDirectoryEntry(entry)
}
const protocol = window.location.protocol
const canUseXHR = protocol === 'http:' || protocol === 'https:'
declare function require(x: string): any
const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
const enum ViewMode {
CHRONO_FLAME_CHART,
LEFT_HEAVY_FLAME_GRAPH,
SANDWICH_VIEW,
}
interface ApplicationState {
profile: Profile | null
activeProfile: Profile | null
flattenRecursion: boolean
chronoFlamechart: Flamechart | null
chronoFlamechartRenderer: FlamechartRenderer | null
leftHeavyFlamegraph: Flamechart | null
leftHeavyFlamegraphRenderer: FlamechartRenderer | null
tableSortMethod: SortMethod
viewMode: ViewMode
dragActive: boolean
loading: boolean
error: boolean
}
interface ToolbarProps extends ApplicationState {
setViewMode(order: ViewMode): void
browseForFile(): void
saveFile(): void
}
export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
export class Toolbar extends StatelessComponent<ToolbarProps> {
setTimeOrder = () => {
this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART)
}
@@ -154,21 +119,19 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
}
interface GLCanvasProps {
setCanvasContext(canvasContext: CanvasContext | null): void
dispatch: Dispatch
}
export class GLCanvas extends ReloadableComponent<GLCanvasProps, void> {
export class GLCanvas extends Component<GLCanvasProps, void> {
private canvas: HTMLCanvasElement | null = null
private canvasContext: CanvasContext | null = null
private ref = (canvas?: Element) => {
if (canvas instanceof HTMLCanvasElement) {
this.canvas = canvas
this.canvasContext = new CanvasContext(canvas)
} else {
this.canvas = null
this.canvasContext = null
}
this.props.setCanvasContext(this.canvasContext)
this.props.dispatch(actions.setGLCanvas(this.canvas))
}
private maybeResize() {
@@ -200,76 +163,17 @@ export class GLCanvas extends ReloadableComponent<GLCanvasProps, void> {
componentWillUnmount() {
window.removeEventListener('resize', this.onWindowResize)
}
render() {
return <canvas className={css(style.glCanvasView)} ref={this.ref} width={1} height={1} />
}
}
export class Application extends ReloadableComponent<{}, ApplicationState> {
hashParams: HashParams
constructor() {
super()
this.hashParams = getHashParams()
this.state = {
// Start out at a loading state if we know that we'll immediately be fetching a profile to
// view.
loading:
(canUseXHR && this.hashParams.profileURL != null) ||
this.hashParams.localProfilePath != null,
dragActive: false,
error: false,
profile: null,
activeProfile: null,
flattenRecursion: false,
chronoFlamechart: null,
chronoFlamechartRenderer: null,
leftHeavyFlamegraph: null,
leftHeavyFlamegraphRenderer: null,
tableSortMethod: {
field: SortField.SELF,
direction: SortDirection.DESCENDING,
},
viewMode: ViewMode.CHRONO_FLAME_CHART,
}
}
serialize() {
const result = super.serialize()
delete result.state.chronoFlamechartRenderer
delete result.state.leftHeavyFlamegraphRenderer
return result
}
rehydrate(serialized: SerializedComponent<ApplicationState>) {
super.rehydrate(serialized)
const {chronoFlamechart, leftHeavyFlamegraph} = serialized.state
if (this.canvasContext && this.rowAtlas && chronoFlamechart && leftHeavyFlamegraph) {
this.setState({
chronoFlamechartRenderer: new FlamechartRenderer(
this.canvasContext,
this.rowAtlas,
chronoFlamechart,
),
leftHeavyFlamegraphRenderer: new FlamechartRenderer(
this.canvasContext,
this.rowAtlas,
leftHeavyFlamegraph,
),
})
}
}
export class Application extends StatelessComponent<WithDispatch<ApplicationState>> {
async loadProfile(loader: () => Promise<Profile | null>) {
await new Promise(resolve => this.setState({loading: true}, resolve))
this.props.dispatch(actions.setLoading(true))
await new Promise(resolve => setTimeout(resolve, 0))
if (!this.canvasContext || !this.rowAtlas) return
if (!this.props.glCanvas) return
console.time('import')
@@ -278,30 +182,31 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
profile = await loader()
} catch (e) {
console.log('Failed to load format', e)
this.setState({error: true})
this.props.dispatch(actions.setError(true))
return
}
if (profile == null) {
// TODO(jlfwong): Make this a nicer overlay
alert('Unrecognized format! See documentation about supported formats.')
await new Promise(resolve => this.setState({loading: false}, resolve))
this.props.dispatch(actions.setLoading(false))
return
}
await profile.demangle()
const title = this.hashParams.title || profile.getName()
const title = this.props.hashParams.title || profile.getName()
profile.setName(title)
await this.setActiveProfile(profile)
console.timeEnd('import')
this.setState({profile})
this.props.dispatch(actions.setProfile(profile))
this.props.dispatch(actions.setLoading(false))
}
async setActiveProfile(profile: Profile) {
if (!this.canvasContext || !this.rowAtlas) return
if (!this.props.glCanvas) return
document.title = `${profile.getName()} - speedscope`
@@ -318,50 +223,9 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
for (let i = 0; i < frames.length; i++) {
frameToColorBucket.set(frames[i].key, Math.floor(255 * i / frames.length))
}
function getColorBucketForFrame(frame: Frame) {
return frameToColorBucket.get(frame.key) || 0
}
const chronoFlamechart = new Flamechart({
getTotalWeight: profile.getTotalWeight.bind(profile),
forEachCall: profile.forEachCall.bind(profile),
formatValue: profile.formatValue.bind(profile),
getColorBucketForFrame,
})
const chronoFlamechartRenderer = new FlamechartRenderer(
this.canvasContext,
this.rowAtlas,
chronoFlamechart,
)
const leftHeavyFlamegraph = new Flamechart({
getTotalWeight: profile.getTotalNonIdleWeight.bind(profile),
forEachCall: profile.forEachCallGrouped.bind(profile),
formatValue: profile.formatValue.bind(profile),
getColorBucketForFrame,
})
const leftHeavyFlamegraphRenderer = new FlamechartRenderer(
this.canvasContext,
this.rowAtlas,
leftHeavyFlamegraph,
)
await new Promise(resolve => {
this.setState(
{
activeProfile: profile,
chronoFlamechart,
chronoFlamechartRenderer,
leftHeavyFlamegraph,
leftHeavyFlamegraphRenderer,
loading: false,
},
resolve,
)
})
this.props.dispatch(actions.setActiveProfile(profile))
this.props.dispatch(actions.setFrameToColorBucket(frameToColorBucket))
}
loadFromFile(file: File) {
@@ -379,7 +243,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
return profile
}
if (this.state.profile) {
if (this.props.profile) {
// If a profile is already loaded, it's possible the file being imported is
// a symbol map. If that's the case, we want to parse it, and apply the symbol
// mapping to the already loaded profile. This can be use to take an opaque
@@ -387,7 +251,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
const map = importEmscriptenSymbolMap(reader.result)
if (map) {
console.log('Importing as emscripten symbol map')
let profile = this.state.profile
let profile = this.props.profile
profile.remapNames(name => map.get(name) || name)
return profile
}
@@ -410,7 +274,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
onDrop = (ev: DragEvent) => {
this.setState({dragActive: false})
this.props.dispatch(actions.setDragActive(false))
ev.preventDefault()
const firstItem = ev.dataTransfer.items[0]
@@ -432,44 +296,31 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
onDragOver = (ev: DragEvent) => {
this.setState({dragActive: true})
this.props.dispatch(actions.setDragActive(true))
ev.preventDefault()
}
onDragLeave = (ev: DragEvent) => {
this.setState({dragActive: false})
this.props.dispatch(actions.setDragActive(false))
ev.preventDefault()
}
onWindowKeyPress = async (ev: KeyboardEvent) => {
if (ev.key === '1') {
this.setState({
viewMode: ViewMode.CHRONO_FLAME_CHART,
})
this.props.dispatch(actions.setViewMode(ViewMode.CHRONO_FLAME_CHART))
} else if (ev.key === '2') {
this.setState({
viewMode: ViewMode.LEFT_HEAVY_FLAME_GRAPH,
})
this.props.dispatch(actions.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH))
} else if (ev.key === '3') {
this.setState({
viewMode: ViewMode.SANDWICH_VIEW,
})
this.props.dispatch(actions.setViewMode(ViewMode.SANDWICH_VIEW))
} else if (ev.key === 'r') {
const {flattenRecursion, profile} = this.state
if (!profile) return
if (flattenRecursion) {
await this.setActiveProfile(profile)
this.setState({flattenRecursion: false})
} else {
await this.setActiveProfile(profile.getProfileWithRecursionFlattened())
this.setState({flattenRecursion: true})
}
const {flattenRecursion} = this.props
this.props.dispatch(actions.setFlattenRecursion(!flattenRecursion))
}
}
private saveFile = () => {
if (this.state.profile) {
saveToFile(this.state.profile)
if (this.props.profile) {
saveToFile(this.props.profile)
}
}
@@ -514,20 +365,22 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
async maybeLoadHashParamProfile() {
if (this.hashParams.profileURL) {
if (this.props.hashParams.profileURL) {
if (!canUseXHR) {
alert(`Cannot load a profile URL when loading from "${protocol}" URL protocol`)
alert(
`Cannot load a profile URL when loading from "${window.location.protocol}" URL protocol`,
)
return
}
this.loadProfile(async () => {
const response = await fetch(this.hashParams.profileURL!)
let filename = new URL(this.hashParams.profileURL!).pathname
const response = await fetch(this.props.hashParams.profileURL!)
let filename = new URL(this.props.hashParams.profileURL!).pathname
if (filename.includes('/')) {
filename = filename.slice(filename.lastIndexOf('/') + 1)
}
return await importProfile(filename, await response.text())
})
} else if (this.hashParams.localProfilePath) {
} else if (this.props.hashParams.localProfilePath) {
// There isn't good cross-browser support for XHR of local files, even from
// other local files. To work around this restriction, we load the local profile
// as a JavaScript file which will invoke a global function.
@@ -539,19 +392,11 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
const script = document.createElement('script')
script.src = `file:///${this.hashParams.localProfilePath}`
script.src = `file:///${this.props.hashParams.localProfilePath}`
document.head.appendChild(script)
}
}
flamechartView: FlamechartView | null = null
flamechartRef = (view: FlamechartView | null) => (this.flamechartView = view)
subcomponents() {
return {
flamechart: this.flamechartView,
}
}
onFileSelect = (ev: Event) => {
const file = (ev.target as HTMLInputElement).files!.item(0)
if (file) {
@@ -643,105 +488,36 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
setViewMode = (viewMode: ViewMode) => {
this.setState({viewMode})
}
setTableSortMethod = (tableSortMethod: SortMethod) => {
this.setState({tableSortMethod})
}
private canvasContext: CanvasContext | null = null
private rowAtlas: RowAtlas<FlamechartRowAtlasKey> | null = null
private setCanvasContext = (canvasContext: CanvasContext | null) => {
this.canvasContext = canvasContext
if (canvasContext) {
this.rowAtlas = new RowAtlas(canvasContext)
} else {
this.rowAtlas = null
}
}
getColorBucketForFrame = (frame: Frame): number => {
const {chronoFlamechart} = this.state
if (!chronoFlamechart) return 0
return chronoFlamechart.getColorBucketForFrame(frame)
}
getCSSColorForFrame = (frame: Frame): string => {
const {chronoFlamechart} = this.state
if (!chronoFlamechart) return '#FFFFFF'
const t = chronoFlamechart.getColorBucketForFrame(frame) / 255
const x = triangle(30.0 * t)
const H = 360.0 * (0.9 * t)
const C = 0.25 + 0.2 * x
const L = 0.8 - 0.15 * x
return Color.fromLumaChromaHue(L, C, H).toCSS()
this.props.dispatch(actions.setViewMode(viewMode))
}
renderContent() {
const {viewMode} = this.state
const {viewMode, flattenRecursion, profile, error, loading, glCanvas} = this.props
if (this.state.error) {
if (error) {
return this.renderError()
}
if (this.state.loading) {
if (loading) {
return this.renderLoadingBar()
}
if (!this.state.activeProfile) {
if (!profile || !glCanvas) {
return this.renderLanding()
}
if (!this.canvasContext) {
throw new Error('Missing canvas context')
}
const profileToView = getProfileToView({profile, flattenRecursion})
switch (viewMode) {
case ViewMode.CHRONO_FLAME_CHART: {
const {chronoFlamechart, chronoFlamechartRenderer} = this.state
if (!chronoFlamechart || !chronoFlamechartRenderer)
throw new Error('Missing dependencies for chrono flame chart')
return (
<FlamechartView
canvasContext={this.canvasContext}
flamechartRenderer={chronoFlamechartRenderer}
ref={this.flamechartRef}
flamechart={chronoFlamechart}
getCSSColorForFrame={this.getCSSColorForFrame}
/>
)
return <ChronoFlamechartView profile={profileToView} glCanvas={glCanvas} />
}
case ViewMode.LEFT_HEAVY_FLAME_GRAPH: {
const {leftHeavyFlamegraph, leftHeavyFlamegraphRenderer} = this.state
if (!leftHeavyFlamegraph || !leftHeavyFlamegraphRenderer)
throw new Error('Missing dependencies for left heavy flame graph')
return (
<FlamechartView
canvasContext={this.canvasContext}
flamechartRenderer={leftHeavyFlamegraphRenderer}
ref={this.flamechartRef}
flamechart={leftHeavyFlamegraph}
getCSSColorForFrame={this.getCSSColorForFrame}
/>
)
return <LeftHeavyFlamechartView profile={profileToView} glCanvas={glCanvas} />
}
case ViewMode.SANDWICH_VIEW: {
if (!this.rowAtlas || !this.state.profile) return null
return (
<SandwichView
profile={this.state.profile}
flattenRecursion={this.state.flattenRecursion}
getColorBucketForFrame={this.getColorBucketForFrame}
getCSSColorForFrame={this.getCSSColorForFrame}
sortMethod={this.state.tableSortMethod}
setSortMethod={this.setTableSortMethod}
canvasContext={this.canvasContext}
rowAtlas={this.rowAtlas}
/>
)
if (!this.props.profile) return null
return <SandwichViewContainer />
}
}
}
@@ -752,17 +528,17 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
onDrop={this.onDrop}
onDragOver={this.onDragOver}
onDragLeave={this.onDragLeave}
className={css(style.root, this.state.dragActive && style.dragTargetRoot)}
className={css(style.root, this.props.dragActive && style.dragTargetRoot)}
>
<GLCanvas setCanvasContext={this.setCanvasContext} />
<GLCanvas dispatch={this.props.dispatch} />
<Toolbar
setViewMode={this.setViewMode}
saveFile={this.saveFile}
browseForFile={this.browseForFile}
{...this.state}
{...this.props as ApplicationState}
/>
<div className={css(style.contentContainer)}>{this.renderContent()}</div>
{this.state.dragActive && <div className={css(style.dragTarget)} />}
{this.props.dragActive && <div className={css(style.dragTarget)} />}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import {memoizeByShallowEquality} from './utils'
import {Profile, Frame} from './profile'
import {Flamechart} from './flamechart'
import {createMemoizedFlamechartRenderer} from './flamechart-view-container'
import {createContainer} from './app-state/typed-redux'
import {ApplicationState} from './app-state'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
} from './app-state/getters'
import {FlamechartID} from './app-state/flamechart-view-state'
import {FlamechartWrapper} from './flamechart-wrapper'
const getCalleeProfile = memoizeByShallowEquality<
{
profile: Profile
frame: Frame
flattenRecursion: boolean
},
Profile
>(({profile, frame, flattenRecursion}) => {
let p = profile.getProfileForCalleesOf(frame)
return flattenRecursion ? p.getProfileWithRecursionFlattened() : p
})
const getCalleeFlamegraph = memoizeByShallowEquality<
{
calleeProfile: Profile
getColorBucketForFrame: (frame: Frame) => number
},
Flamechart
>(({calleeProfile, getColorBucketForFrame}) => {
return new Flamechart({
getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
formatValue: calleeProfile.formatValue.bind(calleeProfile),
getColorBucketForFrame,
})
})
const getCalleeFlamegraphRenderer = createMemoizedFlamechartRenderer()
export const CalleeFlamegraphView = createContainer(
FlamechartWrapper,
(state: ApplicationState) => {
const {profile, flattenRecursion, glCanvas, frameToColorBucket, sandwichView} = state
if (!profile) throw new Error('profile missing')
if (!glCanvas) throw new Error('glCanvas missing')
const {callerCallee} = sandwichView
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const canvasContext = getCanvasContext(glCanvas)
const flamechart = getCalleeFlamegraph({
calleeProfile: getCalleeProfile({profile, frame: selectedFrame, flattenRecursion}),
getColorBucketForFrame,
})
const flamechartRenderer = getCalleeFlamegraphRenderer({canvasContext, flamechart})
return {
id: FlamechartID.SANDWICH_CALLEES,
renderInverted: false,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...callerCallee.calleeFlamegraph,
}
},
)
+1
View File
@@ -53,6 +53,7 @@ export class CanvasContext {
}, version: ${this.gl.limits.version}`,
)
;(window as any)['CanvasContext'] = this
this.rectangleBatchRenderer = new RectangleBatchRenderer(this.gl)
this.viewportRectangleRenderer = new ViewportRectangleRenderer(this.gl)
this.textureRenderer = new TextureRenderer(this.gl)
+4 -5
View File
@@ -1,6 +1,5 @@
import {StyleDeclarationValue, css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {h} from 'preact'
import {h, Component} from 'preact'
import {style} from './flamechart-style'
import {formatPercent} from './utils'
import {Frame, CallTreeNode} from './profile'
@@ -16,7 +15,7 @@ interface StatisticsTableProps {
formatter: (v: number) => string
}
class StatisticsTable extends ReloadableComponent<StatisticsTableProps, {}> {
class StatisticsTable extends Component<StatisticsTableProps, {}> {
render() {
const total = this.props.formatter(this.props.selectedTotal)
const self = this.props.formatter(this.props.selectedSelf)
@@ -52,7 +51,7 @@ interface StackTraceViewProps {
getFrameColor: (frame: Frame) => string
node: CallTreeNode
}
class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
class StackTraceView extends Component<StackTraceViewProps, {}> {
render() {
const rows: JSX.Element[] = []
let node: CallTreeNode | null = this.props.node
@@ -93,7 +92,7 @@ interface FlamechartDetailViewProps {
selectedNode: CallTreeNode
}
export class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps, {}> {
export class FlamechartDetailView extends Component<FlamechartDetailViewProps, {}> {
render() {
const {flamechart, selectedNode} = this.props
const {frame} = selectedNode
+14 -9
View File
@@ -3,11 +3,10 @@ import {CallTreeNode} from './profile'
import {Flamechart, FlamechartFrame} from './flamechart'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer} from './flamechart-renderer'
import {ReloadableComponent} from './reloadable'
import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from './text-utils'
import {style} from './flamechart-style'
import {h} from 'preact'
import {h, Component} from 'preact'
import {css} from 'aphrodite'
interface FlamechartFrameLabel {
@@ -42,12 +41,16 @@ export interface FlamechartPanZoomViewProps {
onNodeHover: (hover: {node: CallTreeNode; event: MouseEvent} | null) => void
onNodeSelect: (node: CallTreeNode | null) => void
configSpaceViewportRect: Rect
transformViewport: (transform: AffineTransform) => void
setConfigSpaceViewportRect: (rect: Rect) => void
logicalSpaceViewportSize: Vec2
setLogicalSpaceViewportBounds: (size: Vec2) => void
}
export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoomViewProps, {}> {
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
private container: Element | null = null
private containerRef = (element?: Element) => {
this.container = element || null
@@ -357,16 +360,16 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
}
}
private lastBounds: ClientRect | null = null
private updateConfigSpaceViewport() {
if (!this.container) return
const {logicalSpaceViewportSize} = this.props
const bounds = this.container.getBoundingClientRect()
const {width, height} = bounds
// Still initializing: don't resize yet
if (width < 2 || height < 2) return
if (this.lastBounds == null) {
if (this.props.configSpaceViewportRect.isEmpty()) {
const configSpaceViewportHeight = height / this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
if (this.props.renderInverted) {
this.setConfigSpaceViewportRect(
@@ -380,18 +383,21 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
new Rect(new Vec2(0, -1), new Vec2(this.configSpaceSize().x, configSpaceViewportHeight)),
)
}
} else if (this.lastBounds.width !== width || this.lastBounds.height !== height) {
} else if (
!logicalSpaceViewportSize.equals(Vec2.zero) &&
(logicalSpaceViewportSize.x !== width || logicalSpaceViewportSize.y !== height)
) {
// Resize the viewport rectangle to match the window size aspect
// ratio.
this.setConfigSpaceViewportRect(
this.props.configSpaceViewportRect.withSize(
this.props.configSpaceViewportRect.size.timesPointwise(
new Vec2(width / this.lastBounds.width, height / this.lastBounds.height),
new Vec2(width / logicalSpaceViewportSize.x, height / logicalSpaceViewportSize.y),
),
),
)
}
this.lastBounds = bounds
this.props.setLogicalSpaceViewportBounds(new Vec2(width, height))
}
onWindowResize = () => {
@@ -663,7 +669,6 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
componentWillReceiveProps(nextProps: FlamechartPanZoomViewProps) {
if (this.props.flamechart !== nextProps.flamechart) {
this.hoveredLabel = null
this.lastBounds = null
this.renderCanvas()
} else if (this.props.selectedNode !== nextProps.selectedNode) {
this.renderCanvas()
+2 -2
View File
@@ -121,7 +121,7 @@ export class FlamechartRowAtlasKey {
}
}
interface RendererOptions {
export interface FlamechartRendererOptions {
inverted: boolean
}
@@ -134,7 +134,7 @@ export class FlamechartRenderer {
private canvasContext: CanvasContext,
private rowAtlas: RowAtlas<FlamechartRowAtlasKey>,
private flamechart: Flamechart,
private options: RendererOptions = {inverted: false},
private options: FlamechartRendererOptions = {inverted: false},
) {
const nLayers = flamechart.getLayers().length
for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) {
+136
View File
@@ -0,0 +1,136 @@
import {FlamechartID, FlamechartViewState} from './app-state/flamechart-view-state'
import {CanvasContext} from './canvas-context'
import {Flamechart} from './flamechart'
import {FlamechartRenderer, FlamechartRendererOptions} from './flamechart-renderer'
import {Dispatch, createContainer, WithoutDispatch} from './app-state/typed-redux'
import {Frame, Profile} from './profile'
import {memoizeByShallowEquality} from './utils'
import {ApplicationState} from './app-state'
import {FlamechartView} from './flamechart-view'
import {
getRowAtlas,
createGetColorBucketForFrame,
getCanvasContext,
createGetCSSColorForFrame,
} from './app-state/getters'
export type FlamechartViewProps = {
id: FlamechartID
canvasContext: CanvasContext
flamechart: Flamechart
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
dispatch: Dispatch
getCSSColorForFrame: (frame: Frame) => string
} & FlamechartViewState
export const getChronoViewFlamechart = memoizeByShallowEquality(
({
profile,
getColorBucketForFrame,
}: {
profile: Profile
getColorBucketForFrame: (frame: Frame) => number
}): Flamechart => {
return new Flamechart({
getTotalWeight: profile.getTotalWeight.bind(profile),
forEachCall: profile.forEachCall.bind(profile),
formatValue: profile.formatValue.bind(profile),
getColorBucketForFrame,
})
},
)
export const createMemoizedFlamechartRenderer = (options?: FlamechartRendererOptions) =>
memoizeByShallowEquality(
({
canvasContext,
flamechart,
}: {
canvasContext: CanvasContext
flamechart: Flamechart
}): FlamechartRenderer => {
return new FlamechartRenderer(canvasContext, getRowAtlas(canvasContext), flamechart, options)
},
)
const getChronoViewFlamechartRenderer = createMemoizedFlamechartRenderer()
export const ChronoFlamechartView = createContainer<
{profile: Profile; glCanvas: HTMLCanvasElement},
ApplicationState,
WithoutDispatch<FlamechartViewProps>,
FlamechartView
>(FlamechartView, (state, ownProps) => {
const {profile, glCanvas} = ownProps
const {frameToColorBucket, chronoView} = state
const canvasContext = getCanvasContext(glCanvas)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const flamechart = getChronoViewFlamechart({profile, getColorBucketForFrame})
const flamechartRenderer = getChronoViewFlamechartRenderer({
canvasContext,
flamechart,
})
return {
id: FlamechartID.CHRONO,
renderInverted: false,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...chronoView,
}
})
export const getLeftHeavyFlamechart = memoizeByShallowEquality(
({
profile,
getColorBucketForFrame,
}: {
profile: Profile
getColorBucketForFrame: (frame: Frame) => number
}): Flamechart => {
return new Flamechart({
getTotalWeight: profile.getTotalNonIdleWeight.bind(profile),
forEachCall: profile.forEachCallGrouped.bind(profile),
formatValue: profile.formatValue.bind(profile),
getColorBucketForFrame,
})
},
)
const getLeftHeavyFlamechartRenderer = createMemoizedFlamechartRenderer()
export const LeftHeavyFlamechartView = createContainer<
{profile: Profile; glCanvas: HTMLCanvasElement},
ApplicationState,
WithoutDispatch<FlamechartViewProps>,
FlamechartView
>(FlamechartView, (state, ownProps) => {
const {profile, glCanvas} = ownProps
const {frameToColorBucket, leftHeavyView} = state
const canvasContext = getCanvasContext(glCanvas)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const flamechart = getLeftHeavyFlamechart({profile, getColorBucketForFrame})
const flamechartRenderer = getLeftHeavyFlamechartRenderer({
canvasContext,
flamechart,
})
return {
id: FlamechartID.LEFT_HEAVY,
renderInverted: false,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...leftHeavyView,
}
})
+35 -55
View File
@@ -1,9 +1,7 @@
import {h} from 'preact'
import {h, Component} from 'preact'
import {css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {CallTreeNode, Frame} from './profile'
import {Flamechart} from './flamechart'
import {CallTreeNode} from './profile'
import {Rect, Vec2, AffineTransform, clamp} from './math'
import {formatPercent} from './utils'
@@ -11,38 +9,17 @@ import {FlamechartMinimapView} from './flamechart-minimap-view'
import {style} from './flamechart-style'
import {Sizes, commonStyle} from './style'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer} from './flamechart-renderer'
import {FlamechartDetailView} from './flamechart-detail-view'
import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
import {Hovertip} from './hovertip'
import {actions} from './app-state/actions'
import {FlamechartViewProps} from './flamechart-view-container'
interface FlamechartViewProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
getCSSColorForFrame: (frame: Frame) => string
interface EmptyState {
__dummy: 1
}
interface FlamechartViewState {
hover: {
node: CallTreeNode
event: MouseEvent
} | null
selectedNode: CallTreeNode | null
configSpaceViewportRect: Rect
}
export class FlamechartView extends ReloadableComponent<FlamechartViewProps, FlamechartViewState> {
constructor() {
super()
this.state = {
hover: null,
selectedNode: null,
configSpaceViewportRect: Rect.empty,
}
}
export class FlamechartView extends Component<FlamechartViewProps, EmptyState> {
private configSpaceSize() {
return new Vec2(
this.props.flamechart.getTotalWeight(),
@@ -72,24 +49,36 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
),
)
this.setState({
configSpaceViewportRect: new Rect(origin, viewportRect.size.withX(width)),
})
this.props.dispatch(
actions.flamechart.setConfigSpaceViewportRect({
id: this.props.id,
configSpaceViewportRect: new Rect(origin, viewportRect.size.withX(width)),
}),
)
}
private setLogicalSpaceViewportSize = (logicalSpaceViewportSize: Vec2): void => {
this.props.dispatch(
actions.flamechart.setLogicalSpaceViewportSize({id: this.props.id, logicalSpaceViewportSize}),
)
}
private transformViewport = (transform: AffineTransform): void => {
const viewportRect = transform.transformRect(this.state.configSpaceViewportRect)
const viewportRect = transform.transformRect(this.props.configSpaceViewportRect)
this.setConfigSpaceViewportRect(viewportRect)
}
onNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
this.setState({hover})
this.props.dispatch(
actions.flamechart.setHoveredNode({
id: this.props.id,
hover,
}),
)
}
onNodeClick = (node: CallTreeNode | null) => {
this.setState({
selectedNode: node,
})
this.props.dispatch(actions.flamechart.setSelectedNode({id: this.props.id, selectedNode: node}))
}
formatValue(weight: number) {
@@ -102,7 +91,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
renderTooltip() {
if (!this.container) return null
const {hover} = this.state
const {hover} = this.props
if (!hover) return null
const {width, height, left, top} = this.container.getBoundingClientRect()
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
@@ -122,21 +111,11 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
this.container = (container as HTMLDivElement) || null
}
panZoomView: FlamechartPanZoomView | null = null
panZoomRef = (view: FlamechartPanZoomView | null) => {
this.panZoomView = view
}
subcomponents() {
return {
panZoom: this.panZoomView,
}
}
render() {
return (
<div className={css(style.fill, commonStyle.vbox)} ref={this.containerRef}>
<FlamechartMinimapView
configSpaceViewportRect={this.state.configSpaceViewportRect}
configSpaceViewportRect={this.props.configSpaceViewportRect}
transformViewport={this.transformViewport}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
@@ -144,24 +123,25 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
/>
<FlamechartPanZoomView
ref={this.panZoomRef}
canvasContext={this.props.canvasContext}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
renderInverted={false}
onNodeHover={this.onNodeHover}
onNodeSelect={this.onNodeClick}
selectedNode={this.state.selectedNode}
selectedNode={this.props.selectedNode}
transformViewport={this.transformViewport}
configSpaceViewportRect={this.state.configSpaceViewportRect}
configSpaceViewportRect={this.props.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
/>
{this.renderTooltip()}
{this.state.selectedNode && (
{this.props.selectedNode && (
<FlamechartDetailView
flamechart={this.props.flamechart}
getCSSColorForFrame={this.props.getCSSColorForFrame}
selectedNode={this.state.selectedNode}
selectedNode={this.props.selectedNode}
/>
)}
</div>
+110
View File
@@ -0,0 +1,110 @@
import {CallTreeNode} from './profile'
import {StyleSheet, css} from 'aphrodite'
import {h} from 'preact'
import {commonStyle, Colors} from './style'
import {Rect, AffineTransform, Vec2, clamp} from './math'
import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
import {noop, formatPercent} from './utils'
import {Hovertip} from './hovertip'
import {actions} from './app-state/actions'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from './app-state/typed-redux'
export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
private clampViewportToFlamegraph(viewportRect: Rect) {
const {flamechart, renderInverted} = this.props
const configSpaceSize = new Vec2(flamechart.getTotalWeight(), flamechart.getLayers().length)
const width = clamp(
viewportRect.size.x,
Math.min(configSpaceSize.x, 3 * flamechart.getMinFrameWidth()),
configSpaceSize.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))
}
private setConfigSpaceViewportRect = (configSpaceViewportRect: Rect) => {
this.props.dispatch(
actions.flamechart.setConfigSpaceViewportRect({
id: this.props.id,
configSpaceViewportRect: this.clampViewportToFlamegraph(configSpaceViewportRect),
}),
)
}
private setLogicalSpaceViewportSize = (logicalSpaceViewportSize: Vec2): void => {
this.props.dispatch(
actions.flamechart.setLogicalSpaceViewportSize({id: this.props.id, logicalSpaceViewportSize}),
)
}
private transformViewport = (transform: AffineTransform) => {
this.setConfigSpaceViewportRect(transform.transformRect(this.props.configSpaceViewportRect))
}
private formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
const formattedPercent = formatPercent(percent)
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
private renderTooltip() {
if (!this.container) return null
const {hover} = this.props
if (!hover) return null
const {width, height, left, top} = this.container.getBoundingClientRect()
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
return (
<Hovertip containerSize={new Vec2(width, height)} offset={offset}>
<span className={css(style.hoverCount)}>
{this.formatValue(hover.node.getTotalWeight())}
</span>{' '}
{hover.node.frame.name}
</Hovertip>
)
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
this.container = (container as HTMLDivElement) || null
}
private setNodeHover = (
hover: {
node: CallTreeNode
event: MouseEvent
} | null,
) => {
this.props.dispatch(actions.flamechart.setHoveredNode({id: this.props.id, hover}))
}
render() {
return (
<div
className={css(commonStyle.fillY, commonStyle.fillX, commonStyle.vbox)}
ref={this.containerRef}
>
<FlamechartPanZoomView
selectedNode={null}
onNodeHover={this.setNodeHover}
onNodeSelect={noop}
configSpaceViewportRect={this.props.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
transformViewport={this.transformViewport}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
canvasContext={this.props.canvasContext}
renderInverted={this.props.renderInverted}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
/>
{this.renderTooltip()}
</div>
)
}
}
export const style = StyleSheet.create({
hoverCount: {
color: Colors.GREEN,
},
})
+2 -3
View File
@@ -1,15 +1,14 @@
import {ReloadableComponent} from './reloadable'
import {Vec2} from './math'
import {Sizes, Colors, FontSize, FontFamily, ZIndex} from './style'
import {css, StyleSheet} from 'aphrodite'
import {h} from 'preact'
import {h, Component} from 'preact'
interface HovertipProps {
containerSize: Vec2
offset: Vec2
}
export class Hovertip extends ReloadableComponent<HovertipProps, {}> {
export class Hovertip extends Component<HovertipProps, {}> {
render() {
const {containerSize, offset} = this.props
const width = containerSize.x
-1
View File
@@ -166,7 +166,6 @@
</head>
<body>
<div id="root"></div>
<script src="./speedscope.tsx"></script>
</body>
+86
View File
@@ -0,0 +1,86 @@
import {memoizeByShallowEquality} from './utils'
import {Profile, Frame} from './profile'
import {Flamechart} from './flamechart'
import {createMemoizedFlamechartRenderer} from './flamechart-view-container'
import {createContainer} from './app-state/typed-redux'
import {ApplicationState} from './app-state'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getProfileWithRecursionFlattened,
} from './app-state/getters'
import {FlamechartID} from './app-state/flamechart-view-state'
import {FlamechartWrapper} from './flamechart-wrapper'
const getInvertedCallerProfile = memoizeByShallowEquality(
({
profile,
frame,
flattenRecursion,
}: {
profile: Profile
frame: Frame
flattenRecursion: boolean
}): Profile => {
let p = profile.getInvertedProfileForCallersOf(frame)
return flattenRecursion ? p.getProfileWithRecursionFlattened() : p
},
)
const getInvertedCallerFlamegraph = memoizeByShallowEquality(
({
invertedCallerProfile,
getColorBucketForFrame,
}: {
invertedCallerProfile: Profile
getColorBucketForFrame: (frame: Frame) => number
}): Flamechart => {
return new Flamechart({
getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
getColorBucketForFrame,
})
},
)
const getInvertedCallerFlamegraphRenderer = createMemoizedFlamechartRenderer({inverted: true})
export const InvertedCallerFlamegraphView = createContainer(
FlamechartWrapper,
(state: ApplicationState) => {
let {profile, flattenRecursion, glCanvas, frameToColorBucket, sandwichView} = state
if (!profile) throw new Error('profile missing')
if (!glCanvas) throw new Error('glCanvas missing')
const {callerCallee} = sandwichView
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
profile = flattenRecursion ? getProfileWithRecursionFlattened(profile) : profile
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const canvasContext = getCanvasContext(glCanvas)
const flamechart = getInvertedCallerFlamegraph({
invertedCallerProfile: getInvertedCallerProfile({
profile,
frame: selectedFrame,
flattenRecursion,
}),
getColorBucketForFrame,
})
const flamechartRenderer = getInvertedCallerFlamegraphRenderer({canvasContext, flamechart})
return {
id: FlamechartID.SANDWICH_INVERTED_CALLERS,
renderInverted: true,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...callerCallee.invertedCallerFlamegraph,
}
},
)
+34 -7
View File
@@ -1,6 +1,6 @@
{
"name": "speedscope",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -4555,7 +4555,8 @@
},
"fsevents": {
"version": "1.1.3",
"resolved": "",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz",
"integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==",
"dev": true,
"optional": true,
"requires": {
@@ -4767,7 +4768,8 @@
},
"debug": {
"version": "2.6.8",
"resolved": "",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz",
"integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=",
"dev": true,
"optional": true,
"requires": {
@@ -5285,7 +5287,8 @@
},
"rc": {
"version": "1.2.1",
"resolved": "",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.1.tgz",
"integrity": "sha1-LgPo5C7kULjLPc5lvhv4l04d/ZU=",
"dev": true,
"optional": true,
"requires": {
@@ -5397,7 +5400,8 @@
},
"sshpk": {
"version": "1.13.0",
"resolved": "",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz",
"integrity": "sha1-/yo+T9BEl1Vf7Zezmg/YL6+zozw=",
"dev": true,
"optional": true,
"requires": {
@@ -5443,7 +5447,8 @@
},
"stringstream": {
"version": "0.0.5",
"resolved": "",
"resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
"integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=",
"dev": true,
"optional": true
},
@@ -5493,7 +5498,8 @@
},
"tough-cookie": {
"version": "2.3.2",
"resolved": "",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz",
"integrity": "sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo=",
"dev": true,
"optional": true,
"requires": {
@@ -10611,6 +10617,11 @@
"integrity": "sha512-m34Ke8U32HyKRVzUOCAcaiIBLR2ye6syiuRclU5DxyixDPDFqdLbIElhERBrF6gDbPKQR+Vpv5bZ9CCbvN6pdQ==",
"dev": true
},
"preact-redux": {
"version": "github:jlfwong/preact-redux#a56dcc460f4993c8dd33b7c9caa5e4bde1fa72dd",
"from": "github:jlfwong/preact-redux#a56dcc4",
"dev": true
},
"prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -11012,6 +11023,16 @@
}
}
},
"redux": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.0.tgz",
"integrity": "sha512-NnnHF0h0WVE/hXyrB6OlX67LYRuaf/rJcbWvnHHEPCF/Xa/AZpwhs/20WyqzQae5x4SD2F9nPObgBh2rxAgLiA==",
"dev": true,
"requires": {
"loose-envify": "^1.1.0",
"symbol-observable": "^1.2.0"
}
},
"regenerate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
@@ -12728,6 +12749,12 @@
}
}
},
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"dev": true
},
"symbol-tree": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz",
+22 -4
View File
@@ -16,8 +16,15 @@
"test": "tsc --noEmit && npm run lint && npm run coverage",
"serve": "parcel index.html --open --no-autoinstall"
},
"files": ["cli.js", "dist/release/**", "!*.map"],
"browserslist": ["last 2 Chrome versions", "last 2 Firefox versions"],
"files": [
"cli.js",
"dist/release/**",
"!*.map"
],
"browserslist": [
"last 2 Chrome versions",
"last 2 Firefox versions"
],
"author": "",
"license": "MIT",
"devDependencies": {
@@ -35,8 +42,10 @@
"pako": "1.0.6",
"parcel-bundler": "1.9.2",
"preact": "8.2.7",
"preact-redux": "jlfwong/preact-redux#a56dcc4",
"prettier": "1.12.0",
"quicktype": "15.0.45",
"redux": "^4.0.0",
"regl": "1.3.1",
"ts-jest": "22.4.6",
"typescript": "2.8.1",
@@ -48,8 +57,17 @@
"^.+\\.tsx?$": "ts-jest"
},
"testRegex": "\\.test\\.tsx?$",
"collectCoverageFrom": ["**/*.{ts,tsx}", "!**/*.d.{ts,tsx}"],
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json"]
"collectCoverageFrom": [
"**/*.{ts,tsx}",
"!**/*.d.{ts,tsx}"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json"
]
},
"dependencies": {
"opn": "5.3.0"
+12
View File
@@ -0,0 +1,12 @@
declare module 'preact-redux' {
import {VNode, Component} from 'preact'
import {Store} from 'redux'
// We just export the bare minimum here because we're going
// to implement an API for readability convenience elsewhere
export function connect(...args: any[]): any
export class Provider extends Component<{store: Store<any>}, {}> {
render(): VNode
}
}
+33 -9
View File
@@ -1,11 +1,14 @@
import {h, Component} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {Profile, Frame} from './profile'
import {sortBy, formatPercent} from './utils'
import {FontSize, Colors, Sizes, commonStyle} from './style'
import {ColorChit} from './color-chit'
import {ScrollableListView, ListItem} from './scrollable-list-view'
import {actions} from './app-state/actions'
import {Dispatch, createContainer} from './app-state/typed-redux'
import {ApplicationState} from './app-state'
import {createGetCSSColorForFrame} from './app-state/getters'
export enum SortField {
SYMBOL_NAME,
@@ -66,15 +69,18 @@ class SortIcon extends Component<SortIconProps, {}> {
interface ProfileTableViewProps {
profile: Profile
selectedFrame: Frame | null
setSelectedFrame: (frame: Frame | null) => void
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSortMethod: (sortMethod: SortMethod) => void
dispatch: Dispatch
}
export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps, void> {
export class ProfileTableView extends Component<ProfileTableViewProps, void> {
setSelectedFrame = (frame: Frame | null) => {
this.props.setSelectedFrame(frame)
this.props.dispatch(actions.sandwichView.setSelectedFrame(frame))
}
setSortMethod = (method: SortMethod) => {
this.props.dispatch(actions.sandwichView.setTableSortMethod(method))
}
renderRow(frame: Frame, index: number) {
@@ -122,7 +128,7 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
if (sortMethod.field == field) {
// Toggle
this.props.setSortMethod({
this.setSortMethod({
field,
direction:
sortMethod.direction === SortDirection.ASCENDING
@@ -133,15 +139,15 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
// Set a sane default
switch (field) {
case SortField.SYMBOL_NAME: {
this.props.setSortMethod({field, direction: SortDirection.ASCENDING})
this.setSortMethod({field, direction: SortDirection.ASCENDING})
break
}
case SortField.SELF: {
this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
this.setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
case SortField.TOTAL: {
this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
this.setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
}
@@ -305,3 +311,21 @@ const style = StyleSheet.create({
right: 0,
},
})
export const ProfileTableViewContainer = createContainer(
ProfileTableView,
(state: ApplicationState) => {
const {profile, sandwichView, frameToColorBucket} = state
if (!profile) throw new Error('profile missing')
const {tableSortMethod, callerCallee} = sandwichView
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
return {
profile,
selectedFrame,
getCSSColorForFrame,
sortMethod: tableSortMethod,
}
},
)
-40
View File
@@ -1,40 +0,0 @@
import {Component} from 'preact'
export interface SerializedComponent<S> {
state: S
serializedSubcomponents: {[key: string]: any}
}
export abstract class ReloadableComponent<P, S> extends Component<P, S> {
serialize(): SerializedComponent<S> {
const serializedSubcomponents: {[key: string]: any} = Object.create(null)
const subcomponents = this.subcomponents()
for (const key in subcomponents) {
const val = subcomponents[key]
if (val && val instanceof ReloadableComponent) {
serializedSubcomponents[key] = val.serialize()
}
}
return {
state: this.state,
serializedSubcomponents,
}
}
rehydrate(serialized: SerializedComponent<S>) {
this.setState(serialized.state, () => {
const subcomponents = this.subcomponents()
for (const key in subcomponents) {
const val = subcomponents[key]
const data = serialized.serializedSubcomponents[key]
if (data && val && val instanceof ReloadableComponent) {
val.rehydrate(data)
}
}
})
}
subcomponents(): {[key: string]: any} {
return Object.create(null)
}
}
+23 -255
View File
@@ -1,240 +1,27 @@
import {ReloadableComponent} from './reloadable'
import {Profile, Frame, CallTreeNode} from './profile'
import {Frame} from './profile'
import {StyleSheet, css} from 'aphrodite'
import {SortMethod, ProfileTableView} from './profile-table-view'
import {ProfileTableViewContainer} from './profile-table-view'
import {h} from 'preact'
import {commonStyle, Sizes, Colors, FontSize} from './style'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer'
import {Flamechart} from './flamechart'
import {RowAtlas} from './row-atlas'
import {Rect, AffineTransform, Vec2, clamp} from './math'
import {FlamechartPanZoomView, FlamechartPanZoomViewProps} from './flamechart-pan-zoom-view'
import {noop, formatPercent} from './utils'
import {Hovertip} from './hovertip'
interface FlamechartWrapperProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
}
interface FlamechartWrapperState {
hover: {
node: CallTreeNode
event: MouseEvent
} | null
configSpaceViewportRect: Rect
}
export class FlamechartWrapper extends ReloadableComponent<
FlamechartWrapperProps,
FlamechartWrapperState
> {
constructor(props: FlamechartWrapperProps) {
super(props)
this.state = {
hover: null,
configSpaceViewportRect: Rect.empty,
}
}
private clampViewportToFlamegraph(viewportRect: Rect, flamegraph: Flamechart, inverted: boolean) {
const configSpaceSize = new Vec2(flamegraph.getTotalWeight(), flamegraph.getLayers().length)
const width = clamp(
viewportRect.size.x,
Math.min(configSpaceSize.x, 3 * flamegraph.getMinFrameWidth()),
configSpaceSize.x,
)
const size = viewportRect.size.withX(width)
const origin = Vec2.clamp(
viewportRect.origin,
new Vec2(0, inverted ? 0 : -1),
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
)
return new Rect(origin, viewportRect.size.withX(width))
}
private setConfigSpaceViewportRect = (viewportRect: Rect) => {
this.setState({
configSpaceViewportRect: this.clampViewportToFlamegraph(
viewportRect,
this.props.flamechart,
this.props.renderInverted,
),
})
}
private transformViewport = (transform: AffineTransform) => {
this.setConfigSpaceViewportRect(transform.transformRect(this.state.configSpaceViewportRect))
}
private formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
const formattedPercent = formatPercent(percent)
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
private renderTooltip() {
if (!this.container) return null
const {hover} = this.state
if (!hover) return null
const {width, height, left, top} = this.container.getBoundingClientRect()
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
return (
<Hovertip containerSize={new Vec2(width, height)} offset={offset}>
<span className={css(style.hoverCount)}>
{this.formatValue(hover.node.getTotalWeight())}
</span>{' '}
{hover.node.frame.name}
</Hovertip>
)
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
this.container = (container as HTMLDivElement) || null
}
private setNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
this.setState({hover})
}
render() {
const props: FlamechartPanZoomViewProps = {
...(this.props as FlamechartWrapperProps),
selectedNode: null,
onNodeHover: this.setNodeHover,
onNodeSelect: noop,
configSpaceViewportRect: this.state.configSpaceViewportRect,
setConfigSpaceViewportRect: this.setConfigSpaceViewportRect,
transformViewport: this.transformViewport,
}
return (
<div
className={css(commonStyle.fillY, commonStyle.fillX, commonStyle.vbox)}
ref={this.containerRef}
>
<FlamechartPanZoomView {...props} />
{this.renderTooltip()}
</div>
)
}
}
import {actions} from './app-state/actions'
import {createContainer, Dispatch, StatelessComponent} from './app-state/typed-redux'
import {ApplicationState} from './app-state'
import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
import {CalleeFlamegraphView} from './callee-flamegraph-view'
interface SandwichViewProps {
profile: Profile
flattenRecursion: boolean
// TODO(jlfwong): It's kind of awkward requiring both of these
getColorBucketForFrame: (frame: Frame) => number
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSortMethod: (sortMethod: SortMethod) => void
canvasContext: CanvasContext
rowAtlas: RowAtlas<FlamechartRowAtlasKey>
selectedFrame: Frame | null
dispatch: Dispatch
}
interface CallerCalleeState {
selectedFrame: Frame
invertedCallerFlamegraph: Flamechart
invertedCallerFlamegraphRenderer: FlamechartRenderer
calleeFlamegraph: Flamechart
calleeFlamegraphRenderer: FlamechartRenderer
}
interface SandwichViewState {
callerCallee: CallerCalleeState | null
}
export class SandwichView extends ReloadableComponent<SandwichViewProps, SandwichViewState> {
constructor(props: SandwichViewProps) {
super(props)
this.state = {
callerCallee: null,
}
}
private setSelectedFrame = (
selectedFrame: Frame | null,
props: SandwichViewProps = this.props,
) => {
const {profile, canvasContext, rowAtlas, getColorBucketForFrame, flattenRecursion} = props
if (!selectedFrame) {
this.setState({callerCallee: null})
return
}
let invertedCallerProfile = profile.getInvertedProfileForCallersOf(selectedFrame)
if (flattenRecursion) {
invertedCallerProfile = invertedCallerProfile.getProfileWithRecursionFlattened()
}
const invertedCallerFlamegraph = new Flamechart({
getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
getColorBucketForFrame,
})
const invertedCallerFlamegraphRenderer = new FlamechartRenderer(
canvasContext,
rowAtlas,
invertedCallerFlamegraph,
{inverted: true},
)
let calleeProfile = profile.getProfileForCalleesOf(selectedFrame)
if (flattenRecursion) {
calleeProfile = calleeProfile.getProfileWithRecursionFlattened()
}
const calleeFlamegraph = new Flamechart({
getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
formatValue: calleeProfile.formatValue.bind(calleeProfile),
getColorBucketForFrame,
})
const calleeFlamegraphRenderer = new FlamechartRenderer(
canvasContext,
rowAtlas,
calleeFlamegraph,
)
this.setState({
callerCallee: {
selectedFrame,
invertedCallerFlamegraph,
invertedCallerFlamegraphRenderer,
calleeFlamegraph,
calleeFlamegraphRenderer,
},
})
class SandwichView extends StatelessComponent<SandwichViewProps> {
private setSelectedFrame = (selectedFrame: Frame | null) => {
this.props.dispatch(actions.sandwichView.setSelectedFrame(selectedFrame))
}
onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
this.setState({callerCallee: null})
}
}
componentWillReceiveProps(nextProps: SandwichViewProps) {
if (this.props.flattenRecursion !== nextProps.flattenRecursion) {
if (this.state.callerCallee) {
this.setSelectedFrame(this.state.callerCallee.selectedFrame, nextProps)
}
this.setSelectedFrame(null)
}
}
@@ -246,38 +33,24 @@ export class SandwichView extends ReloadableComponent<SandwichViewProps, Sandwic
}
render() {
const {canvasContext} = this.props
const {callerCallee} = this.state
let selectedFrame: Frame | null = null
const {selectedFrame} = this.props
let flamegraphViews: JSX.Element | null = null
if (callerCallee) {
selectedFrame = callerCallee.selectedFrame
if (selectedFrame) {
flamegraphViews = (
<div className={css(commonStyle.fillY, style.callersAndCallees, commonStyle.vbox)}>
<div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
<div className={css(style.flamechartLabelParent)}>
<div className={css(style.flamechartLabel)}>Callers</div>
</div>
<FlamechartWrapper
flamechart={callerCallee.invertedCallerFlamegraph}
canvasContext={canvasContext}
flamechartRenderer={callerCallee.invertedCallerFlamegraphRenderer}
renderInverted={true}
/>
<InvertedCallerFlamegraphView />
</div>
<div className={css(style.divider)} />
<div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
<div className={css(style.flamechartLabelParent, style.flamechartLabelParentBottom)}>
<div className={css(style.flamechartLabel, style.flamechartLabelBottom)}>Callees</div>
</div>
<FlamechartWrapper
flamechart={callerCallee.calleeFlamegraph}
canvasContext={canvasContext}
flamechartRenderer={callerCallee.calleeFlamegraphRenderer}
renderInverted={false}
/>
<CalleeFlamegraphView />
</div>
</div>
)
@@ -286,14 +59,7 @@ export class SandwichView extends ReloadableComponent<SandwichViewProps, Sandwic
return (
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableView
selectedFrame={selectedFrame}
setSelectedFrame={this.setSelectedFrame}
profile={this.props.profile}
getCSSColorForFrame={this.props.getCSSColorForFrame}
sortMethod={this.props.sortMethod}
setSortMethod={this.props.setSortMethod}
/>
<ProfileTableViewContainer />
</div>
{flamegraphViews}
</div>
@@ -339,7 +105,9 @@ const style = StyleSheet.create({
height: 2,
background: Colors.LIGHT_GRAY,
},
hoverCount: {
color: Colors.GREEN,
},
})
export const SandwichViewContainer = createContainer(SandwichView, (state: ApplicationState) => {
const {callerCallee} = state.sandwichView
return {selectedFrame: callerCallee ? callerCallee.selectedFrame : null}
})
+2 -3
View File
@@ -1,8 +1,7 @@
// A simple implementation of an efficient scrolling list view which
// renders only items within the viewport + a couple extra items.
import {h} from 'preact'
import {ReloadableComponent} from './reloadable'
import {h, Component} from 'preact'
export interface ListItem {
size: number
@@ -22,7 +21,7 @@ interface ScrollableListViewState {
cachedTotalSize: number
}
export class ScrollableListView extends ReloadableComponent<
export class ScrollableListView extends Component<
ScrollableListViewProps,
ScrollableListViewState
> {
+17 -13
View File
@@ -1,26 +1,30 @@
import {h, render} from 'preact'
import {createApplicationStore, ApplicationState} from './app-state'
import {Provider} from 'preact-redux'
import {createContainer} from './app-state/typed-redux'
import {Application} from './application'
console.log(`speedscope v${require('./package.json').version}`)
let app: Application | null = null
const retained = (window as any)['__retained__'] as any
declare const module: any
if (module.hot) {
module.hot.dispose(() => {
if (app) {
;(window as any)['__retained__'] = app.serialize()
}
// Force the old component go through teardown steps
render(<div />, document.body, document.body.lastElementChild || undefined)
})
module.hot.accept()
}
function ref(instance: Application | null) {
app = instance
if (instance && retained) {
console.log('rehydrating: ', retained)
instance.rehydrate(retained)
}
}
const lastStore: any = (window as any)['store']
const store = createApplicationStore(lastStore ? lastStore.getState() : {})
;(window as any)['store'] = store
render(<Application ref={ref} />, document.body, document.body.lastElementChild || undefined)
const ApplicationContainer = createContainer(Application, (state: ApplicationState) => state)
render(
<Provider store={store}>
<ApplicationContainer />
</Provider>,
document.body,
document.body.lastElementChild || undefined,
)
+47
View File
@@ -121,3 +121,50 @@ export function binarySearch(
}
export function noop(...args: any[]) {}
function shallowEquals<T extends object>(a: T, b: T): boolean {
for (let key in a) {
if (a[key] !== b[key]) return false
}
for (let key in b) {
if (a[key] !== b[key]) return false
}
return true
}
// TODO(jlfwong): Write tests for this
export function memoizeByShallowEquality<T extends object, U>(cb: (t: T) => U): (t: T) => U {
let last: {args: T; result: U} | null = null
return (args: T) => {
let result: U
if (last == null) {
result = cb(args)
last = {args, result}
return result
} else if (shallowEquals(last.args, args)) {
return last.result
} else {
last.args = args
last.result = cb(args)
return last.result
}
}
}
export function memoizeByReference<T, U>(cb: (t: T) => U): (t: T) => U {
let last: {args: T; result: U} | null = null
return (args: T) => {
let result: U
if (last == null) {
result = cb(args)
last = {args, result}
return result
} else if (last.args === args) {
return last.result
} else {
last.args = args
last.result = cb(args)
return last.result
}
}
}