Compare commits

..
19 Commits
Author SHA1 Message Date
Jamie Wong 558e98d24d 0.5.1 2018-08-11 22:05:39 -07:00
Jamie Wong 7368e15396 0.5.0 2018-08-09 09:38:55 -07:00
Jamie Wong 1bbe3de944 Update CHANGELOG.md 2018-08-09 09:27:08 -07:00
Jamie Wong 8e09be7f7f Update CHANGELOG.md 2018-08-09 09:26:26 -07:00
Jamie Wong 5ee94ec092 Support emscripten symbols containing dashes (#129)
Apparently emscripten symbols are allowed to contain dashes?

e.g. here's a line from a mapping that failed to import before this PR: `527:i32s-div`
2018-08-09 09:25:27 -07:00
Jamie Wong 4d23f1cdee Update CHANGELOG.md 2018-08-08 19:20:58 -07:00
Jamie Wong fc9260ba1d More firefox import fixes (#128) 2018-08-08 17:15:33 -07:00
Jamie Wong 182563dc7c Scroll selected node into sandwich view on mount (#126) 2018-08-08 08:37:54 -07:00
Jamie Wong e6d9994801 Update CHANGELOG.md 2018-08-07 18:42:56 -07:00
Jamie Wong 29c55c3921 Split CallTreeNodes that are non-contiguous (#123)
This PR exposed some bugs in the Firefox importer, which is also fixed in this PR.

Fixes #86
2018-08-07 18:41:56 -07:00
Jamie Wong 0b1050034f Update CHANGELOG.md 2018-08-05 14:12:31 -07:00
Jamie Wong a79beaa731 Ignore click event following a drag (#122) 2018-08-05 14:10:15 -07:00
Jamie Wong bca9c52ac9 Clamp zoom to prevent floating point issues (#121) 2018-08-05 13:57:29 -07:00
Jamie Wong 970f1b2107 Fix use of incorrect viewport size (#120) 2018-08-05 11:08:43 -07:00
Jamie Wong 72c05fe6ba Backfill some more tests (#112)
* Add tests for getHashParams

* Add tests for Color

* Cover a few more lines
2018-08-01 11:42:55 -07:00
Jamie Wong ee157c4139 Add a simple test for demangleCpp (#111) 2018-08-01 09:55:40 -07:00
Jamie Wong 94d0879417 Upgrade to TypeScript 3.0.1 (#110)
Fixes #103
2018-08-01 01:23:21 -07:00
Jamie Wong 608006232f Reorganize directory structure (#104)
While it was kind of nice having everything at the top level, the number of files is now getting a bit unwieldy and hard to understand, so I took a stab at organizing the directories without introducing too much nesting.

Test Plan:
- Ran `npm run serve` to ensure that local builds still work
- Ran `npm run prepack` then `open dist/release/index.html` to ensure that release builds still work
- Ran `scripts/deploy.sh` to ensure that the deployed version of the site will still work when I eventually redeploy
- Ran `npm run jest` to ensure that tests still work correctly
2018-08-01 00:41:45 -07:00
Jamie Wong 11a3a67dce 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
2018-07-31 23:53:56 -07:00
101 changed files with 1878 additions and 1001 deletions
+19
View File
@@ -1,5 +1,24 @@
## [Unreleased]
### Added
## [0.5.1] - 2018-08-09
### Fixed
* Fixed broken CLI
## [0.5.0] - 2018-08-09
### Fixed
* Fix emscripten remapping when symbols contain dashes, like `527:i32s-div` [#129]
* Improved firefox import speed and fixed bugs in it [#128]
* Prevent non-contiguous blocks in the time ordered flamechart from appearing as a single node for selection [#123]
* Prevent dragging from changing selection [#122]
* Clamp zoom to prevent floating point issues [#121]
* Preserve view state when switching tabs [#100]
## [0.4.0] - 2018-07-21
### Added
View File
+1 -2
View File
@@ -166,8 +166,7 @@
</head>
<body>
<div id="root"></div>
<script src="./speedscope.tsx"></script>
<script src="../src/speedscope.tsx"></script>
</body>
</html>
View File
+2 -2
View File
@@ -52,7 +52,7 @@ async function main() {
}
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log('v' + require('./package.json').version)
console.log('v' + require('../package.json').version)
return
}
@@ -60,7 +60,7 @@ async function main() {
throw new Error('At most one argument expected')
}
let urlToOpen = 'file://' + path.resolve(__dirname, './dist/release/index.html')
let urlToOpen = 'file://' + path.resolve(__dirname, '../dist/release/index.html')
if (process.argv.length === 3) {
const relPath = process.argv[2]
-59
View File
@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`importFromFirefox 1`] = `
Object {
"frames": Array [
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "a (file:///Users/jlfwong/code/speedscope/sample/simple.js:1)",
"line": 1,
"name": "a",
"selfWeight": 0,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "b (file:///Users/jlfwong/code/speedscope/sample/simple.js:8)",
"line": 8,
"name": "b",
"selfWeight": 0,
"totalWeight": 20.971447000018088,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "d (file:///Users/jlfwong/code/speedscope/sample/simple.js:20)",
"line": 20,
"name": "d",
"selfWeight": 31.992820999992546,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "c (file:///Users/jlfwong/code/speedscope/sample/simple.js:14)",
"line": 14,
"name": "c",
"selfWeight": 0,
"totalWeight": 11.021373999974458,
},
],
"stacks": Array [
"a;b;d 989.53µs",
"a;c;d 1.02ms",
"a;b;d 982.04µs",
"a;c;d 1.02ms",
"a;b;d 7.00ms",
"a;c;d 2.00ms",
"a;b;d 981.44µs",
"a;c;d 2.00ms",
"a;b;d 2.00ms",
"a;c;d 3.02ms",
"a;b;d 4.03ms",
"a;c;d 1.96ms",
"a;b;d 4.99ms",
],
}
`;
-5
View File
@@ -1,5 +0,0 @@
import {checkProfileSnapshot} from '../test-utils'
test('importFromFirefox', async () => {
await checkProfileSnapshot('./sample/profiles/Firefox/59/simple-firefox.json')
})
+40 -13
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",
@@ -13360,15 +13387,15 @@
"dev": true
},
"typescript": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-2.8.1.tgz",
"integrity": "sha512-Ao/f6d/4EPLq0YwzsQz8iXflezpTkQzqAyenTiw4kCUGr1uPiFLC3+fZ+gMZz6eeI/qdRUqvC+HxIJzUAzEFdg==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.0.1.tgz",
"integrity": "sha512-zQIMOmC+372pC/CCVLqnQ0zSBiY7HHodU7mpQdjiZddek4GMj31I3dUJ7gAs9o65X7mnRma6OokOkc6f9jjfBg==",
"dev": true
},
"typescript-eslint-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-14.0.0.tgz",
"integrity": "sha512-xDvhgZX08RRKSyz8oWXI/V9gjXelh4/a9jGFmmHnn/j/GMXKOQ6E6NAGVjQSvGBrjT/TMobKqsrxnBcVr4sfIw==",
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-17.0.1.tgz",
"integrity": "sha512-EPCOjxjnGqu9kQwH3CAwa1Jjty2Dn0FnehksVEmfZxXtkEK2Jerb2lnd/htsj1XooqEkKC5AzCaK+qOxHaBDBQ==",
"dev": true,
"requires": {
"lodash.unescape": "4.0.1",
+14 -11
View File
@@ -1,22 +1,23 @@
{
"name": "speedscope",
"version": "0.4.0",
"version": "0.5.1",
"description": "",
"repository": "jlfwong/speedscope",
"main": "index.js",
"bin": {
"speedscope": "./cli.js"
"speedscope": "./bin/cli.js"
},
"scripts": {
"deploy": "./deploy.sh",
"prepack": "./build-release.sh",
"prettier": "prettier --write './**/*.ts' './**/*.tsx'",
"lint": "eslint './**/*.ts' './**/*.tsx'",
"jest": "./test-setup.sh && jest",
"deploy": "./scripts/deploy.sh",
"prepack": "./scripts/build-release.sh",
"prettier": "prettier --write 'src/**/*.ts' 'src/**/*.tsx'",
"lint": "eslint 'src/**/*.ts' 'src/**/*.tsx'",
"jest": "./scripts/test-setup.sh && jest",
"coverage": "npm run jest -- --coverage && coveralls < coverage/lcov.info",
"test": "tsc --noEmit && npm run lint && npm run coverage",
"serve": "parcel index.html --open --no-autoinstall"
"serve": "parcel assets/index.html --open --no-autoinstall"
},
"files": ["cli.js", "dist/release/**", "!*.map"],
"files": ["bin/cli.js", "dist/release/**", "!*.map"],
"browserslist": ["last 2 Chrome versions", "last 2 Firefox versions"],
"author": "",
"license": "MIT",
@@ -35,12 +36,14 @@
"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",
"typescript-eslint-parser": "14.0.0",
"typescript": "3.0.1",
"typescript-eslint-parser": "17.0.1",
"uglify-es": "3.2.2"
},
"jest": {
-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)
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<script src="./recursion.js"></script>
+41
View File
@@ -0,0 +1,41 @@
function alpha(depth) {
if (depth > 15 * Math.random()) {
return
}
beta(depth + 1)
delta()
gamma()
}
function beta(depth) {
alpha(depth + 1)
for (let i = 0; i < 10; i++) {
gamma()
}
}
function delta() {
for (let i = 0; i < 10; i++) {
gamma()
}
}
function gamma() {
let prod = 1
for (let i = 1; i < 1000; i++) {
prod *= i
}
return prod
}
function main() {
for (let i = 0; i < 100; i++) {
alpha(0)
}
}
console.profile('recursion')
main()
setTimeout(() => {
console.profileEnd('recursion')
}, 0)
+11 -13
View File
@@ -1,23 +1,23 @@
function a() {
function alpha() {
for (let i = 0; i < 1000; i++) {
b()
c()
beta()
delta()
}
}
function b() {
function beta() {
for (let i = 0; i < 10; i++) {
d()
gamma()
}
}
function c() {
function delta() {
for (let i = 0; i < 10; i++) {
d()
gamma()
}
}
function d() {
function gamma() {
let prod = 1
for (let i = 1; i < 1000; i++) {
prod *= i
@@ -25,10 +25,8 @@ function d() {
return prod
}
console.profile('a')
a()
console.profile('simple')
alpha()
setTimeout(() => {
console.profileEnd('a')
console.profileEnd('simple')
}, 0)
window.addEventListener('click', a)
-345
View File
@@ -1,345 +0,0 @@
import {ReloadableComponent} from './reloadable'
import {Profile, Frame, CallTreeNode} from './profile'
import {StyleSheet, css} from 'aphrodite'
import {SortMethod, ProfileTableView} 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>
)
}
}
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>
}
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,
},
})
}
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)
}
}
}
componentDidMount() {
window.addEventListener('keydown', this.onWindowKeyPress)
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onWindowKeyPress)
}
render() {
const {canvasContext} = this.props
const {callerCallee} = this.state
let selectedFrame: Frame | null = null
let flamegraphViews: JSX.Element | null = null
if (callerCallee) {
selectedFrame = callerCallee.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}
/>
</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}
/>
</div>
</div>
)
}
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}
/>
</div>
{flamegraphViews}
</div>
)
}
}
const style = StyleSheet.create({
tableView: {
flex: 1,
},
panZoomViewWraper: {
flex: 1,
},
flamechartLabelParent: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'flex-start',
fontSize: FontSize.TITLE,
width: FontSize.TITLE * 1.2,
borderRight: `1px solid ${Colors.LIGHT_GRAY}`,
},
flamechartLabelParentBottom: {
justifyContent: 'flex-start',
},
flamechartLabel: {
transform: 'rotate(-90deg)',
transformOrigin: '50% 50% 0',
width: FontSize.TITLE * 1.2,
flexShrink: 1,
},
flamechartLabelBottom: {
transform: 'rotate(-90deg)',
display: 'flex',
justifyContent: 'flex-end',
},
callersAndCallees: {
flex: 1,
borderLeft: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
},
divider: {
height: 2,
background: Colors.LIGHT_GRAY,
},
hoverCount: {
color: Colors.GREEN,
},
})
@@ -16,7 +16,7 @@ date > "$OUTDIR"/release.txt
git rev-parse HEAD >> "$OUTDIR"/release.txt
# Place a json schema for the file format into the build directory too
node generate-file-format-schema-json.js > "$OUTDIR"/file-format-schema.json
node scripts/generate-file-format-schema-json.js > "$OUTDIR"/file-format-schema.json
# Build the compiled assets
node_modules/.bin/parcel build index.html --no-cache --out-dir "$OUTDIR" --public-url "./" --detailed-report
node_modules/.bin/parcel build assets/index.html --no-cache --out-dir "$OUTDIR" --public-url "./" --detailed-report
+1 -1
View File
@@ -10,7 +10,7 @@ set -e
OUTDIR=`pwd`/dist/release
echo $OUTDIR
./build-release.sh
./scripts/build-release.sh
# Create a shallow clone of the repository
TMPDIR=`mktemp -d -t speedscope-release`
@@ -3,7 +3,7 @@ const child_process = require('child_process')
// Convert the file-format-spec.ts file into a json schema file
let jsonSchema = child_process.execSync(
'node_modules/.bin/quicktype --lang schema ./file-format-spec.ts',
'node_modules/.bin/quicktype --lang schema ./src/lib/file-format-spec.ts',
{
encoding: 'utf8',
},
-26
View File
@@ -1,26 +0,0 @@
import {h, render} from 'preact'
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()
}
})
module.hot.accept()
}
function ref(instance: Application | null) {
app = instance
if (instance && retained) {
console.log('rehydrating: ', retained)
instance.rehydrate(retained)
}
}
render(<Application ref={ref} />, document.body, document.body.lastElementChild || undefined)
@@ -13,9 +13,9 @@ import {
TextureRenderer,
TextureRendererProps,
} from './texture-cached-renderer'
import {StatsPanel} from './stats'
import {StatsPanel} from '../lib/stats'
import {Vec2, Rect} from './math'
import {Vec2, Rect} from '../lib/math'
import {
FlamechartColorPassRenderer,
FlamechartColorPassRenderProps,
@@ -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)
@@ -1,5 +1,5 @@
import regl from 'regl'
import {Vec2, Rect, AffineTransform} from './math'
import {Vec2, Rect, AffineTransform} from '../lib/math'
export interface FlamechartColorPassRenderProps {
rectInfoTexture: regl.Texture
@@ -1,10 +1,10 @@
import regl from 'regl'
import {Flamechart} from './flamechart'
import {Flamechart} from '../lib/flamechart'
import {RectangleBatch} from './rectangle-batch-renderer'
import {CanvasContext} from './canvas-context'
import {Vec2, Rect, AffineTransform} from './math'
import {Color} from './color'
import {KeyedSet} from './utils'
import {Vec2, Rect, AffineTransform} from '../lib/math'
import {Color} from '../lib/color'
import {KeyedSet} from '../lib/utils'
import {RowAtlas} from './row-atlas'
const MAX_BATCH_SIZE = 10000
@@ -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++) {
@@ -1,5 +1,5 @@
import regl from 'regl'
import {AffineTransform, Rect} from './math'
import {AffineTransform, Rect} from '../lib/math'
export interface ViewportRectangleRendererProps {
configSpaceToPhysicalViewSpace: AffineTransform
@@ -1,6 +1,6 @@
import regl from 'regl'
import {Rect, Vec2, AffineTransform} from './math'
import {Color} from './color'
import {Rect, Vec2, AffineTransform} from '../lib/math'
import {Color} from '../lib/color'
export class RectangleBatch {
private rectCapacity = 1000
+3 -3
View File
@@ -1,9 +1,9 @@
import regl from 'regl'
import {LRUCache} from './lru-cache'
import {LRUCache} from '../lib/lru-cache'
import {RectangleBatch} from './rectangle-batch-renderer'
import {CanvasContext} from './canvas-context'
import {Rect, Vec2} from './math'
import {Color} from './color'
import {Rect, Vec2} from '../lib/math'
import {Color} from '../lib/color'
export class RowAtlas<K> {
texture: regl.Texture
@@ -1,5 +1,5 @@
import regl from 'regl'
import {Vec2, Rect, AffineTransform} from './math'
import {Vec2, Rect, AffineTransform} from '../lib/math'
export interface TextureRendererProps {
texture: regl.Texture
@@ -0,0 +1,122 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`importFromFirefox 1`] = `
Object {
"frames": Array [
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "a (file:///Users/jlfwong/code/speedscope/sample/simple.js:1)",
"line": 1,
"name": "a",
"selfWeight": 0,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "b (file:///Users/jlfwong/code/speedscope/sample/simple.js:8)",
"line": 8,
"name": "b",
"selfWeight": 0,
"totalWeight": 20.971447000018088,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "d (file:///Users/jlfwong/code/speedscope/sample/simple.js:20)",
"line": 20,
"name": "d",
"selfWeight": 31.992820999992546,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "c (file:///Users/jlfwong/code/speedscope/sample/simple.js:14)",
"line": 14,
"name": "c",
"selfWeight": 0,
"totalWeight": 11.021373999974458,
},
],
"stacks": Array [
"a;b;d 989.53µs",
"a;c;d 1.02ms",
"a;b;d 982.04µs",
"a;c;d 1.02ms",
"a;b;d 7.00ms",
"a;c;d 2.00ms",
"a;b;d 981.44µs",
"a;c;d 2.00ms",
"a;b;d 2.00ms",
"a;c;d 3.02ms",
"a;b;d 4.03ms",
"a;c;d 1.96ms",
"a;b;d 4.99ms",
],
}
`;
exports[`importFromFirefox recursion 1`] = `
Object {
"frames": Array [
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "main (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:31)",
"line": 31,
"name": "main",
"selfWeight": 1.1944820000207983,
"totalWeight": 11.149112000013702,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "alpha (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:1)",
"line": 1,
"name": "alpha",
"selfWeight": 0,
"totalWeight": 9.954629999992903,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "beta (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:10)",
"line": 10,
"name": "beta",
"selfWeight": 0,
"totalWeight": 8.962158999987878,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "delta (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:17)",
"line": 17,
"name": "delta",
"selfWeight": 0,
"totalWeight": 6.859736999962479,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "gamma (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:23)",
"line": 23,
"name": "gamma",
"selfWeight": 9.954629999992903,
"totalWeight": 9.954629999992903,
},
],
"stacks": Array [
"main;alpha;beta;alpha;beta;alpha;beta;alpha;delta;gamma 998.89µs",
"main 1.19ms",
"main;alpha;beta;alpha;delta;gamma 1.83ms",
"main;alpha;beta;alpha;beta;alpha;beta;gamma 1.16ms",
"main;alpha;beta;alpha;delta;gamma 1.84ms",
"main;alpha;beta;alpha;beta;alpha;beta;alpha;gamma 964.81µs",
"main;alpha;beta;alpha;beta;alpha;beta;alpha;delta;gamma 1.19ms",
"main;alpha;beta;gamma 965.25µs",
"main;alpha;delta;gamma 992.47µs",
],
}
`;
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromBGFlameGraph', async () => {
await checkProfileSnapshot('./sample/profiles/stackcollapse/simple.txt')
@@ -1,6 +1,6 @@
// https://github.com/brendangregg/FlameGraph#2-fold-stacks
import {Profile, FrameInfo, StackListProfileBuilder} from '../profile'
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
interface BGSample {
stack: FrameInfo[]
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromChromeCPUProfile', async () => {
await checkProfileSnapshot('./sample/profiles/Chrome/65/simple.cpuprofile')
+3 -3
View File
@@ -1,6 +1,6 @@
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../profile'
import {getOrInsert, lastOf} from '../utils'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../lib/profile'
import {getOrInsert, lastOf} from '../lib/utils'
import {TimeFormatter} from '../lib/value-formatters'
interface TimelineEvent {
pid: number
+9
View File
@@ -0,0 +1,9 @@
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromFirefox', async () => {
await checkProfileSnapshot('./sample/profiles/Firefox/59/simple-firefox.json')
})
test('importFromFirefox recursion', async () => {
await checkProfileSnapshot('./sample/profiles/Firefox/61/recursion.json')
})
+16 -21
View File
@@ -1,6 +1,6 @@
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../profile'
import {getOrInsert, lastOf} from '../utils'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../lib/profile'
import {getOrInsert} from '../lib/utils'
import {TimeFormatter} from '../lib/value-formatters'
interface Allocations {
frames: any[]
@@ -158,7 +158,7 @@ export function importFromFirefox(firefoxProfile: FirefoxProfile): Profile {
? cpuProfile.threads[0]
: cpuProfile.threads.filter(t => t.name === 'GeckoMain')[0]
const frameIdToFrameInfo = new Map<number, FrameInfo>()
const frameKeyToFrameInfo = new Map<string, FrameInfo>()
function extractStack(sample: Sample): FrameInfo[] {
let stackFrameId: number | null = sample[0]
@@ -185,7 +185,7 @@ export function importFromFirefox(firefoxProfile: FirefoxProfile): Profile {
return null
}
return getOrInsert(frameIdToFrameInfo, f, () => ({
return getOrInsert(frameKeyToFrameInfo, location, () => ({
key: location,
name: match[1]!,
file: match[2]!,
@@ -203,27 +203,22 @@ export function importFromFirefox(firefoxProfile: FirefoxProfile): Profile {
const value = sample[1]
// Find lowest common ancestor of the current stack and the previous one
let lca: FrameInfo | null = null
let lcaIndex = -1
// This is O(n^2), but n should be relatively small here (stack height),
// so hopefully this isn't much of a problem
for (let i = stack.length - 1; i >= 0 && prevStack.indexOf(stack[i]) === -1; i--) {}
for (let i = 0; i < Math.min(stack.length, prevStack.length); i++) {
if (prevStack[i] !== stack[i]) {
break
}
lcaIndex = i
}
// Close frames that are no longer open
while (prevStack.length > 0 && lastOf(prevStack) != lca) {
const closingFrame = prevStack.pop()!
profile.leaveFrame(closingFrame, value)
for (let i = prevStack.length - 1; i > lcaIndex; i--) {
profile.leaveFrame(prevStack[i], value)
}
// Open frames that are now becoming open
const toOpen: FrameInfo[] = []
for (let i = stack.length - 1; i >= 0 && stack[i] != lca; i--) {
toOpen.push(stack[i])
}
toOpen.reverse()
for (let frame of toOpen) {
profile.enterFrame(frame, value)
for (let i = lcaIndex + 1; i < stack.length; i++) {
profile.enterFrame(stack[i], value)
}
prevStack = stack
+3 -3
View File
@@ -1,4 +1,4 @@
import {Profile} from '../profile'
import {Profile} from '../lib/profile'
import {FileSystemDirectoryEntry} from './file-system-entry'
import {importFromChromeCPUProfile, importFromChromeTimeline} from './chrome'
@@ -6,8 +6,8 @@ import {importFromStackprof} from './stackprof'
import {importFromInstrumentsDeepCopy, importFromInstrumentsTrace} from './instruments'
import {importFromBGFlameGraph} from './bg-flamegraph'
import {importFromFirefox} from './firefox'
import {importSpeedscopeProfiles} from '../file-format'
import {FileFormat} from '../file-format-spec'
import {importSpeedscopeProfiles} from '../lib/file-format'
import {FileFormat} from '../lib/file-format-spec'
import {importFromV8ProfLog} from './v8proflog'
export async function importProfile(fileName: string, contents: string): Promise<Profile | null> {
@@ -1,6 +1,6 @@
import * as fs from 'fs'
import * as path from 'path'
import {dumpProfile, checkProfileSnapshot} from '../test-utils'
import {dumpProfile, checkProfileSnapshot} from '../lib/test-utils'
import * as JSZip from 'jszip'
import {FileSystemEntry} from './file-system-entry'
@@ -1,10 +1,10 @@
// This file contains methods to import data from OS X Instruments.app
// https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/index.html
import {Profile, FrameInfo, CallTreeProfileBuilder, StackListProfileBuilder} from '../profile'
import {sortBy, getOrThrow, getOrInsert, lastOf, getOrElse, zeroPad} from '../utils'
import {Profile, FrameInfo, CallTreeProfileBuilder, StackListProfileBuilder} from '../lib/profile'
import {sortBy, getOrThrow, getOrInsert, lastOf, getOrElse, zeroPad} from '../lib/utils'
import * as pako from 'pako'
import {ByteFormatter, TimeFormatter} from '../value-formatters'
import {ByteFormatter, TimeFormatter} from '../lib/value-formatters'
import {FileSystemDirectoryEntry, FileSystemEntry, FileSystemFileEntry} from './file-system-entry'
function parseTSV<T>(contents: string): T[] {
@@ -184,6 +184,9 @@ class MaybeCompressedFileReader {
this.fileData = new Promise(resolve => {
const reader = new FileReader()
reader.addEventListener('loadend', () => {
if (!(reader.result instanceof ArrayBuffer)) {
throw new Error('Expected reader.result to be an instance of ArrayBuffer')
}
resolve(reader.result)
})
reader.readAsArrayBuffer(file)
@@ -410,11 +413,11 @@ async function readFormTemplate(tree: TraceDirectoryTree): Promise<FormTemplateD
const addressToFrameMap = new Map<number, FrameInfo>()
// TODO(jlfwong): Deal with profiles with conflicts addresses?
for (let [_pid, symbols] of symbolsByPid.entries()) {
for (let symbols of symbolsByPid.values()) {
for (let symbol of symbols.symbols) {
if (!symbol) continue
const {sourcePath, symbolName, addressToLine} = symbol
for (let [address, _line] of addressToLine) {
for (let address of addressToLine.keys()) {
getOrInsert(addressToFrameMap, address, () => {
const name = symbolName || `0x${zeroPad(address.toString(16), 16)}`
const frame: FrameInfo = {
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromStackprof', async () => {
await checkProfileSnapshot('./sample/profiles/stackprof/simple-stackprof.json')
@@ -1,7 +1,7 @@
// https://github.com/tmm1/stackprof
import {Profile, FrameInfo, StackListProfileBuilder} from '../profile'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
import {TimeFormatter} from '../lib/value-formatters'
interface StackprofFrame {
name: string
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromV8ProfLog', async () => {
await checkProfileSnapshot('./sample/profiles/node/8.5.0/simple.v8log.json')
@@ -1,6 +1,6 @@
import {Profile, FrameInfo, StackListProfileBuilder} from '../profile'
import {getOrInsert, sortBy} from '../utils'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
import {getOrInsert, sortBy} from '../lib/utils'
import {TimeFormatter} from '../lib/value-formatters'
// This imports profiles generated by a combination of the following commands:
//
@@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallTreeProfileBuilder separates non-contiguous: append order 1`] = `
"((speedscope root):0:5
(a:1:5
(b:1:2
(c:1:1)
)
(b:1:2
(c:1:1)
)
)
)"
`;
exports[`CallTreeProfileBuilder separates non-contiguous: grouped 1`] = `
"((speedscope root):0:5
(a:1:5
(b:2:4
(c:2:2)
)
)
)"
`;
exports[`StackListProfileBuilder separates non-contiguous: append order 1`] = `
"((speedscope root):4:0
(a:1:5
(b:1:2
(c:1:1)
)
(b:1:2
(c:1:1)
)
)
)"
`;
exports[`StackListProfileBuilder separates non-contiguous: grouped 1`] = `
"((speedscope root):4:0
(a:1:5
(b:2:4
(c:2:2)
)
)
)"
`;
+29
View File
@@ -0,0 +1,29 @@
import {Color} from './color'
describe('Color', () => {
test('fromLumaChromaHue', () => {
expect(Color.fromLumaChromaHue(0, 0, 0).toCSS()).toEqual('rgba(0, 0, 0, 1.00)')
expect(Color.fromLumaChromaHue(1, 0, 0).toCSS()).toEqual('rgba(255, 255, 255, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 0, 30).toCSS()).toEqual('rgba(128, 128, 128, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 0, 60).toCSS()).toEqual('rgba(128, 128, 128, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 0.5, 0).toCSS()).toEqual('rgba(217, 89, 89, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 0).toCSS()).toEqual('rgba(255, 51, 51, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 60).toCSS()).toEqual('rgba(156, 156, 0, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 120).toCSS()).toEqual('rgba(0, 232, 0, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 180).toCSS()).toEqual('rgba(0, 204, 204, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 240).toCSS()).toEqual('rgba(99, 99, 255, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 300).toCSS()).toEqual('rgba(255, 23, 255, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 360).toCSS()).toEqual('rgba(255, 51, 51, 1.00)')
})
test('toCSS', () => {
expect(new Color().toCSS()).toEqual('rgba(0, 0, 0, 1.00)')
expect(new Color(1, 0, 0, 1).toCSS()).toEqual('rgba(255, 0, 0, 1.00)')
expect(new Color(0, 1, 0, 1).toCSS()).toEqual('rgba(0, 255, 0, 1.00)')
expect(new Color(0, 0, 1, 1).toCSS()).toEqual('rgba(0, 0, 255, 1.00)')
expect(new Color(0, 0, 1, 0.599).toCSS()).toEqual('rgba(0, 0, 255, 0.60)')
})
})
+6 -1
View File
@@ -1,3 +1,5 @@
import {clamp} from './math'
export class Color {
constructor(
readonly r: number = 0,
@@ -7,6 +9,9 @@ export class Color {
) {}
static fromLumaChromaHue(L: number, C: number, H: number) {
// 0 <= L <= 1
// 0 <= C <= 1
// 0 <= H <= 360
// https://en.wikipedia.org/wiki/HSL_and_HSV#From_luma/chroma/hue
const hPrime = H / 60
@@ -26,7 +31,7 @@ export class Color {
const m = L - (0.3 * R1 + 0.59 * G1 + 0.11 * B1)
return new Color(R1 + m, G1 + m, B1 + m, 1.0)
return new Color(clamp(R1 + m, 0, 1), clamp(G1 + m, 0, 1), clamp(B1 + m, 0, 1), 1.0)
}
toCSS(): string {
+13
View File
@@ -0,0 +1,13 @@
import {demangleCpp} from './demangle-cpp'
test('demangleCpp', () => {
expect(demangleCpp('a')).toBe('a')
expect(demangleCpp('someUnobfuscatedFunction')).toBe('someUnobfuscatedFunction')
expect(demangleCpp('__ZNK7Support6ColorFeqERKS0_')).toBe(
'Support::ColorF::operator==(Support::ColorF const&) const',
)
// Running a second time to test the cache
expect(demangleCpp('__ZNK7Support6ColorFeqERKS0_')).toBe(
'Support::ColorF::operator==(Support::ColorF const&) const',
)
})
File diff suppressed because one or more lines are too long
@@ -21,10 +21,11 @@ test('importEmscriptenSymbolMap', () => {
'a:A',
'b:B',
'c:C',
'd:D-D',
'',
].join('\n'),
),
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C']]))
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C'], ['d', 'D-D']]))
// Valid symbol map with non-alpha characters
expect(importEmscriptenSymbolMap('u6:__ZN8tinyxml210XMLCommentD0Ev\n')).toEqual(
@@ -39,10 +40,16 @@ test('importEmscriptenSymbolMap', () => {
'0:A',
'1:B',
'2:C',
'3:D-D',
].join('\n'),
),
).toEqual(
new Map([['wasm-function[0]', 'A'], ['wasm-function[1]', 'B'], ['wasm-function[2]', 'C']]),
new Map([
['wasm-function[0]', 'A'],
['wasm-function[1]', 'B'],
['wasm-function[2]', 'C'],
['wasm-function[3]', 'D-D'],
]),
)
// Invalid symbol map
+2 -2
View File
@@ -14,8 +14,8 @@ export function importEmscriptenSymbolMap(contents: string): EmscriptenSymbolMap
if (!lines.length) return null
const map: EmscriptenSymbolMap = new Map()
const intRegex = /^(\d+):([\$\w]+)$/
const idRegex = /^([\$\w]+):([\$\w]+)$/
const intRegex = /^(\d+):([\$\w-]+)$/
const idRegex = /^([\$\w]+):([\$\w-]+)$/
for (const line of lines) {
// Match lines like "103:__ZN8tinyxml210XMLCommentD0Ev"
+1 -1
View File
@@ -22,7 +22,7 @@ export function exportProfile(profile: Profile): FileFormat.File {
}
const file: FileFormat.File = {
version: require('./package.json').version,
version: require('../../package.json').version,
$schema: 'https://www.speedscope.app/file-format-schema.json',
shared: {frames},
profiles: [eventedProfile],
+43
View File
@@ -1,6 +1,7 @@
import {Frame, CallTreeNode} from './profile'
import {lastOf} from './utils'
import {clamp} from './math'
export interface FlamechartFrame {
node: CallTreeNode
@@ -47,6 +48,48 @@ export class Flamechart {
return this.source.formatValue(v)
}
getClampedViewportWidth(viewportWidth: number) {
const maxWidth = this.getTotalWeight()
// In order to avoid floating point error, we cap the maximum zoom. In
// particular, it's important that at the maximum zoom level, the total
// trace size + a viewport width is not equal to the trace size due to
// floating point rounding.
//
// For instance, if the profile's total weight is 2^60, and the viewport
// size is 1, trying to move one viewport width right will result in no
// change because 2^60 + 1 = 2^60 in floating point arithmetic. JavaScript
// numbers are 64 bit floats, and therefore have 53 mantissa bits. You can
// see this for yourself in the console. Try:
//
// > Math.pow(2, 60) + 1 === Math.pow(2, 60)
// true
// > Math.pow(2, 53) + 1 === Math.pow(2, 53)
// true
// > Math.pow(2, 52) + 1 === Math.pow(2, 52)
// false
//
// We use 2^40 as a cap instead, since we want to be able to make small
// adjustments within a viewport width.
//
// For reference, this will still allow you to zoom until 1 nanosecond fills
// the screen in a profile with a duration of over 18 minutes.
//
// > Math.pow(2, 40) / (60 * Math.pow(10, 9))
// 18.325193796266667
//
const maxZoom = Math.pow(2, 40)
// In addition to capping zoom to avoid floating point error, we further cap
// zoom to avoid letting you zoom in so that the smallest element more than
// fills the screen, since that probably isn't useful. The final zoom cap is
// determined by the minimum zoom of either 2^40x zoom or the necessary zoom
// for the smallest frame to fill the screen three times.
const minWidth = clamp(3 * this.getMinFrameWidth(), maxWidth / maxZoom, maxWidth)
return clamp(viewportWidth, minWidth, maxWidth)
}
constructor(private source: FlamechartDataSource) {
const stack: FlamechartFrame[] = []
const openFrame = (node: CallTreeNode, value: number) => {
+27
View File
@@ -0,0 +1,27 @@
import {getHashParams} from './hash-params'
test('getHashParams', () => {
expect(getHashParams('')).toEqual({})
expect(getHashParams('#')).toEqual({})
expect(getHashParams('#title=hello')).toEqual({title: 'hello'})
expect(getHashParams('#localProfilePath=file:///tmp/file.js')).toEqual({
localProfilePath: 'file:///tmp/file.js',
})
expect(
getHashParams(
'#profileURL=https://raw.githubusercontent.com/jlfwong/speedscope/master/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json',
),
).toEqual({
profileURL:
'https://raw.githubusercontent.com/jlfwong/speedscope/master/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json',
})
expect(getHashParams('#title=hello&localProfilePath=file:///tmp/file.js')).toEqual({
title: 'hello',
localProfilePath: 'file:///tmp/file.js',
})
expect(getHashParams('#title=hello%20world')).toEqual({
title: 'hello world',
})
expect(getHashParams('#abc=bcd')).toEqual({})
expect(getHashParams('garbage')).toEqual({})
})
+6 -6
View File
@@ -4,22 +4,22 @@ export interface HashParams {
localProfilePath?: string
}
export function getHashParams(): HashParams {
export function getHashParams(hashContents = window.location.hash): HashParams {
try {
const hashContents = window.location.hash
if (!hashContents.startsWith('#')) {
return {}
}
const components = hashContents.substr(1).split('&')
const result: HashParams = {}
for (const component of components) {
const [key, value] = component.split('=')
let [key, value] = component.split('=')
value = decodeURIComponent(value)
if (key === 'profileURL') {
result.profileURL = decodeURIComponent(value)
result.profileURL = value
} else if (key === 'title') {
result.title = decodeURIComponent(value)
result.title = value
} else if (key === 'localProfilePath') {
result.localProfilePath = decodeURIComponent(value)
result.localProfilePath = value
}
}
return result
View File
@@ -50,6 +50,30 @@ function toStackList(profile: Profile, grouped: boolean): string[] {
return stackList
}
function flatten<T>(ts: T[][]): T[] {
let ret: T[] = []
return ret.concat(...ts)
}
function toTreeString(profile: Profile, grouped: boolean): string {
function visit(node: CallTreeNode): string[] {
const childLines = flatten(node.children.map(child => visit(child))).map(l => ` ${l}`)
const nodeStr = `${node.frame.key}:${node.getSelfWeight()}:${node.getTotalWeight()}`
if (childLines.length > 0) {
return [`(${nodeStr}`].concat(childLines).concat(')')
} else {
return [`(${nodeStr})`]
}
}
if (grouped) {
return visit(profile.getGroupedCalltreeRoot()).join('\n')
} else {
return visit(profile.getAppendOrderCalltreeRoot()).join('\n')
}
}
function verifyProfile(profile: Profile) {
const allFrameKeys = new Set([fa, fb, fc, fd, fe].map(f => f.key))
const framesInProfile = new Set<string | number>()
@@ -133,6 +157,28 @@ test('StackListProfileBuilder', () => {
verifyProfile(profile)
})
test('StackListProfileBuilder separates non-contiguous', () => {
const b = new StackListProfileBuilder()
const samples = [
// prettier-ignore
[fa, fb, fc],
[fa, fb],
[fa],
[fa, fb],
[fa, fb, fc],
]
samples.forEach(stack => {
b.appendSample(stack, 1)
})
b.appendSample([], 4)
const profile = b.build()
expect(toTreeString(profile, true)).toMatchSnapshot('grouped')
expect(toTreeString(profile, false)).toMatchSnapshot('append order')
})
test('CallTreeProfileBuilder', () => {
const b = new CallTreeProfileBuilder()
@@ -167,6 +213,30 @@ test('CallTreeProfileBuilder', () => {
verifyProfile(profile)
})
test('CallTreeProfileBuilder separates non-contiguous', () => {
const b = new CallTreeProfileBuilder()
b.enterFrame(fa, 0)
b.enterFrame(fb, 0)
b.enterFrame(fc, 0)
b.leaveFrame(fc, 1)
b.leaveFrame(fb, 2)
b.enterFrame(fb, 3)
b.enterFrame(fc, 4)
b.leaveFrame(fc, 5)
b.leaveFrame(fb, 5)
b.leaveFrame(fa, 5)
const profile = b.build()
expect(toTreeString(profile, true)).toMatchSnapshot('grouped')
expect(toTreeString(profile, false)).toMatchSnapshot('append order')
})
test('getInvertedProfileForCallersOf', () => {
const b = new StackListProfileBuilder()
+40 -5
View File
@@ -89,6 +89,15 @@ export class CallTreeNode extends HasWeights {
return this.frame === Frame.root
}
// If a node is "frozen", it means it should no longer be mutated.
private frozen = false
isFrozen() {
return this.frozen
}
freeze() {
this.frozen = true
}
constructor(readonly frame: Frame, readonly parent: CallTreeNode | null) {
super()
}
@@ -100,9 +109,17 @@ export class Profile {
protected totalWeight: number
protected frames = new KeyedSet<Frame>()
protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null)
protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null)
public getAppendOrderCalltreeRoot() {
return this.appendOrderCalltreeRoot
}
public getGroupedCalltreeRoot() {
return this.groupedCalltreeRoot
}
// List of references to CallTreeNodes at the top of the
// stack at the time of the sample.
protected samples: CallTreeNode[] = []
@@ -395,7 +412,7 @@ export class StackListProfileBuilder extends Profile {
const last = useAppendOrder
? lastOf(node.children)
: node.children.find(c => c.frame === frame)
if (last && last.frame == frame) {
if (last && !last.isFrozen() && last.frame == frame) {
node = last
} else {
const parent = node
@@ -414,6 +431,12 @@ export class StackListProfileBuilder extends Profile {
}
node.addToSelfWeight(weight)
if (useAppendOrder) {
for (let child of node.children) {
child.freeze()
}
}
if (useAppendOrder) {
node.frame.addToSelfWeight(weight)
@@ -493,7 +516,7 @@ export class CallTreeProfileBuilder extends Profile {
? lastOf(prevTop.children)
: prevTop.children.find(c => c.frame === frame)
let node: CallTreeNode
if (last && last.frame == frame) {
if (last && !last.isFrozen() && last.frame == frame) {
node = last
} else {
node = new CallTreeNode(frame, prevTop)
@@ -520,10 +543,17 @@ export class CallTreeProfileBuilder extends Profile {
if (useAppendOrder) {
const leavingStackTop = this.appendOrderStack.pop()
const delta = value - this.lastValue!
if (leavingStackTop == null) {
throw new Error(`Trying to leave ${frame.key} when stack is empty`)
}
if (this.lastValue == null) {
throw new Error(`Trying to leave a ${frame.key} before any have been entered`)
}
leavingStackTop.freeze()
const delta = value - this.lastValue
if (delta > 0) {
this.samples.push(leavingStackTop!)
this.weights.push(value - this.lastValue!)
this.samples.push(leavingStackTop)
this.weights.push(value - this.lastValue)
} else if (delta < 0) {
throw new Error(
`Samples must be provided in increasing order of cumulative value. Last sample was ${this
@@ -556,6 +586,11 @@ export class CallTreeProfileBuilder extends Profile {
}
build(): Profile {
// Each stack is expected to contain a single node which we initialize to be
// the root node.
if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) {
throw new Error('Tried to complete profile construction with a non-empty stack')
}
return this
}
}
View File
+1 -1
View File
@@ -1,7 +1,7 @@
import * as fs from 'fs'
import * as path from 'path'
import {Profile, CallTreeNode, Frame} from './profile'
import {importProfile} from './import'
import {importProfile} from '../import'
import {exportProfile, importSpeedscopeProfiles} from './file-format'
interface DumpedProfile {
+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> {}
+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
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import {h, render} from 'preact'
import {createApplicationStore, ApplicationState} from './store'
import {Provider} from 'preact-redux'
import {createContainer} from './lib/typed-redux'
import {Application} from './views/application'
console.log(`speedscope v${require('../package.json').version}`)
declare const module: any
if (module.hot) {
module.hot.dispose(() => {
// Force the old component go through teardown steps
render(<div />, document.body, document.body.lastElementChild || undefined)
})
module.hot.accept()
}
const lastStore: any = (window as any)['store']
const store = createApplicationStore(lastStore ? lastStore.getState() : {})
;(window as any)['store'] = store
const ApplicationContainer = createContainer(Application, (state: ApplicationState) => state)
render(
<Provider store={store}>
<ApplicationContainer />
</Provider>,
document.body,
document.body.lastElementChild || undefined,
)
+70
View File
@@ -0,0 +1,70 @@
import {actionCreator} from '../lib/typed-redux'
import {Profile, CallTreeNode, Frame} from '../lib/profile'
import {SortMethod} from '../views/profile-table-view'
import {ViewMode} from '.'
import {FlamechartID} from './flamechart-view-state'
import {Rect, Vec2} from '../lib/math'
import {HashParams} from '../lib/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 '../lib/profile'
import {Rect, Vec2} from '../lib/math'
import {Reducer} from '../lib/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 '../lib/profile'
import {triangle, memoizeByReference, memoizeByShallowEquality} from '../lib/utils'
import {RowAtlas} from '../gl/row-atlas'
import {CanvasContext} from '../gl/canvas-context'
import {Color} from '../lib/color'
import {FlamechartRowAtlasKey} from '../gl/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 '../lib/typed-redux'
import {Profile} from '../lib/profile'
import {
createFlamechartViewStateReducer,
FlamechartID,
FlamechartViewState,
} from './flamechart-view-state'
import {SandwichViewState, sandwichView} from './sandwich-view-state'
import {HashParams, getHashParams} from '../lib/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 '../views/profile-table-view'
import {Frame} from '../lib/profile'
import {
FlamechartViewState,
FlamechartID,
createFlamechartViewStateReducer,
} from './flamechart-view-state'
import {Reducer} from '../lib/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
}
+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
}
}
View File
+67 -287
View File
@@ -1,26 +1,19 @@
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 {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 {Profile, Frame} from '../lib/profile'
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 {saveToFile} from './file-format'
import {importEmscriptenSymbolMap} from '../lib/emscripten'
import {SandwichViewContainer} from './sandwich-view'
import {saveToFile} from '../lib/file-format'
import {ApplicationState, ViewMode, canUseXHR} from '../store'
import {actions} from '../store/actions'
import {Dispatch, StatelessComponent, WithDispatch} from '../lib/typed-redux'
import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container'
import {getProfileToView} from '../store/getters'
const importModule = import('./import')
const importModule = import('../import')
// Force eager loading of the module
importModule.then(() => {})
async function importProfile(fileName: string, contents: string): Promise<Profile | null> {
@@ -30,36 +23,8 @@ 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
}
const exampleProfileURL = require('../../sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
interface ToolbarProps extends ApplicationState {
setViewMode(order: ViewMode): void
@@ -67,7 +32,7 @@ interface ToolbarProps extends ApplicationState {
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) {
@@ -371,6 +235,10 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
reader.readAsText(file)
await loadPromise
if (typeof reader.result !== 'string') {
throw new Error('Expected ArrayBuffer')
}
const profile = await importProfile(file.name, reader.result)
if (profile) {
if (!profile.getName()) {
@@ -379,7 +247,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 +255,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 +278,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 +300,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 +369,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 +396,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 +492,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 +532,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 '../lib/utils'
import {Profile, Frame} from '../lib/profile'
import {Flamechart} from '../lib/flamechart'
import {createMemoizedFlamechartRenderer} from './flamechart-view-container'
import {createContainer} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
} from '../store/getters'
import {FlamechartID} from '../store/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,11 +1,10 @@
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'
import {formatPercent} from '../lib/utils'
import {Frame, CallTreeNode} from '../lib/profile'
import {ColorChit} from './color-chit'
import {Flamechart} from './flamechart'
import {Flamechart} from '../lib/flamechart'
interface StatisticsTableProps {
title: string
@@ -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
@@ -1,13 +1,13 @@
import {h, Component} from 'preact'
import {css} from 'aphrodite'
import {Flamechart} from './flamechart'
import {Rect, Vec2, AffineTransform, clamp} from './math'
import {FlamechartRenderer} from './flamechart-renderer'
import {Flamechart} from '../lib/flamechart'
import {Rect, Vec2, AffineTransform, clamp} from '../lib/math'
import {FlamechartRenderer} from '../gl/flamechart-renderer'
import {style} from './flamechart-style'
import {FontFamily, FontSize, Colors, Sizes, commonStyle} from './style'
import {CanvasContext} from './canvas-context'
import {TextureCachedRenderer} from './texture-cached-renderer'
import {cachedMeasureTextWidth} from './text-utils'
import {CanvasContext} from '../gl/canvas-context'
import {TextureCachedRenderer} from '../gl/texture-cached-renderer'
import {cachedMeasureTextWidth} from '../lib/text-utils'
interface FlamechartMinimapViewProps {
flamechart: Flamechart
@@ -131,8 +131,6 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
const physicalViewSize = this.physicalViewSize()
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
this.resizeOverlayCanvasIfNeeded()
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const left = 0
@@ -239,6 +237,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
private onBeforeFrame = () => {
this.maybeClearInteractionLock()
this.resizeOverlayCanvasIfNeeded()
this.renderRects()
this.renderOverlays()
}
@@ -1,14 +1,14 @@
import {Rect, AffineTransform, Vec2, clamp} from './math'
import {CallTreeNode} from './profile'
import {Flamechart, FlamechartFrame} from './flamechart'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer} from './flamechart-renderer'
import {ReloadableComponent} from './reloadable'
import {Rect, AffineTransform, Vec2, clamp} from '../lib/math'
import {CallTreeNode} from '../lib/profile'
import {Flamechart, FlamechartFrame} from '../lib/flamechart'
import {CanvasContext} from '../gl/canvas-context'
import {FlamechartRenderer} from '../gl/flamechart-renderer'
import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from './text-utils'
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from '../lib/text-utils'
import {style} from './flamechart-style'
import {h} from 'preact'
import {h, Component} from 'preact'
import {css} from 'aphrodite'
import {FlamechartID} from '../store/flamechart-view-state'
interface FlamechartFrameLabel {
configSpaceBounds: Rect
@@ -34,6 +34,7 @@ interface FlamechartFrameLabel {
* canvas primitives.
*/
export interface FlamechartPanZoomViewProps {
id: FlamechartID
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
@@ -42,12 +43,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
@@ -148,7 +153,6 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
this.resizeOverlayCanvasIfNeeded()
if (this.props.configSpaceViewportRect.isEmpty()) return
const configToPhysical = this.configSpaceToPhysicalViewSpace()
@@ -357,16 +361,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 +384,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 = () => {
@@ -442,6 +449,7 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
}
private onBeforeFrame = () => {
this.resizeOverlayCanvasIfNeeded()
this.renderRects()
this.renderOverlays()
this.maybeClearInteractionLock()
@@ -484,8 +492,9 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
}
private lastDragPos: Vec2 | null = null
private mouseDownPos: Vec2 | null = null
private onMouseDown = (ev: MouseEvent) => {
this.lastDragPos = new Vec2(ev.offsetX, ev.offsetY)
this.mouseDownPos = this.lastDragPos = new Vec2(ev.offsetX, ev.offsetY)
this.updateCursor()
window.addEventListener('mouseup', this.onWindowMouseUp)
}
@@ -515,6 +524,16 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
}
private onClick = (ev: MouseEvent) => {
const logicalMousePos = new Vec2(ev.offsetX, ev.offsetY)
const mouseDownPos = this.mouseDownPos
this.mouseDownPos = null
if (mouseDownPos && logicalMousePos.minus(mouseDownPos).length() > 5) {
// If the cursor is more than 5 logical space pixels away from the mouse
// down location, then don't interpret this event as a click.
return
}
if (this.hoveredLabel) {
this.props.onNodeSelect(this.hoveredLabel.node)
this.renderCanvas()
@@ -663,7 +682,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()
+136
View File
@@ -0,0 +1,136 @@
import {FlamechartID, FlamechartViewState} from '../store/flamechart-view-state'
import {CanvasContext} from '../gl/canvas-context'
import {Flamechart} from '../lib/flamechart'
import {FlamechartRenderer, FlamechartRendererOptions} from '../gl/flamechart-renderer'
import {Dispatch, createContainer, WithoutDispatch} from '../lib/typed-redux'
import {Frame, Profile} from '../lib/profile'
import {memoizeByShallowEquality} from '../lib/utils'
import {ApplicationState} from '../store'
import {FlamechartView} from './flamechart-view'
import {
getRowAtlas,
createGetColorBucketForFrame,
getCanvasContext,
createGetCSSColorForFrame,
} from '../store/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,
}
})
@@ -1,48 +1,22 @@
import {h} from 'preact'
import {css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {CallTreeNode, Frame} from './profile'
import {Flamechart} from './flamechart'
import {CallTreeNode} from '../lib/profile'
import {Rect, Vec2, AffineTransform, clamp} from './math'
import {formatPercent} from './utils'
import {Rect, Vec2, AffineTransform} from '../lib/math'
import {formatPercent} from '../lib/utils'
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 '../store/actions'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from '../lib/typed-redux'
interface FlamechartViewProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
getCSSColorForFrame: (frame: Frame) => string
}
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 StatelessComponent<FlamechartViewProps> {
private configSpaceSize() {
return new Vec2(
this.props.flamechart.getTotalWeight(),
@@ -55,12 +29,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
const configSpaceSize = this.configSpaceSize()
const width = clamp(
viewportRect.size.x,
Math.min(configSpaceSize.x, 3 * this.props.flamechart.getMinFrameWidth()),
configSpaceSize.x,
)
const width = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x)
const size = viewportRect.size.withX(width)
const origin = Vec2.clamp(
@@ -72,24 +41,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 +83,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 +103,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 +115,26 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
/>
<FlamechartPanZoomView
ref={this.panZoomRef}
canvasContext={this.props.canvasContext}
id={this.props.id}
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>
+107
View File
@@ -0,0 +1,107 @@
import {CallTreeNode} from '../lib/profile'
import {StyleSheet, css} from 'aphrodite'
import {h} from 'preact'
import {commonStyle, Colors} from './style'
import {Rect, AffineTransform, Vec2} from '../lib/math'
import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
import {noop, formatPercent} from '../lib/utils'
import {Hovertip} from './hovertip'
import {actions} from '../store/actions'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from '../lib/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 = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x)
const size = viewportRect.size.withX(width)
const origin = Vec2.clamp(
viewportRect.origin,
new Vec2(0, renderInverted ? 0 : -1),
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
)
return new Rect(origin, viewportRect.size.withX(width))
}
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
id={this.props.id}
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,
},
})
+3 -4
View File
@@ -1,15 +1,14 @@
import {ReloadableComponent} from './reloadable'
import {Vec2} from './math'
import {Vec2} from '../lib/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
@@ -0,0 +1,86 @@
import {memoizeByShallowEquality} from '../lib/utils'
import {Profile, Frame} from '../lib/profile'
import {Flamechart} from '../lib/flamechart'
import {createMemoizedFlamechartRenderer} from './flamechart-view-container'
import {createContainer} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getProfileWithRecursionFlattened,
} from '../store/getters'
import {FlamechartID} from '../store/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,
}
},
)
@@ -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 {Profile, Frame} from '../lib/profile'
import {sortBy, formatPercent} from '../lib/utils'
import {FontSize, Colors, Sizes, commonStyle} from './style'
import {ColorChit} from './color-chit'
import {ScrollableListView, ListItem} from './scrollable-list-view'
import {actions} from '../store/actions'
import {Dispatch, createContainer} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {createGetCSSColorForFrame} from '../store/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,22 +139,22 @@ 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
}
}
}
}
render() {
private getFrameList = (): Frame[] => {
const {profile, sortMethod} = this.props
const frameList: Frame[] = []
@@ -175,6 +181,26 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
frameList.reverse()
}
return frameList
}
private listView: ScrollableListView | null = null
private listViewRef = (listView: ScrollableListView | null) => {
if (listView === this.listView) return
this.listView = listView
const {selectedFrame} = this.props
if (!selectedFrame || !listView) return
const index = this.getFrameList().indexOf(selectedFrame)
if (index === -1) return
listView.scrollIndexIntoView(index)
}
render() {
const {sortMethod} = this.props
const frameList = this.getFrameList()
const renderItems = (firstIndex: number, lastIndex: number) => {
const rows: JSX.Element[] = []
@@ -229,6 +255,8 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
</thead>
</table>
<ScrollableListView
ref={this.listViewRef}
axis={'y'}
items={listItems}
className={css(style.scrollView)}
renderItems={renderItems}
@@ -305,3 +333,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,
}
},
)
+113
View File
@@ -0,0 +1,113 @@
import {Frame} from '../lib/profile'
import {StyleSheet, css} from 'aphrodite'
import {ProfileTableViewContainer} from './profile-table-view'
import {h} from 'preact'
import {commonStyle, Sizes, Colors, FontSize} from './style'
import {actions} from '../store/actions'
import {createContainer, Dispatch, StatelessComponent} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
import {CalleeFlamegraphView} from './callee-flamegraph-view'
interface SandwichViewProps {
selectedFrame: Frame | null
dispatch: Dispatch
}
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.setSelectedFrame(null)
}
}
componentDidMount() {
window.addEventListener('keydown', this.onWindowKeyPress)
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onWindowKeyPress)
}
render() {
const {selectedFrame} = this.props
let flamegraphViews: JSX.Element | null = null
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>
<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>
<CalleeFlamegraphView />
</div>
</div>
)
}
return (
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableViewContainer />
</div>
{flamegraphViews}
</div>
)
}
}
const style = StyleSheet.create({
tableView: {
flex: 1,
},
panZoomViewWraper: {
flex: 1,
},
flamechartLabelParent: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'flex-start',
fontSize: FontSize.TITLE,
width: FontSize.TITLE * 1.2,
borderRight: `1px solid ${Colors.LIGHT_GRAY}`,
},
flamechartLabelParentBottom: {
justifyContent: 'flex-start',
},
flamechartLabel: {
transform: 'rotate(-90deg)',
transformOrigin: '50% 50% 0',
width: FontSize.TITLE * 1.2,
flexShrink: 1,
},
flamechartLabelBottom: {
transform: 'rotate(-90deg)',
display: 'flex',
justifyContent: 'flex-end',
},
callersAndCallees: {
flex: 1,
borderLeft: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
},
divider: {
height: 2,
background: Colors.LIGHT_GRAY,
},
})
export const SandwichViewContainer = createContainer(SandwichView, (state: ApplicationState) => {
const {callerCallee} = state.sandwichView
return {selectedFrame: callerCallee ? callerCallee.selectedFrame : null}
})
@@ -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
@@ -10,6 +9,7 @@ export interface ListItem {
interface ScrollableListViewProps {
items: ListItem[]
axis: 'x' | 'y'
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
className?: string
}
@@ -22,7 +22,7 @@ interface ScrollableListViewState {
cachedTotalSize: number
}
export class ScrollableListView extends ReloadableComponent<
export class ScrollableListView extends Component<
ScrollableListViewProps,
ScrollableListViewState
> {
@@ -80,6 +80,22 @@ export class ScrollableListView extends ReloadableComponent<
this.setState({invisiblePrefixSize, firstVisibleIndex, lastVisibleIndex})
}
private pendingScroll = 0
public scrollIndexIntoView(index: number) {
this.pendingScroll = this.props.items.reduce((sum, cur, i) => {
if (i >= index) return sum
return sum + cur.size
}, 0)
}
private applyPendingScroll() {
if (!this.viewport) return
const leftOrTop = this.props.axis === 'y' ? 'top' : 'left'
this.viewport.scrollTo({
[leftOrTop]: this.pendingScroll,
})
}
componentWillReceiveProps(nextProps: ScrollableListViewProps) {
if (this.props.items !== nextProps.items) {
this.recomputeVisibleIndices(nextProps)
@@ -87,6 +103,7 @@ export class ScrollableListView extends ReloadableComponent<
}
componentDidMount() {
this.applyPendingScroll()
this.recomputeVisibleIndices(this.props)
window.addEventListener('resize', this.onWindowResize)
}

Some files were not shown because too many files have changed in this diff Show More