diff --git a/app-state/actions.ts b/app-state/actions.ts new file mode 100644 index 0000000..c4eebf6 --- /dev/null +++ b/app-state/actions.ts @@ -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('setProfile') + + // Set the profile currently being viewed + export const setActiveProfile = actionCreator('setActiveProfile') + + export const setFrameToColorBucket = actionCreator>( + 'setFrameToColorBucket', + ) + + export const setGLCanvas = actionCreator('setGLCanvas') + + // Set which top-level view should be displayed + export const setViewMode = actionCreator('setViewMode') + + // Set whether or not recursion should be flattened when viewing flamegraphs + export const setFlattenRecursion = actionCreator('setFlattenRecursion') + + // Set whether a file drag is currently active. Used to indicate that the + // application is a valid drop target. + export const setDragActive = actionCreator('setDragActive') + + // Set whether the application is currently in a loading state. Used to + // display a loading progress bar. + export const setLoading = actionCreator('setLoading') + + // Set whether the application is in an errored state. + export const setError = actionCreator('setError') + + // Set whether parameters defined by the URL encoded k=v pairs after the # in the URL + export const setHashParams = actionCreator('setHashParams') + + export namespace sandwichView { + // Set the table sorting method used for the sandwich view. + export const setTableSortMethod = actionCreator('sandwichView.setTableSortMethod') + + export const setSelectedFrame = actionCreator('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') + } +} diff --git a/app-state/flamechart-view-state.ts b/app-state/flamechart-view-state.ts new file mode 100644 index 0000000..6a70851 --- /dev/null +++ b/app-state/flamechart-view-state.ts @@ -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 { + 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 + } +} diff --git a/app-state/getters.ts b/app-state/getters.ts new file mode 100644 index 0000000..5c1c885 --- /dev/null +++ b/app-state/getters.ts @@ -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) => { + return (frame: Frame): number => { + return frameToColorBucket.get(frame.key) || 0 + } + }, +) + +export const createGetCSSColorForFrame = memoizeByReference( + (frameToColorBucket: Map) => { + 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(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 + }, +) diff --git a/app-state/index.ts b/app-state/index.ts new file mode 100644 index 0000000..0f8e0cb --- /dev/null +++ b/app-state/index.ts @@ -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 + + 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, +): redux.Store { + const hashParams = getHashParams() + + const loading = canUseXHR && hashParams.profileURL != null + + const reducer: Reducer = redux.combineReducers({ + profile: setter(actions.setProfile, null), + frameToColorBucket: setter>( + actions.setFrameToColorBucket, + new Map(), + ), + + hashParams: setter(actions.setHashParams, hashParams), + + flattenRecursion: setter(actions.setFlattenRecursion, false), + + viewMode: setter(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART), + + glCanvas: setter(actions.setGLCanvas, null), + + dragActive: setter(actions.setDragActive, false), + loading: setter(actions.setLoading, loading), + error: setter(actions.setError, false), + + chronoView: createFlamechartViewStateReducer(FlamechartID.CHRONO), + leftHeavyView: createFlamechartViewStateReducer(FlamechartID.LEFT_HEAVY), + + sandwichView, + }) + + return redux.createStore(reducer, initialState) +} diff --git a/app-state/sandwich-view-state.ts b/app-state/sandwich-view-state.ts new file mode 100644 index 0000000..58ac665 --- /dev/null +++ b/app-state/sandwich-view-state.ts @@ -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 = ( + 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 +} diff --git a/app-state/typed-redux.ts b/app-state/typed-redux.ts new file mode 100644 index 0000000..1db9aca --- /dev/null +++ b/app-state/typed-redux.ts @@ -0,0 +1,72 @@ +import {connect} from 'preact-redux' +import * as redux from 'redux' +import {ComponentConstructor, Component} from 'preact' + +export interface Action extends redux.Action { + payload: TPayload +} + +export interface ActionCreator { + // Returns an action with a non-empty payload + (payload: TPayload): Action + + // Returns an action with an empty payload ({}) + (): Action + + matches(action: Action): action is Action +} + +const usedActionTypes = new Set() + +export function actionCreator(type: string): ActionCreator +export function actionCreator(type: string): ActionCreator +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) => { + return action.type === type + } + + return creator +} + +export type Reducer = (state: T | undefined, action: Action) => T + +export function setter( + setterAction: ActionCreator, + defaultVal: T, +): (state: T | undefined, action: Action) => T { + return (state = defaultVal, action) => { + if (setterAction.matches(action)) { + return action.payload + } + return state + } +} + +export type Dispatch = redux.Dispatch> +export type WithDispatch = T & {dispatch: Dispatch} +export type WithoutDispatch = Pick> + +// 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( + component: { + new (props: OwnProps & PropsFromState & {dispatch: Dispatch}): ComponentType + }, + mapStateToProps: (state: State, ownProps: OwnProps) => PropsFromState, +): ComponentConstructor { + return connect(mapStateToProps, (dispatch: Dispatch) => ({dispatch}))(component) +} + +export type VoidState = { + __dummyField: void +} + +export abstract class StatelessComponent

extends Component {} diff --git a/application.tsx b/application.tsx index 71b2378..ef4a410 100644 --- a/application.tsx +++ b/application.tsx @@ -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 { +export class Toolbar extends StatelessComponent { setTimeOrder = () => { this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART) } @@ -154,21 +119,19 @@ export class Toolbar extends ReloadableComponent { } interface GLCanvasProps { - setCanvasContext(canvasContext: CanvasContext | null): void + dispatch: Dispatch } -export class GLCanvas extends ReloadableComponent { +export class GLCanvas extends Component { 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 { componentWillUnmount() { window.removeEventListener('resize', this.onWindowResize) } - render() { return } } -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) { - 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> { async loadProfile(loader: () => Promise) { - 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 | 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 ( - - ) + return } 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 ( - - ) + return } case ViewMode.SANDWICH_VIEW: { - if (!this.rowAtlas || !this.state.profile) return null - return ( - - ) + if (!this.props.profile) return null + return } } } @@ -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)} > - +

{this.renderContent()}
- {this.state.dragActive &&
} + {this.props.dragActive &&
}
) } diff --git a/callee-flamegraph-view.ts b/callee-flamegraph-view.ts new file mode 100644 index 0000000..1985f98 --- /dev/null +++ b/callee-flamegraph-view.ts @@ -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, + } + }, +) diff --git a/canvas-context.ts b/canvas-context.ts index 3901017..1e8f6f1 100644 --- a/canvas-context.ts +++ b/canvas-context.ts @@ -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) diff --git a/flamechart-detail-view.tsx b/flamechart-detail-view.tsx index 01aac64..26c0f10 100644 --- a/flamechart-detail-view.tsx +++ b/flamechart-detail-view.tsx @@ -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 { +class StatisticsTable extends Component { 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 { +class StackTraceView extends Component { 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 { +export class FlamechartDetailView extends Component { render() { const {flamechart, selectedNode} = this.props const {frame} = selectedNode diff --git a/flamechart-pan-zoom-view.tsx b/flamechart-pan-zoom-view.tsx index ce0a076..188f746 100644 --- a/flamechart-pan-zoom-view.tsx +++ b/flamechart-pan-zoom-view.tsx @@ -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 { +export class FlamechartPanZoomView extends Component { private container: Element | null = null private containerRef = (element?: Element) => { this.container = element || null @@ -357,16 +360,16 @@ export class FlamechartPanZoomView extends ReloadableComponent { @@ -663,7 +669,6 @@ export class FlamechartPanZoomView extends ReloadableComponent, 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++) { diff --git a/flamechart-view-container.tsx b/flamechart-view-container.tsx new file mode 100644 index 0000000..0da890d --- /dev/null +++ b/flamechart-view-container.tsx @@ -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, + 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, + 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, + } +}) diff --git a/flamechart-view.tsx b/flamechart-view.tsx index c04ed98..09dc639 100644 --- a/flamechart-view.tsx +++ b/flamechart-view.tsx @@ -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 { - constructor() { - super() - this.state = { - hover: null, - selectedNode: null, - configSpaceViewportRect: Rect.empty, - } - } - +export class FlamechartView extends Component { private configSpaceSize() { return new Vec2( this.props.flamechart.getTotalWeight(), @@ -72,24 +49,36 @@ export class FlamechartView extends ReloadableComponent { + 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 { - this.panZoomView = view - } - subcomponents() { - return { - panZoom: this.panZoomView, - } - } - render() { return (
{this.renderTooltip()} - {this.state.selectedNode && ( + {this.props.selectedNode && ( )}
diff --git a/flamechart-wrapper.tsx b/flamechart-wrapper.tsx new file mode 100644 index 0000000..0748be6 --- /dev/null +++ b/flamechart-wrapper.tsx @@ -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 { + 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 ( + + + {this.formatValue(hover.node.getTotalWeight())} + {' '} + {hover.node.frame.name} + + ) + } + 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 ( +
+ + {this.renderTooltip()} +
+ ) + } +} + +export const style = StyleSheet.create({ + hoverCount: { + color: Colors.GREEN, + }, +}) diff --git a/hovertip.tsx b/hovertip.tsx index efbd92f..9ef4f41 100644 --- a/hovertip.tsx +++ b/hovertip.tsx @@ -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 { +export class Hovertip extends Component { render() { const {containerSize, offset} = this.props const width = containerSize.x diff --git a/index.html b/index.html index 2e51e72..323e75c 100644 --- a/index.html +++ b/index.html @@ -166,7 +166,6 @@ -
diff --git a/inverted-caller-flamegraph-view.ts b/inverted-caller-flamegraph-view.ts new file mode 100644 index 0000000..270ffeb --- /dev/null +++ b/inverted-caller-flamegraph-view.ts @@ -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, + } + }, +) diff --git a/package-lock.json b/package-lock.json index 4ad68a3..0f1b8f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dc8c3d3..ec190e3 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/preact-redux.d.ts b/preact-redux.d.ts new file mode 100644 index 0000000..dda0f14 --- /dev/null +++ b/preact-redux.d.ts @@ -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}, {}> { + render(): VNode + } +} diff --git a/profile-table-view.tsx b/profile-table-view.tsx index 649acf2..7403eeb 100644 --- a/profile-table-view.tsx +++ b/profile-table-view.tsx @@ -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 { 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 { +export class ProfileTableView extends Component { 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 { + 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, + } + }, +) diff --git a/reloadable.tsx b/reloadable.tsx deleted file mode 100644 index 09ebd6a..0000000 --- a/reloadable.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import {Component} from 'preact' - -export interface SerializedComponent { - state: S - serializedSubcomponents: {[key: string]: any} -} - -export abstract class ReloadableComponent extends Component { - serialize(): SerializedComponent { - 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) { - 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) - } -} diff --git a/sandwich-view.tsx b/sandwich-view.tsx index 8dc0bfe..281dea0 100644 --- a/sandwich-view.tsx +++ b/sandwich-view.tsx @@ -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 ( - - - {this.formatValue(hover.node.getTotalWeight())} - {' '} - {hover.node.frame.name} - - ) - } - - 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 ( -
- - {this.renderTooltip()} -
- ) - } -} +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 + 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 { - 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 { + 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
Callers
- +
Callees
- +
) @@ -286,14 +59,7 @@ export class SandwichView extends ReloadableComponent
- +
{flamegraphViews}
@@ -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} }) diff --git a/scrollable-list-view.tsx b/scrollable-list-view.tsx index 393f766..84b930c 100644 --- a/scrollable-list-view.tsx +++ b/scrollable-list-view.tsx @@ -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 > { diff --git a/speedscope.tsx b/speedscope.tsx index 562dbd7..627a74d 100644 --- a/speedscope.tsx +++ b/speedscope.tsx @@ -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(
, 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(, document.body, document.body.lastElementChild || undefined) +const ApplicationContainer = createContainer(Application, (state: ApplicationState) => state) + +render( + + + , + document.body, + document.body.lastElementChild || undefined, +) diff --git a/utils.ts b/utils.ts index 82b4f25..6aae95b 100644 --- a/utils.ts +++ b/utils.ts @@ -121,3 +121,50 @@ export function binarySearch( } export function noop(...args: any[]) {} + +function shallowEquals(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(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(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 + } + } +}