From f157fc992488aff5b8aa6be75e06f9bcf7f45053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 29 Oct 2019 15:29:10 +0100 Subject: [PATCH] v5 (#102) * Footer redesign (#85) Squashed commit of the following: commit 37070670c8a547793b439d7d46200b6a9531021a Author: Alessio Carnevale Date: Tue Feb 26 16:58:56 2019 +0100 Filters/footer redesign (#85) * replacing `More info` drop-down with a button * setting drop-down border colours - commenting out 2px border * fixing scroll-bar appearing on side-bar in commit 1f19aed48f959c7882b263619fc13c537a3e425a Author: Alessio Carnevale Date: Mon Feb 25 13:21:36 2019 +0100 removing colored dots on mobile commit 6369782176ad15a25ea7f90e786db93309a39f35 Author: Alessio Carnevale Date: Mon Feb 25 13:16:05 2019 +0100 * adding reference color for each key (app, deps, core) * improving `ellipsis` handling on narrower screens commit 479bfb9ebec9886a008dc06db377485899bbe452 Author: Alessio Carnevale Date: Mon Feb 25 11:26:37 2019 +0100 amending as per PR comments commit 13500d169e60651f30e7345b707350dd03ae0b15 Author: Alessio Carnevale Date: Wed Feb 20 16:08:04 2019 +0100 making use of the new `button` component in `selection-controls` commit 761866fc566bd0add76cf0a25d4ee6079c91fa22 Author: Alessio Carnevale Date: Wed Feb 20 16:07:25 2019 +0100 optimising Dependecies data count check commit a3b35bcc53e6d171ca62598170c79d10120f3ba7 Author: Alessio Carnevale Date: Wed Feb 20 15:53:44 2019 +0100 fixing dataCount value calculation commit 9a0609b8f68d7f25ae6342a7cd0af3d760cbc219 Author: Alessio Carnevale Date: Wed Feb 20 15:53:16 2019 +0100 removing comments commit bb4d816e1461f22e5f0d1d5bd33915c269d72126 Author: Alessio Carnevale Date: Wed Feb 20 15:52:37 2019 +0100 removing `options-menu` commit 050ac9aa9fadb4f6363e727d453bad0e2af4f475 Merge: 9be8ad7 91953fb Author: Alessio Carnevale Date: Wed Feb 20 15:08:43 2019 +0100 Merge remote-tracking branch 'origin/master' into feature/footer_redesign commit 9be8ad76c41c8bc6e9fa05756860819a5c5b1236 Author: Alessio Carnevale Date: Wed Feb 20 14:46:11 2019 +0100 fixing `is:init` option and adding `Close button` to SideBar commit 1cf745b02e87c3db5d713a82e1ae60a83205d120 Merge: 84e8a8a b9406ee Author: Alessio Carnevale Date: Wed Feb 20 13:36:12 2019 +0100 Merge remote-tracking branch 'origin/master' into feature/footer_redesign commit 84e8a8ab96c02355c24ed41a9b6b177385eae2ac Author: Alessio Carnevale Date: Wed Feb 20 13:32:38 2019 +0100 normalising padding value commit ef462b761584cef6128b1c94bd8f9df6774e558c Author: Alessio Carnevale Date: Wed Feb 20 13:32:11 2019 +0100 sideBar responsive version commit 6647ae40705570173fc3ad91f21e69e8d6f64b4a Author: Alessio Carnevale Date: Wed Feb 20 13:31:22 2019 +0100 triggering `sideBar` event commit f9f5b8225cfb254d122926c35f14d9d16214b4a1 Author: Alessio Carnevale Date: Tue Feb 19 20:04:22 2019 +0100 fixing autohide of drop-down content commit feec024f53f6b483ffa2e370d5d6de8e03910120 Author: Alessio Carnevale Date: Tue Feb 19 20:04:05 2019 +0100 clicking on X first clears then close the search-box commit abf5068b93df2e26de3d41b4e3b3af37e5f49c08 Author: Alessio Carnevale Date: Tue Feb 19 15:36:58 2019 +0100 replacing classes with classNames commit 57b00023f5777b5239d910764d4260f1ad339ddf Author: Alessio Carnevale Date: Tue Feb 19 15:36:38 2019 +0100 using DOMParser in `toHtml` helper commit ee8ae461b5435d7b266b7ec5efdc7ec549407042 Author: Alessio Carnevale Date: Tue Feb 19 15:35:54 2019 +0100 replacing `classes` with `classNames` commit dfaa2358e7c0d9b0272acfcf8d4f9904b991336b Author: Alessio Carnevale Date: Tue Feb 19 15:34:34 2019 +0100 adding mobile search-box / adding bp-1 and bp-2 breakpoints commit 592af61262864d7ea89233006e41b8582e7ec020 Author: Alessio Carnevale Date: Tue Feb 19 15:32:59 2019 +0100 Fixing search-box style. It assumed the user would always assign 'search-box' id commit a019306168dc2b4ab6a2a9c4d6e79ecc435b850c Merge: 8e1a2a9 0ec0d59 Author: Alessio Carnevale Date: Mon Feb 18 16:37:52 2019 +0100 Merge branch 'feature/base-components-2' into feature/footer_redesign commit 0ec0d59aaa223ae69d2aedaaf864f22c53ede342 Author: Alessio Carnevale Date: Mon Feb 18 12:10:55 2019 +0100 creating helpers.js commit fddcd8ef62ea3cf4e8f2f3809214f84015aec9b2 Author: Alessio Carnevale Date: Mon Feb 18 12:10:43 2019 +0100 adding CB for click and change events commit c3c6538e35a4ddf4278a30c08fe32e5920959654 Author: Alessio Carnevale Date: Mon Feb 18 12:10:19 2019 +0100 replacing check-button-combo with drop-down commit d52d0b58d788dae89fefa8056a13d38571f7a7d5 Author: Alessio Carnevale Date: Mon Feb 18 12:09:26 2019 +0100 fixing padding commit 8e1a2a95e0f577532c0a9bc3abb35609449fbbce Author: Alessio Carnevale Date: Mon Feb 18 11:58:28 2019 +0100 using button component in selection-controls commit aec330ad501d2eb1653451175cfd2404593d08de Author: Alessio Carnevale Date: Mon Feb 18 11:26:45 2019 +0100 improving setCodeAreaVisibility function commit c256edb870d8956a3625523a4397305fe2fb6e79 Author: Alessio Carnevale Date: Mon Feb 18 11:26:23 2019 +0100 adding onChange cb commit 4b5016f246b6f814ccad5243dd867075413e03e9 Author: Alessio Carnevale Date: Mon Feb 18 11:25:43 2019 +0100 tweking style and making use ot toHtml helper commit 700a928e697cae8f4e7b193e8eb22c3fd34d6141 Author: Alessio Carnevale Date: Mon Feb 18 11:25:20 2019 +0100 weaking button css and onclick function commit c10512d358b81976306d7d899c3da07bcc6fd185 Author: Alessio Carnevale Date: Mon Feb 18 11:24:28 2019 +0100 creating helpers.js to host some common helpers commit c0bb407434dfc733c823c15cd147efdb16033b98 Author: Alessio Carnevale Date: Mon Feb 18 11:23:53 2019 +0100 replacing check-button-combo component with drop-down commit a65461b56e55deaad44162e4a15df98ba5f4b037 Author: Alessio Carnevale Date: Mon Feb 18 11:21:50 2019 +0100 tweaking side-bar slide-in/out animation commit 286900a828b2a5f9a08e61a75c5524c12daf6077 Author: Alessio Carnevale Date: Mon Feb 18 11:21:20 2019 +0100 calling ui.setCodeAreaVisibility and using Object.assign to support IE commit fba3f69c0a5297ec28025cd6c468735eee2f7164 Author: Alessio Carnevale Date: Mon Feb 18 11:20:09 2019 +0100 creating the combos in the footer commit eb6e5a2d450eeaa5ca19bfed5e2aa379490f6973 Author: Alessio Carnevale Date: Mon Feb 11 10:53:39 2019 +0100 Adding `SideBar` container, renaming `filters-bar` and `filters-options` commit f9952fbb9ac8ed4e01224e4f515efb09fd18ebfd Author: Alessio Carnevale Date: Mon Feb 11 10:32:13 2019 +0100 moving common under visualizer commit 0fd376db4f134376a4f531841c0cd5e174c6a61a Author: Alessio Carnevale Date: Mon Feb 11 10:21:57 2019 +0100 creating base components commit 5c73e9aa66216d66039e0bd4930925ee5e072889 Author: Alessio Carnevale Date: Mon Feb 11 10:15:56 2019 +0100 creating base components commit 4075502cbb3830275e0a2aa2e024add2a7e5fce0 Author: Alessio Carnevale Date: Sat Feb 2 23:02:45 2019 +0100 Cleaning code and fixing Advanced section commit 393bfbfed16c513013572b496f42ec6f492c4db2 Author: Alessio Carnevale Date: Sat Feb 2 23:02:16 2019 +0100 style tweaks commit 753d5f121c4b1d2ad8b9635ef825bed0fd0c9b97 Author: Alessio Carnevale Date: Sat Feb 2 23:02:00 2019 +0100 firing 'updateExclusions' on setUseMergedTree and setShowOptimizationStatus commit f30a7b8ed51a2279b7e462a2dd7310d8ac6cd443 Author: Alessio Carnevale Date: Sat Feb 2 19:57:56 2019 +0100 removing toolbar-side-panel and adding 'filters-container' to the footer commit ff540b87bab4eaf4712f35b2157f64b3ff364ece Author: Alessio Carnevale Date: Sat Feb 2 19:56:57 2019 +0100 adding 'filters-content' and 'filters-container' * Flame graph percentage display (#74) * [621] - Implement flame graph percentages as dropdown alongside info box * [621] - Move percentage dropdown above info-box * [621] - Fix percentage dropdown button padding * Feature/walkthrough (#89) * fixing selection-controls css rules * creating `walkthrough` component * creating context-overlay component * adding transition to width and height * defining some demo steps * adding WT to UI * adding `c_` to className to make it less common * fixing toHTML function * creating `element-highlighter` to darken everything out in a page but the selected element * adding `element-highlighter-border` * adding transition to the borders * enabling backdrop on walkthroug component * adding constant elements (logo, docs) * adding the arrow to point the target elem * preventing element-highlighter to show up on resize * moving base components to clinic-common * updating the base style file name * adding custom style for base components * removing the graph percentage dropdown * adding `How does this work` button * using `link` in the walkthrough steps * enabling Walkthrough visualisation through URL * using `howDoesThisWork` component * updating @nearform/clinic-common dep version * removing unneeded rules * renaming howDoesThisWork to walkthroughButton * fixing a typo and updating clinic-common dependency * adding support for base64 images and making use of `chalk` * moving helpButton to footer * updating `walkthrough` steps * adding images for walkthrough demo * removing unneeded var * Use new Clinic Common release * Add real walkthrough content * Remove dummy content and use block link styler * `standard --fix` * Organise sidebar contents under accordions (#94) * renaming filters-options to filters-content * adding accordions to side-bar * refactoring filters-content to make use of `asccordion` component * updating the Deps drop-down content on `setData` * moving `#flame-main .scroll-container` style to more appropriate location * moving `.scroll-container` style to clinic-common * adding some dummy data to test the side-bar * avoiding too many calls to `updateExclusions` when looping through sub filters * reducing the clickable area of `More info` in the side-bar * passing an Object as argument to `setCodeAreaVisibility` and removing console.trace * removing `deps` dummy data * using `childrenVisibilityToggle` to decide when to render the sub filters toggle * enabling back `deps` children * adding spinner to filters-bar and filters-content * fixing `presentation-mode` checkbox and adding spinner * Use Accordion branch of Clinic Common * Design tweaks * More design tweaks * Pulse buttons while applying fliters We want no spinner or overlay over the main flamegraph while applying a filter so that the user can see what has changed. * Improve performance of dependencies filter * Add and style app and deps descriptions * Remove dummy text * Fix property name casing * Remove commented block * simple comments * Basic WebAssembly support (#103) * Basic WebAssembly support * make istanbul happy * Upgrade 0x * Use a separate WASM category * fix test * Remove isWasm, detect wasm frames in same way as others * Fix crash when hiding wasm frames * Info box responsive improvements (#104) * [621] - Create method in DataTree to count total frames from source data and use show percentage on stack top each frame is in the UI * [621] - Implement dropdown with top and overall stack percentage into info-box * [621] - Tidy info-box UI * [621] - Use active tree value to calculate frame percentage * [621] - Fix selection-controls margin * [621] - Optimise text display in info box. Flag future use of visibleRootValue for stack percentages * [621] - Fix type error when generating path title attribute * [621] - Revert changes to text arrangement in info-box * [621] - Ensure line and col numbers are spaced properly * [621] - Use pointer cursor on percentage dropdown button * [621] - Ensure percentage dropdown closes on click outside and remove empty property from DataTree * [621] - Create method in DataTree to count total frames from source data and use show percentage on stack top each frame is in the UI * [621] - Use active tree value to calculate frame percentage * [621] - Tweak responsive nature of info-box text * [621] - Make more sections of info text responsive and rely on labels for full text content where necessary * [621] - Tweak styling to find line of best fit between different outputs, function name lengths etc * Use tooltip component. * make frame info tooltip full-width on mobile, fix some overflows * Implement brand fonts (#87) * [596] - Remove styling for header and defer to common * [596] - Import JS and CSS build scripts from common as use as part of HTML generation * [596] - Remove brfs dep * [647] - Load font from common whilst showing spinner to hide and esnure font has a chance to load before use in Flamegraph * [647] - Ensure flamegraph is redrawn once slow loading UI font is loaded after timeout * [647] - Use clinic common to better orchestrate UI events on font load state * [596] - Add class to body on font load to work in tandem with screenshot function to ensure loader has disappeared * [596] - Ensure UI is drawn immediately despite state of font loading * [596] - Point to Clinic Common dev branch for ease-of-testing * [596] - Remove bodyClass that was hiding loader * [596] - Wrap font loader function in setTimeout to prevent blocking removal of spinner * [596] - Update Clinic Common dep * Use mainline clinic-common. --- analysis/code-areas.js | 1 + analysis/frame-node.js | 48 +++- analysis/index.js | 12 - debug/visualize-mod.js | 6 +- debug/visualize-watch.js | 34 ++- index.js | 4 +- package.json | 5 +- test/analysis-categorise.test.js | 12 + test/basic.test.js | 29 +++ test/fixtures/wasm.js | 8 + visualizer/common/button.css | 31 --- visualizer/common/button.js | 15 -- visualizer/common/checkbox.css | 67 ------ visualizer/common/checkbox.js | 36 --- visualizer/common/drop-down.css | 84 ------- visualizer/common/drop-down.js | 68 ------ visualizer/common/helpers.js | 33 --- visualizer/data-tree.js | 3 + visualizer/filters-bar.css | 87 ++++++++ visualizer/filters-bar.js | 293 +++++++++++++++++++++++++ visualizer/filters-content.css | 105 +++++++++ visualizer/filters-content.js | 311 ++++++++++++++++++++++++++ visualizer/flame-graph-label.js | 1 + visualizer/flame-graph.css | 9 + visualizer/flame-graph.js | 10 +- visualizer/history.js | 11 +- visualizer/html-content-types.js | 6 +- visualizer/info-box.css | 177 ++++++++++++--- visualizer/info-box.js | 131 +++++++++-- visualizer/main.js | 48 ++-- visualizer/options-menu.css | 239 -------------------- visualizer/options-menu.js | 354 ------------------------------ visualizer/search-box.css | 19 +- visualizer/search-box.js | 7 +- visualizer/selection-controls.css | 38 +--- visualizer/selection-controls.js | 52 ++--- visualizer/side-bar.css | 164 ++++++++++++++ visualizer/side-bar.js | 47 ++++ visualizer/style.css | 193 ++++++++++++---- visualizer/toolbar.css | 31 +-- visualizer/tooltip.css | 1 + visualizer/tooltip.js | 47 ++-- visualizer/ui.js | 182 +++++++++++---- visualizer/walkthrough-steps.css | 23 ++ visualizer/walkthrough-steps.js | 89 ++++++++ 45 files changed, 1945 insertions(+), 1226 deletions(-) create mode 100644 test/fixtures/wasm.js delete mode 100644 visualizer/common/button.css delete mode 100644 visualizer/common/button.js delete mode 100644 visualizer/common/checkbox.css delete mode 100644 visualizer/common/checkbox.js delete mode 100644 visualizer/common/drop-down.css delete mode 100644 visualizer/common/drop-down.js delete mode 100644 visualizer/common/helpers.js create mode 100644 visualizer/filters-bar.css create mode 100644 visualizer/filters-bar.js create mode 100644 visualizer/filters-content.css create mode 100644 visualizer/filters-content.js delete mode 100644 visualizer/options-menu.css delete mode 100644 visualizer/options-menu.js create mode 100644 visualizer/side-bar.css create mode 100644 visualizer/side-bar.js create mode 100644 visualizer/walkthrough-steps.css create mode 100644 visualizer/walkthrough-steps.js diff --git a/analysis/code-areas.js b/analysis/code-areas.js index 2c8805fd..d7a7cc6c 100644 --- a/analysis/code-areas.js +++ b/analysis/code-areas.js @@ -36,6 +36,7 @@ function collectCodeAreas (trees) { { id: 'deps', children: toCodeAreaChildren(depCodeAreas), childrenVisibilityToggle: depCodeAreas.size > 2 }, + { id: 'wasm' }, { id: 'core' }, { id: 'all-v8', children: [ diff --git a/analysis/frame-node.js b/analysis/frame-node.js index a3726ae4..daba3928 100644 --- a/analysis/frame-node.js +++ b/analysis/frame-node.js @@ -1,6 +1,7 @@ const path = require('path') const jsFrameRx = /^([~*])?((?:\S+?\(anonymous function\)|\S+)?(?: [a-zA-Z]+)*) (.*?):(\d+):(\d+)( \[INIT])?( \[INLINABLE])?$/ +const wasmFrameRx = /^(.*?) \[WASM:(\w+)]$/ // This one has the /m flag because regexes may contain \n const cppFrameRx = /^(.*) (\[CPP]|\[SHARED_LIB]|\[CODE:\w+])( \[INIT])?$/m @@ -24,6 +25,11 @@ class FrameNode { this.lineNumber = null this.columnNumber = null + this.isInit = false + this.isInlinable = false + this.isOptimized = false + this.isUnoptimized = false + // Don't try to identify anything for the root node if (fixedType) { this.category = 'none' @@ -32,8 +38,8 @@ class FrameNode { } // C++ and v8 functions don't match, but they don't need to - const m = this.name.match(jsFrameRx) - if (m) { + let m + if ((m = this.name.match(jsFrameRx))) { const [ input, // eslint-disable-line no-unused-vars optimizationFlag, @@ -53,20 +59,29 @@ class FrameNode { this.isInlinable = isInlinable != null this.isOptimized = optimizationFlag === '~' this.isUnoptimized = optimizationFlag === '*' + } else if ((m = this.name.match(cppFrameRx))) { + const [ + input, // eslint-disable-line no-unused-vars + functionName, + tag, + isInit + ] = m + const isSharedLib = tag === '[SHARED_LIB]' + this.functionName = isSharedLib ? '[SHARED_LIB]' : functionName + this.fileName = isSharedLib ? functionName : null + this.isInit = isInit != null } else { - const m = this.name.match(cppFrameRx) - /* istanbul ignore else: Only triggers if there's a bug */ - if (m) { + /* istanbul ignore else: if none of the regexes we are missing a feature */ + if ((m = this.name.match(wasmFrameRx))) { const [ input, // eslint-disable-line no-unused-vars functionName, - tag, - isInit + optimizationTag ] = m - const isSharedLib = tag === '[SHARED_LIB]' - this.functionName = isSharedLib ? '[SHARED_LIB]' : functionName - this.fileName = isSharedLib ? functionName : null - this.isInit = isInit != null + this.functionName = functionName + this.fileName = null + this.isOptimized = optimizationTag === 'Opt' + this.isUnoptimized = optimizationTag === 'Unopt' } else { throw new Error(`Encountered an unparseable frame "${this.name}"`) } @@ -89,7 +104,8 @@ class FrameNode { const { category, type - } = this.getCoreOrV8Type(name, systemInfo) || + } = this.getWasmType(name) || + this.getCoreOrV8Type(name, systemInfo) || this.getDepType(name, systemInfo) || this.getAppType(name, appName) @@ -116,6 +132,13 @@ class FrameNode { // TODO: add more cases like this } + getWasmType (name) { + if (/\[WASM:\w+]$/.test(name)) { + return { type: 'wasm', category: 'wasm' } + } + return null + } + getCoreOrV8Type (name, systemInfo) { // TODO: see if any subdivisions of core are useful const core = { type: 'core', category: 'core' } @@ -236,6 +259,7 @@ class FrameNode { isUnoptimized: this.isUnoptimized, isInlinable: this.isInlinable, isInit: this.isInit, + isWasm: this.isWasm, value: this.onStack, onStackTop: this.onStackTop, diff --git a/analysis/index.js b/analysis/index.js index c26b0e4b..10bcd56e 100644 --- a/analysis/index.js +++ b/analysis/index.js @@ -12,14 +12,6 @@ const { const readFile = promisify(fs.readFile) -// TODO Remove this function when the UI side of dependency code areas -// is implemented. -function removeDependencyAreasFromCodeAreas (codeAreas) { - const depArea = codeAreas.find((area) => area.id === 'deps') - delete depArea.children - delete depArea.childrenVisibilityToggle -} - async function analyse (paths) { const [systemInfo, ticks, inlined] = await Promise.all([ readFile(paths['/systeminfo'], 'utf8').then(JSON.parse), @@ -51,10 +43,6 @@ async function analyse (paths) { const codeAreas = collectCodeAreas({ merged, unmerged }) - // TODO Remove this TEMPORARY call when UI side of code area collection - // is implemented - removeDependencyAreasFromCodeAreas(codeAreas) - return { appName, pathSeparator, diff --git a/debug/visualize-mod.js b/debug/visualize-mod.js index 9e2f7973..9c3adfcf 100644 --- a/debug/visualize-mod.js +++ b/debug/visualize-mod.js @@ -1,5 +1,5 @@ #!/usr/bin/env node - +const chalk = require('chalk') const Tool = require('../') module.exports = { @@ -7,6 +7,7 @@ module.exports = { for (const file of process.argv.slice(2).map(trim)) { const tool = new Tool({ debug: true }) + console.log(chalk.blue('building...')) tool.visualize( file, file + '.html', @@ -14,7 +15,8 @@ module.exports = { if (err) { throw err } else { - console.log('Wrote', file + '.html') + console.log('-------') + console.log(chalk.bgBlue(' WROTE '), file + '.html') } } ) diff --git a/debug/visualize-watch.js b/debug/visualize-watch.js index bbd04799..2f8c69fb 100644 --- a/debug/visualize-watch.js +++ b/debug/visualize-watch.js @@ -1,23 +1,49 @@ #!/usr/bin/env node -var chokidar = require('chokidar') -var v = require('./visualize-mod.js') +const chokidar = require('chokidar') +const chalk = require('chalk') +const v = require('./visualize-mod.js') + +const buildImg = require('@nearform/clinic-common/scripts/build-images') +// build images +const imgDestDir = `${__dirname}/../visualizer/assets/images` const debounce = require('lodash.debounce') // this is useful when updating multiple files in just one go (i.e. checking-out a branch) const debVisualize = debounce(v.visualize, 100) +// building the images +chokidar + .watch([ + 'visualizer/assets/images/*.png', + 'visualizer/assets/images/*.jpeg', + 'visualizer/assets/images/*.jpg', + 'visualizer/assets/images/*.gif' + ]) + .on('add', (path) => { + console.log(chalk.green('Image:'), path) + buildImg.file(path, imgDestDir) + }) + .on('change', (path) => { + console.log(chalk.green('Image:'), path) + buildImg.file(path, imgDestDir) + }) + +// building css and js files chokidar .watch([ 'visualizer/**/*.css', 'visualizer/**/*.js', 'index.js' ], { - ignoreInitial: true + ignoreInitial: true, + ignored: [ + 'visualizer/assets' + ] }) .on('all', (event, path) => { - console.log(event, path) + console.log(chalk.blue(event.toUpperCase()), path) debVisualize() }) diff --git a/index.js b/index.js index 2f80e44b..023a716c 100644 --- a/index.js +++ b/index.js @@ -129,7 +129,7 @@ class ClinicFlame extends events.EventEmitter { } }) - // uild CSS + // build CSS const styleFile = buildCss({ stylePath, debug: this.debug @@ -147,7 +147,7 @@ class ClinicFlame extends events.EventEmitter { headerText: 'Flame', nearFormLogo: nearFormLogoFile, uploadId: outputFilename.split('/').pop().split('.html').shift(), - body: `
` + body: '
' }) pump( diff --git a/package.json b/package.json index abce76b9..72b8d427 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "author": "", "license": "GPL-3.0-or-later", "dependencies": { - "0x": "^4.7.2", - "@nearform/clinic-common": "^1.5.0", + "0x": "^4.9.1", + "@nearform/clinic-common": "^2.2.0", "copy-to-clipboard": "^3.0.8", "d3-array": "^2.0.2", "d3-fg": "^6.13.1", @@ -43,6 +43,7 @@ }, "devDependencies": { "chokidar": "^3.0.2", + "murmur3hash-wasm": "1.0.1", "snazzy": "^8.0.0", "standard": "^13.0.0", "tap": "^12.0.0" diff --git a/test/analysis-categorise.test.js b/test/analysis-categorise.test.js index a5aecc66..f7ec5a78 100644 --- a/test/analysis-categorise.test.js +++ b/test/analysis-categorise.test.js @@ -53,6 +53,18 @@ test('analysis - categorise node names', (t) => { isInit: false, isInlinable: true }) + t.match(byProps({ name: 'wasm-function[0] [WASM:Opt]' }, linux), { + category: 'wasm', + type: 'wasm', + fileName: null, + isOptimized: true + }) + t.match(byProps({ name: 'ressa::Parser::parse_statement_list_item::ha21ba52d257287dd [WASM:Opt]' }, linux), { + category: 'wasm', + type: 'wasm', + fileName: null, + isOptimized: true + }) t.equal(byName('/usr/bin/node [SHARED_LIB]', linux), 'cpp') t.equal(byName('C:\\Program Files\\nodejs\\node.exe [SHARED_LIB]', windows), 'cpp') diff --git a/test/basic.test.js b/test/basic.test.js index 65b258c8..015d53b4 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -108,3 +108,32 @@ test('cmd - test collect - system info, data files and html', function (t) { } ) }) + +test('cmd - test collect - does not crash on webassembly frames', function (t) { + const tool = new ClinicFlame() + + function cleanup (err, dirname) { + t.ifError(err) + t.match(dirname, /^[0-9]+\.clinic-flame$/) + rimraf(dirname, (err) => { + t.ifError(err) + t.end() + }) + } + + tool.collect( + [process.execPath, path.join('test', 'fixtures', 'wasm.js')], + function (err, dirname) { + if (err) return cleanup(err, dirname) + + const basename = path.basename(dirname) + const systeminfo = JSON.parse(fs.readFileSync(path.join(dirname, `${basename}-systeminfo`))) + // check that samples data and inlined function data exists and is valid JSON + JSON.parse(fs.readFileSync(path.join(dirname, `${basename}-samples`))) + JSON.parse(fs.readFileSync(path.join(dirname, `${basename}-inlinedfunctions`))) + + t.ok(fs.statSync(systeminfo.mainDirectory).isDirectory()) + cleanup(null, dirname) + } + ) +}) diff --git a/test/fixtures/wasm.js b/test/fixtures/wasm.js new file mode 100644 index 00000000..c6b0ebcd --- /dev/null +++ b/test/fixtures/wasm.js @@ -0,0 +1,8 @@ +var f = require('murmur3hash-wasm') +var tenKb = require('crypto').randomBytes(10 * 1024) +var start = Date.now() +var result = 0 +while (Date.now() < start + 1000) { + result ^= f(tenKb, Math.floor(Math.random() * (1 << 31))) +} +console.log(result) diff --git a/visualizer/common/button.css b/visualizer/common/button.css deleted file mode 100644 index cff8c3d5..00000000 --- a/visualizer/common/button.css +++ /dev/null @@ -1,31 +0,0 @@ -.button { - align-items : center; - background-color: var(--opposite-contrast); - border : none; - color : var(--max-contrast); - cursor : pointer; - display : flex; - font-size : var(--small-text-size); - justify-content: center; - line-height : 1; - min-width : 0; - padding : 0.3em; -} -.button:hover:not(:disabled) { - background-color: var(--clickable-bg-hover); - outline : 1px dotted var(--light-glare); -} - -.button .icon-img { - font-size: 1.5em; - margin : 0.133em 0.2em; - flex-shrink: 0; -} - -.button .label { - margin : 0.3em; - overflow : hidden; - text-overflow: ellipsis; - white-space : nowrap; - min-width : 0; -} \ No newline at end of file diff --git a/visualizer/common/button.js b/visualizer/common/button.js deleted file mode 100644 index 3886aa07..00000000 --- a/visualizer/common/button.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = ({ label, classNames = [], leftIcon = '', rightIcon = '', disabled = false, onClick } = {}) => { - const button = document.createElement('button') - button.classList.add('button', ...classNames) - if (disabled) button.attr('disabled', true) - - if (onClick) { - button.addEventListener('click', onClick) - } - button.innerHTML = ` - ${leftIcon} - ${label ? `${label}` : ``} - ${rightIcon} - ` - return button -} diff --git a/visualizer/common/checkbox.css b/visualizer/common/checkbox.css deleted file mode 100644 index 18eb0a5c..00000000 --- a/visualizer/common/checkbox.css +++ /dev/null @@ -1,67 +0,0 @@ -.checkbox { - background-color: var(--opposite-contrast); - padding : 0.3em; - display : flex; - cursor : pointer; - align-items : center; - min-width : 0; -} - -.checkbox input { - display: none; -} - -.checkbox .copy-wrapper { - margin : 0.3em; - overflow : hidden; - text-overflow: ellipsis; - white-space : nowrap; - min-width : 0; -} - -.checkbox:hover:not(:disabled) { - background-color: var(--clickable-bg-hover); - outline: 1px dotted var(--light-glare); -} - -.checkbox .icon-wrapper { - align-items : center; - border-radius : 3px; - display : flex; - font-size : 1.5em; - margin : 0.133em 0.2em; - flex-shrink : 0; - justify-content: center; - color : var(--checkbox-border-color); -} -.checkbox .icon-wrapper svg{ - display: none; -} -.checkbox .icon-wrapper .checkbox-unchecked-svg{ - display: initial; -} - -.checkbox :checked ~ .icon-wrapper .checkbox-unchecked-svg{ - display: none; -} - -.checkbox :checked ~ .icon-wrapper{ - color: var(--max-contrast); -} - -.checkbox :checked ~ .icon-wrapper .checkbox-checked-svg{ - display: initial; -} - -.checkbox :indeterminate ~ .icon-wrapper { - color: var(--grey-highlight); -} - -.checkbox :indeterminate ~ .icon-wrapper .checkbox-indetermined-svg { - display: initial; -} - -.checkbox :indeterminate ~ .icon-wrapper .checkbox-unchecked-svg, -.checkbox :indeterminate ~ .icon-wrapper .checkbox-checked-svg{ - display: none; -} \ No newline at end of file diff --git a/visualizer/common/checkbox.js b/visualizer/common/checkbox.js deleted file mode 100644 index bb0ac152..00000000 --- a/visualizer/common/checkbox.js +++ /dev/null @@ -1,36 +0,0 @@ -const checkboxCheckedIcon = require('@nearform/clinic-common/icons/checkbox-checked') -const checkboxUncheckedIcon = require('@nearform/clinic-common/icons/checkbox-unchecked') -const checkboxIndeterminedIcon = require('@nearform/clinic-common/icons/checkbox-indetermined') - -module.exports = ({ leftLabel, rightLabel, classNames = [], checked = false, disabled = false, indeterminate = false, onChange } = {}) => { - const wrappingLabel = document.createElement('label') - wrappingLabel.classList.add('checkbox', ...classNames) - - wrappingLabel.innerHTML = ` - - ${leftLabel ? ` - - ${leftLabel} - ` : ``} - - - ${checkboxCheckedIcon} - ${checkboxUncheckedIcon} - ${checkboxIndeterminedIcon} - - ${rightLabel ? ` - - ${rightLabel} - ` : ``} - ` - const input = wrappingLabel.querySelector('input') - input.indeterminate = indeterminate - - if (onChange) { - input.addEventListener('change', onChange) - } - return wrappingLabel -} diff --git a/visualizer/common/drop-down.css b/visualizer/common/drop-down.css deleted file mode 100644 index dc67d4b5..00000000 --- a/visualizer/common/drop-down.css +++ /dev/null @@ -1,84 +0,0 @@ -.dropdown { - background-color: rgba(255, 255, 255, 0.7); - padding: 1px; - display: flex; - position: relative; -} -.dropdown .label{ - padding: 0.2em 0.5em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dropdown .label-wrapper { - background-color: var(--opposite-contrast); - align-items: center; - display: flex; - min-width: 2.4em; -} - -.dropdown .button{ - margin-left: 1px; - padding: 2px; -} - -.dropdown .button .icon-img { - margin: 2px; - transition: transform 0.6s cubic-bezier(0.42, 0, 0.23, 1.65); - transform-origin: 50%; -} - -.dropdown.expanded .button .icon-img { - transform: rotateZ(-90deg); -} - -.dropdown .dropdown-content-wrapper { - background-color: var(--opposite-contrast); - position: absolute; - right: 0; - top: calc(100% + 2px); - display: none; - opacity: 0; - --anim-duration: 0.2s; - transform-origin: right bottom; - border: 1px solid rgba(255, 255, 255, 0.5); -} - -.dropdown.contracted .dropdown-content-wrapper { - display: block; - animation: var(--anim-duration) dropdown-contract 0s 1 normal forwards; -} -.dropdown.expanded .dropdown-content-wrapper { - display: block; - animation: var(--anim-duration) dropdown-expand 0.001s 1 normal forwards; -} -.dropdown.direction-up .dropdown-content-wrapper { - top: auto; - bottom: calc(100% + 2px); -} - - /* animation */ -@keyframes dropdown-expand { - 0% { - opacity: 0; - transform: scale(0.9); - } - - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes dropdown-contract { - 0% { - opacity: 1; - transform: scale(1); - } - - 100% { - opacity: 0; - transform: scale(0.9); - } -} \ No newline at end of file diff --git a/visualizer/common/drop-down.js b/visualizer/common/drop-down.js deleted file mode 100644 index 00da4203..00000000 --- a/visualizer/common/drop-down.js +++ /dev/null @@ -1,68 +0,0 @@ -const caretRight = require('@nearform/clinic-common/icons/caret-right') -const caretLeft = require('@nearform/clinic-common/icons/caret-left') -const button = require('./button.js') -const { toHtml } = require('./helpers.js') - -let currentlyExpandedDropDown = null - -// Closes when the user clicks outside the dropdown content. -document.body.addEventListener('click', (event) => { - if (event.target.closest('.dropdown-content-wrapper') !== currentlyExpandedDropDown) closeCurrentlyExpandedDropDown() -}) - -function closeCurrentlyExpandedDropDown () { - currentlyExpandedDropDown && currentlyExpandedDropDown.closest('.dropdown').close() -} - -module.exports = ({ label, classNames = [], disabled = false, expandAbove = false, content } = {}) => { - const wrapper = document.createElement('div') - wrapper.classList.add('dropdown', ...classNames) - wrapper.classList.toggle('direction-up', expandAbove) - - const labelWrapper = document.createElement('div') - labelWrapper.classList.add('label-wrapper') - const labelHtml = toHtml(label, 'label') - labelWrapper.appendChild(labelHtml) - wrapper.appendChild(labelWrapper) - - wrapper.appendChild(button({ - disabled, - leftIcon: expandAbove ? caretRight : caretLeft, - onClick: e => { - e.stopPropagation() - - if (wrapper.classList.contains('expanded')) { - wrapper.close() - } else { - wrapper.open() - } - } - })) - - wrapper.addEventListener('animationend', () => { - wrapper.classList.toggle('contracted', false) - }) - - const contentWrapper = document.createElement('div') - contentWrapper.classList.add('dropdown-content-wrapper') - - wrapper.close = () => { - wrapper.classList.toggle('expanded', false) - wrapper.classList.toggle('contracted', true) - currentlyExpandedDropDown = null - } - wrapper.open = () => { - closeCurrentlyExpandedDropDown() - contentWrapper.innerHTML = '' - if (content) { - contentWrapper.appendChild(toHtml(content), 'content') - } - wrapper.classList.toggle('contracted', false) - wrapper.classList.toggle('expanded', true) - currentlyExpandedDropDown = contentWrapper - } - - wrapper.appendChild(contentWrapper) - - return wrapper -} diff --git a/visualizer/common/helpers.js b/visualizer/common/helpers.js deleted file mode 100644 index 870ab2dc..00000000 --- a/visualizer/common/helpers.js +++ /dev/null @@ -1,33 +0,0 @@ -const helpers = { - toHtml: (content, defaultClass) => { - switch (typeof content) { - case 'string': - if (content.indexOf('<') === 0) { - const parser = new window.DOMParser() - return parser.parseFromString(content, 'text/html') - // returns a HTMLDocument, which also is a Document. - } else { - var node = document.createElement('span') - node.className = defaultClass || '' - node.textContent = content - return node - } - - case 'function': - return helpers.toHtml(content()) - - case 'object': - if (content.nodeType === 1) { - // it is an HTMLElement - if (content.classList.length === 0) { - content.className = defaultClass || '' - } - return content - } - } - - throw new TypeError('The provided content is not a String, a function or an HTMLElement ') - } -} - -module.exports = helpers diff --git a/visualizer/data-tree.js b/visualizer/data-tree.js index 6c9555fe..7d474c80 100644 --- a/visualizer/data-tree.js +++ b/visualizer/data-tree.js @@ -30,6 +30,9 @@ class DataTree { this.maxRootAboveMean = 0 this.maxRootBelowMean = 0 + // Set by countTotalFrames + this.totalFrames = null + this.setStackTop = shared.setStackTop.bind(this) this.isNodeExcluded = shared.isNodeExcluded.bind(this) } diff --git a/visualizer/filters-bar.css b/visualizer/filters-bar.css new file mode 100644 index 00000000..099d3909 --- /dev/null +++ b/visualizer/filters-bar.css @@ -0,0 +1,87 @@ +#filters-bar { + align-items : center; + background-color: var(--banner-bg-color); + display : flex; + justify-content : space-between; + padding : 8px; + position : relative; +} + +#filters-bar .options-menu-toggle { + min-width: 130px; +} + +#filters-bar .col-wrapper { + display: flex; + min-width: 0; +} +#filters-bar .col-wrapper > * { + margin: 0 2px; + min-width: 0; +} + +.dropdown-content-wrapper { + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; +} + +#filters-bar .filter-option { + padding: 1px; +} + +#filters-bar .filter-option .label-wrapper { + background-color: var(--opposite-contrast); +} + +#filters-bar .filter-option.nc-dropdown > .nc-button .nc-button__inner-container { + padding-left: 0; + padding-right: 0; +} + +#filters-bar .filter-option .nc-dropdown-content-wrapper { + min-width: 100%; +} + +#filters-bar .key-app { + color: var(--area-color-app); + background-color: var(--area-color-app); +} + +#filters-bar .key-deps { + color: var(--area-color-deps); + background-color: var(--area-color-deps); +} + +#filters-bar .key-core, +#filters-bar .key-wasm, +#filters-bar .key-v8, +#filters-bar .key-v8 .dropdown-content-wrapper { + color: var(--area-color-core); + background-color: var(--area-color-core); +} + +#filters-bar .key-app .checkbox-copy-label:before, +#filters-bar .key-deps .checkbox-copy-label:before, +#filters-bar .key-core .checkbox-copy-label:before, +#filters-bar .key-wasm .checkbox-copy-label:before { + content: ''; + width: 1em; + height: 1em; + border-radius: 50%; + background-color: currentColor; + vertical-align: text-top; + margin-right: 0.3em; +} + +@media screen and (min-width: 630px) { + #filters-bar .key-app .checkbox-copy-label:before, + #filters-bar .key-deps .checkbox-copy-label:before, + #filters-bar .key-core .checkbox-copy-label:before, + #filters-bar .key-wasm .checkbox-copy-label:before { + display: inline-flex; + } +} + +#filters-bar .filter-option .nc-dropdown-content-wrapper .checkbox-copy-label:before { + display: none; +} diff --git a/visualizer/filters-bar.js b/visualizer/filters-bar.js new file mode 100644 index 00000000..7ad3c897 --- /dev/null +++ b/visualizer/filters-bar.js @@ -0,0 +1,293 @@ +'use strict' +const HtmlContent = require('./html-content.js') +const sidePanelExpand = require('@nearform/clinic-common/icons/sidepanel-expand') +const sidePanelCollapse = require('@nearform/clinic-common/icons/sidepanel-collapse') +const search = require('@nearform/clinic-common/icons/search') + +const { button, checkbox, dropdown } = require('@nearform/clinic-common/base') + +class FiltersContainer extends HtmlContent { + constructor (parentContent, contentProperties = {}) { + super(parentContent, contentProperties) + + this.getSpinner = contentProperties.getSpinner + + // layout wrappers + this.d3Left = this.addContent('HtmlContent', { + classNames: 'left-col col-wrapper' + }) + + this.d3Center = this.addContent('HtmlContent', { + classNames: 'center-col col-wrapper' + }) + + this.d3Right = this.addContent('HtmlContent', { + classNames: 'right-col col-wrapper' + }) + + // components + this.d3Right.addContent('SearchBox', { + id: 'search-box', + classNames: 'inline-panel after-bp-2' + }) + + this.showSideBar = false + + this.ui.on('sideBar', show => { + this.showSideBar = show + this.draw() + }) + + this.ui.on('setData', () => { + this.draw() + }) + this.toggleSideBar = contentProperties.toggleSideBar.bind(this.ui) + } + + initializeElements () { + super.initializeElements() + + // App checkbox + this.d3AppCheckBox = this.d3Center.d3Element.append('div') + .classed('filter-option', true) + .classed('key-app', true) + .append('div') + .classed('label-wrapper', true) + .append(() => + checkbox({ + leftLabel: 'app', + onChange: e => this.setCodeAreaVisibility('app', e.target) + }) + ) + + // Dependencies combo **** + this.depsDropDown = dropdown({ + classNames: ['filter-option', 'key-deps'], + label: checkbox({ + leftLabel: `Dependencies + Deps`, + onChange: e => this.setCodeAreaVisibility('deps', e.target) + }), + content: getChildrenHtml.bind(this, 'deps'), + expandAbove: true + }) + this.d3DepsCombo = this.d3Center.d3Element.append(() => this.depsDropDown) + + // TODO maybe disable if there are no wasm frames? + this.d3WasmCheckBox = this.d3Center.d3Element.append('div') + .classed('filter-option', true) + .classed('key-wasm', true) + .append('div') + .classed('label-wrapper', true) + .append(() => + checkbox({ + leftLabel: `WebAssembly + WASM`, + onChange: e => this.setCodeAreaVisibility('wasm', e.target) + }) + ) + + // NodeJS checkbox **** + this.d3NodeCheckBox = this.d3Center.d3Element.append('div') + .classed('filter-option', true) + .classed('key-core', true) + .append('div') + .classed('label-wrapper', true) + .append(() => + checkbox({ + leftLabel: `Node JS + Node`, + onChange: e => this.setCodeAreaVisibility('core', e.target) + }) + ) + + // V8 combo **** + this.d3V8Combo = this.d3Center.d3Element.append(() => dropdown({ + classNames: ['filter-option', 'key-v8'], + label: checkbox({ + leftLabel: 'V8', + onChange: e => { + this.setCodeAreaVisibility('all-v8', e.target) + } + }), + content: getChildrenHtml.bind(this, 'all-v8'), + expandAbove: true + })) + + this.d3Right.d3Element + .append(() => button({ + leftIcon: search, + classNames: ['before-bp-2'], + onClick: () => this.ui.toggleMobileSearchBox() + })) + + this.optionsBp1 = button({ + classNames: ['sidebar-toggler', 'before-bp-1'], + rightIcon: sidePanelExpand, + onClick: () => this.toggleSideBar() + }) + + this.d3Right.d3Element + .append(() => this.optionsBp1) + + this.optionsBp2 = button({ + classNames: ['sidebar-toggler', 'after-bp-1'], + label: 'Options', + rightIcon: sidePanelExpand, + onClick: () => this.toggleSideBar() + }) + this.d3Right.d3Element + .append(() => this.optionsBp2) + } + + setCodeAreaVisibility (key, checkboxElement) { + const parent = checkboxElement.parentElement + const checked = checkboxElement.checked + parent.classList.add('pulsing') + + // Need to give the browser the time to actually execute spinner.show + setTimeout( + () => { + const area = this.ui.dataTree.codeAreas + .find( + data => data.excludeKey === key + ) + if (area) { + this.ui.setCodeAreaVisibility({ + codeArea: area, + visible: checked + }) + this.ui.updateExclusions() + this.ui.draw() + } else { + console.error('Could not find code area for', key, 'in', this.ui) + } + parent.classList.remove('pulsing') + } + , 15) + } + + draw () { + super.draw() + + this.optionsBp1.update({ + rightIcon: this.showSideBar ? sidePanelCollapse : sidePanelExpand + }) + this.optionsBp2.update({ + rightIcon: this.showSideBar ? sidePanelCollapse : sidePanelExpand + }) + + // app + this.d3AppCheckBox.select('input').node() + .checked = !this.ui.dataTree.exclude.has('app') + this.d3AppCheckBox.select('.checkbox-copy-label') + .html(` + + ${this.ui.dataTree.appName} + + App + `) + + this.d3WasmCheckBox.select('input').node() + .checked = !this.ui.dataTree.exclude.has('wasm') + + // node js + this.d3NodeCheckBox.select('input').node() + .checked = !this.ui.dataTree.exclude.has('core') + + // deps + const d3DepsInput = this.d3DepsCombo.select('input').node() + const deps = this.ui.dataTree.codeAreas.find( + data => data.excludeKey === 'deps' + ) + this.depsDropDown.update({ + content: getChildrenHtml.bind(this, 'deps') + }) + d3DepsInput.indeterminate = (() => { + const { children } = deps + if (!Array.isArray(children) || children.length === 0) { + return false + } + const first = this.ui.dataTree.exclude.has(children[0].excludeKey) + + return children.some((child) => this.ui.dataTree.exclude.has(child.excludeKey) !== first) + })() + d3DepsInput.checked = (() => { + if (deps.children && deps.children.length) { + return deps.children.some((child) => { + return !this.ui.dataTree.exclude.has(child.excludeKey) + }) + } + return !this.ui.dataTree.exclude.has(deps.excludeKey) + })() + + // V8 + const d3V8Input = this.d3V8Combo.select('input').node() + const V8 = this.ui.dataTree.codeAreas.find( + data => data.excludeKey === 'all-v8' + ) + d3V8Input.indeterminate = (() => { + const { children } = V8 + if (!Array.isArray(children) || children.length === 0) { + return false + } + const first = this.ui.dataTree.exclude.has(children[0].excludeKey) + + return children.some((child) => this.ui.dataTree.exclude.has(child.excludeKey) !== first) + })() + d3V8Input.checked = (() => { + if (V8.children && V8.children.length) { + return V8.children.some((child) => { + return !this.ui.dataTree.exclude.has(child.excludeKey) + }) + } + return !this.ui.dataTree.exclude.has(V8.excludeKey) + })() + } +} + +function getChildrenHtml (key) { + const area = this.ui.dataTree.codeAreas + .find( + data => data.excludeKey === key + ) + + if (!area.children) return '' + const list = area.children + .map(d => { + const elem = checkbox({ + rightLabel: d.label, + checked: !this.ui.dataTree.exclude.has(d.excludeKey) + }) + elem.querySelector('input').dataset.excludeKey = d.excludeKey + + return elem + }) + + const wrapper = document.createElement('div') + + list.forEach(l => wrapper.appendChild(l)) + + wrapper.addEventListener('change', e => { + const target = e.target + const parent = target.parentElement + parent.classList.add('pulsing') + + const codeArea = area.children.find(d => d.excludeKey === target.dataset.excludeKey) + + setTimeout(() => { + this.ui.setCodeAreaVisibility({ + codeArea, + visible: target.checked + }) + + this.ui.updateExclusions() + this.ui.draw() + parent.classList.remove('pulsing') + }, 15) + }) + + return wrapper +} + +module.exports = FiltersContainer diff --git a/visualizer/filters-content.css b/visualizer/filters-content.css new file mode 100644 index 00000000..3203c0f8 --- /dev/null +++ b/visualizer/filters-content.css @@ -0,0 +1,105 @@ +.filters-content { + padding: 1px; + box-shadow: 3px 0px 4px -1px rgba(0,0,0,0.7) inset; + color: var(--grey-highlight); +} + +.filters-content .nc-accordion > .nc-button { + position: relative; /* Appear above button hover */ + font-size: 1.4rem; +} + +.filters-content .description { + color: var(--primary-grey); +} + +.filters-content .nc-accordion.nc-accordion--secondary > .nc-button { + /* Solid colors overriding default */ + --nc-button-bgColor: var(--banner-bg-color); + --nc-button-bgHover: var(--banner-bg-color); +} + +.filters-content .nc-accordion.nc-accordion--secondary { + margin-top: -8px; + padding-right: 8px; + text-align: right; +} + +.filters-content .nc-accordion.nc-accordion--secondary .nc-collapsible-container { + text-align: left; + border-bottom: none; + margin-left: 2px; +} + +.filters-content .nc-accordion.nc-accordion--secondary.expanded .nc-collapsible-container { + margin-bottom: 8px; +} + +.filters-content .options > li { + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.filters-content li>ul { + padding-left: 24px; +} + +.filters-content ul { + margin: 0; + padding: 0; + list-style: none; +} + +.filters-content .section { + margin: 1px; +} + +.filters-content .options { + padding-bottom: 1em; +} + +.filters-content .options label { + align-items: flex-start; + background-color: transparent; + cursor: pointer; + display: flex; + line-height: 1.3; + padding: 0.7em 5px; + width: 100%; +} + +.filters-content .options li.disabled { + opacity: 0.3; +} +.filters-content .options li.disabled label { + cursor: default; +} + +.filters-content .options .app { + color: var(--area-color-app); +} + +.filters-content .options .core, +.filters-content .options .all-v8 { + color: var(--area-color-core); +} + +.filters-content .options .deps { + color: var(--area-color-deps); +} + +.filters-content .options .description .more-info{ + color: white; + opacity: 0.7; + text-decoration: none; +} + +.filters-content .options .description .more-info:hover{ + opacity: 1; +} + +.filters-content .nc-checkbox .copy-wrapper { + flex-grow: 1; + white-space: normal; +} + + diff --git a/visualizer/filters-content.js b/visualizer/filters-content.js new file mode 100644 index 00000000..bc6bf649 --- /dev/null +++ b/visualizer/filters-content.js @@ -0,0 +1,311 @@ +'use strict' + +const HtmlContent = require('./html-content.js') +const { checkbox, accordion, helpers } = require('@nearform/clinic-common/base') + +class FiltersContent extends HtmlContent { + constructor (parentContent, contentProperties = {}) { + super(parentContent, contentProperties) + + this.sections = null + this.currentAccordion = null + this.expandedSubAccordions = {} + + this.getSpinner = contentProperties.getSpinner + + this.ui.on('presentationMode', mode => { + this.presentationMode = mode + this.update() + this.draw() + }) + + this.ui.on('updateExclusions', () => { + this.update() + this.draw() + }) + + this.ui.on('setData', () => { + this.update() + this.draw() + }) + } + + update () { + // Preferences + this.sections = { + preferences: [ + { + id: 'presentation_mode', + label: 'Presentation mode', + checked: this.ui.presentationMode === true, + value: false, + onChange: (datum, event) => { + this.ui.setPresentationMode(event.target.checked) + } + } + ] + } + + const data = this.ui.dataTree + + if (data) { + const { + codeAreas, + useMerged, + showOptimizationStatus, + exclude + } = data + + // Code Areas + this.sections.codeAreas = codeAreas.map(area => { + const dataCount = this.getDataCountFromKey(area.excludeKey) + + let disabled = data.disabled === true + + if (area.excludeKey === 'deps') { + // checking Dependencies only + disabled = dataCount === 0 || data.disabled === true + } + + if (area.children) { + area.children = area.children.map(c => { + const child = Object.assign({}, c) + child.label = this.ui.getLabelFromKey(child.excludeKey) + child.description = this.ui.getDescriptionFromKey(child.excludeKey) + child.checked = (() => { + if (child.children) { + return child.children.some((ch) => !exclude.has(ch.excludeKey)) + } + return !exclude.has(child.excludeKey) + })() + child.onChange = (datum, event) => { + this._onVisibilityChange(datum, event.target) + } + + return child + }) + } + + const checked = (() => { + if (area.children && area.children.length) { + return area.children.some((child) => !exclude.has(child.excludeKey)) + } + return !exclude.has(area.excludeKey) + })() + + const indeterminate = (() => { + const { children } = area + if (!Array.isArray(children) || children.length === 0) { + return false + } + + const first = exclude.has(children[0].excludeKey) + return children.some((child) => exclude.has(child.excludeKey) !== first) + })() + + return Object.assign({}, area, { + label: this.ui.getLabelFromKey(area.excludeKey), + description: this.ui.getDescriptionFromKey(area.excludeKey), + disabled, + checked, + indeterminate, + onChange: (datum, event) => { + this._onVisibilityChange(datum, event.target) + } + }) + }) + + // Advanced + this.sections.advanced = [ + { + id: 'option-init', + label: 'Init', + description: 'Show initialization operations hidden by default, like module loading', + checked: !exclude.has('is:init'), + onChange: (datum, event) => { + this.ui.setCodeAreaVisibility({ excludeKey: 'is:init' }, event.target.checked) + this.ui.draw() + } + }, + { + id: 'option-usemergedtree', + label: 'Merge', + description: 'Join optimized and unoptimized versions of frames', + checked: useMerged, + onChange: (datum, event) => { + this.ui.setUseMergedTree(event.target.checked) + } + }, + { + id: 'option-showoptimizationstatus', + label: 'Show optimization status', + description: 'Highlight frames that are optimized functions', + disabled: useMerged, + checked: showOptimizationStatus, + onChange: (datum, event) => { + this.ui.setShowOptimizationStatus(event.target.checked) + } + } + + ] + + this.exclude = exclude + } + } + + initializeElements () { + super.initializeElements() + this.d3ContentWrapper + .classed('filters-content', true) + .classed('scroll-container', true) + + // creating the main sections + // Code Areas + this.d3CodeArea = this.d3ContentWrapper.append('div') + .classed('code-area section', true) + + const visibilityAcc = accordion({ + label: 'Visibility by code area', + isExpanded: true, + content: `
    `, + classNames: ['visibility-acc'], + onClick: () => { + this._exclusiveAccordion(visibilityAcc) + } + }) + this.d3CodeArea.append(() => visibilityAcc) + this.currentAccordion = visibilityAcc + + // Advanced + this.d3Advanced = this.d3ContentWrapper.append('div') + .classed('advanced section', true) + + const advancedAcc = accordion({ + label: 'Advanced', + content: `
      `, + classNames: ['advanced-acc'], + onClick: () => { + this._exclusiveAccordion(advancedAcc) + } + }) + this.d3Advanced.append(() => advancedAcc) + + // Preferences + this.d3Preferences = this.d3ContentWrapper.append('div') + .classed('preferences section', true) + + const preferencesAcc = accordion({ + label: 'Preferences', + content: `
        `, + classNames: ['preferences-acc'], + onClick: () => { + this._exclusiveAccordion(preferencesAcc) + } + }) + this.d3Preferences.append(() => preferencesAcc) + } + + getDataCountFromKey (key) { + return this.ui.dataTree ? this.ui.dataTree.activeNodes().filter(n => n.category === key).length : 0 + } + + _exclusiveAccordion (clickedAccordion) { + // auto collapses the previously expanded accordion + (this.currentAccordion !== clickedAccordion) && this.currentAccordion.toggle(false) + this.currentAccordion = clickedAccordion + } + + _onVisibilityChange (datum, targetElement, updatingChildren) { + const checked = targetElement.checked + const parent = targetElement.parentElement + + parent.classList.add('pulsing') + + // Need to give the browser the time to apply the class + setTimeout( + () => { + this.ui.setCodeAreaVisibility({ + codeArea: datum, + visible: checked + }) + this.ui.draw() + parent.classList.remove('pulsing') + }, 15) + } + + _createListItems (items) { + const fragment = document.createDocumentFragment() + + items.forEach(item => { + const li = helpers.toHtml('
      • ') + fragment.appendChild(li) + li.appendChild(this._createOptionElement(item)) + + if (item.children && item.children.length > 0) { + const childrenUl = helpers.toHtml(`
          `) + childrenUl.appendChild(this._createListItems(item.children)) + li.appendChild(childrenUl) + + if (item.childrenVisibilityToggle) { + const acc = accordion({ + isExpanded: this.expandedSubAccordions[item.excludeKey] === true, + classNames: [`${item.excludeKey}-show-all-acc`, `nc-accordion--secondary`], + label: `Show more (${item.children.length})`, + content: childrenUl, + onClick: (expanded) => { + this.expandedSubAccordions[item.excludeKey] = expanded + } + }) + + li.appendChild(acc) + } + } + }) + + return fragment + } + + _createOptionElement (data) { + const div = helpers.toHtml(`
          `) + + div.appendChild(checkbox({ + checked: data.checked, + indeterminate: data.indeterminate, + rightLabel: ` + ${data.label} + ${data.description ? `- ${data.description}` : ``} + `, + onChange: (event) => { + data.onChange && data.onChange(data, event) + } + })) + + return div + } + + draw () { + super.draw() + + if (this.sections) { + // Code Areas + const codeAreas = this.sections.codeAreas.map(d => { + return Object.assign({}, d, { children: d.children && d.children.length ? d.children : undefined }) + }) + let ul = this.d3CodeArea.select('ul').node() + ul.innerHTML = '' + ul.appendChild(this._createListItems(codeAreas)) + + // Advanced + ul = this.d3Advanced.select('ul').node() + ul.innerHTML = '' + ul.appendChild(this._createListItems(this.sections.advanced)) + + // Preferences + ul = this.d3Preferences.select('ul').node() + ul.innerHTML = '' + ul.appendChild(this._createListItems(this.sections.preferences)) + } + } +} + +module.exports = FiltersContent diff --git a/visualizer/flame-graph-label.js b/visualizer/flame-graph-label.js index 5e8ff896..e73f3697 100644 --- a/visualizer/flame-graph-label.js +++ b/visualizer/flame-graph-label.js @@ -54,6 +54,7 @@ function renderFrameLabel (frameHeight, options) { if (fileName === null) { if (nodeData.type === 'v8') fileName = 'Compiled V8 C++' if (nodeData.type === 'cpp') fileName = 'Compiled C++' + if (nodeData.type === 'wasm') fileName = 'Compiled WebAssembly' } if (nodeData.category === 'deps') fileName = fileName.replace(/\.\.?[\\/]node_modules[\\/]/, '') diff --git a/visualizer/flame-graph.css b/visualizer/flame-graph.css index 2e9cb8ce..a76b80e1 100644 --- a/visualizer/flame-graph.css +++ b/visualizer/flame-graph.css @@ -10,6 +10,15 @@ section#flame-main { display: block; position: relative; } + +#flame-main .scroll-container { + flex-grow: 1; + flex-shrink: 1; + overflow: auto; + overflow-x: hidden; + position: relative; +} + chart { display: block; } diff --git a/visualizer/flame-graph.js b/visualizer/flame-graph.js index bc714c25..06e37fbb 100644 --- a/visualizer/flame-graph.js +++ b/visualizer/flame-graph.js @@ -18,7 +18,7 @@ class FlameGraph extends HtmlContent { constructor (parentContent, contentProperties = {}) { const defaults = { showOptimizationStatus: false, - labelFont: 'Verdana, sans-serif', + labelFont: 'Archia, sans-serif', labelPadding: 3 } contentProperties = Object.assign({}, defaults, contentProperties) @@ -42,6 +42,10 @@ class FlameGraph extends HtmlContent { this.onNextAnimationEnd = null + this.ui.on('uiFontLoaded', () => { + this.draw(true) + }) + this.ui.on('setData', () => { this.initializeFromData() }) @@ -402,7 +406,7 @@ class FlameGraph extends HtmlContent { } } - draw () { + draw (forceRedraw = false) { super.draw() const { dataTree } = this.ui @@ -417,7 +421,7 @@ class FlameGraph extends HtmlContent { this.sizeChanged = false } - let redrawGraph = false + let redrawGraph = forceRedraw if (this.renderedTree !== dataTree.activeTree()) { this.renderedTree = dataTree.activeTree() diff --git a/visualizer/history.js b/visualizer/history.js index c4afed27..307dd4a2 100644 --- a/visualizer/history.js +++ b/visualizer/history.js @@ -108,13 +108,15 @@ class History extends EventEmitter { const selectedNodeId = parseInt(params.selectedNode, 10) const zoomedNodeId = parseInt(params.zoomedNode, 10) const search = params.search || null + const walkthroughIndex = params.walkthroughIndex ? parseInt(params.walkthroughIndex) : undefined return { exclude, useMerged, showOptimizationStatus, selectedNodeId, zoomedNodeId, - search + search, + walkthroughIndex } } @@ -125,7 +127,8 @@ class History extends EventEmitter { showOptimizationStatus, selectedNodeId, zoomedNodeId, - search + search, + walkthroughIndex }) { const params = { selectedNode: selectedNodeId, @@ -138,6 +141,10 @@ class History extends EventEmitter { if (search != null && search !== '') { params.search = search } + + if (walkthroughIndex !== undefined) { + params.walkthroughIndex = walkthroughIndex + } if (useMerged) { params.merged = true } diff --git a/visualizer/html-content-types.js b/visualizer/html-content-types.js index 79bf5985..f56ecf23 100644 --- a/visualizer/html-content-types.js +++ b/visualizer/html-content-types.js @@ -10,11 +10,13 @@ module.exports = { Key: require('./key.js'), FlameGraph: require('./flame-graph.js'), Tooltip: require('./tooltip.js'), - OptionsMenu: require('./options-menu.js'), SearchBox: require('./search-box.js'), StackBar: require('./stack-bar.js'), SelectionControls: require('./selection-controls.js'), - InfoBox: require('./info-box.js') + InfoBox: require('./info-box.js'), + FiltersContainer: require('./filters-bar.js'), + FiltersContent: require('./filters-content.js'), + SideBar: require('./side-bar.js') // TODO: add these ↴ // FrameInfo: require('./frame-info.js'), diff --git a/visualizer/info-box.css b/visualizer/info-box.css index 6e65ac1a..bc058819 100644 --- a/visualizer/info-box.css +++ b/visualizer/info-box.css @@ -1,18 +1,51 @@ +#info-box { + display: flex; + flex-direction: row-reverse; + justify-content: stretch; + position: relative; +} + #info-box .frame-info { - background-color: rgba(var(--opposite-color-val), 0.5); - color: var(--grey-highlight); display: flex; + flex-direction: column; + flex-grow: 1; + align-items: center; font-size: var(--normal-text-size); + line-height: 1.1; height: 2.9em; margin: 0; + padding: 4px 8px; + font-family: monospace, monospace; + font-size: var(--normal-text-size); + white-space: nowrap; overflow: hidden; - padding: 2px 8px; + background-color: rgba(var(--opposite-color-val), 0.5); + color: var(--grey-highlight); + /* make sure the text has enough space as dictated by `height` */ + box-sizing: content-box; +} + +#info-box .frame-dropdown { + display: flex; + align-items: center; + justify-content: center; + min-width: 70px; + padding: 2px 4px; white-space: pre-wrap; + font-weight: bold; + background-color: black; + color: white; + border: 0; + cursor: pointer; } -#info-box .frame-info:hover { - height: auto; - min-height: 2.9em; +#info-box .frame-dropdown:focus { + outline: 0; +} + +#info-box .frame-dropdown span { + min-width: 36px; + text-align: left; } #info-box .frame-info strong { @@ -20,53 +53,137 @@ word-break: break-word; } -#info-box span.frame-info-item { - flex-grow: 1; +#info-box .frame-dropdown svg { + margin-right: -8px; + font-size: 2em; + transition: transform 0.6s cubic-bezier(0.42, 0, 0.23, 1.65); } -#info-box .frame-info .frame-area { - color: var(--area-color-core); - word-break: break-word; - text-align: right; +#info-box.collapsed .frame-dropdown svg { + transform: rotate(180deg); +} + +#info-box .collapsible-content-wrapper { + position: absolute; + right: 0; + top: 100%; + min-width: 320px; + padding: 8px; + font-size: var(--small-text-size); + color: white; + background-color: var(--options-menu-bg-color); +} + +#info-box .collapsible-content-wrapper h2 { + padding: 0 8px 6px 8px; + margin: 0 -8px; + font-size: var(--small-text-size); + border-bottom: 3px solid var(--main-bg-color); +} + +#info-box .collapsible-content-wrapper > :first-child { + margin-top: 0; } #info-box .frame-info-item { - padding: 0 8px; + padding: 0 4px; } -#info-box .frame-info .frame-path { +#info-box .collapsible-content-wrapper > :last-child { + margin-bottom: 0; +} + +#info-box .frame-info-item { + max-height: 100%; overflow: hidden; - word-break: break-word; - flex-shrink: 0; + text-overflow: ellipsis; } -/* Share space, with the file path always starting in a consistent position, */ -/* unless function name is unusually long, in which case it shrinks to fit */ #info-box .frame-function { - flex-grow: 0; + color: var(--max-contrast); + word-break: break-word; } -#info-box .frame-path, #info-box .frame-area { - flex-grow: 1; + color: var(--area-color-core); } -#info-box .frame-function, -#info-box .frame-area { - min-width: 16.67%; +#info-box .frame-line-col { + color: var(--primary-grey); } -#info-box .frame-path { - max-width: 66.67%; +#info-box .frame-line-col span { + display: none; } -#info-box .frame-line-col { - color: var(--primary-grey); - white-space: nowrap; +.visible-md { + display: none; } +#ui-tooltip .frame-tooltip { + width: 100vw; + max-width: 100vw; +} + +@media (min-width: 680px) { + #info-box .frame-info { + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 4em; + } + + #info-box .frame-info-item { + line-height: 1; + white-space: pre-wrap; + } + + #info-box .frame-function { + min-width: 20%; + } + + #info-box .frame-path { + flex-grow: 1; + max-width: 60%; + } + + #info-box .frame-area { + flex-grow: 1; + min-width: 20%; + text-align: right; + } +} + +@media (min-width: 992px) { + .visible-md { + display: initial; + } + + #info-box .frame-info-item { + overflow: initial; + text-overflow: initial; + } + + #info-box .frame-info { + align-items: center; + } + + #info-box .frame-line-col span { + display: inline-block; + } + + #ui-tooltip .frame-tooltip { + width: 33vw; + width: max-content; + max-width: none; + } +} /* presentation mode */ .presentation-mode #info-box .frame-info { background-color: var(--opposite-contrast); -} \ No newline at end of file +} + +.presentation-mode #info-box .frame-dropdown { + border-left: solid var(--banner-bg-color) 1px; +} diff --git a/visualizer/info-box.js b/visualizer/info-box.js index 0e025774..575c7504 100644 --- a/visualizer/info-box.js +++ b/visualizer/info-box.js @@ -1,10 +1,23 @@ 'use strict' +const d3 = require('./d3.js') const HtmlContent = require('./html-content.js') const getNoDataNode = require('./no-data-node.js') +const caretUpIcon = require('@nearform/clinic-common/icons/caret-up') + +const stripTags = html => html.replace(/(<([^>]+)>)/ig, '') + +const addResponsiveSpan = str => `${str}` + +const wrapTooltipText = text => + d3.create('span') + .classed('tooltip-default-message frame-tooltip', true) + .text(text) + .node() class InfoBox extends HtmlContent { constructor (parentContent, contentProperties = {}) { super(parentContent, contentProperties) + this.tooltip = contentProperties.customTooltip const { functionName, @@ -13,28 +26,79 @@ class InfoBox extends HtmlContent { this.functionText = functionName this.pathHtml = fileName - this.areaText = 'Processing data...' + this.areaHtml = 'Processing data...' + this.stackPercentages = { + top: 0, + overall: 0 + } + + this.addCollapseControl(true, { + classNames: 'frame-dropdown', + htmlElementType: 'button', + htmlContent: `0% ${caretUpIcon}` + }) } initializeElements () { super.initializeElements() // Initialize frame info - this.d3FrameInfo = this.d3Element.append('pre') + this.d3FrameInfo = this.d3Element.append('div') .classed('frame-info', true) .classed('panel', true) this.d3FrameFunction = this.d3FrameInfo.append('strong') .classed('frame-info-item', true) .classed('frame-function', true) + this.tooltip.attach({ + msg: () => wrapTooltipText(this.functionText), + d3TargetElement: this.d3FrameFunction + }) this.d3FramePath = this.d3FrameInfo.append('span') .classed('frame-info-item', true) .classed('frame-path', true) + this.tooltip.attach({ + msg: () => wrapTooltipText(stripTags(this.pathHtml)), + d3TargetElement: this.d3FramePath + }) this.d3FrameArea = this.d3FrameInfo.append('span') .classed('frame-info-item', true) .classed('frame-area', true) + this.tooltip.attach({ + msg: () => wrapTooltipText(stripTags(this.areaHtml)), + d3TargetElement: this.d3FrameArea + }) + + this.d3StackInfoTitle = this.d3ContentWrapper + .append('h2') + .text('Stack info') + + this.d3StackPercentageTop = this.d3ContentWrapper + .append('p') + .classed('frame-percentage', true) + .classed('frame-percentage-top', true) + .text('0%') + + this.d3StackPercentageOverall = this.d3ContentWrapper + .append('p') + .classed('frame-percentage', true) + .classed('frame-percentage-overall', true) + .text('0%') + + this.d3CollapseButton = this.collapseControl.d3Element + .attr('title', 'Show stack info') + + // Close when the user clicks outside the options menu. + document.body.addEventListener('click', (event) => { + if (!this.collapseClose.isCollapsed && + !this.d3CollapseButton.node().contains(event.target) && + !this.d3ContentWrapper.node().contains(event.target)) { + this.collapseClose() + } + }, + true) } contentFromNode (node) { @@ -43,12 +107,28 @@ class InfoBox extends HtmlContent { return } + // Todo: Use visibleRootValue when ready + const totalValue = this.ui.dataTree.activeTree().value + + this.stackPercentages = { + top: Math.round(100 * (node.onStackTop.asViewed / totalValue) * 10) / 10, + overall: Math.round(100 * (node.value / totalValue) * 10) / 10 + } + this.functionText = node.functionName + this.pathHtml = '' + + if (node.fileName) { + const fileNameParts = (node.fileName || '').split('/') + const baseName = fileNameParts.pop() + const prefix = fileNameParts.join('/') + + this.pathHtml = `${addResponsiveSpan(`${prefix}/`)}${baseName}` + } - this.pathHtml = node.fileName if (node.lineNumber && node.columnNumber) { // Two spaces (in
           tag) so this is visually linked to but distinct from main path, including when wrapped
          -      this.pathHtml += `  line:${node.lineNumber} column:${node.columnNumber}`
          +      this.pathHtml += `${addResponsiveSpan('  line')}:${node.lineNumber}${addResponsiveSpan(' column')}:${node.columnNumber}`
               }
           
               this.rankNumber = this.ui.dataTree.getSortPosition(node)
          @@ -56,14 +136,23 @@ class InfoBox extends HtmlContent {
               const typeLabel = node.category === 'core' ? '' : ` (${this.ui.getLabelFromKey(`${node.category}:${node.type}`, true)})`
               const categoryLabel = this.ui.getLabelFromKey(node.category, true)
           
          +    this.areaHtmlColour = this.ui.getFrameColor(
          +      {
          +        category: node.category
          +      },
          +      'foreground',
          +      false
          +    )
          +
               // e.g. The no-data-node has an .areaText containing a custom message
          -    this.areaText = node.areaText || `In ${categoryLabel}${typeLabel}`
          +    this.areaHtml = node.areaText || `${addResponsiveSpan(`In ${categoryLabel} `)}${typeLabel}`
           
          -    if (node.isInit) this.areaText += '. In initialization process'
          -    if (node.isInlinable) this.areaText += '. Inlinable'
          -    if (node.isUnoptimized) this.areaText += '. Unoptimized'
          -    if (node.isOptimized) this.areaText += '. Optimized'
          -    this.areaText += '.'
          +    if (node.isInit) this.areaHtml += '. In initialization process'
          +    if (node.isInlinable) this.areaHtml += '. Inlinable'
          +    if (node.isUnoptimized) this.areaHtml += '. Unoptimized'
          +    if (node.isOptimized) this.areaHtml += '. Optimized'
          +
          +    this.areaHtml += addResponsiveSpan('.')
           
               this.draw()
             }
          @@ -75,9 +164,25 @@ class InfoBox extends HtmlContent {
             draw () {
               super.draw()
           
          -    this.d3FrameFunction.text(this.functionText)
          -    this.d3FramePath.html(this.pathHtml)
          -    this.d3FrameArea.text(this.areaText)
          +    this.d3FrameFunction
          +      .text(this.functionText)
          +
          +    this.d3FramePath
          +      .html(this.pathHtml)
          +
          +    this.d3FrameArea
          +      .html(this.areaHtml)
          +      .style('color', this.areaHtmlColour)
          +
          +    this.d3CollapseButton
          +      .select('span')
          +      .text(`${this.stackPercentages.top}%`)
          +
          +    this.d3StackPercentageTop
          +      .text(`Top of stack: ${this.stackPercentages.top}%`)
          +
          +    this.d3StackPercentageOverall
          +      .text(`On stack: ${this.stackPercentages.overall}%`)
             }
           }
           
          diff --git a/visualizer/main.js b/visualizer/main.js
          index e026139f..f9b8ae81 100644
          --- a/visualizer/main.js
          +++ b/visualizer/main.js
          @@ -2,24 +2,44 @@
           require('@nearform/clinic-common/spinner')
           const Ui = require('./ui.js')
           const askBehaviours = require('@nearform/clinic-common/behaviours/ask')
          +const loadFonts = require('@nearform/clinic-common/behaviours/font-loader')
           
          -askBehaviours()
          -
          +// Create UI
           const ui = new Ui('main')
          -ui.initializeElements()
          -
          -// TODO: see if there's a way to load this asyncronously (in case of huge data) that works with puppeteer
          -const dataTree = require('./data.json')
          -ui.setData(dataTree)
          -
          -// Select hottest frame, after frame visibility has been set in d3-fg
          -// And only if no node was selected during initialization by some other means
          -// (eg from parsing the history hash).
          -ui.draw()
          -if (!ui.selectedNode || ui.selectedNode.category === 'none') {
          -  ui.selectHottestNode()
          +
          +// Called on font load or timeout
          +const drawUi = () => {
          +  document.body.classList.remove('is-loading-font')
          +  document.body.classList.add('is-font-loaded')
          +
          +  ui.initializeElements()
          +
          +  // TODO: see if there's a way to load this asyncronously (in case of huge data) that works with puppeteer
          +  const dataTree = require('./data.json')
          +  ui.setData(dataTree)
          +
          +  // Select hottest frame, after frame visibility has been set in d3-fg
          +  // And only if no node was selected during initialization by some other means
          +  // (eg from parsing the history hash).
          +  ui.draw()
          +  if (!ui.selectedNode || ui.selectedNode.category === 'none') {
          +    ui.selectHottestNode()
          +  }
           }
           
          +// Attach ask tray behaviours
          +askBehaviours()
          +
          +// Orchestrate font loading
          +setTimeout(() => (
          +  loadFonts({
          +    onLoad: () => ui.emit('uiFontLoaded'),
          +    onTimeout: () => ui.emit('uiFontLoaded')
          +  })
          +))
          +
          +drawUi()
          +
           if (process.env.DEBUG_MODE) {
             window.ui = ui
           }
          diff --git a/visualizer/options-menu.css b/visualizer/options-menu.css
          deleted file mode 100644
          index 04917c71..00000000
          --- a/visualizer/options-menu.css
          +++ /dev/null
          @@ -1,239 +0,0 @@
          -#options-menu .options {
          -  right: 0;
          -  width: 100%;
          -  font-size: var(--small-text-size);
          -  position: absolute;
          -  z-index: 1;
          -  background-color: var(--options-menu-bg-color);
          -  margin-top: 8px;
          -}
          -
          -#options-menu .options .children-toggle-btn{
          -  border: none;
          -  padding: 0.2em 0.5em;
          -  margin: 0.5em 0 0;
          -  background-color: transparent;
          -  color: inherit;
          -  font-size: 1em;
          -  cursor: pointer;
          -  align-self: flex-end;
          -  outline: none;
          -  display: flex;
          -  flex-wrap: nowrap;
          -  align-items: center;
          -}
          -
          -#options-menu .options .children-toggle-btn svg{
          -  font-size: 1.6em;
          -}
          -
          -#options-menu .options .children-toggle-btn:hover {
          -  background-color: rgba(255, 255, 255, 0.06);
          -}
          -
          -#options-menu .options .section {
          -  border: none;
          -  padding: 8px 0 0 0;
          -  position: relative; /* forces this to show on _top_ of the .options element, else it's not clickable */
          -}
          -
          -#options-menu .options .section:last-child {
          -  margin-bottom: 4px;
          -}
          -
          -#options-menu .options .section h2 {
          -  width: 100%;
          -  font-weight: bold;
          -  font-size: var(--small-text-size);
          -  color: white;
          -  border-bottom: 3px solid var(--main-bg-color);
          -  padding: 2px 8px 6px 8px;
          -  margin: 0;
          -  margin-bottom: 8px;
          -}
          -
          -#options-menu .options ul {
          -  margin: 0;
          -  padding: 0 10px;
          -  list-style: none;
          -  color: var(--grey-highlight);
          -}
          -
          -#options-menu .options ul ul {
          -  padding: 0;
          -  margin-left: 10px;
          -  color: inherit;
          -}
          -
          -#options-menu .options li {
          -  white-space: nowrap;
          -  display: flex;
          -  flex-direction: column;
          -}
          -
          -#options-menu .options .childrenVisibilityToggle li {
          -  display: none;
          -}
          -#options-menu .options .childrenVisibilityToggle.show-more li,
          -#options-menu .options .childrenVisibilityToggle li.visible {
          -  display: initial;
          -}
          -
          -#options-menu .show-more .children-toggle-btn svg {
          -  transform: rotateZ(180deg);
          -  transform-origin: 50%;
          -}
          -
          -#options-menu .options .overflow-wrapper {
          -  height: 2.8em;
          -  position: relative;
          -}
          -
          -#options-menu .options label:hover {
          -  white-space: normal;
          -  height: auto;
          -  z-index: 3;
          -  background: var(--options-menu-bg-hover);
          -  opacity: 1;
          -  outline: 1px dotted var(--light-glare);  
          -}
          -
          -#options-menu .options label {
          -  position: absolute;
          -  width: 100%;
          -  display: flex;
          -  align-items: flex-start;
          -  cursor: pointer;
          -  height: 100%;
          -  min-height: 100%;
          -  line-height: 1.3;
          -  padding: 0.7em 5px;
          -}
          -
          -#options-menu .options li.disabled {
          -  opacity: 0.3;
          -}
          -#options-menu .options li.disabled label {
          -  cursor: default;
          -}
          -
          -#options-menu .options .copy-wrapper{
          -  overflow: hidden;
          -  text-overflow: ellipsis;
          -  max-width: 100%;
          -}
          -
          -#options-menu .options .name {
          -  font-weight: bold;
          -}
          -
          -
          -#options-menu .options .description {
          -  color: var(--primary-grey);
          -  font-weight: normal;
          -}
          -
          -#options-menu .options .description .more-info{
          -  color: white;
          -  opacity: 0.7;
          -  text-decoration: none;
          -}
          -
          -#options-menu .options .description .more-info:hover{
          -  opacity: 1;
          -}
          -
          -#options-menu .options input {
          -  margin: 0 8px 0 0;
          -  vertical-align: middle;
          -  display: none;
          -}
          -
          -#options-menu .options .icon-wrapper {
          -  align-items: center;
          -  border-radius: 3px;
          -  display: flex;
          -  font-size: 1.4em;
          -  margin-right: 0.5em;
          -  flex-shrink: 0;
          -  justify-content: center;
          -  color: var(--checkbox-border-color);
          -}
          -#options-menu .options .icon-wrapper svg{
          -  display: none;
          -}
          -#options-menu .options .icon-wrapper .checkbox-unchecked-svg{
          -  display: initial;
          -}
          -
          -#options-menu .options :checked ~ .icon-wrapper .checkbox-unchecked-svg{
          -  display: none;
          -}
          -
          -#options-menu .options :checked ~ .icon-wrapper{
          -  color: var(--max-contrast);
          -}
          -
          -#options-menu .options :checked ~ .icon-wrapper .checkbox-checked-svg{
          -  display: initial;
          -}
          -
          -#options-menu .options :indeterminate ~ .icon-wrapper {
          -  color: var(--grey-highlight);
          -}
          -
          -#options-menu .options :indeterminate ~ .icon-wrapper .checkbox-indetermined-svg {
          -  display: initial;
          -}
          -
          -#options-menu .options :indeterminate ~ .icon-wrapper .checkbox-unchecked-svg,
          -#options-menu .options :indeterminate ~ .icon-wrapper .checkbox-checked-svg{
          -  display: none;
          -}
          -
          -#options-menu .options li[data-category="app"] {
          -  color: var(--area-color-app);
          -}
          -
          -#options-menu .options li[data-category="core"],
          -#options-menu .options li[data-category="all-v8"] {
          -  color: var(--area-color-core);
          -}
          -
          -#options-menu .options li[data-category="deps"] {
          -  color: var(--area-color-deps);
          -}
          -
          -#options-menu .options-menu-toggle {
          -  align-items: center;
          -  background: #000;
          -  border: none;
          -  color: white;
          -  cursor: pointer;
          -  display: flex;
          -  justify-content: space-between;
          -  font-size: var(--small-text-size);
          -  font-weight: bold;
          -  padding: 2px 6px;
          -  width: 100%;
          -  height: 100%;
          -  outline: none;
          -  position: relative;
          -}
          -#options-menu .options-menu-toggle:hover {
          -  background-color: var(--clickable-bg-hover);
          -}
          -
          -#options-menu .options-menu-toggle .caret-up-svg {
          -  position: absolute;
          -  right: 5px;
          -  font-size: 2em;
          -  color: white;
          -  transition: transform 0.6s cubic-bezier(0.42, 0, 0.23, 1.65);
          -  transform-origin: 50%;
          -  transform: rotateZ(0deg)
          -}
          -
          -#options-menu.collapsed .options-menu-toggle .caret-up-svg {
          -  transform: rotateZ(180deg)
          -}
          diff --git a/visualizer/options-menu.js b/visualizer/options-menu.js
          deleted file mode 100644
          index 6fbcb40e..00000000
          --- a/visualizer/options-menu.js
          +++ /dev/null
          @@ -1,354 +0,0 @@
          -'use strict'
          -
          -const HtmlContent = require('./html-content.js')
          -const d3 = require('./d3.js')
          -const caretUpIcon = require('@nearform/clinic-common/icons/caret-up')
          -const checkboxCheckedIcon = require('@nearform/clinic-common/icons/checkbox-checked')
          -const checkboxUncheckedIcon = require('@nearform/clinic-common/icons/checkbox-unchecked')
          -const checkboxIndeterminedIcon = require('@nearform/clinic-common/icons/checkbox-indetermined')
          -
          -const preferences = [
          -  {
          -    id: 'presentation_mode',
          -    title: 'Presentation mode',
          -    value: false,
          -    onChange: (ui, checked) => {
          -      ui.setPresentationMode(checked)
          -    }
          -  }
          -]
          -
          -class OptionsMenu extends HtmlContent {
          -  constructor (parentContent, contentProperties) {
          -    super(parentContent, contentProperties)
          -
          -    this.setCodeAreas([
          -      { id: 'app', excludeKey: 'app' }
          -    ])
          -
          -    this.addCollapseControl(true, {
          -      classNames: 'options-menu-toggle',
          -      htmlElementType: 'button',
          -      htmlContent: `Options ${caretUpIcon}`
          -    })
          -
          -    this.showMore = {}
          -
          -    this.ui.on('presentationMode', mode => {
          -      const pref = preferences.find(pref => {
          -        return pref.id === 'presentation_mode'
          -      })
          -      pref.value = mode === true
          -    })
          -  }
          -
          -  initializeElements () {
          -    super.initializeElements()
          -
          -    this.d3OptionsList = this.d3ContentWrapper.append('div')
          -      .classed('options', true)
          -
          -    // Visibility options.
          -    this.d3VisibilityOptions = this.d3OptionsList.append('div')
          -      .classed('section', true)
          -    this.d3VisibilityOptions.append('h2')
          -      .text('Visibility by code area')
          -
          -    this.d3VisibilityOptions.append('ul')
          -
          -    this.drawCodeAreaList()
          -
          -    // Merging and highlighting options
          -    this.d3FgOptions = this.d3OptionsList.append('div')
          -      .classed('section', true)
          -    this.d3FgOptions.append('h2')
          -      .text('Advanced')
          -    this.d3FgOptions.append('ul')
          -
          -    this.d3InitCheckbox = this.addFgOptionCheckbox({
          -      id: 'option-init',
          -      name: 'Init',
          -      description: 'Show initialization operations hidden by default, like module loading',
          -      onChange: (checked) => {
          -        this.ui.setCodeAreaVisibility('is:init', checked)
          -        this.ui.draw()
          -      }
          -    })
          -
          -    this.d3MergeCheckbox = this.addFgOptionCheckbox({
          -      id: 'option-usemergedtree',
          -      name: 'Merge',
          -      description: 'Join optimized and unoptimized versions of frames',
          -      onChange: (checked) => {
          -        this.ui.setUseMergedTree(checked)
          -      }
          -    })
          -
          -    this.d3OptCheckbox = this.addFgOptionCheckbox({
          -      id: 'option-showoptimizationstatus',
          -      name: 'Show optimization status',
          -      description: 'Highlight frames that are optimized functions',
          -      onChange: (checked) => {
          -        this.ui.setShowOptimizationStatus(checked)
          -      }
          -    })
          -
          -    // preferences
          -    this.d3Preferences = this.d3OptionsList.append('div')
          -      .classed('section preferences', true)
          -    this.d3Preferences.append('h2')
          -      .text('Preferences')
          -
          -    const prefUl = this.d3Preferences.append('ul')
          -    const prefLi = prefUl.selectAll('li').data(preferences)
          -    prefLi.exit().remove()
          -    prefLi.enter().append('li').call((li) => {
          -      const datum = li.datum()
          -      this.addFgOptionCheckbox({
          -        id: datum.id,
          -        name: datum.title,
          -        onChange: (checked) => {
          -          datum.onChange(this.ui, checked)
          -        }
          -      }, prefUl)
          -    })
          -
          -    this.ui.on('setData', () => {
          -      this.setData()
          -    })
          -
          -    // Close when the user clicks outside the options menu.
          -    document.body.addEventListener('click', (event) => {
          -      if (!this.collapseClose.isCollapsed &&
          -          !this.d3Element.node().contains(event.target)) {
          -        this.collapseClose()
          -      }
          -    },
          -    true) // using useCapture here so that we can handle the event before `.showMore` button updates its content
          -  }
          -
          -  addFgOptionCheckbox ({ id, name, description, onChange }, d3ParentNode) {
          -    d3ParentNode = d3ParentNode || this.d3FgOptions.select('ul')
          -    const d3Li = d3ParentNode.append('li')
          -      .attr('id', id)
          -    const d3Wrapper = d3Li.append('div')
          -      .classed('overflow-wrapper', true)
          -    const d3Label = d3Wrapper.append('label')
          -    const d3Checkbox = d3Label.append('input')
          -      .attr('type', 'checkbox')
          -      .on('click', () => {
          -        const { checked } = d3.event.target
          -        onChange(checked)
          -      })
          -
          -    d3Label.append('span')
          -      .classed('icon-wrapper', true)
          -      .html(`
          -        ${checkboxCheckedIcon}
          -        ${checkboxUncheckedIcon}
          -        ${checkboxIndeterminedIcon}        
          -      `)
          -
          -    const d3CopyWrapper = d3Label.append('span')
          -      .classed('copy-wrapper', true)
          -    d3CopyWrapper.append('span')
          -      .classed('name', true)
          -      .text(name)
          -    if (description) {
          -      d3CopyWrapper.append('span')
          -        .classed('description', true)
          -        .html(` - ${description}`)
          -    }
          -
          -    return d3Checkbox
          -  }
          -
          -  drawCodeAreaList () {
          -    const { ui } = this
          -    const self = this
          -
          -    // Create the top-level filter options, like "app" / "deps" / "core"
          -    const d3RootItems = this.d3VisibilityOptions.select('ul')
          -      .selectAll('li').data(this.codeAreas)
          -    d3RootItems.exit().remove()
          -
          -    const d3NewRootItems = d3RootItems.enter().append('li')
          -      .call(createOptionElement)
          -    d3NewRootItems.merge(d3RootItems)
          -      .classed('childrenVisibilityToggle', d => d.childrenVisibilityToggle === true)
          -      .call(renderOptionElement)
          -
          -    // Create or update the required number of sub-
            s: 1 if there are any children; - // 0 if there are none. - const d3SubLists = d3NewRootItems.merge(d3RootItems) - .selectAll('ul').data(d => d.children ? [d.children] : []) - d3SubLists.exit().remove() - const d3NewSubLists = d3SubLists.enter().append('ul') - - // Populate sub-
              s with child filter options. - const d3SubListItems = d3NewSubLists.merge(d3SubLists) - .selectAll('li').data(d => d) - d3SubListItems.exit().remove() - d3SubListItems.enter().append('li') - .call(createOptionElement) - // Update the labels for both new and existing items. - .merge(d3SubListItems) - .call(renderOptionElement) - - // I am sure there's a better way to do this... - this.d3VisibilityOptions.selectAll('.childrenVisibilityToggle') - .append('button') - .html(`show more ${caretUpIcon}`) - .classed('children-toggle-btn', true) - .on('click', function (d) { - const showMore = !(self.showMore[d.id] === true) - - self.showMore[d.id] = showMore - - const parent = d3.select(this.closest('.childrenVisibilityToggle')) - parent.classed('show-more', showMore) - - d3.select(this).html(`show ${showMore ? 'less' : 'more'} ${caretUpIcon}`) - }) - - // Insert a new filter option element, - // for use with a d3.enter() selection. - function createOptionElement (li) { - li.classed('visible', d => d.visible === true) - li.classed('disabled', (d) => { - // check Dependencies only - if (d.excludeKey === 'deps') { - return self.getDataCountFromKey(d.excludeKey) === 0 - } - return false - }) - const wrapper = li.append('div') - .classed('overflow-wrapper', true) - const label = wrapper.append('label') - label.append('input') - .attr('type', 'checkbox') - .attr('disabled', (d) => { - // check Dependencies only - if (d.excludeKey !== 'deps') return null - return self.getDataCountFromKey(d.excludeKey) === 0 ? true : null - }) - .on('change', onchange) - label.append('span') - .classed('icon-wrapper', true) - .html(` - ${checkboxCheckedIcon} - ${checkboxUncheckedIcon} - ${checkboxIndeterminedIcon} - `) - - const copyWrapper = label.append('span') - .classed('copy-wrapper', true) - copyWrapper.append('span') - .classed('name', true) - copyWrapper.append('description') - .classed('description', true) - .html((d) => { - const description = ui.getDescriptionFromKey(d.excludeKey) - return description ? ` - ${description}` : '' - }) - } - - // Update an existing filter option element, - // for use with a d3.enter() + update selection. - function renderOptionElement (li) { - li.attr('data-category', data => data.excludeKey.split(':')[0]) - li.select('.name') - .text(data => ui.getLabelFromKey(data.excludeKey)) - } - - // Toggle a code area visibility setting. - function onchange (data) { - const { checked } = d3.event.target - - if (data.children) { - let anyChanges = false - data.children.forEach((child) => { - // Pass flag to only call ui.updateExclusions() when all changes are made - const isChanged = ui.setCodeAreaVisibility(child.excludeKey, checked, true) - if (isChanged) anyChanges = true - }) - if (anyChanges) ui.updateExclusions() - } else { - ui.setCodeAreaVisibility(data.excludeKey, checked) - } - ui.draw() - } - - this.codeAreasChanged = false - } - - getDataCountFromKey (key) { - return this.ui.dataTree ? this.ui.dataTree.activeNodes().filter(n => n.category === key).length : 0 - } - - setData () { - const { - codeAreas, - useMerged, - showOptimizationStatus - } = this.ui.dataTree - - this.d3MergeCheckbox.property('checked', useMerged) - this.d3OptCheckbox.property('checked', showOptimizationStatus) - - this.setCodeAreas(codeAreas) - } - - setCodeAreas (codeAreas) { - this.codeAreas = codeAreas - this.codeAreasChanged = true - } - - applyCodeVisibilityExclusions (excludes) { - this.d3VisibilityOptions - .selectAll('li input') - .property('checked', (area) => { - if (area.children) { - return area.children.some((child) => !excludes.has(child.excludeKey)) - } - return !excludes.has(area.excludeKey) - }) - .property('indeterminate', (area) => { - const { children } = area - if (!Array.isArray(children) || children.length === 0) { - return false - } - - const first = excludes.has(children[0].excludeKey) - return children.some((child) => excludes.has(child.excludeKey) !== first) - }) - } - - draw () { - super.draw() - - // Update option checkbox values. - const { useMerged, showOptimizationStatus, exclude, appName } = this.ui.dataTree - this.d3MergeCheckbox.property('checked', useMerged) - this.d3OptCheckbox - .attr('disabled', useMerged ? 'disabled' : null) - .property('checked', showOptimizationStatus) - .select(function () { return this.closest('li') }) - .classed('disabled', useMerged) - this.d3InitCheckbox.property('checked', !exclude.has('is:init')) - - // Updating the app name - this.d3VisibilityOptions.select('.name') - .text(appName) - if (this.codeAreasChanged) { - this.drawCodeAreaList() - } - - this.applyCodeVisibilityExclusions(this.ui.dataTree.exclude) - - this.d3Preferences.select('#presentation_mode') - .property('checked', this.ui.presentationMode) - } -} - -module.exports = OptionsMenu diff --git a/visualizer/search-box.css b/visualizer/search-box.css index 17dee12e..2eb2b84e 100644 --- a/visualizer/search-box.css +++ b/visualizer/search-box.css @@ -1,10 +1,11 @@ -#search-box input { - border: none; - height: 100%; - background: black; - color: white; - font-family: inherit; - font-size: var(--small-text-size); - padding: 4px 8px; - margin-right: 8px; +.search-box input { + background : black; + border : none; + color : white; + font-family : inherit; + font-size : var(--small-text-size); + height : 100%; + margin : 0px; + padding : 4px 8px; + width : 100%; } diff --git a/visualizer/search-box.js b/visualizer/search-box.js index 42e37e56..1faeda88 100644 --- a/visualizer/search-box.js +++ b/visualizer/search-box.js @@ -3,7 +3,12 @@ const debounce = require('lodash.debounce') class SearchBox extends HtmlContent { constructor (parentContent, contentProperties) { - super(parentContent, contentProperties) + const properties = Object.assign({}, contentProperties) + properties.classNames = properties.classNames.split(' ') + properties.classNames.push('search-box') + properties.classNames = properties.classNames.join(' ') + + super(parentContent, properties) this.ui.on('clearSearch', () => { this.d3Input.property('value', '') diff --git a/visualizer/selection-controls.css b/visualizer/selection-controls.css index 1ad5315e..cb019185 100644 --- a/visualizer/selection-controls.css +++ b/visualizer/selection-controls.css @@ -2,26 +2,8 @@ display: flex; } -#selection-controls .hotness-selector:disabled { - color: var(--primary-grey); - cursor: auto; - background: rgba(var(--opposite-color-val), 0.5); -} - #selection-controls .hotness-selector { - padding: 4px 8px; margin-right: 4px; - background-color: var(--opposite-contrast); - color: var(--max-contrast); - border: none; - cursor: pointer; - display: flex; - align-items: center; - line-height: 1; -} -#selection-controls button.hotness-selector:hover:not(:disabled) { - background-color: var(--clickable-bg-hover); - outline: 1px dotted var(--light-glare); } #selection-controls .rank-wrapper { @@ -32,17 +14,13 @@ } #selection-controls input.hotness-selector { - width: 3.3em; + background-color: var(--opposite-contrast); + border: none; + color: var(--max-contrast); + height: 100%; padding: 0; text-align: center; - height: 100%; -} - -#selection-controls svg{ - font-size: 1.5em; -} -#selection-controls .next-btn span{ - margin-right: .5em; + width: 3.3em; } #selection-controls .hotness-selector, @@ -55,7 +33,7 @@ } .visible-from-bp-1, -.visible-from-bp-2{ +.visible-from-bp-2 .label { display: none; } @@ -66,7 +44,7 @@ } @media screen and (min-width: 680px) { - .visible-from-bp-2 { + .visible-from-bp-2 .label { display: initial; } } @@ -75,4 +53,4 @@ .presentation-mode #selection-controls { background-color: transparent; padding: 0px; -} \ No newline at end of file +} diff --git a/visualizer/selection-controls.js b/visualizer/selection-controls.js index 5e8862ad..369f5a07 100644 --- a/visualizer/selection-controls.js +++ b/visualizer/selection-controls.js @@ -6,6 +6,8 @@ const chevronLeft = require('@nearform/clinic-common/icons/chevron-left') const chevronRight = require('@nearform/clinic-common/icons/chevron-right') const chevronRightLast = require('@nearform/clinic-common/icons/chevron-right-last') +const button = require('@nearform/clinic-common/base/button.js') + class SelectionControls extends HtmlContent { constructor (parentContent, contentProperties = {}) { super(parentContent, contentProperties) @@ -72,13 +74,12 @@ class SelectionControls extends HtmlContent { super.initializeElements() // Initialize controls - this.d3SelectHottest = this.d3Element.append('button') - .classed('hotness-selector', true) - .html(`${chevronLeftFirst}`) - .on('click', () => { - this.selectByRank(0) - }) + this.d3SelectHottest = this.d3Element.append(() => button({ + rightIcon: chevronLeftFirst, + classNames: ['hotness-selector'], + onClick: () => this.selectByRank(0) + })) this.tooltip.attach({ msg: 'Select the hottest frame (meaning, most time at the top of the stack)', d3TargetElement: this.d3SelectHottest, @@ -87,13 +88,11 @@ class SelectionControls extends HtmlContent { } }) - this.d3SelectHotter = this.d3Element.append('button') - .classed('hotness-selector', true) - .html(`${chevronLeft}`) - .on('click', () => { - this.selectByRank(this.rankNumber - 1) - }) - + this.d3SelectHotter = this.d3Element.append(() => button({ + rightIcon: chevronLeft, + classNames: ['hotness-selector'], + onClick: () => this.selectByRank(this.rankNumber - 1) + })) this.tooltip.attach({ msg: 'Select the frame before the selected frame when ranked from hottest to coldest', d3TargetElement: this.d3SelectHotter, @@ -107,18 +106,17 @@ class SelectionControls extends HtmlContent { d3RankWrapper.append('label').text('#') this.d3SelectNumber = d3RankWrapper.append('input') - .classed('hotness-selector', true) + .classed('hotness-selector button', true) .property('value', this.rankNumber) this.d3FramesCount = d3RankWrapper.append('label').html('hottest frame, ').append('span') - this.d3SelectCooler = this.d3Element.append('button') - .classed('hotness-selector next-btn', true) - .html(`Next hottest${chevronRight}`) - .on('click', () => { - this.selectByRank(this.rankNumber + 1) - }) - + this.d3SelectCooler = this.d3Element.append(() => button({ + rightIcon: chevronRight, + label: `Next hottest`, + classNames: ['hotness-selector', 'visible-from-bp-2'], + onClick: () => this.selectByRank(this.rankNumber + 1) + })) this.tooltip.attach({ msg: 'Select the frame after the selected frame when ranked from hottest to coldest', d3TargetElement: this.d3SelectCooler, @@ -127,13 +125,11 @@ class SelectionControls extends HtmlContent { } }) - this.d3SelectColdest = this.d3Element.append('button') - .classed('hotness-selector', true) - .html(`${chevronRightLast}`) - .on('click', () => { - this.selectByRank('last') - }) - + this.d3SelectColdest = this.d3Element.append(() => button({ + rightIcon: chevronRightLast, + classNames: ['hotness-selector'], + onClick: () => this.selectByRank('last') + })) this.tooltip.attach({ msg: 'Select the coldest frame (meaning, least time at the top of the stack)', d3TargetElement: this.d3SelectColdest, diff --git a/visualizer/side-bar.css b/visualizer/side-bar.css new file mode 100644 index 00000000..26327366 --- /dev/null +++ b/visualizer/side-bar.css @@ -0,0 +1,164 @@ +#side-bar { + --anim-duration : 0.3s; + --min-width : 300px; + --width : 25vw; + + --height : 100%; + + background-color: rgba(var(--dark-grey-val), 0.95); + bottom : 0px; + display : none; + left : 0px; + overflow : hidden; + position : absolute; + right : 0px; + height : var(--height); + z-index : 10; + font-size : 1.4rem; + + border-top : 1px solid var(--light-glare); +} + +#side-bar .filters-options{ + overflow: auto; + height: 100%; +} + +#side-bar.expand{ + display: block; + animation: var(--anim-duration) sidebar-expand; +} + + +#side-bar.expand>*{ + animation: var(--anim-duration) sidebar-slidein; +} + +#side-bar.contract{ + display: block; + animation: var(--anim-duration) side-bar-contract; +} + +#side-bar.contract>*{ + animation: var(--anim-duration) sidebar-slideout; +} + + + +@media screen and (min-width: 630px){ + #side-bar { + border-top : none; + flex-shrink: 0; + font-size : 1rem; + height : auto; + position : relative; + top : auto; + width : var(--width); + } + + #side-bar.expand{ + animation: var(--anim-duration) sidebar-expand; + min-width: var(--min-width); + } + + #side-bar>*{ + width: var(--width); + min-width: var(--min-width); + } +} + + +/* side-bar animation */ +@keyframes sidebar-slidein { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes sidebar-slideout { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@media screen and (min-width: 630px){ + @keyframes sidebar-slidein { + 0% { + transform: translateX(-100%); + opacity: 0; + } + + 100% { + transform: translateX(0%); + opacity: 1; + } + } + + @keyframes sidebar-slideout { + 0% { + transform: translateX(0%); + opacity: 1; + } + + 100% { + transform: translateX(-100%); + opacity: 0; + } + } +} + + + +@keyframes sidebar-expand { + 0% { + height: 0%; + } + + 100% { + height: var(--height); + } +} + +@keyframes side-bar-contract { + 0% { + height: var(--height); + } + + 100% { + height: 0%; + } +} + +@media screen and (min-width: 630px){ + @keyframes sidebar-expand { + 0% { + width: 0%; + min-width: 0px; + } + + 100% { + width: var(--width); + min-width: var(--min-width); + } + } + + @keyframes side-bar-contract { + 0% { + width: var(--width); + min-width: var(--min-width); + } + + 100% { + width: 0%; + min-width: 0px; + } + } +} diff --git a/visualizer/side-bar.js b/visualizer/side-bar.js new file mode 100644 index 00000000..10f9857c --- /dev/null +++ b/visualizer/side-bar.js @@ -0,0 +1,47 @@ +'use strict' +const HtmlContent = require('./html-content.js') + +class SideBar extends HtmlContent { + constructor (parentContent, contentProperties = {}) { + super(parentContent, contentProperties) + this.animationEnd = contentProperties.animationEnd + this.isExpanded = false + } + + initializeElements () { + super.initializeElements() + + this.d3ContentWrapper.attr('id', 'side-bar') + + this.d3ContentWrapper.on('animationend', () => { + this.isExpanded = !this.isExpanded + + if (this.isExpanded) { + this.d3ContentWrapper.classed('contract', false) + } + + this.animationEnd(this.isExpanded) + }) + } + + slideIn () { + this.d3ContentWrapper.classed('expand', true) + } + + slideOut () { + this.d3ContentWrapper.classed('expand', false) + this.d3ContentWrapper.classed('contract', true) + } + + toggle (isVisible) { + // const className = isVisible ? 'expand' : 'contract' + this.d3ContentWrapper.classed('expand', isVisible) + this.d3ContentWrapper.classed('contract', !isVisible) + } + + draw () { + super.draw() + } +} + +module.exports = SideBar diff --git a/visualizer/style.css b/visualizer/style.css index 4a492d1e..009f4283 100644 --- a/visualizer/style.css +++ b/visualizer/style.css @@ -1,4 +1,6 @@ @import '@nearform/clinic-common/styles/styles.css'; +@import '@nearform/clinic-common/base/style.css'; +@import "./walkthrough-steps.css"; @import "./toolbar.css"; @import "./stack-bar.css"; @import "./key.css"; @@ -6,14 +8,14 @@ @import "./flame-graph.css"; @import "./message.css"; @import "./tooltip.css"; -@import "./options-menu.css"; @import "./selection-controls.css"; @import "./info-box.css"; @import "@nearform/clinic-common/spinner/style.css"; - +@import "./filters-bar.css"; +@import "./filters-content.css"; +@import "./side-bar.css"; html { - font-family: sans-serif; font-size: 62.5%; } @@ -26,7 +28,7 @@ code, kbd, samp, tt { - font-family:monospace,monospace; + font-family: var(--nc-font-family-monospace); font-size:1em; } @@ -48,13 +50,13 @@ html { --banner-bg-color: rgb(var(--banner-bg-color-val)); --nc-colour-header-background: rgb(var(--banner-bg-color-val)); + --options-menu-bg-color: rgb(15, 18, 26); --footer-bg-color: rgb(15, 18, 26); --footer-color: rgba(255, 255, 255, 0.9); --clickable-bg-hover : rgb(15, 15, 15); - --options-menu-bg-color: rgba(0, 0, 0, 0.85); - --options-menu-bg-hover: var(--clickable-bg-hover); + --dark-grey-val: 25, 26, 30; --grey-blue: rgb(76, 92, 138); --primary-grey: rgb(121, 122, 124); @@ -62,6 +64,8 @@ html { --grey-highlight: rgb(var(--grey-highlight-color-val)); --flame-orange: rgb(255, 170, 43); + + --checkbox-border-color: rgba(var(--grey-highlight-color-val), 0.9); --area-color-app: var(--max-contrast); @@ -79,8 +83,33 @@ html { /* Define text sizes */ --main-text-size: 1.2rem; --small-text-size: 1rem; + + /* base component style */ + --nc-button-bgColor: var(--opposite-contrast); + --nc-button-color: var(--max-contrast); + --nc-button-fontSize: var(--small-text-size); + --nc-button-bgHover: var(--clickable-bg-hover); + --nc-button-hoverOutline: var(--light-glare); + + /* --nc-link-bgColor */ + --nc-link-color: rgb(63, 125, 198); + --nc-link-fontSize: var(--small-text-size); + /* --nc-link-bgHover */ + /* --nc-link-hoverOutline */ + + --nc-checkbox-bgColor: var(--opposite-contrast); + --nc-checkbox-hoverColor: var(--clickable-bg-hover); + --nc-checkbox-hoverOutline: var(--light-glare); + --nc-checkbox-borderColor: var(--checkbox-border-color); + --nc-checkbox-checkedIconColor: var(--max-contrast); + --nc-checkbox-indeterminateIconColor: var(--grey-highlight); + + --nc-dropdown-color: var(--max-contrast); + --nc-dropdown-bgColor: var(--opposite-contrast); + --nc-dropdown-contentBg: var(--opposite-contrast); } + /* Overrides for light theme */ /* just a poc for now... */ html.light { @@ -90,6 +119,7 @@ html.light { --opposite-contrast: rgb(255, 255, 255); --banner-bg-color: rgba(0, 0, 0, 0.1); + --options-menu-bg-color: rgba(0, 0, 0, 0.1); --footer-bg-color: rgba(0, 0, 0, 0.1); --footer-color: rgba(0, 0, 0, 0.9); --nc-colour-header-background: rgba(0, 0, 0, 0.1); @@ -107,7 +137,7 @@ html.presentation-mode { --area-color-deps: rgb(145, 210, 255); --area-color-core: rgb(160, 155, 215); - --options-menu-bg-hover: rgb(0, 0, 0); + --clickable-bg-hover : rgb(0, 0, 0); } /* Main layout */ @@ -128,6 +158,7 @@ body { /* MS Edge doesn't like background colors on body elements when devtools open */ main { + flex-grow: 1; background: var(--main-bg-color); } @@ -146,21 +177,91 @@ html * { display: none; } -/* Header */ +/* breakpoints helpers */ + +.after-bp-1, +.after-bp-2 { + display: none!important; +} + + +@media screen and (min-width: 630px) { + .after-bp-1 { + display: inherit!important; + } + .checkbox .after-bp-1{ + display: inline!important; + } + + .before-bp-1 { + display: none!important; + } +} + + +@media screen and (min-width: 930px) { + .after-bp-2 { + display: inherit!important; + } + .checkbox .after-bp-2{ + display: inline!important; + } + + .before-bp-2 { + display: none!important; + } +} + +/* Header */ .nc-header { flex-shrink: 0; } +.is-loading-font .nc-header { + opacity: 0; +} + +div#main-content { + display: flex; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + position: relative; + z-index: 0; +} + /* Footer */ #footer { background-color: var(--opposite-contrast); color: var(--footer-color); flex-shrink: 0; - padding: 0 8px 6px; + padding: 0; border-top: 1px solid rgba(0, 0, 0, 0.5); } +#footer .m-search-box-wrapper { + display: flex; + width: 100%; + padding: 10px; + background-color: var(--banner-bg-color); + border-bottom: 1px solid var(--light-glare); + display: none; +} + +#footer .m-search-box-wrapper.show { + display: flex; +} + +#footer #m-search-box input{ + font-size: 1.6rem; +} + +#footer #m-search-box{ + width: 100%; + margin-right: 10px; +} + /* Layout definition */ #one-col-layout { @@ -175,34 +276,6 @@ html * { flex-shrink: 0; } -#one-col-layout .scroll-container { - flex-grow: 1; - flex-shrink: 1; - overflow: auto; - position: relative; -} - -.scroll-container::-webkit-scrollbar { - width: 13px; - background-color: var(--scrollbar-bg); -} - -.scroll-container::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 2px var(--scrollbar-shadow); -} - -.scroll-container::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb); - outline: 1px solid var(--scrollbar-thumb-outline); - border-radius: 2px; - box-shadow: 4px 1px 6px -2px var(--light-glare) inset; -} - -@media (pointer: coarse) { - .scroll-container::-webkit-scrollbar { - width: 30px; - } -} /* SVG icons */ svg.icon-img path { /* Default to same fill as adjacent text */ @@ -215,3 +288,49 @@ svg.icon-img { height: 1em; display: block; } + + +/* custom accordion style */ +.nc-accordion.nc-accordion--secondary { + padding: 0 5px; + background-color: transparent; + border: none; + font-size: 1em; +} + +.nc-accordion.nc-accordion--secondary .nc-collapsible-container { + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.nc-accordion.nc-accordion--secondary.expanded .nc-collapsible-container { + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.nc-accordion.nc-accordion--secondary > .nc-button { + --nc-button-bgColor: rgba(255, 255, 255, 0.1); + --nc-button-bgHover: rgba(255, 255, 255, 0.08); + width: auto; + font-size: 0.8rem; + padding: 0 0.5em; +} + +.nc-accordion.nc-accordion--secondary > .nc-button .nc-button__inner-container { + padding-right: 3em; +} + +/* TODO: refine this then migrate it to Clinic Common */ +.pulsing { + animation: pulse 0.8s infinite linear; +} + +@keyframes pulse { + 0% { + opacity: 0.5; + } + 80% { + opacity: 0.1; + } + 100% { + opacity: 0.5; + } +} diff --git a/visualizer/toolbar.css b/visualizer/toolbar.css index f0b875f0..d280e758 100644 --- a/visualizer/toolbar.css +++ b/visualizer/toolbar.css @@ -10,40 +10,21 @@ #toolbar-top-panel { display: flex; - justify-content: center; + justify-content: space-between; margin: 8px; flex-wrap: wrap; } -#selection-controls, #toolbar-side-panel { margin: 8px 8px 0px; -} - -#toolbar-side-panel { flex-grow: 0; display: flex; - position: relative; - min-width: 25em; + align-items: stretch; } -#toolbar-side-panel #options-menu{ - flex-grow: 1; -} - -#toolbar-side-panel #search-box{ - flex-shrink: 0; -} - - -@media screen and (min-width: 500px) { - #selection-controls, - #toolbar-side-panel { - margin: 0px; - } - #toolbar-top-panel { - justify-content: space-between; - } +#helpBtn { + display: flex; + align-items: stretch; } @media screen and (min-width: 720px) { @@ -56,4 +37,4 @@ .presentation-mode #toolbar-side-panel { background-color: transparent; padding: 0px; -} \ No newline at end of file +} diff --git a/visualizer/tooltip.css b/visualizer/tooltip.css index 518c01fe..954cb0c4 100644 --- a/visualizer/tooltip.css +++ b/visualizer/tooltip.css @@ -13,6 +13,7 @@ border-radius: 3px; box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3); opacity: 0.85; + display: block; } .tooltip.hidden { diff --git a/visualizer/tooltip.js b/visualizer/tooltip.js index 1bcf0900..81d445d5 100644 --- a/visualizer/tooltip.js +++ b/visualizer/tooltip.js @@ -1,6 +1,7 @@ 'use strict' const HtmlContent = require('./html-content.js') +const { toHtml } = require('@nearform/clinic-common/base/helpers.js') class Tooltip extends HtmlContent { constructor (parentContent, contentProperties = {}) { @@ -66,11 +67,12 @@ class Tooltip extends HtmlContent { hide ({ delay = this.hideDelay, callback } = {}) { clearTimeout(this.tooltipHandler) - // Callback will be called on next hide, even if this timeout cleared, e.g. moving mouse from frame to tooltip + // Callback will be called next hide, even if this timeout cleared, e.g. moving mouse from frame to tooltip if (callback) this.onHideCallback = callback this.tooltipHandler = setTimeout(() => { this.isHidden = true + this.draw() if (this.onHideCallback) { this.onHideCallback() @@ -79,7 +81,14 @@ class Tooltip extends HtmlContent { }, delay) } - toggle (props, show = !this.isHidden) { + toggle (props, show = this.isHidden) { + if (this.onHideCallback) { + const opt = Object.assign({}, props, { delay: 0 }) + this.hide(opt) + } + // Callback will be called next hide, even if this timeout cleared, e.g. moving mouse from frame to tooltip + if (props.callback && this.onHideCallback !== props.callback) this.onHideCallback = props.callback + if (show) { this.show(props) } else { @@ -91,7 +100,7 @@ class Tooltip extends HtmlContent { // returns if the tooltip is hidden if (this.isHidden) return - const msgHtmlNode = getMsgHtml(msg) + const msgHtmlNode = toHtml(msg, 'tooltip-default-message') this.d3TooltipInner.classed('top bottom', false) this.d3TooltipInner.classed(verticalAlign, true) @@ -112,7 +121,7 @@ class Tooltip extends HtmlContent { clearTimeout(this.tooltipHandler) - let ttLeft = x + width / 2 + let ttLeft = x const ttTop = y + (verticalAlign === 'bottom' ? height : 0) if (pointerCoords) { @@ -144,6 +153,12 @@ class Tooltip extends HtmlContent { deltaX = (ttLeft - deltaX + ttWidth > outerRect.right) ? alignRight : deltaX } + // if it doesn't fit either way, attach to the very left edge of the viewport + // so it has as much space as possible + if (ttLeft - deltaX < 0) { + deltaX = ttLeft + } + this.d3TooltipInner .style('left', `-${deltaX}px`) .style('max-width', outerRect ? `${outerRect.width}px` : 'auto') @@ -154,28 +169,4 @@ class Tooltip extends HtmlContent { } } -function getMsgHtml (msg) { - switch (typeof msg) { - case 'string': - var node = document.createElement('DIV') - node.className = 'tooltip-default-message' - node.textContent = msg - return node - - case 'function': - return getMsgHtml(msg()) - - case 'object': - if (msg.nodeType === 1) { - // it is an HTMLElement - if (msg.classList.length === 0) { - msg.className = 'tooltip-default-message' - } - return msg - } - } - - throw new TypeError('The provided content is not a String nor an HTMLElement ') -} - module.exports = Tooltip diff --git a/visualizer/ui.js b/visualizer/ui.js index fc9fe852..42a9fdc7 100644 --- a/visualizer/ui.js +++ b/visualizer/ui.js @@ -5,14 +5,21 @@ const htmlContentTypes = require('./html-content-types.js') const debounce = require('lodash.debounce') const DataTree = require('./data-tree.js') const History = require('./history.js') +const spinner = require('@nearform/clinic-common/spinner') + +const close = require('@nearform/clinic-common/icons/close') const TooltipHtmlContent = require('./flame-graph-tooltip-content') const getNoDataNode = require('./no-data-node.js') +const { button, walkthroughButton } = require('@nearform/clinic-common/base/index.js') +const wtSteps = require('./walkthrough-steps.js') + class Ui extends events.EventEmitter { constructor (wrapperSelector) { super() + this.flameWrapperSpinner = null this.history = new History() this.dataTree = null @@ -49,7 +56,8 @@ class Ui extends events.EventEmitter { useMerged: this.dataTree.useMerged, showOptimizationStatus: this.dataTree.showOptimizationStatus, exclude: this.dataTree.exclude, - search: this.searchQuery + search: this.searchQuery, + walkthroughIndex: this.helpButton.WtPlayer.currentStepIndex }, opts) } @@ -60,7 +68,8 @@ class Ui extends events.EventEmitter { search, selectedNodeId, showOptimizationStatus, - zoomedNodeId + zoomedNodeId, + walkthroughIndex } = data this.setUseMergedTree(useMerged, { pushState: false, @@ -90,11 +99,15 @@ class Ui extends events.EventEmitter { this.zoomNode(this.dataTree.getNodeById(zoomedNodeId), { pushState: false }) this.selectNode(this.dataTree.getNodeById(selectedNodeId), { pushState: false }) + } + }) - if (search !== this.searchQuery) { - this.search(search, { pushState: false }) - } - } }) + if (search !== this.searchQuery) { + this.search(search, { pushState: false }) + } + if (walkthroughIndex !== undefined) { + this.helpButton.WtPlayer.skipTo(walkthroughIndex) + } } // Temporary e.g. on mouseover, erased on mouseout @@ -240,20 +253,6 @@ class Ui extends events.EventEmitter { customTooltip: tooltip }) - const toolbarSidePanel = toolbarTopPanel.addContent(undefined, { - id: 'toolbar-side-panel', - classNames: 'toolbar-section' - }) - toolbarSidePanel.addContent('SearchBox', { - id: 'search-box', - classNames: 'inline-panel' - }) - toolbarSidePanel.addContent('OptionsMenu', { - id: 'options-menu', - classNames: 'inline-panel', - customTooltip: tooltip - }) - const getZoomFactor = () => { // getting the zoomFactor when the viewport is larger than 600px // and as long as the width / height proportion equals to 16/9 @@ -269,7 +268,11 @@ class Ui extends events.EventEmitter { return 0 } - const flameWrapper = this.uiContainer.addContent('FlameGraph', { + this.mainContent = this.uiContainer.addContent('HtmlContent', { + id: 'main-content' + }) + + const flameWrapper = this.mainContent.addContent('FlameGraph', { id: 'flame-main', htmlElementType: 'section', customTooltip: tooltip, @@ -278,13 +281,38 @@ class Ui extends events.EventEmitter { }) this.flameWrapper = flameWrapper - const footer = this.uiContainer.addContent(undefined, { + this.sideBar = this.mainContent.addContent('SideBar', { + id: 'side-bar', + animationEnd: () => { + const zoomFactor = getZoomFactor() + flameWrapper.resize(zoomFactor) + } + }) + + this.sideBar.addContent('FiltersContent', { + classNames: 'filters-options', + getSpinner: () => this.flameWrapperSpinner + }) + + this.footer = this.uiContainer.addContent(undefined, { id: 'footer', htmlElementType: 'section' }) - footer.addContent('Key', { - id: 'key-panel', - classNames: 'panel' + + // mobile search-box + this.mSearchBoxWrapper = this.footer.addContent(undefined, { + id: 'm-search-box-wrapper', + classNames: 'before-bp-2 m-search-box-wrapper' + }) + this.mSearchBoxWrapper.addContent('SearchBox', { + id: 'm-search-box', + classNames: 'inline-panel' + }) + + this.footer.addContent('FiltersContainer', { + id: 'filters-bar', + toggleSideBar: this.toggleSideBar, + getSpinner: () => this.flameWrapperSpinner }) // TODO: add these ↴ @@ -365,9 +393,10 @@ class Ui extends events.EventEmitter { getLabelFromKey (key, singular = false) { const keysToLabels = { - app: 'profiled application', + app: this.dataTree.appName || 'profiled application', deps: singular ? 'Dependency' : 'Dependencies', core: 'Node JS', + wasm: 'WebAssembly', 'is:inlinable': 'Inlinable', 'is:init': 'Init', @@ -394,12 +423,15 @@ class Ui extends events.EventEmitter { getDescriptionFromKey (key) { const keysToDescriptions = { - core: `JS functions in core Node.js APIs.`, - 'all-v8': `The JavaScript engine used by default in Node.js. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8')}`, - 'all-v8:v8': `Operations in V8's implementation of JS. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-runtime')}`, - 'all-v8:native': `JS compiled into V8, such as prototype methods and eval. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-native')}`, - 'all-v8:cpp': `Native C++ operations called by V8, including shared libraries. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-cpp')}`, - 'all-v8:regexp': `The RegExp notation is shown as the function name. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-regexp')}` + app: `Functions in the code of the application being profiled.`, + deps: `External modules in the application's node_modules directory.`, + core: `JS functions in core Node.js APIs.`, + wasm: `Compiled WebAssembly code.`, + 'all-v8': `The JavaScript engine used by default in Node.js. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8')}`, + 'all-v8:v8': `Operations in V8's implementation of JS. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-runtime')}`, + 'all-v8:native': `JS compiled into V8, such as prototype methods and eval. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-native')}`, + 'all-v8:cpp': `Native C++ operations called by V8, including shared libraries. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-cpp')}`, + 'all-v8:regexp': `The RegExp notation is shown as the function name. ${this.createMoreInfoLink('https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-regexp')}` } if (keysToDescriptions[key]) { @@ -415,19 +447,31 @@ class Ui extends events.EventEmitter { return null } - setCodeAreaVisibility (name, visible, manyTimes) { + setCodeAreaVisibility ({ codeArea, visible, pushState = true, isRecursing = false }) { // Apply a single possible change to dataTree.exclude, updating what's necessary let isChanged = false - if (visible) { - isChanged = this.dataTree.show(name) - if (isChanged) this.changedExclusions.toShow.add(name) + if (codeArea.children && codeArea.children.length) { + const childrenChanged = codeArea.children.forEach(child => this.setCodeAreaVisibility({ + codeArea: child, + visible, + pushState: false, + isRecursing: true + })) + this.updateExclusions({ pushState }) + return childrenChanged } else { - isChanged = this.dataTree.hide(name) - if (isChanged) this.changedExclusions.toHide.add(name) - } + const name = codeArea.excludeKey + if (visible) { + isChanged = this.dataTree.show(name) + if (isChanged) this.changedExclusions.toShow.add(name) + } else { + isChanged = this.dataTree.hide(name) + if (isChanged) this.changedExclusions.toHide.add(name) + } - if (isChanged && !manyTimes) this.updateExclusions() + if (isChanged && !isRecursing) this.updateExclusions({ pushState }) + } return isChanged } @@ -443,9 +487,7 @@ class Ui extends events.EventEmitter { const cb = () => { if (!initial) this.emit('updateExclusions') - if (pushState) { - this.pushHistory() - } + if (pushState) this.pushHistory() } // Zoom out before updating exclusions if the user excludes the node they're zoomed in on @@ -476,6 +518,8 @@ class Ui extends events.EventEmitter { if (pushState) this.pushHistory() if (cb) cb() + + this.emit('updateExclusions') } }) } @@ -483,6 +527,7 @@ class Ui extends events.EventEmitter { this.dataTree.showOptimizationStatus = showOptimizationStatus this.draw() this.pushHistory() + this.emit('updateExclusions') } setData (dataTree) { @@ -496,6 +541,17 @@ class Ui extends events.EventEmitter { this.infoBox.showNodeInfo(nodeData) } + toggleMobileSearchBox (show = !this.mSearchBoxWrapper.d3Element.classed('show')) { + clearTimeout(this.mSearchBoxAutoHideHnd) + this.mSearchBoxWrapper.d3Element.classed('show', show) + if (show) this.mSearchBoxWrapper.d3Element.select('input').node().focus() + } + + toggleSideBar (show = !this.sideBar.d3Element.classed('expand')) { + this.sideBar.toggle(show) + this.emit('sideBar', show) + } + /** * Initialization and draw **/ @@ -507,6 +563,7 @@ class Ui extends events.EventEmitter { app: computedStyle.getPropertyValue('--area-color-app').trim(), deps: computedStyle.getPropertyValue('--area-color-deps').trim(), core: computedStyle.getPropertyValue('--area-color-core').trim(), + wasm: computedStyle.getPropertyValue('--area-color-core').trim(), 'all-v8': computedStyle.getPropertyValue('--area-color-core').trim(), 'opposite-contrast': computedStyle.getPropertyValue('--opposite-contrast').trim(), @@ -533,6 +590,45 @@ class Ui extends events.EventEmitter { initializeElements () { // Cascades down tree in addContent() append/prepend order this.uiContainer.initializeElements() + + // auto hiding mobile search-box on blur if empty + this.mSearchBoxWrapper.d3Element.select('input') + .on('blur', (datum, index, nodes) => { + this.mSearchBoxAutoHideHnd = setTimeout(() => { + // this little delay is to avoid clashes between the 'blur' cb and clicking on the search button + if (nodes[index].value.trim() === '') { + this.toggleMobileSearchBox(false) + } + }, 300) + }) + + // adding the mSearchBox close button + this.mSearchBoxWrapper.d3Element.append(() => button({ + leftIcon: close, + onClick: () => { + clearTimeout(this.mSearchBoxAutoHideHnd) + if (this.searchQuery === null) { + // close if empty + this.toggleMobileSearchBox(false) + } else { + // clear otherwise + this.clearSearch() + } + } + })) + + // walkthrough init + this.helpButton = walkthroughButton({ + steps: wtSteps, + onProgress: () => { + this.pushHistory() + }, + label: 'GuideShow how to use this', + title: 'Click to start the step-by-step UI features guide!' + }) + this.footer.d3Element.select('#filters-bar .left-col').append(() => this.helpButton.button) + + this.flameWrapperSpinner = spinner.attachTo(document.querySelector('#flame-main')) } draw () { diff --git a/visualizer/walkthrough-steps.css b/visualizer/walkthrough-steps.css new file mode 100644 index 00000000..64961897 --- /dev/null +++ b/visualizer/walkthrough-steps.css @@ -0,0 +1,23 @@ +.nc-walkthrough-content .scrollable { + max-height: 200px; + overflow: auto; +} + +.nc-walkthrough-content .welcome-step { + display: flex; + align-items: center; + margin-top: 0; +} + +.nc-walkthrough-content .icon { + margin: -4px 8px 0 -4px; +} + +.nc-walkthrough-content ul, +.nc-walkthrough-content ol { + padding-left: 1.5em; +} + +.nc-walkthrough-content li { + margin: 0.5em 0; +} diff --git a/visualizer/walkthrough-steps.js b/visualizer/walkthrough-steps.js new file mode 100644 index 00000000..ec400ca4 --- /dev/null +++ b/visualizer/walkthrough-steps.js @@ -0,0 +1,89 @@ +const link = require('@nearform/clinic-common/base/link.js') + +const docs = link({ + label: 'Clinic Flame Documentation', + href: 'https://clinicjs.org/documentation/flame/', + target: '_blank', + leftIcon: `` +}) + +const flame = `` +const WalkthroughSteps = [ + { + attachTo: '#flame-main', + msg: ` +
              +

              + ${flame} + Welcome to Clinic.js Flame! +

              +

              This is a Flamegraph. Each block represents the time spent executing calls to a function. The wider the block, the more time was spent.

              +

              Blocks sit on the function that called them, so the stack below each block shows its stack trace.

              +
              + ` + }, + { + attachTo: '#flame-main .highlighter-box', + msg: ` +
              +

              A hot function

              +

              This is a "hot" function - a lot of time was spent at the top of the stack, running the code inside this function. The brighter the colour on the exposed top of a block, the "hotter" it is compared to the rest of the profile.

              +

              This might signify a problem: for example, it might be a slow function that can be optimised, or that is called very many times by functions below it.

              +
              ` + }, + { + attachTo: '#toolbar', + msg: ` +
              +

              About this function

              +

              The "hottest" function in the profile is selected by default.

              +

              Here, we can see the function name and file location (or equivalent), so we can inspect the underlying code and decide if this function is something we can and should optimise.

              +
              ` + }, + { + attachTo: '#selection-controls', + msg: ` +
              +

              Selection controls

              +

              One good way to start using a Clinic.js Flame profile is to cycle through the "hottest" frames in order. Then, for each one, we can look down the stack and work out why a relatively high amount of time is spent here.

              +

              These buttons locate and select the next or previous hottest block in the flamegraph.

              +
              ` + }, + { + attachTo: '#stack-bar', + msg: ` +
              +

              Functions by heat

              +

              This bar shows the "heat" of every block (the time spent in that function and not any functions it calls), in order; and shows where the currently selected block sits in this ranking.

              +

              Moving to the next hottest block using the selection controls below moves this selection one block to the right.

              +
              ` + }, + { + attachTo: '#filters-bar .center-col', + msg: ` +
              +

              Functions by code area

              +

              Blocks are colour-coded by position in the Node.js application's architecture:

              +
                +
              • Functions from this application's own code are highlighted in white
              • +
              • Dependencies in node_modules are blue
              • +
              • Calls to Node.js APIs are shown in grey
              • +
              • Activity in the V8 JavaScript engine + is hidden by default, to reduce complexity
              • +
              +

              These code areas can be hidden or shown using these checkboxes.

              +
              ` + }, + { + attachTo: '#filters-bar .right-col', + msg: ` +
              +

              Advanced options

              +

              Typing part of a function name, file path or equivalent into this search box highlights every matching block.

              +

              Advanced options can also be accessed here.

              +

              For more infomation and detailed walkthroughs, see:

              ${docs.outerHTML} +
              ` + } +] + +module.exports = WalkthroughSteps