Compare commits

..
15 Commits
Author SHA1 Message Date
Jamie Wong ff447c2719 1.7.0 2020-07-13 22:10:28 -07:00
Jamie Wong 668bb032ba Introduce filtering via Ctrl+F/Cmd+F into the sandwich view (#293)
This is the first step towards fixing #38. 

I started with the easiest part from a UI-paradigm perspective, and also the place that's the most confusing that search doesn't work. Before this PR, browers' Cmd+F/Ctrl+F would *look* like it worked in the Sandwich view, but they wouldn't work fully because the view in the sandwich view is a virtualized table, meaning that it doesn't put all of the rows in the DOM. Instead, it only renders enough to fill the viewport to make rendering much faster.

Here's what the changes from this PR look like in action:

![Kapture 2020-07-12 at 23 17 33](https://user-images.githubusercontent.com/150329/87276802-ef2b8780-c495-11ea-9856-9c834ea7f028.gif)

Before closing #38, I'll be adding search functionality to the flamechart views too.
2020-07-13 22:04:19 -07:00
Jamie Wong 9ed1eb192c 1.6.0 2020-05-30 21:54:44 -07:00
Jamie Wong 8620432cbc Introduce a profile selector dropdown (#282)
This adds much better UI for selecting different profiles within a single import.

![Kapture 2020-05-30 at 21 34 06](https://user-images.githubusercontent.com/150329/83344564-595ce400-a2bd-11ea-8306-e5d8f647b65e.gif)

You can now hover over the middle of the toolbar or hit `t` on your keyboard to bring up the profile selector. From there, you can use fuzzy-find to switch to the profile you want, and hit "enter" to select it. The up and down arrow keys can be used while the profile selector filter input is focused to move through the list of profiles.

I think the "next" and "prev" buttons are now totally useless, so I removed them.

Fixes #167
2020-05-30 21:42:27 -07:00
Jamie Wong dead3f9ad9 Fix bug with bad caching of action creators (#281)
Profile switching was subtly broken because action creators weren't being correctly re-bound due to a missing dependency in a `useCallback` call.

I also tried to reduce boilerplate in this PR by adding additional exhaustive deps protection via eslint for `useSelector`, `useAppSelector`, and `useActionCreator`. The removes the need for using `useCallback` or each of those.

Fixes #280
2020-05-25 19:10:40 -07:00
Jamie Wong 80b747a55e Fix hot module reload issues caused by subtle bug in useSelector (#279)
To test this, load a profile, then save a `.tsx` file locally. Before this change, it would bring you back to the welcome screen after hot reload. After this change, application state is still displayed. This is because before the change, the `setGLCanvas` action wasn't resulting in a re-render because it occurred between the initial render and the `useLayoutEffect` callback.

Fixes #276
2020-05-25 15:42:19 -07:00
Jamie Wong 351994972d Upgrade to Preact X, partially convert to using hooks (#267)
I'd like to try writing new components using hooks, and to do that I need to upgrade from preact 8 to preact X.

For reasons that are... complicated, in order to upgrade without breaking part of my build process, I had to remove the dependency on `preact-redux` altogether. This led me to write my own implementation, and as part of that I realized I could remove `createContainer` in favour of some simple hooks that use redux.

Before landing:
- [x] Investigate performance issues in the sandwich views
- [x] Investigate es-lint checks for exhaustive hook dependencies
2020-05-23 16:42:31 -07:00
Jamie Wong ca1abfdd32 Fix schema generation in new TypeScript version (#274)
Fixes #268 

I fixed it by dropping the dependency on quicktype entirely, and using its dependency directly. I still don't understand why the version of typescript used in this repository affects what quicktype is doing, but it seems like the issue is in quicktype, not its dependency.

I validated this change was correct by diffing the output of `node scripts/generate-file-format-schema-json.js` with what's currently on http://speedscope.app/file-format-schema.json. There's no difference.

This PR also includes changes to the CI script to ensure that we can catch this before hitting master next time.
2020-05-23 16:07:38 -07:00
Jamie Wong dee9e5ade4 Fail loudly when profile is imported with unmatched open/close events (#273)
Before this change, profiles like those in #272 would import but would display misleading data. Let's fail hard instead.

Fixes #272
2020-05-23 15:56:17 -07:00
Jamie Wong 2077a905a9 Upgrade TypeScript from 3.2.4 to 3.9.2 (#266) 2020-05-16 17:02:51 -07:00
Jamie Wong e969178e65 More npm audit fixes 2020-05-16 16:46:01 -07:00
Justin Beckwith 56f6459af9 Bump parcel and audit fix (#264) 2020-05-16 16:44:45 -07:00
Jamie Wong 3f79e0fe96 Update README.md to include link for importing from ruby-prof
Fixes #265
2020-04-28 11:00:53 -07:00
Jamie Wong d30bb2ef7e Fix the build for node 13.x, make travis test 10, 12, 13, stable (#263)
@JustinBeckwith pointed out in #262 that `npm install` was broken in node 13.x, and @DanielRuf pointed in #254 that test fail for node 11+ because of a change to stability of sorting.

This PR seeks to address both of those.

The installation issue was fixed by just regenerating `package-lock.json` without needing to bump any of the direct dependency versions. The test failure issue requires manual intervention.

To fix the sort stability issue, I updated the tests to use the stable sort values (these were all the correct values, though some of the test values were incorrect).

To make the suite still pass for node 10, I added a hack where I override `Array.prototype.sort` with a stable implementation that's *only* used in tests (See comments in code for a justification for why)

## Test Plan

Before this PR: `npm install` on node 13.x fails & `npm run jest` results in test failures
After this PR: `npm install` on node 13.x passes & `npm run jest` passes for node 10, 12, and 13.
2020-04-20 08:26:59 -07:00
Jamie Wong fd4195da10 Update CHANGELOG.md 2020-01-16 00:17:47 -08:00
61 changed files with 6856 additions and 6566 deletions
+11 -4
View File
@@ -1,13 +1,20 @@
module.exports = {
parser: 'typescript-eslint-parser',
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
plugins: ['prettier'],
plugins: ['prettier', '@typescript-eslint', 'react-hooks'],
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'error',
{
additionalHooks: '(useSelector|useAppSelector|useActionCreator)',
},
],
},
};
}
+4 -1
View File
@@ -1,3 +1,6 @@
language: node_js
node_js:
- '9'
- '10'
- '12'
- '13'
- 'node'
+60 -44
View File
@@ -1,167 +1,183 @@
## Unreleased
## [1.7.0] - 2020-07-13
### Added
- Introduced filtering via Ctrl+F/Cmd+F into the sandwich view [[#293](https://github.com/jlfwong/speedscope/pull/293)]
## [1.6.0] - 2020-05-30
### Added
- Improved profile/thread selection UI [[#282](https://github.com/jlfwong/speedscope/pull/282)]
### Fixed
- Crash instead of incorrectly interpreting profiles with incorrectly ordered events [[#273](https://github.com/jlfwong/speedscope/pull/273)]
- A large refactor to upgrade to Preact X was performed [[#267](https://github.com/jlfwong/speedscope/pull/267)]
## [1.5.3] - 2020-01-16
### Fixed
- Bump dependency versions to unbreak build [#253] (by @jlfwong, with changes from @Archerlly's #215)
- Trace event: Prevent event re-ordering from generating incorrect flam
egraphs (#252, with changes from @hwajaywang's #249)
- Make tooltip width wider [#239] (by @miso11)
- Bump dependency versions to unbreak build [[#253](https://github.com/jlfwong/speedscope/pull/253)] (by [@jlfwong](https://github.com/jlfwong), with changes from [@Archerlly](https://github.com/Archerlly)'s [#215](https://github.com/jlfwong/speedscope/pull/215))
- Trace event: Prevent event re-ordering from generating incorrect flamegraphs ([#252](https://github.com/jlfwong/speedscope/pull/252), with changes from [@hwajaywang](https://github.com/hwajaywang)'s [#249](https://github.com/jlfwong/speedscope/pull/249))
- Make tooltip width wider [[#239](https://github.com/jlfwong/speedscope/pull/239)] (by [@miso11](https://github.com/miso11))
## [1.5.2] - 2019-10-10
### Fixed
* Fix emscripten remapping when symbols are hex-escaped, like `a\20b` [#233] (by @jyc)
- Fix emscripten remapping when symbols are hex-escaped, like `a\20b` [[#233](https://github.com/jlfwong/speedscope/pull/233)] (by [@jyc](https://github.com/jyc))
## [1.5.1] - 2019-06-06
### Fixed
* Fixed import of trace event files which contain unmatched "E" events (#222) (by @jlfwong)
- Fixed import of trace event files which contain unmatched "E" events ([#222](https://github.com/jlfwong/speedscope/pull/222)) (by [@jlfwong](https://github.com/jlfwong))
## [1.5.0] - 2019-02-17
### Added
* Support importing unterminated JSON in simple cases (#208) (by @jlfwong)
- Support importing unterminated JSON in simple cases ([#208](https://github.com/jlfwong/speedscope/pull/208)) (by [@jlfwong](https://github.com/jlfwong))
### Fixed
* Fix crash when importing from stackpro without raw_timestamp_deltas (#207) (by @jlfwong)
* Alert instead of crash when importing a file containing no profiles (#205) (by @jlfwong)
* Fixed import of multithreaded profiles from Chrome 66 (#206) (by @jlfwong)
* Fixed import of instruments trace files with missing run number (#203) (by @Archerlly)
- Fix crash when importing from stackprof without raw_timestamp_deltas ([#207](https://github.com/jlfwong/speedscope/pull/207)) (by [@jlfwong](https://github.com/jlfwong))
- Alert instead of crash when importing a file containing no profiles ([#205](https://github.com/jlfwong/speedscope/pull/205)) (by [@jlfwong](https://github.com/jlfwong))
- Fixed import of multithreaded profiles from Chrome 66 ([#206](https://github.com/jlfwong/speedscope/pull/206)) (by [@jlfwong](https://github.com/jlfwong))
- Fixed import of instruments trace files with missing run number ([#203](https://github.com/jlfwong/speedscope/pull/203)) (by [@Archerlly](https://github.com/Archerlly))
## [1.4.1] - 2019-01-22
### Fixed
* Fix importing of Trace Event Format files with no ts field on M events [#198] (by @jlfwong)
- Fix importing of Trace Event Format files with no ts field on M events [[#198](https://github.com/jlfwong/speedscope/pull/198)] (by [@jlfwong](https://github.com/jlfwong))
## [1.4.0] - 2019-01-22
### Added
* Import v8 cpu profile (old format) [#177] (by @vmarchaud)
* Import basic "Trace Event Format" profiles [#197] (by @jlfwong)
- Import v8 cpu profile (old format) [[#177](https://github.com/jlfwong/speedscope/pull/177)] (by [@vmarchaud](https://github.com/vmarchaud))
- Import basic "Trace Event Format" profiles [[#197](https://github.com/jlfwong/speedscope/pull/197)] (by [@jlfwong](https://github.com/jlfwong))
## [1.3.2] - 2018-12-03
### Fixed
* Fixed import of multithreaded Chrome profiles [#19] (by @jlfwong)
- Fixed import of multithreaded Chrome profiles [[#19](https://github.com/jlfwong/speedscope/pull/19)] (by [@jlfwong](https://github.com/jlfwong))
## [1.3.1] - 2018-11-08
### Fixed
* Fixed a file import performance regression by using TextDecoder [#188] (by @jlfwong)
- Fixed a file import performance regression by using TextDecoder [[#188](https://github.com/jlfwong/speedscope/pull/188)] (by [@jlfwong](https://github.com/jlfwong))
## [1.3.0] - 2018-10-29
### Added
* Support import from Haskell GHC JSON format support [#183] (by @trishume)
- Support import from Haskell GHC JSON format support [[#183](https://github.com/jlfwong/speedscope/pull/183)] (by [@trishume](https://github.com/trishume))
### Fixed
* Make the wasd keymappings work on azerty keyboards [#184] (by @vrischmann)
* Fix import of binary formats via profileURL [#179] (by @f-hj)
- Make the wasd keymappings work on azerty keyboards [[#184](https://github.com/jlfwong/speedscope/pull/184)] (by [@vrischmann](https://github.com/vrischmann))
- Fix import of binary formats via profileURL [[#179](https://github.com/jlfwong/speedscope/pull/179)] (by [@f](https://github.com/f)-hj)
## [1.2.0] - 2018-10-08
### Added
* Add import of v8 heap allocation profile [#170] (by @vmarchaud)
- Add import of v8 heap allocation profile [[#170](https://github.com/jlfwong/speedscope/pull/170)] (by [@vmarchaud](https://github.com/vmarchaud))
## [1.1.0] - 2018-09-26
### Added
* Add go tool pprof import support [#165]
- Add go tool pprof import support [[#165](https://github.com/jlfwong/speedscope/pull/165)]
## [1.0.4] - 2018-09-12
### Fixed
* Fix import from Chrome < 69 when there are multiple profiles [#161]
- Fix import from Chrome < 69 when there are multiple profiles [[#161](https://github.com/jlfwong/speedscope/pull/161)]
## [1.0.3] - 2018-09-10
### Fixed
* Fix import for Chrome 69, support leading idle time before first call [#160]
- Fix import for Chrome 69, support leading idle time before first call [[#160](https://github.com/jlfwong/speedscope/pull/160)]
## [1.0.2] - 2018-09-04
### Fixed
* Allow optional CR before LF when probing collapsed stacks files [#154]
* Fix import for Firefox 63 [#156]
* Change time formatting for minutes from 1.50min to 1:30 [#153] (by @Alex-Diez)
- Allow optional CR before LF when probing collapsed stacks files [[#154](https://github.com/jlfwong/speedscope/pull/154)]
- Fix import for Firefox 63 [[#156](https://github.com/jlfwong/speedscope/pull/156)]
- Change time formatting for minutes from 1.50min to 1:30 [[#153](https://github.com/jlfwong/speedscope/pull/153)] (by [@Alex](https://github.com/Alex)-Diez)
## [1.0.1] - 2018-08-23
* Fixed an issue where flamegraph bounds were not always being cleared correctly, leading to visual artifacts [#150]
- Fixed an issue where flamegraph bounds were not always being cleared correctly, leading to visual artifacts [[#150](https://github.com/jlfwong/speedscope/pull/150)]
## [1.0.0] - 2018-08-23
### Fixed
* Fixed rendering issues when switching between screens w/ different `devicePixelRatios` [#147]
- Fixed rendering issues when switching between screens w/ different `devicePixelRatios` [[#147](https://github.com/jlfwong/speedscope/pull/147)]
## [0.7.1] - 2018-08-20
### Fixed
* Removed dependency on regl in order to allow speedscope to run in strict content-security-policy environments [#140]
* Fixed text culling bug [#143]
- Removed dependency on regl in order to allow speedscope to run in strict content-security-policy environments [[#140](https://github.com/jlfwong/speedscope/pull/140)]
- Fixed text culling bug [[#143](https://github.com/jlfwong/speedscope/pull/143)]
## [0.7.0] - 2018-08-16
### Added
* Added support to import from linux `perf script` [#135]
- Added support to import from linux `perf script` [[#135](https://github.com/jlfwong/speedscope/pull/135)]
## [0.6.0] - 2018-08-14
### Added
* Added support for multiple threads/processes [#130]
* Import all runs & threads from Instruments .trace files instead of just main thread from selected run [#130]
- Added support for multiple threads/processes [[#130](https://github.com/jlfwong/speedscope/pull/130)]
- Import all runs & threads from Instruments .trace files instead of just main thread from selected run [[#130](https://github.com/jlfwong/speedscope/pull/130)]
### Fixed
* Ensure the JSON schema has actual contents [#133]
- Ensure the JSON schema has actual contents [[#133](https://github.com/jlfwong/speedscope/pull/133)]
## [0.5.1] - 2018-08-09
### Fixed
* Fixed broken CLI
- 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]
- Fix emscripten remapping when symbols contain dashes, like `527:i32s-div` [[#129](https://github.com/jlfwong/speedscope/pull/129)]
- Improved firefox import speed and fixed bugs in it [[#128](https://github.com/jlfwong/speedscope/pull/128)]
- Prevent non-contiguous blocks in the time ordered flamechart from appearing as a single node for selection [[#123](https://github.com/jlfwong/speedscope/pull/123)]
- Prevent dragging from changing selection [[#122](https://github.com/jlfwong/speedscope/pull/122)]
- Clamp zoom to prevent floating point issues [[#121](https://github.com/jlfwong/speedscope/pull/121)]
- Preserve view state when switching tabs [[#100](https://github.com/jlfwong/speedscope/pull/100)]
## [0.4.0] - 2018-07-21
### Added
* Support for importing v8 logs from node [#98]
* Optionally read from stdin via cli [#99]
- Support for importing v8 logs from node [[#98](https://github.com/jlfwong/speedscope/pull/98)]
- Optionally read from stdin via cli [[#99](https://github.com/jlfwong/speedscope/pull/99)]
## [0.3.0] - 2018-07-18
### Added
* Support for remapping profiles using a wasm symbol file [#93]
- Support for remapping profiles using a wasm symbol file [[#93](https://github.com/jlfwong/speedscope/pull/93)]
+2
View File
@@ -40,6 +40,7 @@ speedscope is designed to ingest profiles from a variety of different profilers
- Ruby
- [Importing from stackprof](https://github.com/jlfwong/speedscope/wiki/Importing-from-stackprof-(ruby))
- [Importing from rbspy](https://github.com/jlfwong/speedscope/wiki/Importing-from-rbspy-(ruby))
- [Importing from ruby-prof](https://github.com/jlfwong/speedscope/wiki/Importing-from-ruby-prof)
- Python
- [Importing from py-spy](https://github.com/jlfwong/speedscope/wiki/Importing-from-py-spy-(python))
- [pyspeedscope](https://github.com/windelbouwman/pyspeedscope)
@@ -114,6 +115,7 @@ Once a profile has loaded, the main view is split into two: the top area is the
* `Cmd+O`/`Ctrl+O` to open a new profile
* `n`: Go to next profile/thread if one is available
* `p`: Go to previous profile/thread if one is available
* `t`: Open the profile/thread selector if available
## Contributing
+4330 -5313
View File
File diff suppressed because it is too large Load Diff
+21 -12
View File
@@ -1,6 +1,6 @@
{
"name": "speedscope",
"version": "1.5.3",
"version": "1.7.0",
"description": "",
"repository": "jlfwong/speedscope",
"main": "index.js",
@@ -14,7 +14,8 @@
"lint": "eslint 'src/**/*.ts' 'src/**/*.tsx'",
"jest": "./scripts/test-setup.sh && jest --runInBand",
"coverage": "npm run jest -- --coverage && coveralls < coverage/lcov.info",
"test": "tsc --noEmit && npm run lint && npm run coverage",
"typecheck": "tsc --noEmit",
"test": "./scripts/ci.sh",
"serve": "parcel assets/index.html --open --no-autoinstall"
},
"files": [
@@ -31,32 +32,39 @@
"devDependencies": {
"@types/jest": "22.2.3",
"@types/jszip": "3.1.4",
"@types/node": "10.1.4",
"@types/node": "14.0.1",
"@types/pako": "1.0.0",
"@typescript-eslint/eslint-plugin": "2.33.0",
"@typescript-eslint/parser": "2.33.0",
"acorn": "7.2.0",
"aphrodite": "2.1.0",
"coveralls": "3.0.1",
"eslint": "4.19.1",
"eslint": "6.0.0",
"eslint-plugin-prettier": "2.6.0",
"eslint-plugin-react-hooks": "4.0.2",
"jest": "24.3.0",
"jsverify": "0.8.3",
"jszip": "3.1.5",
"pako": "1.0.6",
"parcel-bundler": "1.9.2",
"preact": "8.2.7",
"preact-redux": "jlfwong/preact-redux#a56dcc4",
"prettier": "1.12.0",
"parcel-bundler": "1.12.4",
"preact": "10.4.1",
"prettier": "2.0.4",
"protobufjs": "6.8.8",
"quicktype": "15.0.209",
"redux": "^4.0.0",
"react-redux": "^7.2.0",
"redux": "^4.0.5",
"ts-jest": "24.3.0",
"typescript": "3.2.4",
"typescript": "3.9.2",
"typescript-eslint-parser": "17.0.1",
"typescript-json-schema": "0.42.0",
"uglify-es": "3.2.2"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"setupFilesAfterEnv": [
"./src/jest-setup.js"
],
"testRegex": "\\.test\\.tsx?$",
"collectCoverageFrom": [
"**/*.{ts,tsx}",
@@ -71,6 +79,7 @@
]
},
"dependencies": {
"opn": "5.3.0"
"opn": "5.3.0",
"react": "^16.13.1"
}
}
+2 -1
View File
@@ -4,4 +4,5 @@ module.exports = {
semi: false,
singleQuote: true,
trailingComma: 'all',
};
arrowParens: 'avoid'
}
@@ -0,0 +1,16 @@
{
"$schema": "https://www.speedscope.app/file-format-schema.json",
"shared": {
"frames": [{"name": "A"}]
},
"profiles": [
{
"type": "evented",
"name": "p1",
"unit": "none",
"startValue": 0,
"endValue": 100,
"events": [{"type": "O", "frame": 0, "at": 0}]
}
]
}
@@ -0,0 +1,25 @@
{
"$schema": "https://www.speedscope.app/file-format-schema.json",
"shared": {
"frames": [{"name": "A"}, {"name": "B"}]
},
"profiles": [
{
"type": "evented",
"name": "p1",
"unit": "none",
"startValue": 0,
"endValue": 100,
"events": [
{"type": "O", "frame": 0, "at": 0},
{"type": "C", "frame": 0, "at": 1},
{"type": "O", "frame": 1, "at": 2},
{"type": "O", "frame": 0, "at": 2},
{"type": "O", "frame": 0, "at": 3},
{"type": "C", "frame": 0, "at": 4},
{"type": "C", "frame": 1, "at": 4},
{"type": "C", "frame": 0, "at": 5}
]
}
]
}
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -euxo pipefail
npm run typecheck
npm run lint
npm run coverage
node scripts/generate-file-format-schema-json.js > /dev/null
+1 -1
View File
@@ -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 ./src/lib/file-format-spec.ts',
'node_modules/.bin/typescript-json-schema ./src/lib/file-format-spec.ts --titles --required --topRef "*"',
{
encoding: 'utf8',
},
+1 -3
View File
@@ -24,9 +24,7 @@ export class CanvasContext {
const webGLInfo = this.gl.getWebGLInfo()
if (webGLInfo) {
console.log(
`WebGL initialized. renderer: ${webGLInfo.renderer}, vendor: ${
webGLInfo.vendor
}, version: ${webGLInfo.version}`,
`WebGL initialized. renderer: ${webGLInfo.renderer}, vendor: ${webGLInfo.vendor}, version: ${webGLInfo.version}`,
)
}
;(window as any)['testContextLoss'] = () => {
+4 -4
View File
@@ -175,8 +175,8 @@ export class FlamechartRenderer {
// and the blue channel to indicate the color bucket to render.
// We add one to each so we have zero reserved for the background color.
const color = new Color(
(1 + i % 255) / 256,
(1 + stackDepth % 255) / 256,
(1 + (i % 255)) / 256,
(1 + (stackDepth % 255)) / 256,
(1 + this.flamechart.getColorBucketForFrame(frame.node.frame)) / 256,
)
batch.addRect(configSpaceBounds, color)
@@ -288,10 +288,10 @@ export class FlamechartRenderer {
const configSpaceContentWidth = this.flamechart.getTotalWeight()
const numAtlasEntriesPerLayer = Math.pow(2, zoomLevel)
const left = Math.floor(
numAtlasEntriesPerLayer * configSpaceSrcRect.left() / configSpaceContentWidth,
(numAtlasEntriesPerLayer * configSpaceSrcRect.left()) / configSpaceContentWidth,
)
const right = Math.ceil(
numAtlasEntriesPerLayer * configSpaceSrcRect.right() / configSpaceContentWidth,
(numAtlasEntriesPerLayer * configSpaceSrcRect.right()) / configSpaceContentWidth,
)
const nLayers = this.flamechart.getLayers().length
+6 -1
View File
@@ -65,7 +65,12 @@ export class ViewportRectangleRenderer {
private buffer: Graphics.VertexBuffer
constructor(private gl: Graphics.Context) {
const vertices = [[-1, 1], [1, 1], [-1, -1], [1, -1]]
const vertices = [
[-1, 1],
[1, 1],
[-1, -1],
[1, -1],
]
const floats: number[] = []
for (let v of vertices) {
floats.push(v[0])
+8 -1
View File
@@ -45,7 +45,14 @@ export class RectangleBatch {
return this.buffer
}
const corners = [[0, 0], [1, 0], [0, 1], [1, 0], [0, 1], [1, 1]]
const corners = [
[0, 0],
[1, 0],
[0, 1],
[1, 0],
[0, 1],
[1, 1],
]
const bytes = new Uint8Array(vertexFormat.stride * corners.length * this.rects.length)
const floats = new Float32Array(bytes.buffer)
+92 -92
View File
@@ -516,97 +516,6 @@ exports[`importFromChromeTimeline Chrome 69: indexToView 1`] = `0`;
exports[`importFromChromeTimeline Chrome 69: profileGroup.name 1`] = `"simple.json"`;
exports[`importFromChromeTimeline Workers Chrome 66 1`] = `
Object {
"frames": Array [
Frame {
"col": -1,
"file": "",
"key": "(program)::-1:-1",
"line": -1,
"name": "(program)",
"selfWeight": 819120,
"totalWeight": 819120,
},
Frame {
"col": 20,
"file": "http://172.30.3.176:7777/worker.js",
"key": "onmessage:http://172.30.3.176:7777/worker.js:14:20",
"line": 14,
"name": "onmessage",
"selfWeight": 639,
"totalWeight": 857719,
},
Frame {
"col": 14,
"file": "http://172.30.3.176:7777/worker.js",
"key": "alpha:http://172.30.3.176:7777/worker.js:8:14",
"line": 8,
"name": "alpha",
"selfWeight": 19976,
"totalWeight": 835149,
},
Frame {
"col": 14,
"file": "http://172.30.3.176:7777/worker.js",
"key": "gamma:http://172.30.3.176:7777/worker.js:0:14",
"line": 0,
"name": "gamma",
"selfWeight": 22197,
"totalWeight": 30000,
},
Frame {
"col": -1,
"file": "",
"key": "postMessage::-1:-1",
"line": -1,
"name": "postMessage",
"selfWeight": 1278,
"totalWeight": 21931,
},
],
"name": "worker.json - DedicatedWorker thread",
"stacks": Array [
" 9.07ms",
"(program) 5.49ms",
"onmessage;alpha;gamma 3.69ms",
"onmessage;postMessage 212.00µs",
"onmessage;postMessage;(program) 3.97ms",
"onmessage;alpha;gamma 3.82ms",
"onmessage;postMessage 414.00µs",
"onmessage;postMessage;(program) 3.71ms",
"onmessage;alpha;gamma 1.13ms",
"onmessage 188.00µs",
"onmessage;alpha;gamma 2.48ms",
"onmessage;alpha;gamma;(program) 3.89ms",
"onmessage;alpha;gamma 373.00µs",
"onmessage;alpha 184.00µs",
"onmessage;alpha;gamma 3.36ms",
"onmessage;alpha;gamma;(program) 3.91ms",
"onmessage;alpha;gamma 3.94ms",
"onmessage;postMessage 205.00µs",
"onmessage;postMessage;(program) 3.91ms",
"onmessage 451.00µs",
"onmessage;alpha;gamma 566.00µs",
"onmessage;alpha 194.00µs",
"onmessage;alpha;gamma 2.09ms",
"onmessage;alpha 189.00µs",
"onmessage;alpha;gamma 746.00µs",
"onmessage;postMessage 211.00µs",
"onmessage;postMessage;(program) 4.00ms",
"onmessage;alpha 4.80ms",
"onmessage;alpha;(program) 5.28ms",
"onmessage;alpha 4.70ms",
"onmessage;postMessage 236.00µs",
"onmessage;postMessage;(program) 5.06ms",
"onmessage;alpha 5.07ms",
"onmessage;alpha;(program) 4.81ms",
"onmessage;alpha 4.84ms",
"onmessage;alpha;(program) 775.08ms",
],
}
`;
exports[`importFromChromeTimeline Workers Chrome 66 2`] = `
Object {
"frames": Array [
Frame {
@@ -913,7 +822,98 @@ Object {
}
`;
exports[`importFromChromeTimeline Workers Chrome 66: indexToView 1`] = `1`;
exports[`importFromChromeTimeline Workers Chrome 66 2`] = `
Object {
"frames": Array [
Frame {
"col": -1,
"file": "",
"key": "(program)::-1:-1",
"line": -1,
"name": "(program)",
"selfWeight": 819120,
"totalWeight": 819120,
},
Frame {
"col": 20,
"file": "http://172.30.3.176:7777/worker.js",
"key": "onmessage:http://172.30.3.176:7777/worker.js:14:20",
"line": 14,
"name": "onmessage",
"selfWeight": 639,
"totalWeight": 857719,
},
Frame {
"col": 14,
"file": "http://172.30.3.176:7777/worker.js",
"key": "alpha:http://172.30.3.176:7777/worker.js:8:14",
"line": 8,
"name": "alpha",
"selfWeight": 19976,
"totalWeight": 835149,
},
Frame {
"col": 14,
"file": "http://172.30.3.176:7777/worker.js",
"key": "gamma:http://172.30.3.176:7777/worker.js:0:14",
"line": 0,
"name": "gamma",
"selfWeight": 22197,
"totalWeight": 30000,
},
Frame {
"col": -1,
"file": "",
"key": "postMessage::-1:-1",
"line": -1,
"name": "postMessage",
"selfWeight": 1278,
"totalWeight": 21931,
},
],
"name": "worker.json - DedicatedWorker thread",
"stacks": Array [
" 9.07ms",
"(program) 5.49ms",
"onmessage;alpha;gamma 3.69ms",
"onmessage;postMessage 212.00µs",
"onmessage;postMessage;(program) 3.97ms",
"onmessage;alpha;gamma 3.82ms",
"onmessage;postMessage 414.00µs",
"onmessage;postMessage;(program) 3.71ms",
"onmessage;alpha;gamma 1.13ms",
"onmessage 188.00µs",
"onmessage;alpha;gamma 2.48ms",
"onmessage;alpha;gamma;(program) 3.89ms",
"onmessage;alpha;gamma 373.00µs",
"onmessage;alpha 184.00µs",
"onmessage;alpha;gamma 3.36ms",
"onmessage;alpha;gamma;(program) 3.91ms",
"onmessage;alpha;gamma 3.94ms",
"onmessage;postMessage 205.00µs",
"onmessage;postMessage;(program) 3.91ms",
"onmessage 451.00µs",
"onmessage;alpha;gamma 566.00µs",
"onmessage;alpha 194.00µs",
"onmessage;alpha;gamma 2.09ms",
"onmessage;alpha 189.00µs",
"onmessage;alpha;gamma 746.00µs",
"onmessage;postMessage 211.00µs",
"onmessage;postMessage;(program) 4.00ms",
"onmessage;alpha 4.80ms",
"onmessage;alpha;(program) 5.28ms",
"onmessage;alpha 4.70ms",
"onmessage;postMessage 236.00µs",
"onmessage;postMessage;(program) 5.06ms",
"onmessage;alpha 5.07ms",
"onmessage;alpha;(program) 4.81ms",
"onmessage;alpha 4.84ms",
"onmessage;alpha;(program) 775.08ms",
],
}
`;
exports[`importFromChromeTimeline Workers Chrome 66: indexToView 1`] = `0`;
exports[`importFromChromeTimeline Workers Chrome 66: profileGroup.name 1`] = `"worker.json"`;
@@ -44,17 +44,8 @@ Object {
"key": "A",
"line": undefined,
"name": "A",
"selfWeight": 1,
"totalWeight": 10,
},
Frame {
"col": undefined,
"file": undefined,
"key": "C",
"line": undefined,
"name": "C",
"selfWeight": 3,
"totalWeight": 10,
"selfWeight": 2,
"totalWeight": 6,
},
Frame {
"col": undefined,
@@ -62,9 +53,18 @@ Object {
"key": "B",
"line": undefined,
"name": "B",
"selfWeight": 3,
"selfWeight": 2,
"totalWeight": 4,
},
Frame {
"col": undefined,
"file": undefined,
"key": "C",
"line": undefined,
"name": "C",
"selfWeight": 2,
"totalWeight": 2,
},
Frame {
"col": undefined,
"file": undefined,
@@ -77,12 +77,14 @@ Object {
],
"name": "pid 0, tid 1",
"stacks": Array [
"A;C;B 2.00µs",
"A;C 2.00µs",
"A;C;A 1.00µs",
"A;C;A;B 1.00µs",
"A;C;A;B;C 1.00µs",
"A;C;X 3.00µs",
"A;B;C 1.00µs",
"A;B 1.00µs",
"A 1.00µs",
" 1.00µs",
"A 1.00µs",
"A;B 1.00µs",
"A;B;C 1.00µs",
"X 3.00µs",
],
}
`;
+51
View File
@@ -0,0 +1,51 @@
// Versions of node before 10 had an unstable sort. This isn't really an issue in browsers
// that speedscope supports, but for the purposes of supporting node 10, we'll polyfill
// a stable sort to make the tests pass.
//
// See:
// - https://v8.dev/features/stable-sort
// - https://v8.dev/blog/array-sort
// - https://github.com/jlfwong/speedscope/pull/254#issuecomment-575116995
//
// Once we stop supporting node 10, this can be removed.
//
// An alternative would be to change our sort implementation to be stable by definition
// rather than relying upon native sort being stable. I don't want to do that because
// we'd take a perf hit.
//
// Because we're not going to use this in our actual build, it's okay for this
// to be inefficient.
(function () {
const nodeVersion = process.versions.node
const versionParts = nodeVersion.split('.')
const majorVersion = parseInt(versionParts[0], 10)
if (majorVersion > 10) {
// Don't need to do the patch for newer node versions
return
}
const defaultCompareFunction = (a, b) => {
const sa = '' + a
const sb = '' + b
if (sa < sb) return -1
if (sa > sb) return 1
return 0
}
const originalSort = Array.prototype.sort
Array.prototype.sort = function (compareFunction) {
const arrayWithIndices = this.map((x, i) => [x, i])
originalSort.call(arrayWithIndices, (a, b) => {
if (!compareFunction) {
compareFunction = defaultCompareFunction
}
const res = compareFunction(a[0], b[0])
if (res !== 0) return res
return a[1] < b[1] ? -1 : 1
})
this.splice(0, this.length, ...arrayWithIndices.map(x => x[0]))
return this
}
})()
@@ -211,3 +211,7 @@ Object {
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles: indexToView 1`] = `1`;
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles: profileGroup.name 1`] = `"Two Samples"`;
exports[`importSpeedscopeProfiles invalid due to incomplete trace 1`] = `"Tried to complete profile construction with a non-empty stack"`;
exports[`importSpeedscopeProfiles invalid due to out of order events 1`] = `"Tried to leave frame \\"B\\" while frame \\"A\\" was at the top at 4"`;
+9 -9
View File
@@ -15,19 +15,19 @@ export class Color {
// https://en.wikipedia.org/wiki/HSL_and_HSV#From_luma/chroma/hue
const hPrime = H / 60
const X = C * (1 - Math.abs(hPrime % 2 - 1))
const X = C * (1 - Math.abs((hPrime % 2) - 1))
const [R1, G1, B1] =
hPrime < 1
? [C, X, 0]
: hPrime < 2
? [X, C, 0]
: hPrime < 3
? [0, C, X]
: hPrime < 4
? [0, X, C]
: hPrime < 5
? [X, 0, C]
: [C, 0, X]
? [X, C, 0]
: hPrime < 3
? [0, C, X]
: hPrime < 4
? [0, X, C]
: hPrime < 5
? [X, 0, C]
: [C, 0, X]
const m = L - (0.3 * R1 + 0.59 * G1 + 0.11 * B1)
+15 -2
View File
@@ -11,7 +11,13 @@ test('importEmscriptenSymbolMap', () => {
'c:C',
].join('\n'),
),
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C']]))
).toEqual(
new Map([
['a', 'A'],
['b', 'B'],
['c', 'C'],
]),
)
// Valid symbol map with trailing newline
expect(
@@ -25,7 +31,14 @@ test('importEmscriptenSymbolMap', () => {
'',
].join('\n'),
),
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C'], ['d', 'D-D']]))
).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(
+10 -1
View File
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from './test-utils'
import {checkProfileSnapshot, expectImportFailure} from './test-utils'
describe('importSpeedscopeProfiles', () => {
test('0.0.1 evented profile', async () => {
@@ -12,4 +12,13 @@ describe('importSpeedscopeProfiles', () => {
test('0.6.0 multiple profiles', async () => {
await checkProfileSnapshot('./sample/profiles/speedscope/0.6.0/two-sampled.speedscope.json')
})
test('invalid due to out of order events', async () => {
// See: https://github.com/jlfwong/speedscope/issues/272
await expectImportFailure('./sample/profiles/speedscope/invalid/out-of-order-events.json')
})
test('invalid due to incomplete trace', async () => {
await expectImportFailure('./sample/profiles/speedscope/invalid/incomplete-trace.json')
})
})
+4 -1
View File
@@ -138,7 +138,10 @@ function importSpeedscopeProfile(
for (let i = 0; i < samples.length; i++) {
const stack = samples[i]
const weight = weights[i]
profile.appendSampleWithWeight(stack.map(n => frameInfos[n]), weight)
profile.appendSampleWithWeight(
stack.map(n => frameInfos[n]),
weight,
)
}
return profile.build()
+81
View File
@@ -0,0 +1,81 @@
import {fuzzyMatchStrings} from './fuzzy-find'
import {sortBy} from './utils'
function assertMatches(texts: string[], pattern: string, expectedResults: string[]) {
const results: {score: number; highlighted: string}[] = []
for (let text of texts) {
const match = fuzzyMatchStrings(text, pattern)
if (match == null) {
continue
}
let highlighted = ''
let last = 0
for (let range of match.matchedRanges) {
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
last = range[1]
}
highlighted += text.slice(last)
results.push({score: match.score, highlighted})
}
// Sort scores in descending order
sortBy(results, r => -r.score)
expect(results.map(r => r.highlighted)).toEqual(expectedResults)
}
function assertMatch(text: string, pattern: string, expected: string) {
assertMatches([text], pattern, [expected])
}
function assertNoMatch(text: string, pattern: string) {
assertMatches([text], pattern, [])
}
describe('fuzzyMatchStrings', () => {
test('no match', () => {
assertNoMatch('a', 'b')
assertNoMatch('aa', 'ab')
assertNoMatch('a', 'aa')
assertNoMatch('ca', 'ac')
})
test('full text match', () => {
assertMatch('hello', 'hello', '[hello]')
assertMatch('multiple words', 'multiple words', '[multiple words]')
})
test('case sensitivity', () => {
assertMatch('HELLO', 'hello', '[HELLO]')
assertMatch('Hello', 'hello', '[Hello]')
assertNoMatch('hello', 'Hello')
assertNoMatch('hello', 'HELLO')
})
test('multiple occurrences', () => {
assertMatch('hello hello', 'hello', '[hello] hello')
assertMatch('hellohello', 'hello', '[hello]hello')
})
test('prefer earlier matches', () => {
assertMatches(['cab', 'ab'], 'ab', ['[ab]', 'c[ab]'])
})
test('prefer shorter matches', () => {
assertMatches(['abbc', 'abc', 'abbbc'], 'ac', ['[a]b[c]', '[a]bb[c]', '[a]bbb[c]'])
})
test('prefer word boundaries', () => {
assertMatches(['abc', 'a c'], 'ac', ['[a] [c]', '[a]b[c]'])
})
test('prefer camelCase matches', () => {
assertMatches(['downtown', 'OutNode'], 'n', ['Out[N]ode', 'dow[n]town'])
})
test('prefer number prefix matches', () => {
assertMatches(['211', 'a123'], '1', ['a[1]23', '2[1]1'])
})
})
+246
View File
@@ -0,0 +1,246 @@
/**
* This file contains an implementation of fuzzy string matching.
*/
export interface FuzzyMatch {
// List of [start, end] indices in the haystack string that match the needle string
matchedRanges: [number, number][]
// The score of the match for relative ranking. Higher scores indicate
// "better" matches.
score: number
}
export function fuzzyMatchStrings(text: string, pattern: string): FuzzyMatch | null {
return fzfFuzzyMatchV1(text, pattern)
}
// The implementation here is based on FuzzyMatchV1, as described here:
// https://github.com/junegunn/fzf/blob/f81feb1e69e5cb75797d50817752ddfe4933cd68/src/algo/algo.go#L8-L15
//
// This is a hand-port to better understand what the code is doing and for added
// clarity.
//
// Capitalized letters only match capitalized letters, but lower-case letters
// match both.
//
// Note: fzf includes a normalization table for homoglyphs. I'm going to ignore that too
// https://github.com/junegunn/fzf/blob/master/src/algo/normalize.go
const charCodeLowerA = 'a'.charCodeAt(0)
const charCodeLowerZ = 'z'.charCodeAt(0)
const charCodeUpperA = 'A'.charCodeAt(0)
const charCodeUpperZ = 'Z'.charCodeAt(0)
const charCodeDigit0 = '0'.charCodeAt(0)
const charCodeDigit9 = '9'.charCodeAt(0)
enum fzfCharClass {
charNonWord,
charLower,
charUpper,
charNumber,
}
function fzfCharClassOf(char: string): fzfCharClass {
const code = char.charCodeAt(0)
if (charCodeLowerA <= code && code <= charCodeLowerZ) {
return fzfCharClass.charLower
} else if (charCodeUpperA <= code && code <= charCodeUpperZ) {
return fzfCharClass.charUpper
} else if (charCodeDigit0 <= code && code <= charCodeDigit9) {
return fzfCharClass.charNumber
}
return fzfCharClass.charNonWord
}
function charsMatch(textChar: string, patternChar: string): boolean {
if (textChar === patternChar) return true
const patternCharCode = patternChar.charCodeAt(0)
if (charCodeLowerA <= patternCharCode && patternCharCode <= charCodeLowerZ) {
return textChar.charCodeAt(0) === patternCharCode - charCodeLowerA + charCodeUpperA
}
return false
}
function fzfFuzzyMatchV1(text: string, pattern: string): FuzzyMatch | null {
if (pattern.length == 0) {
return {matchedRanges: [], score: 0}
}
// I removed the fzfAsciiFuzzyIndex code because it's not actually clear to
// me that it's a very helpful optimization.
let pidx = 0
let sidx = -1
let eidx = -1
let lenRunes = text.length
let lenPattern = pattern.length
// Forward pass: scan over the text pattern, identifying the earliest start
// and the latest end to consider.
for (let index = 0; index < lenRunes; index++) {
let char = text[index]
let pchar = pattern[pidx]
if (charsMatch(char, pchar)) {
if (sidx < 0) {
sidx = index
}
pidx++
if (pidx == lenPattern) {
// We found the last character in the pattern! eidx is exclusive, so
// we'll set it to the current index + 1.
eidx = index + 1
break
}
}
}
if (eidx == -1) {
// We couldn't find all the characters in the pattern. No match.
return null
}
// Assuming we found all the characters in the pattern, perform the backwards
// pass.
pidx--
for (let index = eidx - 1; index >= sidx; index--) {
const char = text[index]
const pchar = pattern[pidx]
if (charsMatch(char, pchar)) {
pidx--
if (pidx < 0) {
// We found the first character of the pattern, scanning
// backwards. This *may* have narrowed the match further.
// For example, for the following inputs:
//
// text = "xxx a b c abc xxx"
// pattern = "abc"
//
// For the forward pass, you get:
//
// "xxx a b c abc xxx"
// start^ ^end
//
// But after the backward pass, we can narrow this to:
//
// "xxx a b c abc xxx"
// start^ ^end
sidx = index
return fzfCalculateScore(text, pattern, sidx, eidx)
}
}
}
// This should be unreachable.
throw new Error('Implementation error. This must be a bug in fzfFuzzyMatchV1')
}
const fzfScoreMatch = 16
const fzfScoreGapStart = -3
const fzfScoreGapExtension = -1
const fzfBonusBoundary = fzfScoreMatch / 2
const fzfBonusNonWord = fzfScoreMatch / 2
const fzfBonusCamel123 = fzfBonusBoundary + fzfScoreGapExtension
const fzfBonusConsecutive = -(fzfScoreGapStart + fzfScoreGapExtension)
const fzfBonusFirstCharMultiplier = 2
function bonusFor(prevClass: fzfCharClass, curClass: fzfCharClass): number {
if (prevClass === fzfCharClass.charNonWord && curClass !== fzfCharClass.charNonWord) {
// Prefer matching at word boundaries
//
// This should prefer "a c" over "abc" for a pattern of "ac".
return fzfBonusBoundary
}
if (
(prevClass === fzfCharClass.charLower && curClass == fzfCharClass.charUpper) ||
(prevClass !== fzfCharClass.charNumber && curClass == fzfCharClass.charNumber)
) {
// Prefer matching at the transition point between lower & upper for camelCase,
// and from transition from letter to number for identifiers like letter123.
//
// This should prefer "OutNode" over "phone" for a pattern of "n",
// and "abc123" over "x211" for a pattern of "1".
return fzfBonusCamel123
}
if (curClass === fzfCharClass.charNonWord) {
return fzfBonusNonWord
}
return 0
}
function fzfCalculateScore(text: string, pattern: string, sidx: number, eidx: number): FuzzyMatch {
let pidx = 0
let score = 0
let inGap = false
let consecutive = 0
let firstBonus = 0
let pos: number[] = new Array(pattern.length)
let prevClass = fzfCharClass.charNonWord
if (sidx > 0) {
prevClass = fzfCharClassOf(text[sidx - 1])
}
for (let idx = sidx; idx < eidx; idx++) {
let char = text[idx]
let curClass = fzfCharClassOf(char)
if (charsMatch(char, pattern[pidx])) {
pos[pidx] = idx
score += fzfScoreMatch
let bonus = bonusFor(prevClass, curClass)
if (consecutive == 0) {
firstBonus = bonus
} else {
// Break consecutive chunk
if (bonus === fzfBonusBoundary) {
firstBonus = bonus
}
bonus = Math.max(bonus, firstBonus, fzfBonusConsecutive)
}
if (pidx === 0) {
score += bonus * fzfBonusFirstCharMultiplier
} else {
score += bonus
}
inGap = false
consecutive++
pidx++
} else {
if (inGap) {
// Penalize gaps (this bonus is negative)
score += fzfScoreGapExtension
} else {
// Penalize the beginning of gaps more harshly
score += fzfScoreGapStart
}
inGap = true
consecutive = 0
firstBonus = 0
}
prevClass = curClass
}
if (pidx !== pattern.length) {
throw new Error(
'fzfCalculateScore should only be called when pattern is found between sidx and eidx',
)
}
let matchedRanges: [number, number][] = [[pos[0], pos[0] + 1]]
for (let i = 1; i < pos.length; i++) {
const curPos = pos[i]
const curRange = matchedRanges[matchedRanges.length - 1]
if (curRange[1] === curPos) {
curRange[1] = curPos + 1
} else {
matchedRanges.push([curPos, curPos + 1])
}
}
return {
score,
matchedRanges,
}
}
+25 -45
View File
@@ -13,9 +13,10 @@ test('clamp', () => {
// Change this to jsc.integer to debug failures more easily
let numericType = jsc.number
const arbitraryVec2 = jsc
.record({x: jsc.integer, y: numericType})
.smap(v => new Vec2(v.x, v.y), v => v)
const arbitraryVec2 = jsc.record({x: jsc.integer, y: numericType}).smap(
v => new Vec2(v.x, v.y),
v => v,
)
const positiveVec2 = jsc.suchthat(arbitraryVec2, v => v.x > 0 && v.y > 0)
@@ -28,23 +29,25 @@ const arbitraryTransform = jsc
m11: numericType,
m12: numericType,
})
.smap(t => new AffineTransform(t.m00, t.m01, t.m02, t.m10, t.m11, t.m12), t => t)
.smap(
t => new AffineTransform(t.m00, t.m01, t.m02, t.m10, t.m11, t.m12),
t => t,
)
const invertibleTransform = jsc.suchthat(arbitraryTransform, t => t.det() != 0)
const simpleTransform = jsc.suchthat(
jsc
.record({scale: arbitraryVec2, translation: arbitraryVec2})
.smap(
t => AffineTransform.withScale(t.scale).withTranslation(t.translation),
t => ({scale: t.getScale(), translation: t.getTranslation()}),
),
jsc.record({scale: arbitraryVec2, translation: arbitraryVec2}).smap(
t => AffineTransform.withScale(t.scale).withTranslation(t.translation),
t => ({scale: t.getScale(), translation: t.getTranslation()}),
),
t => t.det() != 0,
)
const arbitraryRect = jsc
.record({origin: arbitraryVec2, size: positiveVec2})
.smap(r => new Rect(r.origin, r.size), r => r)
const arbitraryRect = jsc.record({origin: arbitraryVec2, size: positiveVec2}).smap(
r => new Rect(r.origin, r.size),
r => r,
)
describe('Vec2', () => {
test('constructor', () => {
@@ -263,51 +266,35 @@ describe('AffineTransform', () => {
expect(new AffineTransform(0, 0, 0, 0, 0, 0).inverted()).toBe(null)
jsc.assertForall(invertibleTransform, t => {
return t
.inverted()!
.inverted()!
.approxEquals(t)
return t.inverted()!.inverted()!.approxEquals(t)
})
})
test('translation', () => {
jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => {
return t
.withTranslation(v1)
.getTranslation()
.equals(v1)
return t.withTranslation(v1).getTranslation().equals(v1)
})
jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => {
const initialTranslation = t.getTranslation()
return t
.translatedBy(v1)
.getTranslation()
.approxEquals(initialTranslation.plus(v1))
return t.translatedBy(v1).getTranslation().approxEquals(initialTranslation.plus(v1))
})
})
test('scale', () => {
jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => {
return t
.withScale(v1)
.getScale()
.equals(v1)
return t.withScale(v1).getScale().equals(v1)
})
})
test('transformVector', () => {
// Vector transformation are translation-invariant
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
return AffineTransform.withTranslation(v1)
.transformVector(v2)
.approxEquals(v2)
return AffineTransform.withTranslation(v1).transformVector(v2).approxEquals(v2)
})
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
return AffineTransform.withScale(v1)
.transformVector(v2)
.approxEquals(v2.timesPointwise(v1))
return AffineTransform.withScale(v1).transformVector(v2).approxEquals(v2.timesPointwise(v1))
})
})
@@ -319,15 +306,11 @@ describe('AffineTransform', () => {
test('transformPosition', () => {
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
return AffineTransform.withTranslation(v1)
.transformPosition(v2)
.approxEquals(v2.plus(v1))
return AffineTransform.withTranslation(v1).transformPosition(v2).approxEquals(v2.plus(v1))
})
jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => {
return AffineTransform.withScale(v1)
.transformPosition(v2)
.approxEquals(v2.timesPointwise(v1))
return AffineTransform.withScale(v1).transformPosition(v2).approxEquals(v2.timesPointwise(v1))
})
})
@@ -359,10 +342,7 @@ describe('AffineTransform', () => {
test('times', () => {
jsc.assertForall(invertibleTransform, invertibleTransform, (t1, t2) => {
return t1
.times(t2)
.times(t2.inverted()!)
.approxEquals(t1)
return t1.times(t2).times(t2.inverted()!).approxEquals(t1)
})
})
})
+74
View File
@@ -0,0 +1,74 @@
/**
* As of Preact 10.x, they no longer have an officially supported preact-redux library.
* It's possible to use react-redux with some hacks, but these hacks cause npm run pack
* to error out because of (intentinoally) unmet peer dependencies.
*
* I could stack more hacks to fix this problem, but I'd rather just drop the dependency
* and remove the need to do any dependency hacking by writing the very small part of
* react-redux that I actually need myself.
*/
import {h} from 'preact'
import * as redux from 'redux'
import {createContext, ComponentChildren} from 'preact'
import {Dispatch, Action} from './typed-redux'
import {useState, useContext, useCallback, useLayoutEffect} from 'preact/hooks'
const PreactRedux = createContext<redux.Store<any> | null>(null)
interface ProviderProps {
store: redux.Store<any>
children?: ComponentChildren
}
export function Provider(props: ProviderProps) {
return <PreactRedux.Provider value={props.store} children={props.children} />
}
function useStore<T>(): redux.Store<T> {
const store = useContext(PreactRedux)
if (store == null) {
throw new Error('Called useStore when no store exists in context')
}
return store
}
export function useDispatch(): Dispatch {
const store = useStore()
return store.dispatch
}
export function useActionCreator<T, U>(
creator_: (payload: T) => Action<U>,
cacheArgs: any[],
): (t: T) => void {
const dispatch = useDispatch()
/* eslint-disable react-hooks/exhaustive-deps */
const creator = useCallback(creator_, cacheArgs)
return useCallback((t: T) => dispatch(creator(t)), [dispatch, creator])
}
export function useSelector<T, U>(selector_: (t: T) => U, cacheArgs: any[]): U {
const store = useStore<T>()
/* eslint-disable react-hooks/exhaustive-deps */
const selector = useCallback(selector_, cacheArgs)
const getValueFromStore = useCallback(() => selector(store.getState()), [store, selector])
const [value, setValue] = useState(getValueFromStore)
useLayoutEffect(() => {
// We need to setValue here because it's possible something has changed the
// value in the store between the useSelector call and layout. In most cases
// this should no-op.
setValue(getValueFromStore())
return store.subscribe(() => {
setValue(getValueFromStore())
})
}, [store, getValueFromStore])
return value
}
+49 -46
View File
@@ -26,19 +26,22 @@ function toStackList(profile: Profile, grouped: boolean): string[] {
const curStack: (number | string)[] = []
let lastValue = 0
function openFrame(node: CallTreeNode, value: number) {
function maybeEmit(value: number) {
if (lastValue != value) {
stackList.push(curStack.map(k => `${k}`).join(';'))
stackList.push(
curStack.map(k => `${k}`).join(';') + ` ${profile.formatValue(value - lastValue)}`,
)
lastValue = value
}
curStack.push(node.frame.key)
}
function openFrame(node: CallTreeNode, value: number) {
maybeEmit(value)
curStack.push(node.frame.name)
}
function closeFrame(node: CallTreeNode, value: number) {
if (lastValue != value) {
stackList.push(curStack.map(k => `${k}`).join(';'))
lastValue = value
}
maybeEmit(value)
curStack.pop()
}
@@ -82,49 +85,49 @@ function verifyProfile(profile: Profile) {
expect(toStackList(profile, false)).toEqual([
// prettier-ignore
'a',
'a;b',
'a;b;d',
'a;b;c',
'',
'a',
'a;b',
'a;b;b',
'a;b;e',
'a',
'a 1',
'a;b 2',
'a;b;d 1',
'a;b;c 1',
' 1',
'a 1',
'a;b 1',
'a;b;b 1',
'a;b;e 1',
'a 1',
])
expect(toStackList(profile, true)).toEqual([
// prettier-ignore
'a;b;e',
'a;b;b',
'a;b;c',
'a;b;d',
'a;b',
'a',
'a;b;d 1',
'a;b;c 1',
'a;b;b 1',
'a;b;e 1',
'a;b 3',
'a 3',
])
const flattened = profile.getProfileWithRecursionFlattened()
expect(toStackList(flattened, false)).toEqual([
// prettier-ignore
'a',
'a;b',
'a;b;d',
'a;b;c',
'',
'a',
'a;b',
'a;b;e',
'a',
'a 1',
'a;b 2',
'a;b;d 1',
'a;b;c 1',
' 1',
'a 1',
'a;b 2',
'a;b;e 1',
'a 1',
])
expect(toStackList(flattened, true)).toEqual([
// prettier-ignore
'a;b;e',
'a;b;c',
'a;b;d',
'a;b',
'a',
'a;b;d 1',
'a;b;c 1',
'a;b;e 1',
'a;b 4',
'a 3',
])
}
@@ -259,9 +262,9 @@ test('getInvertedProfileForCallersOf', () => {
expect(toStackList(inverted, false)).toEqual([
// prettier-ignore
'b',
'b;a',
'b;d',
'b 1',
'b;a 3',
'b;d 1',
])
})
@@ -287,10 +290,10 @@ test('getProfileForCalleesOf', () => {
expect(toStackList(inverted, false)).toEqual([
// prettier-ignore
'b',
'b;c',
'b;d',
'b',
'b 2',
'b;c 1',
'b;d 1',
'b 1',
])
})
@@ -313,8 +316,8 @@ test('getProfileWithRecursionFlattened', () => {
expect(toStackList(inverted, false)).toEqual([
// prettier-ignore
'a',
'a;b',
'a 1',
'a;b 3',
])
const framesInProfile = new Set<string | number>()
+14 -6
View File
@@ -181,9 +181,9 @@ export class Profile {
let childTime = 0
const children = [...node.children]
children.sort((a, b) => (a.getTotalWeight() > b.getTotalWeight() ? -1 : 1))
children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
children.forEach(function(child) {
children.forEach(function (child) {
visit(child, start + childTime)
childTime += child.getTotalWeight()
})
@@ -507,7 +507,10 @@ export class StackListProfileBuilder extends Profile {
this.setValueFormatter(new RawValueFormatter())
}
}
this.totalWeight = Math.max(this.totalWeight, this.weights.reduce((a, b) => a + b, 0))
this.totalWeight = Math.max(
this.totalWeight,
this.weights.reduce((a, b) => a + b, 0),
)
return this
}
}
@@ -557,9 +560,7 @@ export class CallTreeProfileBuilder extends Profile {
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.lastValue
}, this sample was ${value}`,
`Samples must be provided in increasing order of cumulative value. Last sample was ${this.lastValue}, this sample was ${value}`,
)
}
}
@@ -602,6 +603,13 @@ export class CallTreeProfileBuilder extends Profile {
throw new Error(`Trying to leave a ${frame.key} before any have been entered`)
}
leavingStackTop.freeze()
if (leavingStackTop.frame.key !== frame.key) {
throw new Error(
`Tried to leave frame "${frame.name}" while frame "${leavingStackTop.frame.name}" was at the top at ${value}`,
)
}
const delta = value - this.lastValue
if (delta > 0) {
this.samples.push(leavingStackTop)
+1 -1
View File
@@ -53,7 +53,7 @@ export class StatsPanel {
this.msPanel.update(time - this.beginTime, 200)
if (time >= this.prevTime + 1000) {
this.fpsPanel.update(this.frames * 1000 / (time - this.prevTime), 100)
this.fpsPanel.update((this.frames * 1000) / (time - this.prevTime), 100)
this.prevTime = time
this.frames = 0
}
+11
View File
@@ -97,3 +97,14 @@ export async function checkProfileSnapshot(filepath: string) {
const reexported = exportProfileGroup(reimportedGroup)
expect(exported).toEqual(reexported)
}
export async function expectImportFailure(filepath: string) {
const buffer = fs.readFileSync(filepath)
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
try {
await importProfilesFromArrayBuffer(path.basename(filepath), arrayBuffer)
fail('Expected import to fail but it succeeded')
} catch (error) {
expect(error.message).toMatchSnapshot()
}
}
+1 -27
View File
@@ -1,6 +1,5 @@
import {connect} from 'preact-redux'
import * as redux from 'redux'
import {ComponentConstructor, Component} from 'preact'
import {Component} from 'preact'
export interface Action<TPayload> extends redux.Action<string> {
payload: TPayload
@@ -53,31 +52,6 @@ export function setter<T>(
export type Dispatch = redux.Dispatch<Action<any>>
// We make this into a single function invocation instead of the connect(map, map)(Component)
// syntax to make better use of type inference.
//
// NOTE: To avoid this returning objects which do not compare shallow equal, it's the
// responsibility of the caller to ensure that the props returned by map compare shallow
// equal. This most importantly mean memoizing functions which wrap dispatch to avoid
// all callback props from being regenerated on every call.
export function createContainer<OwnProps, State, ComponentProps, ComponentType>(
component: {
new (props: ComponentProps): ComponentType
},
map: (state: State, dispatch: Dispatch, ownProps: OwnProps) => ComponentProps,
): ComponentConstructor<OwnProps, {}> {
const mapStateToProps = (state: State) => state
const mapDispatchToProps = (dispatch: Dispatch) => ({dispatch})
const mergeProps = (
stateProps: State,
dispatchProps: {dispatch: Dispatch},
ownProps: OwnProps,
) => {
return map(stateProps, dispatchProps.dispatch, ownProps)
}
return connect(mapStateToProps, mapDispatchToProps, mergeProps)(component)
}
export type VoidState = {
__dummyField: void
}
+15 -13
View File
@@ -4,7 +4,9 @@ export function lastOf<T>(ts: T[]): T | null {
export function sortBy<T>(ts: T[], key: (t: T) => number | string): void {
function comparator(a: T, b: T) {
return key(a) < key(b) ? -1 : 1
const keyA = key(a)
const keyB = key(b)
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0
}
ts.sort(comparator)
}
@@ -178,15 +180,17 @@ export function lazyStatic<T>(cb: () => T): () => T {
}
}
const base64lookupTable = lazyStatic((): Map<string, number> => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const ret = new Map<string, number>()
for (let i = 0; i < alphabet.length; i++) {
ret.set(alphabet.charAt(i), i)
}
ret.set('=', -1)
return ret
})
const base64lookupTable = lazyStatic(
(): Map<string, number> => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const ret = new Map<string, number>()
for (let i = 0; i < alphabet.length; i++) {
ret.set(alphabet.charAt(i), i)
}
ret.set('=', -1)
return ret
},
)
// NOTE: There are probably simpler solutions to this problem, but I have this written already, so
// until we run into problems with this, let's just use this.
@@ -210,9 +214,7 @@ export function decodeBase64(encoded: string): Uint8Array {
if (encoded.length % 4 !== 0) {
throw new Error(
`Invalid length for base64 encoded string. Expected length % 4 = 0, got length = ${
encoded.length
}`,
`Invalid length for base64 encoded string. Expected length % 4 = 0, got length = ${encoded.length}`,
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import {h, render} from 'preact'
import {createApplicationStore} from './store'
import {Provider} from 'preact-redux'
import {createAppStore} from './store'
import {ApplicationContainer} from './views/application-container'
import {Provider} from './lib/preact-redux'
console.log(`speedscope v${require('../package.json').version}`)
@@ -15,7 +15,7 @@ if (module.hot) {
}
const lastStore: any = (window as any)['store']
const store = createApplicationStore(lastStore ? lastStore.getState() : {})
const store = lastStore ? createAppStore(lastStore.getState()) : createAppStore()
;(window as any)['store'] = store
render(
+3 -20
View File
@@ -8,40 +8,23 @@ import {HashParams} from '../lib/hash-params'
import {actionCreatorWithIndex} from './profiles-state'
export namespace actions {
// Set the top-level profile group from which other data will be derived
export const setProfileGroup = actionCreator<ProfileGroup>('setProfileGroup')
// Set the index into the profile group to view
export const setProfileIndexToView = actionCreator<number>('setProfileIndexToView')
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 setSearchQuery = actionCreator<string>('setSearchQuery')
export const setSearchIsActive = actionCreator<boolean>('setSearchIsActive')
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 = actionCreatorWithIndex<Frame | null>(
'sandwichView.setSelectedFarmr',
'sandwichView.setSelectedFrame',
)
}
+18 -19
View File
@@ -49,23 +49,22 @@ export const getProfileToView = memoizeByShallowEquality(
return flattenRecursion ? profile.getProfileWithRecursionFlattened() : profile
},
)
export const getFrameToColorBucket = memoizeByReference((profile: Profile): Map<
string | number,
number
> => {
const frames: Frame[] = []
profile.forEachFrame(f => frames.push(f))
function key(f: Frame) {
return (f.file || '') + f.name
}
function compare(a: Frame, b: Frame) {
return key(a) > key(b) ? 1 : -1
}
frames.sort(compare)
const frameToColorBucket = new Map<string | number, number>()
for (let i = 0; i < frames.length; i++) {
frameToColorBucket.set(frames[i].key, Math.floor(255 * i / frames.length))
}
export const getFrameToColorBucket = memoizeByReference(
(profile: Profile): Map<string | number, number> => {
const frames: Frame[] = []
profile.forEachFrame(f => frames.push(f))
function key(f: Frame) {
return (f.file || '') + f.name
}
function compare(a: Frame, b: Frame) {
return key(a) > key(b) ? 1 : -1
}
frames.sort(compare)
const frameToColorBucket = new Map<string | number, number>()
for (let i = 0; i < frames.length; i++) {
frameToColorBucket.set(frames[i].key, Math.floor((255 * i) / frames.length))
}
return frameToColorBucket
})
return frameToColorBucket
},
)
+36 -6
View File
@@ -10,6 +10,7 @@ import {setter, Reducer} from '../lib/typed-redux'
import {HashParams, getHashParams} from '../lib/hash-params'
import {ProfileGroupState, profileGroup} from './profiles-state'
import {SortMethod, SortField, SortDirection} from '../views/profile-table-view'
import {useSelector} from '../lib/preact-redux'
export const enum ViewMode {
CHRONO_FLAME_CHART,
@@ -18,20 +19,43 @@ export const enum ViewMode {
}
export interface ApplicationState {
// The top-level profile group from which most other data will be derived
profileGroup: ProfileGroupState
// Parameters defined by the URL encoded k=v pairs after the # in the URL
hashParams: HashParams
glCanvas: HTMLCanvasElement | null
// Which top-level view should be displayed
viewMode: ViewMode
// True if recursion should be flattened when viewing flamegraphs
flattenRecursion: boolean
viewMode: ViewMode
// The query used in top-level views
//
// An empty string indicates that the search is open by no filter is applied.
// searchIsActive is stored separately, because we may choose to persist the
// query even when the search input is closed.
searchQuery: string
searchIsActive: boolean
// True when a file drag is currently active. Used to indicate that the
// application is a valid drop target.
dragActive: boolean
// True when the application is currently in a loading state. Used to
// display a loading progress bar.
loading: boolean
// True when the application is an error state, e.g. because the profile
// imported was invalid.
error: boolean
// The table sorting method using for the sandwich view, specifying the column
// to sort by, and the direction to sort that clumn.
tableSortMethod: SortMethod
profileGroup: ProfileGroupState
}
const protocol = window.location.protocol
@@ -41,9 +65,7 @@ const protocol = window.location.protocol
// 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> {
export function createAppStore(initialState?: ApplicationState): redux.Store<ApplicationState> {
const hashParams = getHashParams()
const loading = canUseXHR && hashParams.profileURL != null
@@ -57,6 +79,9 @@ export function createApplicationStore(
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
searchQuery: setter<string>(actions.setSearchQuery, ''),
searchIsActive: setter<boolean>(actions.setSearchIsActive, false),
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
dragActive: setter<boolean>(actions.setDragActive, false),
@@ -71,3 +96,8 @@ export function createApplicationStore(
return redux.createStore(reducer, initialState)
}
export function useAppSelector<T>(selector: (t: ApplicationState) => T, cacheArgs: any[]): T {
/* eslint-disable react-hooks/exhaustive-deps */
return useSelector(selector, cacheArgs)
}
+3
View File
@@ -12,7 +12,10 @@ import {objectsHaveShallowEquality} from '../lib/utils'
export type ProfileGroupState = {
name: string
// The index within the list of profiles currently being viewed
indexToView: number
profiles: ProfileState[]
} | null
+2 -2
View File
@@ -1,11 +1,11 @@
import * as fs from 'fs'
import {Store, AnyAction} from 'redux'
import {ApplicationState, createApplicationStore} from '.'
import {ApplicationState, createAppStore} from '.'
import {importSpeedscopeProfiles} from '../lib/file-format'
export function storeTest(name: string, cb: (store: Store<ApplicationState, AnyAction>) => void) {
const store = createApplicationStore({})
const store = createAppStore()
test(name, () => {
cb(store)
})
-12
View File
@@ -1,12 +0,0 @@
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
}
}
+50 -54
View File
@@ -1,63 +1,59 @@
import {createContainer, Dispatch, bindActionCreator, ActionCreator} from '../lib/typed-redux'
import {h} from 'preact'
import {Application, ActiveProfileState} from './application'
import {ApplicationState} from '../store'
import {getProfileToView, getCanvasContext} from '../store/getters'
import {actions} from '../store/actions'
import {Graphics} from '../gl/graphics'
import {useActionCreator} from '../lib/preact-redux'
import {memo} from 'preact/compat'
import {useAppSelector} from '../store'
export const ApplicationContainer = createContainer(
Application,
(state: ApplicationState, dispatch: Dispatch) => {
const {flattenRecursion, profileGroup} = state
const {
setLoading,
setError,
setProfileGroup,
setDragActive,
setViewMode,
setGLCanvas,
setFlattenRecursion,
setProfileIndexToView,
} = actions
let activeProfileState: ActiveProfileState | null = null
if (profileGroup) {
if (profileGroup.profiles.length > profileGroup.indexToView) {
const index = profileGroup.indexToView
const profileState = profileGroup.profiles[index]
activeProfileState = {
...profileGroup.profiles[profileGroup.indexToView],
profile: getProfileToView({profile: profileState.profile, flattenRecursion}),
index: profileGroup.indexToView,
}
}
}
export const ApplicationContainer = memo(() => {
const appState = useAppSelector(state => state, [])
const canvasContext = useAppSelector(
state => (state.glCanvas ? getCanvasContext(state.glCanvas) : null),
[],
)
function wrapActionCreator<T>(actionCreator: ActionCreator<T>): (t: T) => void {
return bindActionCreator(dispatch, actionCreator)
}
// TODO(jlfwong): Cache this and resizeCanvas below to prevent re-renders
// due to changing props.
const setters = {
setGLCanvas: wrapActionCreator(actions.setGLCanvas),
setLoading: wrapActionCreator(actions.setLoading),
setError: wrapActionCreator(actions.setError),
setProfileGroup: wrapActionCreator(actions.setProfileGroup),
setDragActive: wrapActionCreator(actions.setDragActive),
setViewMode: wrapActionCreator(actions.setViewMode),
setFlattenRecursion: wrapActionCreator(actions.setFlattenRecursion),
setProfileIndexToView: wrapActionCreator(actions.setProfileIndexToView),
}
const activeProfileState: ActiveProfileState | null = useAppSelector(state => {
const {profileGroup} = state
if (!profileGroup) return null
if (profileGroup.indexToView >= profileGroup.profiles.length) return null
const index = profileGroup.indexToView
const profileState = profileGroup.profiles[index]
return {
activeProfileState,
dispatch,
canvasContext: state.glCanvas ? getCanvasContext(state.glCanvas) : null,
resizeCanvas: (
widthInPixels: number,
heightInPixels: number,
widthInAppUnits: number,
heightInAppUnits: number,
) => {
if (state.glCanvas) {
const gl = getCanvasContext(state.glCanvas).gl
gl.resize(widthInPixels, heightInPixels, widthInAppUnits, heightInAppUnits)
gl.clear(new Graphics.Color(1, 1, 1, 1))
}
},
...setters,
...state,
...profileGroup.profiles[profileGroup.indexToView],
profile: getProfileToView({
profile: profileState.profile,
flattenRecursion: state.flattenRecursion,
}),
index: profileGroup.indexToView,
}
},
)
}, [])
return (
<Application
activeProfileState={activeProfileState}
canvasContext={canvasContext}
setGLCanvas={useActionCreator(setGLCanvas, [])}
setLoading={useActionCreator(setLoading, [])}
setError={useActionCreator(setError, [])}
setProfileGroup={useActionCreator(setProfileGroup, [])}
setDragActive={useActionCreator(setDragActive, [])}
setViewMode={useActionCreator(setViewMode, [])}
setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
{...appState}
/>
)
})
+8 -234
View File
@@ -1,9 +1,9 @@
import {h, Component} from 'preact'
import {h} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {FileSystemDirectoryEntry} from '../import/file-system-entry'
import {Profile, ProfileGroup} from '../lib/profile'
import {FontFamily, FontSize, Colors, Sizes, Duration} from './style'
import {FontFamily, FontSize, Colors, Duration} from './style'
import {importEmscriptenSymbolMap} from '../lib/emscripten'
import {SandwichViewContainer} from './sandwich-view'
import {saveToFile} from '../lib/file-format'
@@ -14,6 +14,7 @@ import {SandwichViewState} from '../store/sandwich-view-state'
import {FlamechartViewState} from '../store/flamechart-view-state'
import {CanvasContext} from '../gl/canvas-context'
import {Graphics} from '../gl/graphics'
import {Toolbar} from './toolbar'
const importModule = import('../import')
// Force eager loading of the module
@@ -50,155 +51,14 @@ async function importFromFileSystemDirectoryEntry(entry: FileSystemDirectoryEntr
declare function require(x: string): any
const exampleProfileURL = require('../../sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
interface ToolbarProps extends ApplicationProps {
browseForFile(): void
saveFile(): void
}
export class Toolbar extends StatelessComponent<ToolbarProps> {
setTimeOrder = () => {
this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART)
}
setLeftHeavyOrder = () => {
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
}
setSandwichView = () => {
this.props.setViewMode(ViewMode.SANDWICH_VIEW)
}
renderLeftContent() {
if (!this.props.activeProfileState) return null
return (
<div className={css(style.toolbarLeft)}>
<div
className={css(
style.toolbarTab,
this.props.viewMode === ViewMode.CHRONO_FLAME_CHART && style.toolbarTabActive,
)}
onClick={this.setTimeOrder}
>
<span className={css(style.emoji)}>🕰</span>Time Order
</div>
<div
className={css(
style.toolbarTab,
this.props.viewMode === ViewMode.LEFT_HEAVY_FLAME_GRAPH && style.toolbarTabActive,
)}
onClick={this.setLeftHeavyOrder}
>
<span className={css(style.emoji)}></span>Left Heavy
</div>
<div
className={css(
style.toolbarTab,
this.props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
)}
onClick={this.setSandwichView}
>
<span className={css(style.emoji)}>🥪</span>Sandwich
</div>
</div>
)
}
renderCenterContent() {
const {activeProfileState, profileGroup} = this.props
if (activeProfileState && profileGroup) {
const {index} = activeProfileState
if (profileGroup.profiles.length === 1) {
return activeProfileState.profile.getName()
} else {
function makeNavButton(content: string, disabled: boolean, onClick: () => void) {
return (
<button
disabled={disabled}
onClick={onClick}
className={css(
style.emoji,
style.toolbarProfileNavButton,
disabled && style.toolbarProfileNavButtonDisabled,
)}
>
{content}
</button>
)
}
const prevButton = makeNavButton('⬅️', index === 0, () =>
this.props.setProfileIndexToView(index - 1),
)
const nextButton = makeNavButton('➡️', index >= profileGroup.profiles.length - 1, () =>
this.props.setProfileIndexToView(index + 1),
)
return (
<div className={css(style.toolbarCenter)}>
{prevButton}
{activeProfileState.profile.getName()}{' '}
<span className={css(style.toolbarProfileIndex)}>
({activeProfileState.index + 1}/{profileGroup.profiles.length})
</span>
{nextButton}
</div>
)
}
}
return '🔬speedscope'
}
renderRightContent() {
const importFile = (
<div className={css(style.toolbarTab)} onClick={this.props.browseForFile}>
<span className={css(style.emoji)}></span>Import
</div>
)
const help = (
<div className={css(style.toolbarTab)}>
<a
href="https://github.com/jlfwong/speedscope#usage"
className={css(style.noLinkStyle)}
target="_blank"
>
<span className={css(style.emoji)}></span>Help
</a>
</div>
)
return (
<div className={css(style.toolbarRight)}>
{this.props.activeProfileState && (
<div className={css(style.toolbarTab)} onClick={this.props.saveFile}>
<span className={css(style.emoji)}></span>Export
</div>
)}
{importFile}
{help}
</div>
)
}
render() {
return (
<div className={css(style.toolbar)}>
{this.renderLeftContent()}
{this.renderCenterContent()}
{this.renderRightContent()}
</div>
)
}
}
interface GLCanvasProps {
canvasContext: CanvasContext | null
setGLCanvas: (canvas: HTMLCanvasElement | null) => void
}
export class GLCanvas extends Component<GLCanvasProps, void> {
export class GLCanvas extends StatelessComponent<GLCanvasProps> {
private canvas: HTMLCanvasElement | null = null
private ref = (canvas?: Element) => {
private ref = (canvas: Element | null) => {
if (canvas instanceof HTMLCanvasElement) {
this.canvas = canvas
} else {
@@ -209,7 +69,7 @@ export class GLCanvas extends Component<GLCanvasProps, void> {
}
private container: HTMLElement | null = null
private containerRef = (container?: Element) => {
private containerRef = (container: Element | null) => {
if (container instanceof HTMLElement) {
this.container = container
} else {
@@ -323,8 +183,8 @@ export class Application extends StatelessComponent<ApplicationProps> {
if (this.props.hashParams.title) {
profileGroup = {
name: this.props.hashParams.title,
...profileGroup,
name: this.props.hashParams.title,
}
}
document.title = `${profileGroup.name} - speedscope`
@@ -678,7 +538,7 @@ export class Application extends StatelessComponent<ApplicationProps> {
<Toolbar
saveFile={this.saveFile}
browseForFile={this.browseForFile}
{...this.props as ApplicationProps}
{...(this.props as ApplicationProps)}
/>
<div className={css(style.contentContainer)}>{this.renderContent()}</div>
{this.props.dragActive && <div className={css(style.dragTarget)} />}
@@ -790,90 +650,4 @@ const style = StyleSheet.create({
cursor: 'pointer',
textDecoration: 'none',
},
toolbar: {
height: Sizes.TOOLBAR_HEIGHT,
flexShrink: 0,
background: Colors.BLACK,
color: Colors.WHITE,
textAlign: 'center',
fontFamily: FontFamily.MONOSPACE,
fontSize: FontSize.TITLE,
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
userSelect: 'none',
},
toolbarLeft: {
position: 'absolute',
height: Sizes.TOOLBAR_HEIGHT,
overflow: 'hidden',
top: 0,
left: 0,
marginRight: 2,
textAlign: 'left',
},
toolbarCenter: {
paddingTop: 1,
height: Sizes.TOOLBAR_HEIGHT,
},
toolbarRight: {
height: Sizes.TOOLBAR_HEIGHT,
overflow: 'hidden',
position: 'absolute',
top: 0,
right: 0,
marginRight: 2,
textAlign: 'right',
},
toolbarProfileIndex: {
color: Colors.LIGHT_GRAY,
},
toolbarProfileNavButton: {
opacity: 0.8,
fontSize: FontSize.TITLE,
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
':hover': {
opacity: 1.0,
},
background: 'none',
border: 'none',
padding: 0,
marginLeft: '0.3em',
marginRight: '0.3em',
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
},
toolbarProfileNavButtonDisabled: {
opacity: 0.5,
':hover': {
opacity: 0.5,
},
},
toolbarTab: {
background: Colors.DARK_GRAY,
marginTop: Sizes.SEPARATOR_HEIGHT,
height: Sizes.TOOLBAR_TAB_HEIGHT,
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
paddingLeft: 2,
paddingRight: 8,
display: 'inline-block',
marginLeft: 2,
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
':hover': {
background: Colors.GRAY,
},
},
toolbarTabActive: {
background: Colors.BRIGHT_BLUE,
':hover': {
background: Colors.BRIGHT_BLUE,
},
},
noLinkStyle: {
textDecoration: 'none',
color: 'inherit',
},
emoji: {
display: 'inline-block',
verticalAlign: 'middle',
paddingTop: '0px',
marginRight: '0.3em',
},
})
-84
View File
@@ -1,84 +0,0 @@
import {memoizeByShallowEquality} from '../lib/utils'
import {Profile, Frame} from '../lib/profile'
import {Flamechart} from '../lib/flamechart'
import {
createMemoizedFlamechartRenderer,
FlamechartViewContainerProps,
createFlamechartSetters,
} from './flamechart-view-container'
import {createContainer, Dispatch} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getFrameToColorBucket,
} 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, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
const {activeProfileState} = ownProps
const {index, profile, sandwichViewState} = activeProfileState
const {flattenRecursion, glCanvas} = state
if (!profile) throw new Error('profile missing')
if (!glCanvas) throw new Error('glCanvas missing')
const {callerCallee} = sandwichViewState
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
const frameToColorBucket = getFrameToColorBucket(profile)
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 {
renderInverted: false,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...createFlamechartSetters(dispatch, FlamechartID.SANDWICH_CALLEES, index),
// This overrides the setSelectedNode specified in createFlamechartSettesr
setSelectedNode: () => {},
...callerCallee.calleeFlamegraph,
}
},
)
+86
View File
@@ -0,0 +1,86 @@
import {memoizeByShallowEquality, noop} from '../lib/utils'
import {Profile, Frame} from '../lib/profile'
import {Flamechart} from '../lib/flamechart'
import {
createMemoizedFlamechartRenderer,
FlamechartViewContainerProps,
useFlamechartSetters,
} from './flamechart-view-container'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getFrameToColorBucket,
} from '../store/getters'
import {FlamechartID} from '../store/flamechart-view-state'
import {FlamechartWrapper} from './flamechart-wrapper'
import {useAppSelector} from '../store'
import {h} from 'preact'
import {memo} from 'preact/compat'
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 = memo((ownProps: FlamechartViewContainerProps) => {
const {activeProfileState} = ownProps
const {index, profile, sandwichViewState} = activeProfileState
const flattenRecursion = useAppSelector(state => state.flattenRecursion, [])
const glCanvas = useAppSelector(state => state.glCanvas, [])
if (!profile) throw new Error('profile missing')
if (!glCanvas) throw new Error('glCanvas missing')
const {callerCallee} = sandwichViewState
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
const frameToColorBucket = getFrameToColorBucket(profile)
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 (
<FlamechartWrapper
renderInverted={false}
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)}
// This overrides the setSelectedNode specified in useFlamechartSettesr
setSelectedNode={noop}
{...callerCallee.calleeFlamegraph}
/>
)
})
+3 -5
View File
@@ -1,4 +1,4 @@
import {h, Component} from 'preact'
import {h} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {Colors, FontSize} from './style'
@@ -6,10 +6,8 @@ interface ColorChitProps {
color: string
}
export class ColorChit extends Component<ColorChitProps, {}> {
render() {
return <span className={css(style.stackChit)} style={{backgroundColor: this.props.color}} />
}
export function ColorChit(props: ColorChitProps) {
return <span className={css(style.stackChit)} style={{backgroundColor: props.color}} />
}
const style = StyleSheet.create({
+4 -4
View File
@@ -1,5 +1,5 @@
import {StyleDeclarationValue, css} from 'aphrodite'
import {h, Component} from 'preact'
import {h, Component, JSX} from 'preact'
import {style} from './flamechart-style'
import {formatPercent} from '../lib/utils'
import {Frame, CallTreeNode} from '../lib/profile'
@@ -19,8 +19,8 @@ class StatisticsTable extends Component<StatisticsTableProps, {}> {
render() {
const total = this.props.formatter(this.props.selectedTotal)
const self = this.props.formatter(this.props.selectedSelf)
const totalPerc = 100.0 * this.props.selectedTotal / this.props.grandTotal
const selfPerc = 100.0 * this.props.selectedSelf / this.props.grandTotal
const totalPerc = (100.0 * this.props.selectedTotal) / this.props.grandTotal
const selfPerc = (100.0 * this.props.selectedSelf) / this.props.grandTotal
return (
<div className={css(style.statsTable)}>
@@ -62,7 +62,7 @@ class StackTraceView extends Component<StackTraceViewProps, {}> {
row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
if (rows.length) {
row.push(<span className={css(style.stackFileLine)}>> </span>)
row.push(<span className={css(style.stackFileLine)}>&gt; </span>)
}
row.push(frame.name)
+3 -5
View File
@@ -26,7 +26,7 @@ enum DraggingMode {
export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps, {}> {
container: Element | null = null
containerRef = (element?: Element) => {
containerRef = (element: Element | null) => {
this.container = element || null
}
@@ -120,9 +120,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
FontFamily.MONOSPACE
}`
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
ctx.textBaseline = 'top'
const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
@@ -396,7 +394,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
this.updateCursor(configSpaceMouse)
}
private overlayCanvasRef = (element?: Element) => {
private overlayCanvasRef = (element: Element | null) => {
if (element) {
this.overlayCanvas = element as HTMLCanvasElement
this.overlayCtx = this.overlayCanvas.getContext('2d')
+3 -5
View File
@@ -52,7 +52,7 @@ export interface FlamechartPanZoomViewProps {
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
private container: Element | null = null
private containerRef = (element?: Element) => {
private containerRef = (element: Element | null) => {
this.container = element || null
}
@@ -65,7 +65,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
this.props.setConfigSpaceViewportRect(r)
}
private overlayCanvasRef = (element?: Element) => {
private overlayCanvasRef = (element: Element | null) => {
if (element) {
this.overlayCanvas = element as HTMLCanvasElement
this.overlayCtx = this.overlayCanvas.getContext('2d')
@@ -181,9 +181,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
)
}
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
FontFamily.MONOSPACE
}`
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = Colors.DARK_GRAY
+79 -91
View File
@@ -1,11 +1,11 @@
import {h} from 'preact'
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, ActionCreator} from '../lib/typed-redux'
import {useActionCreator} from '../lib/preact-redux'
import {Frame, Profile, CallTreeNode} from '../lib/profile'
import {memoizeByShallowEquality} from '../lib/utils'
import {ApplicationState} from '../store'
import {FlamechartView} from './flamechart-view'
import {
getRowAtlas,
@@ -17,6 +17,7 @@ import {
import {ActiveProfileState} from './application'
import {Vec2, Rect} from '../lib/math'
import {actions} from '../store/actions'
import {memo} from 'preact/compat'
interface FlamechartSetters {
setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
@@ -25,46 +26,35 @@ interface FlamechartSetters {
setSelectedNode: (node: CallTreeNode | null) => void
}
interface WithFlamechartContext<T> {
profileIndex: number
args: {
id: FlamechartID
} & T
}
export function createFlamechartSetters(
dispatch: Dispatch,
id: FlamechartID,
profileIndex: number,
): FlamechartSetters {
function wrapActionCreator<T, U>(
actionCreator: ActionCreator<WithFlamechartContext<U>>,
map: (t: T) => U,
): (t: T) => void {
return (t: T) => {
const args = Object.assign({}, map(t), {id})
dispatch(actionCreator({profileIndex, args}))
}
}
const {
setHoveredNode,
setLogicalSpaceViewportSize,
setConfigSpaceViewportRect,
setSelectedNode,
} = actions.flamechart
const {
setHoveredNode,
setLogicalSpaceViewportSize,
setConfigSpaceViewportRect,
setSelectedNode,
} = actions.flamechart
export function useFlamechartSetters(id: FlamechartID, profileIndex: number): FlamechartSetters {
return {
setNodeHover: wrapActionCreator(setHoveredNode, hover => ({hover})),
setLogicalSpaceViewportSize: wrapActionCreator(
setLogicalSpaceViewportSize,
logicalSpaceViewportSize => ({logicalSpaceViewportSize}),
setNodeHover: useActionCreator(
(hover: {node: CallTreeNode; event: MouseEvent} | null) =>
setHoveredNode({profileIndex, args: {id, hover}}),
[profileIndex, id],
),
setConfigSpaceViewportRect: wrapActionCreator(
setConfigSpaceViewportRect,
configSpaceViewportRect => ({configSpaceViewportRect}),
setLogicalSpaceViewportSize: useActionCreator(
(logicalSpaceViewportSize: Vec2) =>
setLogicalSpaceViewportSize({profileIndex, args: {id, logicalSpaceViewportSize}}),
[profileIndex, id],
),
setConfigSpaceViewportRect: useActionCreator(
(configSpaceViewportRect: Rect) =>
setConfigSpaceViewportRect({profileIndex, args: {id, configSpaceViewportRect}}),
[profileIndex, id],
),
setSelectedNode: useActionCreator(
(selectedNode: CallTreeNode | null) =>
setSelectedNode({profileIndex, args: {id, selectedNode}}),
[profileIndex, id],
),
setSelectedNode: wrapActionCreator(setSelectedNode, selectedNode => ({selectedNode})),
}
}
@@ -121,34 +111,33 @@ export interface FlamechartViewContainerProps {
glCanvas: HTMLCanvasElement
}
export const ChronoFlamechartView = createContainer(
FlamechartView,
(state: ApplicationState, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
const {index, profile, chronoViewState} = activeProfileState
export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) => {
const {activeProfileState, glCanvas} = props
const {index, profile, chronoViewState} = activeProfileState
const canvasContext = getCanvasContext(glCanvas)
const frameToColorBucket = getFrameToColorBucket(profile)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const canvasContext = getCanvasContext(glCanvas)
const frameToColorBucket = getFrameToColorBucket(profile)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const flamechart = getChronoViewFlamechart({profile, getColorBucketForFrame})
const flamechartRenderer = getChronoViewFlamechartRenderer({
canvasContext,
flamechart,
})
const flamechart = getChronoViewFlamechart({profile, getColorBucketForFrame})
const flamechartRenderer = getChronoViewFlamechartRenderer({
canvasContext,
flamechart,
})
return {
renderInverted: false,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...createFlamechartSetters(dispatch, FlamechartID.CHRONO, index),
...chronoViewState,
}
},
)
return (
<FlamechartView
renderInverted={false}
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.CHRONO, index)}
{...chronoViewState}
/>
)
})
export const getLeftHeavyFlamechart = memoizeByShallowEquality(
({
@@ -169,35 +158,34 @@ export const getLeftHeavyFlamechart = memoizeByShallowEquality(
const getLeftHeavyFlamechartRenderer = createMemoizedFlamechartRenderer()
export const LeftHeavyFlamechartView = createContainer(
FlamechartView,
(state: ApplicationState, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
const {index, profile, leftHeavyViewState} = activeProfileState
const {index, profile, leftHeavyViewState} = activeProfileState
const canvasContext = getCanvasContext(glCanvas)
const frameToColorBucket = getFrameToColorBucket(profile)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const canvasContext = getCanvasContext(glCanvas)
const frameToColorBucket = getFrameToColorBucket(profile)
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const flamechart = getLeftHeavyFlamechart({
profile,
getColorBucketForFrame,
})
const flamechartRenderer = getLeftHeavyFlamechartRenderer({
canvasContext,
flamechart,
})
const flamechart = getLeftHeavyFlamechart({
profile,
getColorBucketForFrame,
})
const flamechartRenderer = getLeftHeavyFlamechartRenderer({
canvasContext,
flamechart,
})
return {
renderInverted: false,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...createFlamechartSetters(dispatch, FlamechartID.LEFT_HEAVY, index),
...leftHeavyViewState,
}
},
)
return (
<FlamechartView
renderInverted={false}
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
{...leftHeavyViewState}
/>
)
})
+2 -2
View File
@@ -62,7 +62,7 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
const percent = (100 * weight) / totalWeight
const formattedPercent = formatPercent(percent)
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
@@ -86,7 +86,7 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
containerRef = (container: Element | null) => {
this.container = (container as HTMLDivElement) || null
}
+2 -2
View File
@@ -34,7 +34,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
}
private formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
const percent = (100 * weight) / totalWeight
const formattedPercent = formatPercent(percent)
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
@@ -54,7 +54,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
)
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
containerRef = (container: Element | null) => {
this.container = (container as HTMLDivElement) || null
}
private setNodeHover = (
+1 -6
View File
@@ -14,12 +14,7 @@ export class Hovertip extends Component<HovertipProps, {}> {
const width = containerSize.x
const height = containerSize.y
const positionStyle: {
left?: number
right?: number
top?: number
bottom?: number
} = {}
const positionStyle: {[key: string]: number} = {}
const OFFSET_FROM_MOUSE = 7
if (offset.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
@@ -1,96 +0,0 @@
import {memoizeByShallowEquality} from '../lib/utils'
import {Profile, Frame} from '../lib/profile'
import {Flamechart} from '../lib/flamechart'
import {
createMemoizedFlamechartRenderer,
FlamechartViewContainerProps,
createFlamechartSetters,
} from './flamechart-view-container'
import {createContainer, Dispatch} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getProfileWithRecursionFlattened,
getFrameToColorBucket,
} 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, dispatch: Dispatch, ownProps: FlamechartViewContainerProps) => {
const {activeProfileState} = ownProps
let {profile, sandwichViewState, index} = activeProfileState
let {flattenRecursion, glCanvas} = state
if (!profile) throw new Error('profile missing')
if (!glCanvas) throw new Error('glCanvas missing')
const {callerCallee} = sandwichViewState
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
profile = flattenRecursion ? getProfileWithRecursionFlattened(profile) : profile
const frameToColorBucket = getFrameToColorBucket(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 {
renderInverted: true,
flamechart,
flamechartRenderer,
canvasContext,
getCSSColorForFrame,
...createFlamechartSetters(dispatch, FlamechartID.SANDWICH_INVERTED_CALLERS, index),
// This overrides the setSelectedNode specified in createFlamechartSettesr
setSelectedNode: () => {},
...callerCallee.invertedCallerFlamegraph,
}
},
)
@@ -0,0 +1,98 @@
import {memoizeByShallowEquality, noop} from '../lib/utils'
import {Profile, Frame} from '../lib/profile'
import {Flamechart} from '../lib/flamechart'
import {
createMemoizedFlamechartRenderer,
FlamechartViewContainerProps,
useFlamechartSetters,
} from './flamechart-view-container'
import {
getCanvasContext,
createGetColorBucketForFrame,
createGetCSSColorForFrame,
getProfileWithRecursionFlattened,
getFrameToColorBucket,
} from '../store/getters'
import {FlamechartID} from '../store/flamechart-view-state'
import {useAppSelector} from '../store'
import {FlamechartWrapper} from './flamechart-wrapper'
import {h} from 'preact'
import {memo} from 'preact/compat'
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 = memo((ownProps: FlamechartViewContainerProps) => {
const {activeProfileState} = ownProps
let {profile, sandwichViewState, index} = activeProfileState
const flattenRecursion = useAppSelector(state => state.flattenRecursion, [])
const glCanvas = useAppSelector(state => state.glCanvas, [])
if (!profile) throw new Error('profile missing')
if (!glCanvas) throw new Error('glCanvas missing')
const {callerCallee} = sandwichViewState
if (!callerCallee) throw new Error('callerCallee missing')
const {selectedFrame} = callerCallee
profile = flattenRecursion ? getProfileWithRecursionFlattened(profile) : profile
const frameToColorBucket = getFrameToColorBucket(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 (
<FlamechartWrapper
renderInverted={true}
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)}
// This overrides the setSelectedNode specified in useFlamechartSettesr
setSelectedNode={noop}
{...callerCallee.invertedCallerFlamegraph}
/>
)
})
+410
View File
@@ -0,0 +1,410 @@
import {Profile} from '../lib/profile'
import {h, JSX, ComponentChild, Ref} from 'preact'
import {useCallback, useState, useMemo, useEffect, useRef} from 'preact/hooks'
import {StyleSheet, css} from 'aphrodite'
import {Colors, ZIndex, Sizes} from './style'
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
import {sortBy} from '../lib/utils'
interface ProfileSelectRowProps {
setProfileIndexToView: (profileIndex: number) => void
setHoveredProfileIndex: (profileIndex: number) => void
profile: Profile
matchedRanges: [number, number][]
hovered: boolean
selected: boolean
indexInProfileGroup: number
indexInFilteredListView: number
profileCount: number
nodeRef?: Ref<HTMLDivElement>
closeProfileSelect: () => void
}
function highlightRanges(text: string, ranges: [number, number][]): JSX.Element {
const spans: ComponentChild[] = []
let last = 0
for (let range of ranges) {
spans.push(text.slice(last, range[0]))
spans.push(<span className={css(style.highlighted)}>{text.slice(range[0], range[1])}</span>)
last = range[1]
}
spans.push(text.slice(last))
return <span>{spans}</span>
}
export function ProfileSelectRow({
setProfileIndexToView,
setHoveredProfileIndex,
profile,
selected,
hovered,
profileCount,
nodeRef,
closeProfileSelect,
indexInProfileGroup,
matchedRanges,
indexInFilteredListView,
}: ProfileSelectRowProps) {
const onMouseUp = useCallback(() => {
closeProfileSelect()
setProfileIndexToView(indexInProfileGroup)
}, [closeProfileSelect, setProfileIndexToView, indexInProfileGroup])
const onMouseEnter = useCallback(
(ev: Event) => {
setHoveredProfileIndex(indexInProfileGroup)
},
[setHoveredProfileIndex, indexInProfileGroup],
)
const name = profile.getName()
const maxDigits = 1 + Math.floor(Math.log10(profileCount))
const highlighted = useMemo(() => {
const result = highlightRanges(name, matchedRanges)
return result
}, [name, matchedRanges])
// TODO(jlfwong): There's a really gnarly edge-case here where the highlighted
// ranges are part of the text truncated by ellipsis. I'm just going to punt
// on solving for that.
return (
<div
ref={nodeRef}
onMouseUp={onMouseUp}
onMouseEnter={onMouseEnter}
title={name}
className={css(
style.profileRow,
indexInFilteredListView % 2 === 0 && style.profileRowEven,
selected && style.profileRowSelected,
hovered && style.profileRowHovered,
)}
>
<span className={css(style.profileIndex)} style={{width: maxDigits + 'em'}}>
{indexInProfileGroup + 1}:
</span>{' '}
{highlighted}
</div>
)
}
interface ProfileSelectProps {
setProfileIndexToView: (profileIndex: number) => void
indexToView: number
profiles: Profile[]
closeProfileSelect: () => void
visible: boolean
}
function stopPropagation(ev: Event) {
ev.stopPropagation()
}
interface FilteredProfile {
indexInProfileGroup: number
profile: Profile
matchedRanges: [number, number][]
score: number
}
function getSortedFilteredProfiles(profiles: Profile[], filterText: string): FilteredProfile[] {
const filtered: FilteredProfile[] = []
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i]
const match = fuzzyMatchStrings(profile.getName(), filterText)
if (!match) continue
filtered.push({
indexInProfileGroup: i,
profile,
...match,
})
}
sortBy(filtered, p => -p.score)
return filtered
}
export function ProfileSelect({
profiles,
closeProfileSelect,
indexToView,
visible,
setProfileIndexToView,
}: ProfileSelectProps) {
const [filterText, setFilterText] = useState('')
const onFilterTextChange = useCallback(
(ev: Event) => {
const value = (ev.target as HTMLInputElement).value
setFilterText(value)
},
[setFilterText],
)
const focusFilterInput = useCallback(
(node: HTMLInputElement | null) => {
if (node) {
if (visible) {
node.select()
} else {
node.blur()
}
}
},
[visible],
)
const filteredProfiles = useMemo(() => {
return getSortedFilteredProfiles(profiles, filterText)
}, [profiles, filterText])
const [hoveredProfileIndex, setHoveredProfileIndex] = useState<number | null>(0)
const selectedNodeRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (visible) {
// Whenever the profile select becomes visible...
// Clear any hovered element
setHoveredProfileIndex(null)
// And scroll the selected profile into view, if possible
if (selectedNodeRef.current !== null) {
selectedNodeRef.current.scrollIntoView({
behavior: 'auto',
block: 'nearest',
inline: 'nearest',
})
}
}
}, [visible])
// TODO(jlfwong): Hi-jacking the behavior of enter and the arrow keys won't
// work well for some composition methods (e.g. a Chinese character
// composition keyboard input method).
const onFilterKeyUp = useCallback(
(ev: KeyboardEvent) => {
// Prevent the key-press from propagating to other keyboard shortcut
// handlers in other components.
ev.stopPropagation()
let newHoveredIndexInFilteredList: number | null = null
switch (ev.key) {
case 'Enter': {
if (hoveredProfileIndex != null) {
closeProfileSelect()
setProfileIndexToView(hoveredProfileIndex)
}
break
}
case 'Escape': {
closeProfileSelect()
break
}
case 'ArrowDown': {
ev.preventDefault()
newHoveredIndexInFilteredList = 0
if (hoveredProfileIndex != null) {
const indexInFilteredList = filteredProfiles.findIndex(
p => p.indexInProfileGroup === hoveredProfileIndex,
)
if (indexInFilteredList !== -1) {
newHoveredIndexInFilteredList = indexInFilteredList + 1
}
}
break
}
case 'ArrowUp': {
ev.preventDefault()
newHoveredIndexInFilteredList = filteredProfiles.length - 1
if (hoveredProfileIndex != null) {
const indexInFilteredList = filteredProfiles.findIndex(
p => p.indexInProfileGroup === hoveredProfileIndex,
)
if (indexInFilteredList !== -1) {
newHoveredIndexInFilteredList = indexInFilteredList - 1
}
}
break
}
}
if (
newHoveredIndexInFilteredList != null &&
newHoveredIndexInFilteredList >= 0 &&
newHoveredIndexInFilteredList < filteredProfiles.length
) {
const indexInProfileGroup =
filteredProfiles[newHoveredIndexInFilteredList].indexInProfileGroup
setHoveredProfileIndex(indexInProfileGroup)
setPendingForcedScroll(true)
}
},
[closeProfileSelect, setProfileIndexToView, hoveredProfileIndex, filteredProfiles],
)
const [pendingForcedScroll, setPendingForcedScroll] = useState(false)
useEffect(() => {
// Whenever the list of filtered profiles changes, set the first element hovered.
if (filteredProfiles.length > 0) {
setHoveredProfileIndex(filteredProfiles[0].indexInProfileGroup)
setPendingForcedScroll(true)
}
}, [setHoveredProfileIndex, filteredProfiles])
const hoveredNodeRef = useCallback(
(hoveredNode: HTMLDivElement | null) => {
if (pendingForcedScroll && hoveredNode) {
hoveredNode.scrollIntoView({
behavior: 'auto',
block: 'nearest',
inline: 'nearest',
})
setPendingForcedScroll(false)
}
},
[pendingForcedScroll, setPendingForcedScroll],
)
const selectedHoveredRef = useCallback(
(node: HTMLDivElement | null) => {
selectedNodeRef.current = node
hoveredNodeRef(node)
},
[selectedNodeRef, hoveredNodeRef],
)
// We allow ProfileSelect to be aware of its own visibility in order to retain
// its scroll offset state between times when it's hidden & shown, and also to
// scroll the selected node into view once it becomes shown again after the
// selected profile has changed.
return (
<div className={css(style.profileSelectOuter)}>
<div className={css(style.caret)} />
<div className={css(style.profileSelectBox)}>
{/* We stop event propagation for key events on the input to prevent
this from triggering keyboard shortcuts. */}
<div className={css(style.filterInputContainer)}>
<input
type="text"
ref={focusFilterInput}
placeholder={'Filter...'}
value={filterText}
onInput={onFilterTextChange}
onKeyDown={onFilterKeyUp}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
/>
</div>
<div className={css(style.profileSelectScrolling)}>
{filteredProfiles.map(({profile, matchedRanges, indexInProfileGroup}, indexInList) => {
let ref: Ref<HTMLDivElement> | undefined = undefined
const selected = indexInProfileGroup === indexToView
const hovered = indexInProfileGroup === hoveredProfileIndex
if (selected && hovered) {
ref = selectedHoveredRef
} else if (selected) {
ref = selectedNodeRef
} else if (hovered) {
ref = hoveredNodeRef
}
return (
<ProfileSelectRow
setHoveredProfileIndex={setHoveredProfileIndex}
indexInProfileGroup={indexInProfileGroup}
indexInFilteredListView={indexInList}
hovered={indexInProfileGroup == hoveredProfileIndex}
selected={indexInProfileGroup === indexToView}
profile={profile}
profileCount={profiles.length}
nodeRef={ref}
matchedRanges={matchedRanges}
setProfileIndexToView={setProfileIndexToView}
closeProfileSelect={closeProfileSelect}
/>
)
})}
{filteredProfiles.length === 0 ? (
<div className={css(style.profileRow)}>No results match filter "{filterText}"</div>
) : null}
</div>
</div>
</div>
)
}
const paddingHeight = 10
const style = StyleSheet.create({
filterInputContainer: {
display: 'flex',
flexDirection: 'column',
padding: 10,
alignItems: 'stretch',
},
caret: {
width: 0,
height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: '5px solid black',
},
highlighted: {
background: Colors.PALE_DARK_BLUE,
},
padding: {
height: paddingHeight,
background: Colors.BLACK,
},
profileRow: {
height: Sizes.FRAME_HEIGHT - 2,
border: '1px solid transparent',
textAlign: 'left',
paddingLeft: 10,
paddingRight: 10,
background: Colors.BLACK,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
cursor: 'pointer',
},
profileRowHovered: {
border: `1px solid ${Colors.DARK_BLUE}`,
},
profileRowSelected: {
background: Colors.DARK_BLUE,
},
profileRowEven: {
background: Colors.DARK_GRAY,
},
profileSelectScrolling: {
maxHeight: `min(calc(100vh - ${Sizes.TOOLBAR_HEIGHT - 2 * paddingHeight}px), ${
20 * Sizes.FRAME_HEIGHT
}px)`,
overflow: 'auto',
},
profileSelectBox: {
width: '100%',
paddingBottom: 10,
background: Colors.BLACK,
color: Colors.WHITE,
},
profileSelectOuter: {
width: '100%',
maxWidth: 480,
margin: '0 auto',
position: 'relative',
zIndex: ZIndex.PROFILE_SELECT,
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
},
profileIndex: {
textAlign: 'right',
display: 'inline-block',
color: Colors.LIGHT_GRAY,
},
})
+270 -168
View File
@@ -1,15 +1,18 @@
import {h, Component} from 'preact'
import {h, Component, JSX, ComponentChild} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {Profile, Frame} from '../lib/profile'
import {sortBy, formatPercent} from '../lib/utils'
import {FontSize, Colors, Sizes, commonStyle} from './style'
import {ColorChit} from './color-chit'
import {ScrollableListView, ListItem} from './scrollable-list-view'
import {ListItem, ScrollableListView} from './scrollable-list-view'
import {actions} from '../store/actions'
import {Dispatch, createContainer} from '../lib/typed-redux'
import {ApplicationState} from '../store'
import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters'
import {ActiveProfileState} from './application'
import {useActionCreator} from '../lib/preact-redux'
import {useAppSelector} from '../store'
import {memo} from 'preact/compat'
import {useCallback, useMemo} from 'preact/hooks'
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
export enum SortField {
SYMBOL_NAME,
@@ -31,14 +34,12 @@ interface HBarProps {
perc: number
}
class HBarDisplay extends Component<HBarProps, {}> {
render() {
return (
<div className={css(style.hBarDisplay)}>
<div className={css(style.hBarDisplayFilled)} style={{width: `${this.props.perc}%`}} />
</div>
)
}
function HBarDisplay(props: HBarProps) {
return (
<div className={css(style.hBarDisplay)}>
<div className={css(style.hBarDisplayFilled)} style={{width: `${props.perc}%`}} />
</div>
)
}
interface SortIconProps {
@@ -67,156 +68,247 @@ class SortIcon extends Component<SortIconProps, {}> {
}
}
interface ProfileTableRowInfo {
frame: Frame
matchedRanges: [number, number][] | null
}
interface ProfileTableRowViewProps {
info: ProfileTableRowInfo
index: number
profile: Profile
selectedFrame: Frame | null
setSelectedFrame: (f: Frame) => void
getCSSColorForFrame: (frame: Frame) => string
}
function highlightRanges(
text: string,
ranges: [number, number][],
highlightedClassName: string,
): JSX.Element {
const spans: ComponentChild[] = []
let last = 0
for (let range of ranges) {
spans.push(text.slice(last, range[0]))
spans.push(<span className={highlightedClassName}>{text.slice(range[0], range[1])}</span>)
last = range[1]
}
spans.push(text.slice(last))
return <span>{spans}</span>
}
const ProfileTableRowView = ({
info,
profile,
index,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
}: ProfileTableRowViewProps) => {
const {frame, matchedRanges} = info
const totalWeight = frame.getTotalWeight()
const selfWeight = frame.getSelfWeight()
const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight()
const selfPerc = (100.0 * selfWeight) / profile.getTotalNonIdleWeight()
const selected = frame === selectedFrame
// We intentionally use index rather than frame.key here as the tr key
// in order to re-use rows when sorting rather than creating all new elements.
return (
<tr
key={`${index}`}
onClick={setSelectedFrame.bind(null, frame)}
className={css(
style.tableRow,
index % 2 == 0 && style.tableRowEven,
selected && style.tableRowSelected,
)}
>
<td className={css(style.numericCell)}>
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
<HBarDisplay perc={totalPerc} />
</td>
<td className={css(style.numericCell)}>
{profile.formatValue(selfWeight)} ({formatPercent(selfPerc)})
<HBarDisplay perc={selfPerc} />
</td>
<td title={frame.file} className={css(style.textCell)}>
<ColorChit color={getCSSColorForFrame(frame)} />
{matchedRanges
? highlightRanges(
frame.name,
matchedRanges,
css(style.matched, selected && style.matchedSelected),
)
: frame.name}
</td>
</tr>
)
}
interface ProfileTableViewProps {
profile: Profile
profileIndex: number
selectedFrame: Frame | null
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSelectedFrame: (frame: Frame | null) => void
setSortMethod: (sortMethod: SortMethod) => void
searchQuery: string
searchIsActive: boolean
}
export class ProfileTableView extends Component<ProfileTableViewProps, void> {
renderRow(frame: Frame, index: number) {
const {profile, selectedFrame} = this.props
export const ProfileTableView = memo(
({
profile,
sortMethod,
setSortMethod,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
searchQuery,
searchIsActive,
}: ProfileTableViewProps) => {
const onSortClick = useCallback(
(field: SortField, ev: MouseEvent) => {
ev.preventDefault()
const totalWeight = frame.getTotalWeight()
const selfWeight = frame.getSelfWeight()
const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
const selfPerc = 100.0 * selfWeight / profile.getTotalNonIdleWeight()
const selected = frame === selectedFrame
// We intentionally use index rather than frame.key here as the tr key
// in order to re-use rows when sorting rather than creating all new elements.
return (
<tr
key={`${index}`}
onClick={this.props.setSelectedFrame.bind(null, frame)}
className={css(
style.tableRow,
index % 2 == 0 && style.tableRowEven,
selected && style.tableRowSelected,
)}
>
<td className={css(style.numericCell)}>
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
<HBarDisplay perc={totalPerc} />
</td>
<td className={css(style.numericCell)}>
{profile.formatValue(selfWeight)} ({formatPercent(selfPerc)})
<HBarDisplay perc={selfPerc} />
</td>
<td title={frame.file} className={css(style.textCell)}>
<ColorChit color={this.props.getCSSColorForFrame(frame)} />
{frame.name}
</td>
</tr>
if (sortMethod.field == field) {
// Toggle
setSortMethod({
field,
direction:
sortMethod.direction === SortDirection.ASCENDING
? SortDirection.DESCENDING
: SortDirection.ASCENDING,
})
} else {
// Set a sane default
switch (field) {
case SortField.SYMBOL_NAME: {
setSortMethod({field, direction: SortDirection.ASCENDING})
break
}
case SortField.SELF: {
setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
case SortField.TOTAL: {
setSortMethod({field, direction: SortDirection.DESCENDING})
break
}
}
}
},
[sortMethod, setSortMethod],
)
}
onSortClick = (field: SortField, ev: MouseEvent) => {
ev.preventDefault()
const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => {
const rowList: ProfileTableRowInfo[] = []
const {sortMethod} = this.props
if (sortMethod.field == field) {
// Toggle
this.props.setSortMethod({
field,
direction:
sortMethod.direction === SortDirection.ASCENDING
? SortDirection.DESCENDING
: SortDirection.ASCENDING,
profile.forEachFrame(frame => {
let matchedRanges: [number, number][] | null = null
if (searchIsActive) {
const match = fuzzyMatchStrings(frame.name, searchQuery)
if (match == null) return
matchedRanges = match.matchedRanges
}
rowList.push({frame, matchedRanges})
})
} else {
// Set a sane default
switch (field) {
switch (sortMethod.field) {
case SortField.SYMBOL_NAME: {
this.props.setSortMethod({field, direction: SortDirection.ASCENDING})
sortBy(rowList, f => f.frame.name.toLowerCase())
break
}
case SortField.SELF: {
this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
sortBy(rowList, f => f.frame.getSelfWeight())
break
}
case SortField.TOTAL: {
this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
sortBy(rowList, f => f.frame.getTotalWeight())
break
}
}
}
}
private getFrameList = (): Frame[] => {
const {profile, sortMethod} = this.props
const frameList: Frame[] = []
profile.forEachFrame(f => frameList.push(f))
// TODO(jlfwong): This is pretty inefficient to do this on every render, but doesn't
// seem to be a bottleneck, so we'll leave it alone.
switch (sortMethod.field) {
case SortField.SYMBOL_NAME: {
sortBy(frameList, f => f.name.toLowerCase())
break
}
case SortField.SELF: {
sortBy(frameList, f => f.getSelfWeight())
break
}
case SortField.TOTAL: {
sortBy(frameList, f => f.getTotalWeight())
break
}
}
if (sortMethod.direction === SortDirection.DESCENDING) {
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[] = []
for (let i = firstIndex; i <= lastIndex; i++) {
rows.push(this.renderRow(frameList[i], i))
if (sortMethod.direction === SortDirection.DESCENDING) {
rowList.reverse()
}
return <table className={css(style.tableView)}>{rows}</table>
}
return rowList
}, [profile, sortMethod, searchQuery, searchIsActive])
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
const renderItems = useCallback(
(firstIndex: number, lastIndex: number) => {
const rows: JSX.Element[] = []
for (let i = firstIndex; i <= lastIndex; i++) {
rows.push(
ProfileTableRowView({
info: rowList[i],
index: i,
profile: profile,
selectedFrame: selectedFrame,
setSelectedFrame: setSelectedFrame,
getCSSColorForFrame: getCSSColorForFrame,
}),
)
}
if (rows.length === 0) {
if (searchIsActive) {
rows.push(
<tr>
<td className={css(style.emptyState)}>
No symbol names match query "{searchQuery}".
</td>
</tr>,
)
} else {
rows.push(
<tr>
<td className={css(style.emptyState)}>No symbols found.</td>
</tr>,
)
}
}
return <table className={css(style.tableView)}>{rows}</table>
},
[
rowList,
profile,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
searchIsActive,
searchQuery,
],
)
const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
rowList,
])
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
onSortClick,
])
const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [
onSortClick,
])
const onSymbolNameClick = useCallback(
(ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev),
[onSortClick],
)
return (
<div className={css(commonStyle.vbox, style.profileTableView)}>
<table className={css(style.tableView)}>
<thead className={css(style.tableHeader)}>
<tr>
<th
className={css(style.numericCell)}
onClick={ev => this.onSortClick(SortField.TOTAL, ev)}
>
<th className={css(style.numericCell)} onClick={onTotalClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.TOTAL ? sortMethod.direction : null
@@ -224,10 +316,7 @@ export class ProfileTableView extends Component<ProfileTableViewProps, void> {
/>
Total
</th>
<th
className={css(style.numericCell)}
onClick={ev => this.onSortClick(SortField.SELF, ev)}
>
<th className={css(style.numericCell)} onClick={onSelfClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SELF ? sortMethod.direction : null
@@ -235,10 +324,7 @@ export class ProfileTableView extends Component<ProfileTableViewProps, void> {
/>
Self
</th>
<th
className={css(style.textCell)}
onClick={ev => this.onSortClick(SortField.SYMBOL_NAME, ev)}
>
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
<SortIcon
activeDirection={
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
@@ -250,16 +336,18 @@ export class ProfileTableView extends Component<ProfileTableViewProps, void> {
</thead>
</table>
<ScrollableListView
ref={this.listViewRef}
axis={'y'}
items={listItems}
className={css(style.scrollView)}
renderItems={renderItems}
initialIndexInView={
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
}
/>
</div>
)
}
}
},
)
const style = StyleSheet.create({
profileTableView: {
@@ -327,40 +415,54 @@ const style = StyleSheet.create({
background: Colors.GREEN,
right: 0,
},
matched: {
borderBottom: `2px solid ${Colors.BLACK}`,
},
matchedSelected: {
borderColor: Colors.WHITE,
},
emptyState: {
textAlign: 'center',
fontWeight: 'bold',
},
})
interface ProfileTableViewContainerProps {
activeProfileState: ActiveProfileState
}
export const ProfileTableViewContainer = createContainer(
ProfileTableView,
(state: ApplicationState, dispatch: Dispatch, ownProps: ProfileTableViewContainerProps) => {
const {activeProfileState} = ownProps
const {profile, sandwichViewState, index} = activeProfileState
if (!profile) throw new Error('profile missing')
const {tableSortMethod} = state
const {callerCallee} = sandwichViewState
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
const frameToColorBucket = getFrameToColorBucket(profile)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const {setTableSortMethod} = actions.sandwichView
const setSelectedFrame = (selectedFrame: Frame | null) => {
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: index, args: selectedFrame}))
}
export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContainerProps) => {
const {activeProfileState} = ownProps
const {profile, sandwichViewState, index} = activeProfileState
if (!profile) throw new Error('profile missing')
const tableSortMethod = useAppSelector(state => state.tableSortMethod, [])
const {callerCallee} = sandwichViewState
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
const frameToColorBucket = getFrameToColorBucket(profile)
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
const setSortMethod = (sortMethod: SortMethod) => {
dispatch(actions.sandwichView.setTableSortMethod(sortMethod))
}
const setSelectedFrame = useActionCreator(
(selectedFrame: Frame | null) => {
return actions.sandwichView.setSelectedFrame({profileIndex: index, args: selectedFrame})
},
[index],
)
const setSortMethod = useActionCreator(setTableSortMethod, [])
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
const searchQuery = useAppSelector(state => state.searchQuery, [])
return {
profile,
profileIndex: activeProfileState.index,
selectedFrame,
getCSSColorForFrame,
sortMethod: tableSortMethod,
setSelectedFrame,
setSortMethod,
}
},
)
return (
<ProfileTableView
profile={profile}
selectedFrame={selectedFrame}
getCSSColorForFrame={getCSSColorForFrame}
sortMethod={tableSortMethod}
setSelectedFrame={setSelectedFrame}
setSortMethod={setSortMethod}
searchIsActive={searchIsActive}
searchQuery={searchQuery}
/>
)
})
+51 -21
View File
@@ -1,14 +1,18 @@
import {Frame} from '../lib/profile'
import {StyleSheet, css} from 'aphrodite'
import {ProfileTableViewContainer} from './profile-table-view'
import {h} from 'preact'
import {h, JSX} from 'preact'
import {memo} from 'preact/compat'
import {useCallback} from 'preact/hooks'
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 {StatelessComponent} from '../lib/typed-redux'
import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
import {CalleeFlamegraphView} from './callee-flamegraph-view'
import {ActiveProfileState} from './application'
import {useDispatch, useActionCreator} from '../lib/preact-redux'
import {SearchView} from './search-view'
import {useAppSelector} from '../store'
interface SandwichViewProps {
selectedFrame: Frame | null
@@ -16,6 +20,10 @@ interface SandwichViewProps {
activeProfileState: ActiveProfileState
setSelectedFrame: (selectedFrame: Frame | null) => void
glCanvas: HTMLCanvasElement
searchQuery: string
searchIsActive: boolean
setSearchQuery: (query: string | null) => void
setSearchIsActive: (active: boolean) => void
}
class SandwichView extends StatelessComponent<SandwichViewProps> {
@@ -37,7 +45,13 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
}
render() {
const {selectedFrame} = this.props
const {
selectedFrame,
searchIsActive,
setSearchIsActive,
searchQuery,
setSearchQuery,
} = this.props
let flamegraphViews: JSX.Element | null = null
if (selectedFrame) {
@@ -70,6 +84,12 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
<SearchView
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchIsActive={searchIsActive}
setSearchIsActive={setSearchIsActive}
/>
</div>
{flamegraphViews}
</div>
@@ -79,6 +99,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
const style = StyleSheet.create({
tableView: {
position: 'relative',
flex: 1,
},
panZoomViewWraper: {
@@ -122,28 +143,37 @@ interface SandwichViewContainerProps {
glCanvas: HTMLCanvasElement
}
export const SandwichViewContainer = createContainer(
SandwichView,
(state: ApplicationState, dispatch: Dispatch, ownProps: SandwichViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
const {sandwichViewState, index} = activeProfileState
const {callerCallee} = sandwichViewState
const {setSearchQuery, setSearchIsActive} = actions
const setSelectedFrame = (selectedFrame: Frame | null) => {
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
const {sandwichViewState, index} = activeProfileState
const {callerCallee} = sandwichViewState
const dispatch = useDispatch()
const setSelectedFrame = useCallback(
(selectedFrame: Frame | null) => {
dispatch(
actions.sandwichView.setSelectedFrame({
profileIndex: index,
args: selectedFrame,
}),
)
}
},
[dispatch, index],
)
return {
activeProfileState: activeProfileState,
glCanvas,
setSelectedFrame,
selectedFrame: callerCallee ? callerCallee.selectedFrame : null,
profileIndex: index,
}
},
)
return (
<SandwichView
activeProfileState={activeProfileState}
glCanvas={glCanvas}
setSelectedFrame={setSelectedFrame}
selectedFrame={callerCallee ? callerCallee.selectedFrame : null}
profileIndex={index}
searchQuery={useAppSelector(state => state.searchQuery, [])}
setSearchQuery={useActionCreator(setSearchQuery, [])}
searchIsActive={useAppSelector(state => state.searchIsActive, [])}
setSearchIsActive={useActionCreator(setSearchIsActive, [])}
/>
)
})
+92 -80
View File
@@ -1,57 +1,77 @@
// A simple implementation of an efficient scrolling list view which
// renders only items within the viewport + a couple extra items.
import {h, Component} from 'preact'
import {h, JSX} from 'preact'
import {useState, useCallback, useRef, useMemo, useEffect} from 'preact/hooks'
export interface ListItem {
size: number
}
interface RangeResult {
firstVisibleIndex: number
lastVisibleIndex: number
invisiblePrefixSize: number
}
interface ScrollableListViewProps {
items: ListItem[]
axis: 'x' | 'y'
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
className?: string
initialIndexInView?: number | null
}
interface ScrollableListViewState {
firstVisibleIndex: number | null
lastVisibleIndex: number | null
invisiblePrefixSize: number | null
viewportSize: number | null
cachedTotalSize: number
}
export const ScrollableListView = ({
items,
axis,
renderItems,
className,
initialIndexInView,
}: ScrollableListViewProps) => {
const [viewportSize, setViewportSize] = useState<number | null>(null)
const [viewportScrollOffset, setViewportScrollOffset] = useState<number>(0)
export class ScrollableListView extends Component<
ScrollableListViewProps,
ScrollableListViewState
> {
constructor(props: ScrollableListViewProps) {
super(props)
this.state = {
firstVisibleIndex: null,
lastVisibleIndex: null,
invisiblePrefixSize: null,
viewportSize: null,
cachedTotalSize: props.items.reduce((a, b) => a + b.size, 0),
const viewportRef = useRef<HTMLDivElement | null>(null)
const widthOrHeight = axis === 'x' ? 'width' : 'height'
const leftOrTop = axis === 'x' ? 'left' : 'top'
const scrollLeftOrScrollTop = axis === 'x' ? 'scrollLeft' : 'scrollTop'
// This is kind of a weird hack, but I'm not sure what the better of doing something like this is.
const offset = initialIndexInView
? items.reduce((a, b, i) => (i < initialIndexInView ? a + b.size : a), 0)
: 0
const initialScroll = useRef<number | null>(offset)
const viewportCallback = useCallback(
(viewport: HTMLDivElement | null) => {
if (viewport) {
requestAnimationFrame(() => {
setViewportSize(viewport.getBoundingClientRect()[widthOrHeight])
if (initialScroll.current != null) {
console.log('executing initial scroll to ', initialScroll.current)
viewport.scrollTo({[leftOrTop]: initialScroll.current})
initialScroll.current = null
}
})
} else {
setViewportSize(null)
}
viewportRef.current = viewport
},
[setViewportSize, widthOrHeight, leftOrTop],
)
const rangeResult: RangeResult | null = useMemo(() => {
if (viewportRef.current == null || viewportSize == null || viewportScrollOffset == null) {
return null
}
}
private viewport: HTMLDivElement | null = null
private viewportRef = (viewport?: Element) => {
this.viewport = (viewport as HTMLDivElement) || null
}
private recomputeVisibleIndices(props: ScrollableListViewProps) {
if (!this.viewport) return
const {items} = props
const viewportSize = this.viewport.getBoundingClientRect().height
// We render items up to a quarter viewport height outside of the
// viewport both above and below to prevent flickering.
const minY = this.viewport.scrollTop - viewportSize / 4
const maxY = this.viewport.scrollTop + viewportSize + viewportSize / 4
const minY = viewportScrollOffset - viewportSize / 4
const maxY = viewportScrollOffset + viewportSize + viewportSize / 4
let total = 0
let invisiblePrefixSize = 0
@@ -77,62 +97,54 @@ export class ScrollableListView extends Component<
}
const lastVisibleIndex = Math.min(i, items.length - 1)
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)
return {
firstVisibleIndex,
lastVisibleIndex,
invisiblePrefixSize,
}
}
}, [viewportSize, viewportScrollOffset, items])
componentDidMount() {
this.applyPendingScroll()
this.recomputeVisibleIndices(this.props)
window.addEventListener('resize', this.onWindowResize)
}
const totalSize = useMemo(() => items.reduce((a, b) => a + b.size, 0), [items])
componentWillUnmount() {
window.removeEventListener('resize', this.onWindowResize)
}
const onViewportScroll = useCallback(() => {
if (viewportRef.current != null) {
setViewportScrollOffset(viewportRef.current[scrollLeftOrScrollTop])
}
}, [scrollLeftOrScrollTop])
onWindowResize = () => {
this.recomputeVisibleIndices(this.props)
}
useEffect(() => {
const resizeListener = () => {
if (viewportRef.current != null) {
setViewportSize(viewportRef.current.getBoundingClientRect()[widthOrHeight])
}
}
onViewportScroll = (ev: UIEvent) => {
this.recomputeVisibleIndices(this.props)
}
window.addEventListener('resize', resizeListener)
return () => {
window.removeEventListener('resize', resizeListener)
}
}, [widthOrHeight])
render() {
const {cachedTotalSize, firstVisibleIndex, lastVisibleIndex, invisiblePrefixSize} = this.state
const visibleItems = useMemo(() => {
return rangeResult
? renderItems(rangeResult.firstVisibleIndex, rangeResult.lastVisibleIndex)
: null
}, [renderItems, rangeResult])
const content = useMemo(() => {
return (
<div className={this.props.className} ref={this.viewportRef} onScroll={this.onViewportScroll}>
<div style={{height: cachedTotalSize}}>
<div style={{transform: `translateY(${invisiblePrefixSize}px)`}}>
{firstVisibleIndex != null &&
lastVisibleIndex != null &&
this.props.renderItems(firstVisibleIndex, lastVisibleIndex)}
</div>
<div style={{height: totalSize}}>
<div style={{transform: `translateY(${rangeResult?.invisiblePrefixSize || 0}px)`}}>
{visibleItems}
</div>
</div>
)
}
}, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize])
return (
<div className={className} ref={viewportCallback} onScroll={onViewportScroll}>
{content}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import {StyleSheet, css} from 'aphrodite'
import {h} from 'preact'
import {useCallback, useRef, useEffect} from 'preact/hooks'
import {memo} from 'preact/compat'
import {Sizes, Colors, FontSize} from './style'
function stopPropagation(ev: Event) {
ev.stopPropagation()
}
interface SearchViewProps {
searchQuery: string
searchIsActive: boolean
setSearchQuery: (query: string | null) => void
setSearchIsActive: (active: boolean) => void
}
export const SearchView = memo(
({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
const onInput = useCallback(
(ev: Event) => {
const value = (ev.target as HTMLInputElement).value
setSearchQuery(value)
},
[setSearchQuery],
)
const inputRef = useRef<HTMLInputElement | null>(null)
const onKeyDown = useCallback(
(ev: KeyboardEvent) => {
ev.stopPropagation()
// Hitting Esc should close the search box
if (ev.key === 'Escape') {
setSearchIsActive(false)
}
},
[setSearchIsActive],
)
useEffect(() => {
const onWindowKeyDown = (ev: KeyboardEvent) => {
// Cmd+F or Ctrl+F open the search box
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
// Prevent the browser's search menu from appearing
ev.preventDefault()
if (inputRef.current) {
// If the search box is already open, then re-select it.
inputRef.current.select()
} else {
setSearchIsActive(true)
}
}
}
window.addEventListener('keydown', onWindowKeyDown)
return () => {
window.removeEventListener('keydown', onWindowKeyDown)
}
}, [setSearchIsActive])
const focusInput = useCallback((node: HTMLInputElement | null) => {
if (node) {
requestAnimationFrame(() => {
node.select()
})
}
inputRef.current = node
}, [])
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
if (!searchIsActive) return null
return (
<div className={css(style.searchView)}>
<span className={css(style.icon)}>🔍</span>
<input
className={css(style.input)}
value={searchQuery}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
ref={focusInput}
/>
<svg
onClick={close}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.99999 4.16217L11.6427 10.8048M11.6427 4.16217L4.99999 10.8048"
stroke="#BDBDBD"
/>
</svg>
</div>
)
},
)
const style = StyleSheet.create({
searchView: {
position: 'absolute',
top: 0,
right: 10,
height: Sizes.TOOLBAR_HEIGHT,
width: 150,
borderWidth: 2,
borderColor: Colors.BLACK,
borderStyle: 'solid',
fontSize: FontSize.LABEL,
boxSizing: 'border-box',
background: Colors.DARK_GRAY,
color: Colors.WHITE,
display: 'flex',
},
input: {
border: 'none',
background: 'none',
fontSize: FontSize.LABEL,
flex: 1,
color: Colors.WHITE,
':focus': {
border: 'none',
outline: 'none',
},
'::selection': {
color: Colors.WHITE,
background: Colors.DARK_BLUE,
},
},
icon: {
display: 'inline-block',
verticalAlign: 'middle',
paddingTop: '0px',
margin: '0 2px 0 4px',
},
})
+2 -1
View File
@@ -40,7 +40,8 @@ export enum Duration {
}
export enum ZIndex {
HOVERTIP = 1,
PROFILE_SELECT = 1,
HOVERTIP = 2,
}
export const commonStyle = StyleSheet.create({
+260
View File
@@ -0,0 +1,260 @@
import {ApplicationProps} from './application'
import {ViewMode} from '../store'
import {h, JSX, Fragment} from 'preact'
import {useCallback, useState, useEffect} from 'preact/hooks'
import {StyleSheet, css} from 'aphrodite'
import {Sizes, Colors, FontFamily, FontSize, Duration} from './style'
import {ProfileSelect} from './profile-select'
import {ProfileGroupState} from '../store/profiles-state'
import {Profile} from '../lib/profile'
import {objectsHaveShallowEquality} from '../lib/utils'
interface ToolbarProps extends ApplicationProps {
browseForFile(): void
saveFile(): void
}
function useSetViewMode(setViewMode: (viewMode: ViewMode) => void, viewMode: ViewMode) {
return useCallback(() => setViewMode(viewMode), [setViewMode, viewMode])
}
function ToolbarLeftContent(props: ToolbarProps) {
const setChronoFlameChart = useSetViewMode(props.setViewMode, ViewMode.CHRONO_FLAME_CHART)
const setLeftHeavyFlameGraph = useSetViewMode(props.setViewMode, ViewMode.LEFT_HEAVY_FLAME_GRAPH)
const setSandwichView = useSetViewMode(props.setViewMode, ViewMode.SANDWICH_VIEW)
if (!props.activeProfileState) return null
return (
<div className={css(style.toolbarLeft)}>
<div
className={css(
style.toolbarTab,
props.viewMode === ViewMode.CHRONO_FLAME_CHART && style.toolbarTabActive,
)}
onClick={setChronoFlameChart}
>
<span className={css(style.emoji)}>🕰</span>Time Order
</div>
<div
className={css(
style.toolbarTab,
props.viewMode === ViewMode.LEFT_HEAVY_FLAME_GRAPH && style.toolbarTabActive,
)}
onClick={setLeftHeavyFlameGraph}
>
<span className={css(style.emoji)}></span>Left Heavy
</div>
<div
className={css(
style.toolbarTab,
props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
)}
onClick={setSandwichView}
>
<span className={css(style.emoji)}>🥪</span>Sandwich
</div>
</div>
)
}
const getCachedProfileList = (() => {
// TODO(jlfwong): It would be nice to just implement this as useMemo, but if
// we do that using profileGroup or profileGroup.profiles as the cache key,
// then it will invalidate whenever *anything* changes, because
// profileGroup.profiles is ProfileState[], which contains component state
// information for each tab for each profile. So whenever any property in any
// persisted view state changes for *any* view in *any* profile, the profiles
// list will get re-generated.
let cachedProfileList: Profile[] | null = null
return (profileGroup: ProfileGroupState): Profile[] | null => {
let nextProfileList = profileGroup?.profiles.map(p => p.profile) || null
if (
cachedProfileList === null ||
(nextProfileList != null && !objectsHaveShallowEquality(cachedProfileList, nextProfileList))
) {
cachedProfileList = nextProfileList
}
return cachedProfileList
}
})()
function ToolbarCenterContent(props: ToolbarProps): JSX.Element {
const {activeProfileState, profileGroup} = props
const profiles = getCachedProfileList(profileGroup)
const [profileSelectShown, setProfileSelectShown] = useState(false)
const openProfileSelect = useCallback(() => {
setProfileSelectShown(true)
}, [setProfileSelectShown])
const closeProfileSelect = useCallback(() => {
setProfileSelectShown(false)
}, [setProfileSelectShown])
useEffect(() => {
const onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 't') {
ev.preventDefault()
setProfileSelectShown(true)
}
}
window.addEventListener('keypress', onWindowKeyPress)
return () => {
window.removeEventListener('keypress', onWindowKeyPress)
}
}, [setProfileSelectShown])
useEffect(() => {
const onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 't') {
ev.preventDefault()
setProfileSelectShown(true)
}
}
window.addEventListener('keypress', onWindowKeyPress)
return () => {
window.removeEventListener('keypress', onWindowKeyPress)
}
}, [setProfileSelectShown])
if (activeProfileState && profileGroup && profiles) {
if (profileGroup.profiles.length === 1) {
return <Fragment>{activeProfileState.profile.getName()}</Fragment>
} else {
return (
<div className={css(style.toolbarCenter)} onMouseLeave={closeProfileSelect}>
<span onMouseOver={openProfileSelect}>
{activeProfileState.profile.getName()}{' '}
<span className={css(style.toolbarProfileIndex)}>
({activeProfileState.index + 1}/{profileGroup.profiles.length})
</span>
</span>
<div style={{display: profileSelectShown ? 'block' : 'none'}}>
<ProfileSelect
setProfileIndexToView={props.setProfileIndexToView}
indexToView={profileGroup.indexToView}
profiles={profiles}
closeProfileSelect={closeProfileSelect}
visible={profileSelectShown}
/>
</div>
</div>
)
}
}
return <Fragment>{'🔬speedscope'}</Fragment>
}
function ToolbarRightContent(props: ToolbarProps) {
const importFile = (
<div className={css(style.toolbarTab)} onClick={props.browseForFile}>
<span className={css(style.emoji)}></span>Import
</div>
)
const help = (
<div className={css(style.toolbarTab)}>
<a
href="https://github.com/jlfwong/speedscope#usage"
className={css(style.noLinkStyle)}
target="_blank"
>
<span className={css(style.emoji)}></span>Help
</a>
</div>
)
return (
<div className={css(style.toolbarRight)}>
{props.activeProfileState && (
<div className={css(style.toolbarTab)} onClick={props.saveFile}>
<span className={css(style.emoji)}></span>Export
</div>
)}
{importFile}
{help}
</div>
)
}
export function Toolbar(props: ToolbarProps) {
return (
<div className={css(style.toolbar)}>
<ToolbarLeftContent {...props} />
<ToolbarCenterContent {...props} />
<ToolbarRightContent {...props} />
</div>
)
}
const style = StyleSheet.create({
toolbar: {
height: Sizes.TOOLBAR_HEIGHT,
flexShrink: 0,
background: Colors.BLACK,
color: Colors.WHITE,
textAlign: 'center',
fontFamily: FontFamily.MONOSPACE,
fontSize: FontSize.TITLE,
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
userSelect: 'none',
},
toolbarLeft: {
position: 'absolute',
height: Sizes.TOOLBAR_HEIGHT,
overflow: 'hidden',
top: 0,
left: 0,
marginRight: 2,
textAlign: 'left',
},
toolbarCenter: {
paddingTop: 1,
height: Sizes.TOOLBAR_HEIGHT,
},
toolbarRight: {
height: Sizes.TOOLBAR_HEIGHT,
overflow: 'hidden',
position: 'absolute',
top: 0,
right: 0,
marginRight: 2,
textAlign: 'right',
},
toolbarProfileIndex: {
color: Colors.LIGHT_GRAY,
},
toolbarTab: {
background: Colors.DARK_GRAY,
marginTop: Sizes.SEPARATOR_HEIGHT,
height: Sizes.TOOLBAR_TAB_HEIGHT,
lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
paddingLeft: 2,
paddingRight: 8,
display: 'inline-block',
marginLeft: 2,
transition: `all ${Duration.HOVER_CHANGE} ease-in`,
':hover': {
background: Colors.GRAY,
},
},
toolbarTabActive: {
background: Colors.BRIGHT_BLUE,
':hover': {
background: Colors.BRIGHT_BLUE,
},
},
emoji: {
display: 'inline-block',
verticalAlign: 'middle',
paddingTop: '0px',
marginRight: '0.3em',
},
noLinkStyle: {
textDecoration: 'none',
color: 'inherit',
},
})