diff --git a/cypress/integration/basic.ts b/cypress/integration/basic.ts index 8a3041e842..f627bb568c 100644 --- a/cypress/integration/basic.ts +++ b/cypress/integration/basic.ts @@ -357,6 +357,27 @@ describe('basic test', () => { }); }); + describe('focus', () => { + it('it toggles when clicked again', () => { + cy.intercept('**/render*', { + fixture: 'simple-golang-app-cpu.json', + times: 1, + }).as('render'); + + cy.visit('/'); + + // click once once + cy.findByTestId('flamegraph-canvas').click(0, BAR_HEIGHT * 2); + + // click again + cy.findByTestId('flamegraph-canvas').click(0, BAR_HEIGHT * 2); + + cy.findByTestId('flamegraph-canvas').matchImageSnapshot( + `simple-golang-app-focus-toggle` + ); + }); + }); + describe('contextmenu', () => { it("it works when 'clear view' is clicked", () => { cy.intercept('**/render*', { @@ -377,9 +398,10 @@ describe('basic test', () => { // click on the second item cy.findByTestId('flamegraph-canvas').click(0, BAR_HEIGHT * 2); cy.findByTestId('flamegraph-canvas').rightclick(); - cy.findByRole('menuitem') - .contains('Reset View') - .should('not.have.attr', 'aria-disabled'); + cy.findByRole('menuitem', { name: /Reset View/ }).should( + 'not.have.attr', + 'aria-disabled' + ); cy.findByRole('menuitem', { name: /Reset View/ }).click(); // TODO assert that it was indeed reset? diff --git a/cypress/snapshots/basic.ts/pyroscope.server.inuse_objects-flamegraph.snap.png b/cypress/snapshots/basic.ts/pyroscope.server.inuse_objects-flamegraph.snap.png index 7ace5655c6..8011cbe1ec 100644 Binary files a/cypress/snapshots/basic.ts/pyroscope.server.inuse_objects-flamegraph.snap.png and b/cypress/snapshots/basic.ts/pyroscope.server.inuse_objects-flamegraph.snap.png differ diff --git a/cypress/snapshots/basic.ts/pyroscope.server.inuse_space-flamegraph.snap.png b/cypress/snapshots/basic.ts/pyroscope.server.inuse_space-flamegraph.snap.png index e92bad7012..5eda67c9c4 100644 Binary files a/cypress/snapshots/basic.ts/pyroscope.server.inuse_space-flamegraph.snap.png and b/cypress/snapshots/basic.ts/pyroscope.server.inuse_space-flamegraph.snap.png differ diff --git a/cypress/snapshots/basic.ts/simple-golang-app-focus-toggle.snap.png b/cypress/snapshots/basic.ts/simple-golang-app-focus-toggle.snap.png new file mode 100644 index 0000000000..9a73edaaeb Binary files /dev/null and b/cypress/snapshots/basic.ts/simple-golang-app-focus-toggle.snap.png differ diff --git a/cypress/snapshots/lang-smoke.ts/cart-service-dotnet-cpu-flamegraph.snap.png b/cypress/snapshots/lang-smoke.ts/cart-service-dotnet-cpu-flamegraph.snap.png index 7da9831a45..7be1ad52bb 100644 Binary files a/cypress/snapshots/lang-smoke.ts/cart-service-dotnet-cpu-flamegraph.snap.png and b/cypress/snapshots/lang-smoke.ts/cart-service-dotnet-cpu-flamegraph.snap.png differ diff --git a/package.json b/package.json index 4784abe27f..c9da6127e6 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "lodash": "^4.17.20", "moment": "^2.27.0", "normalize.css": "^8.0.1", + "prelude-ts": "^1.0.3", "react": "16.12.0", "react-datepicker": "^3.4.1", "react-dom": "^16.13.1", @@ -138,7 +139,8 @@ "redux-query-sync": "^0.1.10", "redux-thunk": "^2.3.0", "sanitize.css": "^11.0.1", - "style-loader": "^3.2.1" + "style-loader": "^3.2.1", + "ts-essentials": "^9.0.0" }, "optionalDependencies": { "@size-limit/file": "^6.0.3", diff --git a/scripts/jest-snapshots/run-docker.sh b/scripts/jest-snapshots/run-docker.sh index aef9618aa0..0848531157 100755 --- a/scripts/jest-snapshots/run-docker.sh +++ b/scripts/jest-snapshots/run-docker.sh @@ -11,4 +11,4 @@ docker run \ -v $PWD:/app \ -v /app/node_modules \ -w /app \ - node:current-slim ./scripts/jest-snapshots/run-snapshots.sh + node:14.15-slim ./scripts/jest-snapshots/run-snapshots.sh diff --git a/scripts/jest-snapshots/run-snapshots.sh b/scripts/jest-snapshots/run-snapshots.sh index 5624c3c183..db798a7e67 100755 --- a/scripts/jest-snapshots/run-snapshots.sh +++ b/scripts/jest-snapshots/run-snapshots.sh @@ -12,6 +12,10 @@ fi apt update -y apt install fontconfig -y -yarn install +# ignore-engines due to +# warning webpack-plugin-serve@1.5.0: Invalid bin field for "webpack-plugin-serve". +# error eslint-import-resolver-webpack@0.13.1: The engine "node" is incompatible with this module. Expected version "^16 || ^15 || ^14 || ^13 || ^12 || ^11 || ^10 || ^9 || ^8 || ^7 || ^6". Got "17.0.1" +# error Found incompatible module. +yarn install --ignore-engines RUN_SNAPSHOTS=true yarn test --testNamePattern='group:snapshot' --verbose "$updateArg" diff --git a/setupAfterEnv.ts b/setupAfterEnv.ts index e58ff975ff..59f0b79e1a 100644 --- a/setupAfterEnv.ts +++ b/setupAfterEnv.ts @@ -1,12 +1,20 @@ import '@testing-library/jest-dom'; import 'jest-canvas-mock'; -const { toMatchImageSnapshot } = require('jest-image-snapshot'); +const { + toMatchImageSnapshot, + configureToMatchImageSnapshot, +} = require('jest-image-snapshot'); expect.extend({ toMatchImageSnapshot(received: any, options: any) { // If these checks pass, assume we're in a JSDOM environment with the 'canvas' package. if (process.env.RUN_SNAPSHOTS) { + const customConfig = { threshold: 0.01 }; + const toMatchImageSnapshot = configureToMatchImageSnapshot({ + customDiffConfig: customConfig, + }); + return toMatchImageSnapshot.call(this, received, options); } diff --git a/tsconfig.json b/tsconfig.json index 6a1565bbf7..56c6157701 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "baseUrl": ".", "jsx": "react", - "lib": ["dom", "esnext"], + "lib": ["dom", "esnext", "ES2015.Iterable"], "module": "esnext", "moduleResolution": "node", "esModuleInterop": true, diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.spec.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.spec.tsx index 9ef44af462..77008457c7 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.spec.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.spec.tsx @@ -36,7 +36,13 @@ describe('ContextMenu', () => { ]; }; - render(); + render( + {}} + onOpen={() => {}} + /> + ); expect(queryByRole('menu')).not.toBeInTheDocument(); @@ -53,7 +59,13 @@ describe('ContextMenu', () => { it('shows different items depending on the clicked node', () => { const xyToMenuItems = jest.fn(); - render(); + render( + {}} + onOpen={() => {}} + /> + ); expect(queryByRole('menu')).not.toBeInTheDocument(); diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.tsx index c1e35b8980..8bea5fa5c8 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenu.tsx @@ -1,14 +1,5 @@ import React from 'react'; -import { - ControlledMenu, - useMenuState, - MenuItem, - SubMenu, -} from '@szhsin/react-menu'; - -// even though the library support many different types -// we only support these -type SupportedItems = typeof MenuItem | typeof SubMenu; +import { ControlledMenu, useMenuState } from '@szhsin/react-menu'; type xyToMenuItems = (x: number, y: number) => JSX.Element[]; @@ -21,6 +12,9 @@ export interface ContextMenuProps { * only MenuItem and SubMenu should be supported */ xyToMenuItems: xyToMenuItems; + + onClose: () => void; + onOpen: (x: number, y: number) => void; } export default function ContextMenu(props: ContextMenuProps) { @@ -28,20 +22,16 @@ export default function ContextMenu(props: ContextMenuProps) { const [anchorPoint, setAnchorPoint] = React.useState({ x: 0, y: 0 }); const { canvasRef } = props; const [menuItems, setMenuItems] = React.useState([]); + const { + xyToMenuItems, + onClose: onCloseCallback, + onOpen: onOpenCallback, + } = props; - const onContextMenu = (e: MouseEvent) => { - e.preventDefault(); - - const items = props.xyToMenuItems(e.offsetX, e.offsetY); - setMenuItems(items); - - // TODO - // if the menu becomes too large, it may overflow to outside the screen - const x = e.clientX; - const y = e.clientY + 20; + const onClose = () => { + closeMenu(); - setAnchorPoint({ x, y }); - openMenu(); + onCloseCallback(); }; React.useEffect(() => { @@ -55,20 +45,40 @@ export default function ContextMenu(props: ContextMenuProps) { return () => {}; } + const onContextMenu = (e: MouseEvent) => { + e.preventDefault(); + + const items = xyToMenuItems(e.offsetX, e.offsetY); + setMenuItems(items); + + // console.log('set menu items', items); + + // TODO + // if the menu becomes too large, it may overflow to outside the screen + const x = e.clientX; + const y = e.clientY + 20; + + setAnchorPoint({ x, y }); + openMenu(); + + onOpenCallback(e.offsetX, e.offsetY); + }; + // watch for mouse events on the bar canvasEl.addEventListener('contextmenu', onContextMenu); return () => { canvasEl.removeEventListener('contextmenu', onContextMenu); }; - }, []); + }, [xyToMenuItems]); + return ( {menuItems} diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenuHighlight.module.css b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenuHighlight.module.css new file mode 100644 index 0000000000..b24b091c70 --- /dev/null +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenuHighlight.module.css @@ -0,0 +1,7 @@ +.highlightContextMenu { + position: absolute; + pointer-events: none; + /* make it a bit lighter so that it's easier to distinguish when both highlights are on */ + background: #ffffff8c; + mix-blend-mode: overlay; +} diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenuHighlight.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenuHighlight.tsx new file mode 100644 index 0000000000..1eade06375 --- /dev/null +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/ContextMenuHighlight.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Option } from 'prelude-ts'; +import styles from './ContextMenuHighlight.module.css'; + +export interface HighlightProps { + // probably the same as the bar height + barHeight: number; + + node: Option<{ top: number; left: number; width: number }>; +} + +const initialSyle: React.CSSProperties = { + height: '0px', + visibility: 'hidden', +}; + +/** + * Highlight on the node that triggered the context menu + */ +export default function ContextMenuHighlight(props: HighlightProps) { + const { node, barHeight } = props; + const [style, setStyle] = React.useState(initialSyle); + + React.useEffect( + () => { + node.match({ + None: () => setStyle(initialSyle), + Some: (data) => + setStyle({ + visibility: 'visible', + height: `${barHeight}px`, + ...data, + }), + }); + }, + // refresh callback functions when they change + [node] + ); + + return ( +
+ ); +} diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.spec.ts b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.spec.ts index b3b0989a22..7e8cb845bc 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.spec.ts +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.spec.ts @@ -1,86 +1,12 @@ -import { Units } from '@utils/format'; +import { Option } from 'prelude-ts'; import Flamegraph from './Flamegraph'; -import RenderCanvas from './Flamegraph_render'; import { BAR_HEIGHT } from './constants'; +import TestData from './testData'; jest.mock('./Flamegraph_render'); -const format: 'single' | 'double' = 'single'; -const flamebearerSingle = { - format, - numTicks: 988, - sampleRate: 100, - names: [ - 'total', - 'runtime.main', - 'main.slowFunction', - 'main.work', - 'main.main', - 'main.fastFunction', - ], - levels: [ - [0, 988, 0, 0], - [0, 988, 0, 1], - [0, 214, 0, 5, 214, 3, 2, 4, 217, 771, 0, 2], - [0, 214, 214, 3, 216, 1, 1, 5, 217, 771, 771, 3], - ], - units: Units.Samples, - spyName: 'gospy', -}; - -const flamebearerDouble = { - names: [ - 'total', - 'runtime/pprof.profileWriter', - 'runtime.mcall', - 'runtime.park_m', - 'runtime.schedule', - 'runtime.findrunnable', - 'runtime.netpoll', - 'runtime.epollwait', - 'runtime.main', - 'main.slowFunction', - 'main.work', - 'fmt.Printf', - 'fmt.Fprintf', - 'os.(*File).write', - 'syscall.Write', - 'syscall.write', - 'syscall.Syscall', - 'runtime.exitsyscall', - 'runtime.exitsyscallfast', - 'runtime.wirep', - 'main.fastFunction', - ], - levels: [ - [0, 246, 0, 0, 986, 0, 0], - [0, 245, 0, 0, 985, 0, 8, 245, 1, 0, 985, 0, 0, 2, 246, 0, 0, 985, 1, 1, 1], - [ - 0, 49, 0, 0, 181, 0, 20, 49, 196, 0, 181, 804, 0, 9, 245, 1, 0, 985, 0, 0, - 3, - ], - [ - 0, 49, 49, 0, 181, 181, 10, 49, 0, 0, 181, 1, 0, 11, 49, 196, 196, 182, - 803, 803, 10, 245, 1, 0, 985, 0, 0, 4, - ], - [49, 0, 0, 181, 1, 0, 12, 245, 1, 0, 985, 0, 0, 5], - [49, 0, 0, 181, 1, 0, 13, 245, 1, 0, 985, 0, 0, 6], - [49, 0, 0, 181, 1, 0, 14, 245, 1, 1, 985, 0, 0, 7], - [49, 0, 0, 181, 1, 0, 15], - [49, 0, 0, 181, 1, 0, 16], - [49, 0, 0, 181, 1, 0, 17], - [49, 0, 0, 181, 1, 0, 18], - [49, 0, 0, 181, 1, 1, 19], - ], - numTicks: 1232, - maxSelf: 803, - spyName: 'gospy', - sampleRate: 100, - units: Units.Samples, - format: 'double' as const, - leftTicks: 246, - rightTicks: 986, -}; +type focusedNodeType = ConstructorParameters[2]; +type zoomType = ConstructorParameters[5]; describe('Flamegraph', () => { let canvas: any; @@ -88,275 +14,498 @@ describe('Flamegraph', () => { const CANVAS_WIDTH = 600; const CANVAS_HEIGHT = 300; + describe('isWithinBounds', () => { + beforeEach(() => { + canvas = document.createElement('canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const fitMode = 'HEAD'; + const highlightQuery = ''; + const focusedNode: focusedNodeType = Option.none(); + const zoom = Option.of({ i: 2, j: 8 }); + + flame = new Flamegraph( + TestData.ComplexTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + }); + + it('handles within canvas', () => { + expect(flame.isWithinBounds(0, 0)).toBe(true); + expect(flame.isWithinBounds(CANVAS_WIDTH - 1, 0)).toBe(true); + expect(flame.isWithinBounds(-1, 0)).toBe(false); + expect(flame.isWithinBounds(0, -1)).toBe(false); + expect(flame.isWithinBounds(-1, -1)).toBe(false); + }); + + it('handles within canvas but outside the flamegraph', () => { + // this test is a bit difficult to visually + // you just have to know that it has the format such as + // + // | | (level 3) + // |_| (level 4) + // (level 5) + expect(flame.isWithinBounds(0, BAR_HEIGHT * 3 + 1)).toBe(true); + expect(flame.isWithinBounds(0, BAR_HEIGHT * 4 + 1)).toBe(true); + expect(flame.isWithinBounds(0, BAR_HEIGHT * 5 + 1)).toBe(false); + }); + }); + describe('xyToBarData', () => { - describe('single', () => { - beforeEach(() => { + describe('normal', () => { + beforeAll(() => { canvas = document.createElement('canvas'); canvas.width = CANVAS_WIDTH; canvas.height = CANVAS_HEIGHT; - const topLevel = 0; - const selectedLevel = 0; const fitMode = 'HEAD'; const highlightQuery = ''; - const zoom = { i: -1, j: -1 }; + const zoom: zoomType = Option.none(); + const focusedNode: focusedNodeType = Option.none(); flame = new Flamegraph( - flamebearerSingle, + TestData.SimpleTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom ); + + flame.render(); }); - it('maps total row correctly', () => { - expect(flame.xyToBarData(0, 0)).toStrictEqual({ - format: 'single', - name: 'total', - offset: 0, - self: 0, - total: 988, - }); + it('works with the first bar (total)', () => { + const got = flame.xyToBar(0, 0).getOrThrow(); + expect(got.x).toBe(0); + expect(got.y).toBe(0); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); }); - it('maps a full row correctly', () => { - expect(flame.xyToBarData(1, BAR_HEIGHT + 1)).toStrictEqual({ - format: 'single', - name: 'runtime.main', - offset: 0, - self: 0, - total: 988, - }); + it('works a full bar (runtime.main)', () => { + // 2nd line, + const got = flame.xyToBar(0, BAR_HEIGHT + 1).getOrThrow(); + + expect(got.x).toBe(0); + expect(got.y).toBe(22); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); }); - it('maps a row with more items', () => { - expect(flame.xyToBarData(1, BAR_HEIGHT * 2 + 1)).toStrictEqual({ - format: 'single', - name: 'main.fastFunction', - offset: 0, - self: 0, - total: 214, - }); + it('works with (main.fastFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame.xyToBar(1, BAR_HEIGHT * 2 + 1).getOrThrow(); - expect( - flame.xyToBarData(CANVAS_WIDTH - 1, BAR_HEIGHT * 2 + 1) - ).toStrictEqual({ - format: 'single', - name: 'main.slowFunction', - offset: 217, - self: 0, - total: 771, - }); + expect(got.x).toBe(0); + expect(got.y).toBe(44); + expect(got.width).toBeCloseTo(129.95951417004048); }); - it('maps correctly even when zoomed in', () => { - // third row, last item (main.slowFunction) - expect(flame.xyToBarData(canvas.width, BAR_HEIGHT * 3)).toStrictEqual({ - format: 'single', - name: 'main.slowFunction', - offset: 217, - self: 0, - total: 771, + it('works with (main.slowFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame + .xyToBar(CANVAS_WIDTH - 1, BAR_HEIGHT * 2 + 1) + .getOrThrow(); + + expect(got.x).toBeCloseTo(131.78); + expect(got.y).toBe(44); + expect(got.width).toBeCloseTo(468.218); + }); + + describe('boundary testing', () => { + const cases = [ + [0, 0], + [CANVAS_WIDTH, 0], + [1, BAR_HEIGHT], + [CANVAS_WIDTH, BAR_HEIGHT], + [CANVAS_WIDTH / 2, BAR_HEIGHT / 2], + ]; + test.each(cases)( + 'given %p and %p as arguments, returns the total bar', + (i: number, j: number) => { + const got = flame.xyToBar(i, j).getOrThrow(); + expect(got).toMatchObject({ + i: 0, + j: 0, + x: 0, + y: 0, + }); + + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + } + ); + }); + }); + + describe('focused', () => { + describe('on the first row (runtime.main)', () => { + beforeAll(() => { + canvas = document.createElement('canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const fitMode = 'HEAD'; + const highlightQuery = ''; + const zoom: zoomType = Option.none(); + const focusedNode = Option.some({ i: 1, j: 0 }); + + flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); }); - // there's a different item under x=0 - expect(flame.xyToBarData(1, BAR_HEIGHT * 3)).not.toMatchObject({ - format: 'single', - name: 'main.slowFunction', - offset: 217, - self: 0, - total: 771, + it('works with the first bar (total)', () => { + const got = flame.xyToBar(0, 0).getOrThrow(); + expect(got.x).toBe(0); + expect(got.y).toBe(0); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); }); - // zoom on that item - const topLevel = 0; - const selectedLevel = 0; - const fitMode = 'HEAD'; - const highlightQuery = ''; - const zoom = { i: 2, j: 8 }; + it('works with a full bar (runtime.main)', () => { + // 2nd line, + const got = flame.xyToBar(0, BAR_HEIGHT + 1).getOrThrow(); - flame = new Flamegraph( - flamebearerSingle, - canvas, - topLevel, - selectedLevel, - fitMode, - highlightQuery, - zoom - ); + expect(got).toMatchObject({ + i: 1, + j: 0, + x: 0, + y: 22, + }); - // now that same item should be available on x=0 - expect(flame.xyToBarData(1, BAR_HEIGHT * 3)).toMatchObject({ - format: 'single', - name: 'main.slowFunction', - offset: 217, - self: 0, - total: 771, + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + // + // + it('works with (main.fastFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame.xyToBar(1, BAR_HEIGHT * 2 + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 2, + j: 0, + x: 0, + y: 44, + }); + + expect(got.width).toBeCloseTo(129.95951417004048); + }); + // + it('works with (main.slowFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame + .xyToBar(CANVAS_WIDTH - 1, BAR_HEIGHT * 2 + 1) + .getOrThrow(); + + expect(got).toMatchObject({ + i: 2, + j: 8, + }); + expect(got.x).toBeCloseTo(131.78); + expect(got.y).toBe(44); + expect(got.width).toBeCloseTo(468.218); }); }); - }); - describe('double', () => { - beforeAll(() => { - canvas = document.createElement('canvas'); - canvas.width = CANVAS_WIDTH; - canvas.height = CANVAS_HEIGHT; + describe('on main.slowFunction', () => { + beforeAll(() => { + canvas = document.createElement('canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const fitMode = 'HEAD'; + const highlightQuery = ''; + const zoom: zoomType = Option.none(); + const focusedNode = Option.some({ i: 2, j: 8 }); + + flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + }); - const topLevel = 0; - const selectedLevel = 0; - const fitMode = 'HEAD'; - const highlightQuery = ''; - const zoom = { i: -1, j: -1 }; + it('works with the first row (total)', () => { + const got = flame.xyToBar(0, 0).getOrThrow(); + expect(got.x).toBe(0); + expect(got.y).toBe(0); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); - flame = new Flamegraph( - flamebearerDouble, - canvas, - topLevel, - selectedLevel, - fitMode, - highlightQuery, - zoom - ); - }); + it('works with itself as second row (main.slowFunction)', () => { + // 2nd line, + const got = flame.xyToBar(1, BAR_HEIGHT + 1).getOrThrow(); - it('maps total correctly', () => { - expect(flame.xyToBarData(0, 0)).toStrictEqual({ - format: 'double', - name: 'total', - totalLeft: 246, - totalRight: 986, - barTotal: 1232, - totalDiff: 740, + expect(got).toMatchObject({ + i: 2, + j: 8, + x: 0, + y: 22, + }); + + expect(got.width).toBeCloseTo(CANVAS_WIDTH); }); - }); - it('maps a full row correctly', () => { - expect(flame.xyToBarData(1, BAR_HEIGHT + 1)).toStrictEqual({ - format: 'double', - name: 'runtime.main', - totalLeft: 245, - totalRight: 985, - barTotal: 1230, - totalDiff: 740, + it('works with its child as third row (main.work)', () => { + // 2nd line, + const got = flame.xyToBar(1, BAR_HEIGHT * 2 + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 3, + j: 8, + x: 0, + y: 44, + }); + + expect(got.width).toBeCloseTo(CANVAS_WIDTH); }); }); + }); - it('maps a row with more items', () => { - expect(flame.xyToBarData(1, BAR_HEIGHT * 2 + 1)).toStrictEqual({ - format: 'double', - name: 'main.fastFunction', - totalLeft: 49, - totalRight: 181, - barTotal: 230, - totalDiff: 132, + describe('zoomed', () => { + describe('on the first row (runtime.main)', () => { + beforeAll(() => { + canvas = document.createElement('canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const fitMode = 'HEAD'; + const highlightQuery = ''; + + const zoom: zoomType = Option.of({ i: 1, j: 0 }); + const focusedNode: focusedNodeType = Option.none(); + + flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); }); - expect( - flame.xyToBarData(CANVAS_WIDTH - 1, BAR_HEIGHT * 2 + 1) - ).toStrictEqual({ - format: 'double', - name: 'main.slowFunction', - totalDiff: 608, - totalLeft: 196, - totalRight: 804, - barTotal: 1000, + it('works with the first bar (total)', () => { + const got = flame.xyToBar(0, 0).getOrThrow(); + expect(got.x).toBe(0); + expect(got.y).toBe(0); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + // + it('works with a full bar (runtime.main)', () => { + // 2nd line, + const got = flame.xyToBar(0, BAR_HEIGHT + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 1, + j: 0, + x: 0, + y: 22, + }); + + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + // + // + it('works with (main.fastFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame.xyToBar(1, BAR_HEIGHT * 2 + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 2, + j: 0, + x: 0, + y: 44, + }); + + expect(got.width).toBeCloseTo(129.95951417004048); + }); + // + it('works with (main.slowFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame + .xyToBar(CANVAS_WIDTH - 1, BAR_HEIGHT * 2 + 1) + .getOrThrow(); + + expect(got).toMatchObject({ + i: 2, + j: 8, + }); + expect(got.x).toBeCloseTo(131.78); + expect(got.y).toBe(44); + expect(got.width).toBeCloseTo(468.218); }); }); - // TODO: - // test when it's zoomed? - }); - // TODO tests for focused item - }); - - describe('isWithinBounds', () => { - beforeEach(() => { - canvas = document.createElement('canvas'); - canvas.width = CANVAS_WIDTH; - canvas.height = CANVAS_HEIGHT; - - const topLevel = 0; - const selectedLevel = 0; - const fitMode = 'HEAD'; - const highlightQuery = ''; - const zoom = { i: 2, j: 8 }; + describe('on main.slowFunction', () => { + beforeAll(() => { + canvas = document.createElement('canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const fitMode = 'HEAD'; + const highlightQuery = ''; + const zoom = Option.of({ i: 2, j: 8 }); + const focusedNode: focusedNodeType = Option.none(); + + flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + }); - flame = new Flamegraph( - flamebearerSingle, - canvas, - topLevel, - selectedLevel, - fitMode, - highlightQuery, - zoom - ); + it('works with the first bar (total)', () => { + const got = flame.xyToBar(0, 0).getOrThrow(); + expect(got.x).toBe(0); + expect(got.y).toBe(0); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + // + it('works with a full bar (runtime.main)', () => { + // 2nd line, + const got = flame.xyToBar(0, BAR_HEIGHT + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 1, + j: 0, + x: 0, + y: 22, + }); + + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + // + // + it('works with (main.slowFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame.xyToBar(1, BAR_HEIGHT * 2 + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 2, + j: 8, + x: 0, + y: 44, + }); + + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); - flame.render(); - }); - it('handles within canvas', () => { - expect(flame.isWithinBounds(0, 0)).toBe(true); - expect(flame.isWithinBounds(CANVAS_WIDTH - 1, 0)).toBe(true); - expect(flame.isWithinBounds(-1, 0)).toBe(false); - expect(flame.isWithinBounds(0, -1)).toBe(false); - expect(flame.isWithinBounds(-1, -1)).toBe(false); + it('works with main.work (child of main.slowFunction)', () => { + // 4th line, 'main.work' + // TODO why 2?? + const got = flame.xyToBar(1, BAR_HEIGHT * 3 + 2).getOrThrow(); + + expect(got).toMatchObject({ + i: 3, + j: 8, + x: 0, + y: 66, + }); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + }); }); - }); - describe('xyToBarPosition', () => { - beforeEach(() => { - canvas = document.createElement('canvas'); - canvas.width = CANVAS_WIDTH; - canvas.height = CANVAS_HEIGHT; + describe('focused+zoomed', () => { + describe('focused on the first row (runtime.main), zoomed on the third row (main.slowFunction)', () => { + beforeAll(() => { + canvas = document.createElement('canvas'); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const fitMode = 'HEAD'; + const highlightQuery = ''; + const zoom = Option.of({ i: 2, j: 8 }); + const focusedNode = Option.of({ i: 1, j: 0 }); + + flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + }); - const topLevel = 0; - const selectedLevel = 0; - const fitMode = 'HEAD'; - const highlightQuery = ''; - const zoom = { i: -1, j: -1 }; + it('works with the first bar (total)', () => { + const got = flame.xyToBar(0, 0).getOrThrow(); + expect(got).toMatchObject({ + x: 0, + y: 0, + i: 0, + j: 0, + }); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); - flame = new Flamegraph( - flamebearerSingle, - canvas, - topLevel, - selectedLevel, - fitMode, - highlightQuery, - zoom - ); + it('works with a full bar (runtime.main)', () => { + // 2nd line, + const got = flame.xyToBar(0, BAR_HEIGHT + 1).getOrThrow(); - flame.render(); - }); + expect(got).toMatchObject({ + i: 1, + j: 0, + x: 0, + y: 22, + }); - it('works with the first bar (total)', () => { - const got = flame.xyToBarPosition(0, 0); - expect(got).toMatchObject({ - x: 0, - y: 0, - }); - }); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); - it('works a full bar', () => { - // 2nd line, - const got = flame.xyToBarPosition(0, BAR_HEIGHT + 1); - expect(got).toMatchObject({ - x: 0, - y: 22, - }); - expect(got.width).toBeCloseTo(CANVAS_WIDTH); - }); + it('works with (main.slowFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame.xyToBar(1, BAR_HEIGHT * 2 + 1).getOrThrow(); - it('works with a non full bar', () => { - // 3nd line, 'slowFunction' - const got = flame.xyToBarPosition(1, BAR_HEIGHT * 3); + expect(got).toMatchObject({ + i: 2, + j: 8, + x: 0, + y: 44, + }); - expect(got).toMatchObject({ - x: 0, - y: 44, + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); + it('works with (main.slowFunction)', () => { + // 3nd line, 'slowFunction' + const got = flame.xyToBar(1, BAR_HEIGHT * 3 + 1).getOrThrow(); + + expect(got).toMatchObject({ + i: 3, + j: 8, + x: 0, + y: 66, + }); + expect(got.width).toBeCloseTo(CANVAS_WIDTH); + }); }); - expect(got.width).toBeCloseTo(129.95951417004048); }); }); }); diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.ts b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.ts index 48adabbc19..05d975c123 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.ts +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph.ts @@ -1,5 +1,7 @@ import { createFF } from '@utils/flamebearer'; import { Flamebearer } from '@models/flamebearer'; +import { DeepReadonly } from 'ts-essentials'; +import { Option } from 'prelude-ts'; import { PX_PER_LEVEL, BAR_HEIGHT, COLLAPSE_THRESHOLD } from './constants'; // there's a dependency cycle here but it should be fine /* eslint-disable-next-line import/no-cycle */ @@ -7,65 +9,51 @@ import RenderCanvas from './Flamegraph_render'; /* eslint-disable no-useless-constructor */ +/* + * Branded Type to distinguish between x,y that were validated to be within bounds or not. + */ +type XYWithinBounds = { x: number; y: number } & { __brand: 'XYWithinBounds' }; + export default class Flamegraph { private ff: ReturnType; - // used in zoom - private rangeMin: number; - - // used in zoom - private rangeMax: number; - constructor( private readonly flamebearer: Flamebearer, private canvas: HTMLCanvasElement, /** - * What level to start from + * What node to be 'focused' + * ie what node to start the tree */ - private topLevel: number, + private focusedNode: Option>, /** * What level has been "selected" * All nodes above will be dimmed out */ - private selectedLevel: number, - private fitMode: 'HEAD' | 'TAIL', + // private selectedLevel: number, + private readonly fitMode: 'HEAD' | 'TAIL', /** * The query used to match against the node name. * For each node, * if it matches it will be highlighted, * otherwise it will be greyish. */ - private highlightQuery: string, - zoom: { i: number; j: number } + private readonly highlightQuery: string, + private zoom: Option> ) { this.ff = createFF(flamebearer.format); - this.setupZoom(zoom.i, zoom.j, flamebearer); - } - - private setupZoom(i: number, j: number, flamebearer: Flamebearer) { - const { ff } = this; - - // no zoom - if (i === -1 || j === -1) { - this.rangeMin = 0; - this.rangeMax = 1; - this.selectedLevel = 0; - this.topLevel = 0; - return; + // don't allow to have a zoom smaller than the focus + // since it does not make sense + if (focusedNode.isSome() && zoom.isSome()) { + if (zoom.get().i < focusedNode.get().i) { + throw new Error('Zoom i level should be bigger than Focus'); + } } - - this.topLevel = 0; - this.selectedLevel = i; - this.rangeMin = - ff.getBarOffset(flamebearer.levels[i], j) / flamebearer.numTicks; - this.rangeMax = - (ff.getBarOffset(flamebearer.levels[i], j) + - ff.getBarTotal(flamebearer.levels[i], j)) / - this.flamebearer.numTicks; } public render() { + const { rangeMin, rangeMax } = this.getRange(); + const props = { canvas: this.canvas, @@ -77,12 +65,14 @@ export default class Flamegraph { spyName: this.flamebearer.spyName, units: this.flamebearer.units, - topLevel: this.topLevel, - rangeMin: this.rangeMin, - rangeMax: this.rangeMax, + rangeMin, + rangeMax, fitMode: this.fitMode, - selectedLevel: this.selectedLevel, highlightQuery: this.highlightQuery, + zoom: this.zoom, + focusedNode: this.focusedNode, + pxPerTick: this.pxPerTick(), + tickToX: this.tickToX, }; const { format: viewType } = this.flamebearer; @@ -107,15 +97,109 @@ export default class Flamegraph { } private pxPerTick() { - const graphWidth = this.canvas.width; + const { rangeMin, rangeMax } = this.getRange(); + // const graphWidth = this.canvas.width; + const graphWidth = this.getCanvasWidth(); - return ( - graphWidth / this.flamebearer.numTicks / (this.rangeMax - this.rangeMin) - ); + return graphWidth / this.flamebearer.numTicks / (rangeMax - rangeMin); } - private tickToX(i: number) { - return (i - this.flamebearer.numTicks * this.rangeMin) * this.pxPerTick(); + private tickToX = (i: number) => { + const { rangeMin } = this.getRange(); + return (i - this.flamebearer.numTicks * rangeMin) * this.pxPerTick(); + }; + + private getRange() { + const { ff } = this; + + // delay calculation since they may not be set + const calculatedZoomRange = ( + zoom: ReturnType + ) => { + const zoomMin = + ff.getBarOffset(this.flamebearer.levels[zoom.i], zoom.j) / + this.flamebearer.numTicks; + const zoomMax = + (ff.getBarOffset(this.flamebearer.levels[zoom.i], zoom.j) + + ff.getBarTotal(this.flamebearer.levels[zoom.i], zoom.j)) / + this.flamebearer.numTicks; + + return { + rangeMin: zoomMin, + rangeMax: zoomMax, + }; + }; + + const calculatedFocusRange = ( + focusedNode: ReturnType + ) => { + const focusMin = + ff.getBarOffset(this.flamebearer.levels[focusedNode.i], focusedNode.j) / + this.flamebearer.numTicks; + const focusMax = + (ff.getBarOffset( + this.flamebearer.levels[focusedNode.i], + focusedNode.j + ) + + ff.getBarTotal( + this.flamebearer.levels[focusedNode.i], + focusedNode.j + )) / + this.flamebearer.numTicks; + + return { + rangeMin: focusMin, + rangeMax: focusMax, + }; + }; + + const { zoom, focusedNode } = this; + + return zoom.match({ + Some: (z) => { + return focusedNode.match({ + // both are set + Some: (f) => { + const fRange = calculatedFocusRange(f); + const zRange = calculatedZoomRange(z); + + // focus is smaller, let's use it + if ( + fRange.rangeMax - fRange.rangeMin < + zRange.rangeMax - zRange.rangeMin + ) { + console.warn( + 'Focus is smaller than range, this shouldnt happen. Verify that the zoom is always bigger than the focus.' + ); + return calculatedFocusRange(f); + } + + return calculatedZoomRange(z); + }, + + // only zoom is set + None: () => { + return calculatedZoomRange(z); + }, + }); + }, + + None: () => { + return focusedNode.match({ + Some: (f) => { + // only focus is set + return calculatedFocusRange(f); + }, + None: () => { + // neither are set + return { + rangeMin: 0, + rangeMax: 1, + }; + }, + }); + }, + }); } private getCanvasWidth() { @@ -124,12 +208,12 @@ export default class Flamegraph { } private isFocused() { - return this.topLevel > 0; + return this.focusedNode.isSome(); } // binary search of a block in a stack level // TODO(eh-am): calculations seem wrong when x is 0 and y != 0, - // also when on the border + // also on the border private binarySearchLevel(x: number, level: number[]) { const { ff } = this; @@ -156,16 +240,57 @@ export default class Flamegraph { return -1; } - public xyToBar(x: number, y: number) { + private xyToBarIndex(x: number, y: number) { if (x < 0 || y < 0) { throw new Error(`x and y must be bigger than 0. x = ${x}, y = ${y}`); } + // clicked on the top bar and it's focused + if (this.isFocused() && y <= BAR_HEIGHT) { + return { i: 0, j: 0 }; + } + // in focused mode there's a "fake" bar at the top // so we must discount for it const computedY = this.isFocused() ? y - BAR_HEIGHT : y; - const i = Math.floor(computedY / PX_PER_LEVEL) + this.topLevel; + const compensatedFocusedY = this.focusedNode + .map((node) => { + return node.i <= 0 ? 0 : node.i; + }) + .getOrElse(0); + + const compensation = this.zoom.match({ + Some: () => { + return this.focusedNode.match({ + Some: () => { + // both are set, prefer focus + return compensatedFocusedY; + }, + + None: () => { + // only zoom is set + return 0; + }, + }); + }, + + None: () => { + return this.focusedNode.match({ + Some: () => { + // only focus is set + return compensatedFocusedY; + }, + + None: () => { + // none of them are set + return 0; + }, + }); + }, + }); + + const i = Math.floor(computedY / PX_PER_LEVEL) + compensation; if (i >= 0 && i < this.flamebearer.levels.length) { const j = this.binarySearchLevel(x, this.flamebearer.levels[i]); @@ -176,44 +301,31 @@ export default class Flamegraph { return { i: 0, j: 0 }; } - public isWithinBounds = (x: number, y: number) => { - if (x < 0 || x > this.getCanvasWidth()) { - return false; - } - - try { - const { i, j } = this.xyToBar(x, y); - if (j === -1 || i === -1) { - return false; - } - } catch (e) { - return false; - } + private parseXY(x: number, y: number) { + const withinBounds = this.isWithinBounds(x, y); - return true; - }; + const v = { x, y } as XYWithinBounds; - /* - * Given x and y coordinates - * identify the bar position and width - * - * This can be used for highlighting - */ - public xyToBarPosition = (x: number, y: number) => { - if (!this.isWithinBounds(x, y)) { - throw new Error( - `Value out of bounds. Can't get bar position x:'${x}', y:'${y}'` - ); + if (withinBounds) { + return Option.of(v); } + return Option.none(); + } + + private xyToBarPosition = (xy: XYWithinBounds) => { const { ff } = this; - const { i, j } = this.xyToBar(x, y); + const { i, j } = this.xyToBarIndex(xy.x, xy.y); - const level = this.flamebearer.levels[i]; + const topLevel = this.focusedNode + .map((node) => (node.i < 0 ? 0 : node.i - 1)) + .getOrElse(0); + const level = this.flamebearer.levels[i]; const posX = Math.max(this.tickToX(ff.getBarOffset(level, j)), 0); - const posY = - (i - this.topLevel) * PX_PER_LEVEL + (this.isFocused() ? BAR_HEIGHT : 0); + + // lower bound is 0 + const posY = Math.max((i - topLevel) * PX_PER_LEVEL, 0); const sw = Math.min( this.tickToX(ff.getBarOffset(level, j) + ff.getBarTotal(level, j)) - posX, @@ -227,19 +339,8 @@ export default class Flamegraph { }; }; - // TODO maybe we should combine xyToBarPosition with this? - /* - * Given x and y coordinates - * return all node data available in the flamegraph - */ - public xyToBarData(x: number, y: number) { - if (!this.isWithinBounds(x, y)) { - throw new Error( - `Value out of bounds. Can't get bar position. x: '${x}', y: '${y}'` - ); - } - - const { i, j } = this.xyToBar(x, y); + private xyToBarData = (xy: XYWithinBounds) => { + const { i, j } = this.xyToBarIndex(xy.x, xy.y); const level = this.flamebearer.levels[i]; const { ff } = this; @@ -269,5 +370,43 @@ export default class Flamegraph { throw new Error(`Unsupported type`); } } + }; + + public isWithinBounds = (x: number, y: number) => { + if (x < 0 || x > this.getCanvasWidth()) { + return false; + } + + try { + const { i, j } = this.xyToBarIndex(x, y); + if (j === -1 || i === -1) { + return false; + } + } catch (e) { + return false; + } + + return true; + }; + + /* + * Given x and y coordinates + * return all information about the bar under those coordinates + */ + public xyToBar(x: number, y: number) { + return this.parseXY(x, y).map((xyWithinBounds) => { + const { i, j } = this.xyToBarIndex(x, y); + const position = this.xyToBarPosition(xyWithinBounds); + const data = this.xyToBarData(xyWithinBounds); + + return { + x: xyWithinBounds.x, + y: xyWithinBounds.y, + i, + j, + ...position, + ...data, + }; + }); } } diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.spec.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.spec.tsx index df33e20754..04a0c2fb94 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.spec.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.spec.tsx @@ -1,25 +1,27 @@ import CanvasConverter from 'canvas-to-buffer'; import { createCanvas } from 'canvas'; +import { Option } from 'prelude-ts'; import TestData from './testData'; import Flamegraph from './Flamegraph'; +type focusedNodeType = ConstructorParameters[2]; +type zoomType = ConstructorParameters[5]; + // All tests here refer strictly to the rendering bit of "Flamegraph" describe("render group:snapshot'", () => { // TODO i'm thinking here if we can simply reuse this? const canvas = createCanvas(800, 0) as unknown as HTMLCanvasElement; - const topLevel = 0; - const selectedLevel = 0; const fitMode = 'HEAD'; const highlightQuery = ''; - const zoom = { i: -1, j: -1 }; + const zoom: zoomType = Option.none(); + const focusedNode: focusedNodeType = Option.none(); it('renders a simple flamegraph', () => { const flame = new Flamegraph( TestData.SimpleTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom @@ -34,8 +36,7 @@ describe("render group:snapshot'", () => { const flame = new Flamegraph( TestData.ComplexTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom @@ -49,8 +50,7 @@ describe("render group:snapshot'", () => { const flame = new Flamegraph( TestData.DiffTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom @@ -62,12 +62,12 @@ describe("render group:snapshot'", () => { it('renders a highlighted flamegraph', () => { const highlightQuery = 'main'; + const focusedNode: focusedNodeType = Option.none(); const flame = new Flamegraph( TestData.SimpleTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom @@ -78,13 +78,13 @@ describe("render group:snapshot'", () => { }); it('renders a zoomed flamegraph', () => { - const zoom = { i: 2, j: 8 }; + const zoom = Option.some({ i: 2, j: 8 }); + const focusedNode: focusedNodeType = Option.none(); const flame = new Flamegraph( TestData.SimpleTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom @@ -99,12 +99,12 @@ describe("render group:snapshot'", () => { // so that the function names don't fit const canvas = createCanvas(300, 0) as unknown as HTMLCanvasElement; const fitMode = 'TAIL'; + const focusedNode: focusedNodeType = Option.none(); const flame = new Flamegraph( TestData.SimpleTree, canvas, - topLevel, - selectedLevel, + focusedNode, fitMode, highlightQuery, zoom @@ -113,6 +113,59 @@ describe("render group:snapshot'", () => { flame.render(); expect(canvasToBuffer(canvas)).toMatchImageSnapshot(); }); + + describe('focused', () => { + it('renders a focused node in the beginning', () => { + const zoom: zoomType = Option.none(); + const focusedNode = Option.some({ i: 2, j: 0 }); + + const flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + expect(canvasToBuffer(canvas)).toMatchImageSnapshot(); + }); + + it('renders a focused node (when node is not in the beginning)', () => { + const zoom: zoomType = Option.none(); + const focusedNode = Option.some({ i: 2, j: 8 }); + + const flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + expect(canvasToBuffer(canvas)).toMatchImageSnapshot(); + }); + + it('also zooms', () => { + const focusedNode = Option.some({ i: 1, j: 0 }); + const zoom = Option.some({ i: 2, j: 0 }); // main.fastFunction + + const flame = new Flamegraph( + TestData.SimpleTree, + canvas, + focusedNode, + fitMode, + highlightQuery, + zoom + ); + + flame.render(); + expect(canvasToBuffer(canvas)).toMatchImageSnapshot(); + }); + }); }); function canvasToBuffer(canvas: HTMLCanvasElement) { diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.ts b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.ts index 6d197c08ee..cd8483f747 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.ts +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Flamegraph_render.ts @@ -51,39 +51,36 @@ import Flamegraph from './Flamegraph'; type CanvasRendererConfig = Flamebearer & { canvas: HTMLCanvasElement; - topLevel: ConstructorParameters[2]; - selectedLevel: ConstructorParameters[3]; - fitMode: ConstructorParameters[4]; - highlightQuery: ConstructorParameters[5]; + focusedNode: ConstructorParameters[2]; + fitMode: ConstructorParameters[3]; + highlightQuery: ConstructorParameters[4]; + zoom: ConstructorParameters[5]; /** * Used when zooming, values between 0 and 1. * For illustration, in a non zoomed state it has the value of 0 */ - rangeMin: number; + readonly rangeMin: number; /** * Used when zooming, values between 0 and 1. * For illustration, in a non zoomed state it has the value of 1 */ - rangeMax: number; + readonly rangeMax: number; + + tickToX: (i: number) => number; + + pxPerTick: number; }; export default function RenderCanvas(props: CanvasRendererConfig) { const { canvas } = props; - const { numTicks, rangeMin, rangeMax, sampleRate } = props; + const { numTicks, sampleRate, pxPerTick } = props; const { fitMode } = props; const { units } = props; + const { rangeMin, rangeMax } = props; + const { tickToX } = props; - // clientWidth includes padding - // however it's not present in node-canvas (used for testing) - // so we also fallback to canvas.width - const graphWidth = canvas.clientWidth || canvas.width; - if (!graphWidth) { - throw new Error( - `Could not infer canvasWidth. Tried 'canvas.clientWidth' and 'canvas.width'` - ); - } - + const graphWidth = getCanvasWidth(canvas); // TODO: why is this needed? otherwise height is all messed up canvas.width = graphWidth; @@ -91,27 +88,22 @@ export default function RenderCanvas(props: CanvasRendererConfig) { throw new Error(`'rangeMin' should be strictly smaller than 'rangeMax'`); } - const pxPerTick = graphWidth / numTicks / (rangeMax - rangeMin); - - const ctx = canvas.getContext('2d'); - const { selectedLevel } = props; - - // TODO what does ff mean? const { format } = props; const ff = createFF(format); - const { topLevel } = props; - const formatter = getFormatter(numTicks, sampleRate, units); - const { levels } = props; + const { focusedNode, zoom } = props; - let focused = false; - if (topLevel > 0) { - focused = true; - } + // const pxPerTick = graphWidth / numTicks / (rangeMax - rangeMin); + const ctx = canvas.getContext('2d'); + const selectedLevel = zoom.map((z) => z.i).getOrElse(0); + const formatter = getFormatter(numTicks, sampleRate, units); + const isFocused = focusedNode.isSome(); + const topLevel = focusedNode.map((f) => f.i).getOrElse(0); const canvasHeight = - PX_PER_LEVEL * (levels.length - topLevel) + (focused ? BAR_HEIGHT : 0); + PX_PER_LEVEL * (levels.length - topLevel) + (isFocused ? BAR_HEIGHT : 0); + // const canvasHeight = PX_PER_LEVEL * (levels.length - topLevel); canvas.height = canvasHeight; // increase pixel ratio, otherwise it looks bad in high resolution devices @@ -125,15 +117,19 @@ export default function RenderCanvas(props: CanvasRendererConfig) { // are we focused? // if so, add an initial bar telling it's a collapsed one // TODO clean this up - if (focused) { + if (isFocused) { const width = numTicks * pxPerTick; ctx.beginPath(); ctx.rect(0, 0, numTicks * pxPerTick, BAR_HEIGHT); // TODO find a neutral color - ctx.fillStyle = 'grey'; + // TODO use getColor ? + ctx.fillStyle = colorGreyscale(200, 1).rgb().string(); ctx.fill(); - const shortName = `total (${topLevel} level(s) skipped)`; + // TODO show the samples too? + const shortName = focusedNode + .map((f) => `total (${f.i - 1} level(s) collapsed)`) + .getOrElse('total'); // Set the font syle // It's important to set the font BEFORE calculating 'characterSize' @@ -166,10 +162,10 @@ export default function RenderCanvas(props: CanvasRendererConfig) { for (let i = 0; i < levels.length - topLevel; i += 1) { const level = levels[topLevel + i]; for (let j = 0; j < level.length; j += ff.jStep) { + const name = getFunctionName(names, j, format, level); const barIndex = ff.getBarOffset(level, j); - - const x = tickToX(numTicks, rangeMin, pxPerTick, barIndex); - const y = i * PX_PER_LEVEL + (focused ? BAR_HEIGHT : 0); + const x = tickToX(barIndex); + const y = i * PX_PER_LEVEL + (isFocused ? BAR_HEIGHT : 0); const sh = BAR_HEIGHT; @@ -209,24 +205,24 @@ export default function RenderCanvas(props: CanvasRendererConfig) { } const sw = numBarTicks * pxPerTick - (collapsed ? 0 : GAP); - /*******************************/ /* D r a w R e c t */ /*******************************/ const { spyName } = props; let leftTicks: number | undefined; - if (format === 'double') { - leftTicks = props.leftTicks; - } let rightTicks: number | undefined; if (format === 'double') { + leftTicks = props.leftTicks; rightTicks = props.rightTicks; } const color = getColor({ format, level, j, - i, + // discount for the levels we skipped + // otherwise it will dim out all nodes + i: i + focusedNode.map((f) => f.i).getOrElse(0), + // i: i + (isFocused ? focusedNode.i : 0), names, collapsed, selectedLevel, @@ -374,15 +370,6 @@ function getColor(cfg: getColorCfg) { ); } -function tickToX( - numTicks: number, - rangeMin: number, - pxPerTick: number, - i: number -) { - return (i - numTicks * rangeMin) * pxPerTick; -} - function nodeIsInQuery( index: number, level: number[], @@ -391,3 +378,10 @@ function nodeIsInQuery( ) { return names[level[index]].indexOf(query) >= 0; } + +function getCanvasWidth(canvas: HTMLCanvasElement) { + // clientWidth includes padding + // however it's not present in node-canvas (used for testing) + // so we also fallback to canvas.width + return canvas.clientWidth || canvas.width; +} diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.module.css b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.module.css index ccd909c1e3..c5e3838269 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.module.css +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.module.css @@ -1,5 +1,4 @@ .highlight { - color: red; position: absolute; pointer-events: none; background: #ffffff40; diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.spec.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.spec.tsx index 22a9c396bc..a7638aa973 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.spec.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Option } from 'prelude-ts'; import Highlight, { HighlightProps } from './Highlight'; @@ -18,24 +19,19 @@ function TestComponent(props: Omit) { describe('Highlight', () => { it('works', () => { - const isWithinBounds = jest.fn(); - const xyToHighlightData = jest.fn(); render( - + ); // hover over a bar - isWithinBounds.mockReturnValueOnce(true); - xyToHighlightData.mockReturnValueOnce({ - left: 10, - top: 5, - width: 100, - }); + xyToHighlightData.mockReturnValueOnce( + Option.of({ + left: 10, + top: 5, + width: 100, + }) + ); userEvent.hover(screen.getByTestId('canvas')); expect(screen.getByTestId('flamegraph-highlight')).toBeVisible(); expect(screen.getByTestId('flamegraph-highlight')).toHaveStyle({ @@ -46,7 +42,7 @@ describe('Highlight', () => { }); // hover outside the canvas - isWithinBounds.mockReturnValueOnce(false); + xyToHighlightData.mockReturnValueOnce(Option.none()); userEvent.hover(screen.getByTestId('canvas')); expect(screen.getByTestId('flamegraph-highlight')).not.toBeVisible(); }); diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.tsx index 3a2673f96d..bd76c88a9a 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Highlight.tsx @@ -1,41 +1,44 @@ +import { Option } from 'prelude-ts'; import React from 'react'; import styles from './Highlight.module.css'; export interface HighlightProps { - isWithinBounds: (x: number, y: number) => boolean; - // probably the same as the bar height barHeight: number; xyToHighlightData: ( x: number, y: number - ) => { + ) => Option<{ left: number; top: number; width: number; - }; + }>; canvasRef: React.RefObject; } export default function Highlight(props: HighlightProps) { - const { canvasRef, isWithinBounds, barHeight, xyToHighlightData } = props; + const { canvasRef, barHeight, xyToHighlightData } = props; const [style, setStyle] = React.useState({ height: '0px', visibility: 'hidden', }); const onMouseMove = (e: MouseEvent) => { - if (!isWithinBounds(e.offsetX, e.offsetY)) { + const opt = xyToHighlightData(e.offsetX, e.offsetY); + + if (opt.isSome()) { + const data = opt.get(); + setStyle({ + visibility: 'visible', + height: `${barHeight}px`, + ...data, + }); + } else { + // it doesn't map to a valid xy + // so it means we are hovering out onMouseOut(); - return; } - - setStyle({ - visibility: 'visible', - height: `${barHeight}px`, - ...xyToHighlightData(e.offsetX, e.offsetY), - }); }; const onMouseOut = () => { diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.spec.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.spec.tsx index b9ef34ae2d..891ffec0c6 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.spec.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.spec.tsx @@ -2,9 +2,10 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Option } from 'prelude-ts'; + import { diffColorRed, diffColorGreen } from './color'; import { Units } from '../../../util/format'; - import Tooltip, { TooltipProps } from './Tooltip'; // Omit) wasn't working @@ -21,15 +22,14 @@ function TestCanvas(props: TooltipProps) { } describe('Tooltip', () => { - const isWithinBounds = () => true; - describe('"single" mode', () => { it('renders correctly', () => { - const xyToData = (x: number, y: number) => ({ - format: 'single' as const, - name: 'function_title', - total: 10, - }); + const xyToData = (x: number, y: number) => + Option.of({ + format: 'single' as const, + name: 'function_title', + total: 10, + }); render( { units={Units.Samples} numTicks={100} sampleRate={100} - isWithinBounds={isWithinBounds} xyToData={xyToData} /> ); @@ -79,13 +78,14 @@ describe('Tooltip', () => { } it("works with a function that hasn't changed", () => { - const myxyToData = (x: number, y: number) => ({ - format: 'double' as const, - name: 'my_function', - totalLeft: 100, - totalRight: 100, - barTotal: 100, - }); + const myxyToData = (x: number, y: number) => + Option.of({ + format: 'double' as const, + name: 'my_function', + totalLeft: 100, + totalRight: 100, + barTotal: 100, + }); render( { units={Units.Samples} numTicks={100} sampleRate={100} - isWithinBounds={isWithinBounds} leftTicks={1000} rightTicks={1000} xyToData={myxyToData} @@ -114,13 +113,14 @@ describe('Tooltip', () => { }); it('works with a function that has been added', () => { - const myxyToData = (x: number, y: number) => ({ - format: 'double' as const, - name: 'my_function', - totalLeft: 0, - totalRight: 100, - barTotal: 100, - }); + const myxyToData = (x: number, y: number) => + Option.of({ + format: 'double' as const, + name: 'my_function', + totalLeft: 0, + totalRight: 100, + barTotal: 100, + }); render( { units={Units.Samples} numTicks={100} sampleRate={100} - isWithinBounds={isWithinBounds} leftTicks={1000} rightTicks={1000} - xyToData={myxyToData as any} + xyToData={myxyToData} /> ); // since we are mocking the result @@ -148,13 +147,14 @@ describe('Tooltip', () => { }); it('works with a function that has been removed', () => { - const myxyToData = (x: number, y: number) => ({ - format: 'double' as const, - name: 'my_function', - totalLeft: 100, - totalRight: 0, - barTotal: 100, - }); + const myxyToData = (x: number, y: number) => + Option.of({ + format: 'double' as const, + name: 'my_function', + totalLeft: 100, + totalRight: 0, + barTotal: 100, + }); render( { units={Units.Samples} numTicks={100} sampleRate={100} - isWithinBounds={isWithinBounds} leftTicks={1000} rightTicks={1000} xyToData={myxyToData} @@ -182,13 +181,14 @@ describe('Tooltip', () => { }); it('works with a function that became slower', () => { - const myxyToData = (x: number, y: number) => ({ - format: 'double' as const, - name: 'my_function', - totalLeft: 100, - totalRight: 200, - barTotal: 100, - }); + const myxyToData = (x: number, y: number) => + Option.of({ + format: 'double' as const, + name: 'my_function', + totalLeft: 100, + totalRight: 200, + barTotal: 100, + }); render( { units={Units.Samples} numTicks={100} sampleRate={100} - isWithinBounds={isWithinBounds} leftTicks={1000} rightTicks={1000} xyToData={myxyToData} @@ -216,13 +215,14 @@ describe('Tooltip', () => { }); it('works with a function that became faster', () => { - const myxyToData = (x: number, y: number) => ({ - format: 'double' as const, - name: 'my_function', - totalLeft: 200, - totalRight: 100, - barTotal: 100, - }); + const myxyToData = (x: number, y: number) => + Option.of({ + format: 'double' as const, + name: 'my_function', + totalLeft: 200, + totalRight: 100, + barTotal: 100, + }); render( { units={Units.Samples} numTicks={100} sampleRate={100} - isWithinBounds={isWithinBounds} leftTicks={1000} rightTicks={1000} xyToData={myxyToData} diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.tsx index e94abdd88d..e7abed46eb 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/Tooltip.tsx @@ -6,29 +6,28 @@ import { formatPercent, ratioToPercent, } from '@utils/format'; +import { Option } from 'prelude-ts'; import { diffColorRed, diffColorGreen } from './color'; type xyToDataSingle = ( x: number, y: number -) => { format: 'single'; name: string; total: number }; +) => Option<{ format: 'single'; name: string; total: number }>; type xyToDataDouble = ( x: number, y: number -) => { +) => Option<{ format: 'double'; name: string; totalLeft: number; totalRight: number; barTotal: number; -}; +}>; export type TooltipProps = { canvasRef: React.RefObject; - isWithinBounds: (x: number, y: number) => boolean; - units: Units; sampleRate: number; numTicks: number; @@ -43,7 +42,7 @@ export type TooltipProps = { ); export default function Tooltip(props: TooltipProps) { - const { format, canvasRef, xyToData, isWithinBounds } = props; + const { format, canvasRef, xyToData } = props; const [content, setContent] = React.useState({ title: { text: '', @@ -71,11 +70,6 @@ export default function Tooltip(props: TooltipProps) { // that's to evict stale props const memoizedOnMouseMove = React.useCallback( (e: MouseEvent) => { - if (!isWithinBounds(e.offsetX, e.offsetY)) { - onMouseOut(); - return; - } - const formatter = getFormatter( props.numTicks, props.sampleRate, @@ -93,13 +87,20 @@ export default function Tooltip(props: TooltipProps) { left, visibility: 'visible', }; - setStyle(style); + + const opt = props.xyToData(e.offsetX, e.offsetY); + const isNone = opt.isNone(); + + if (isNone) { + onMouseOut(); + return; + } + + const data = opt.get(); // set the content - switch (props.format) { + switch (data.format) { case 'single': { - const data = props.xyToData(e.offsetX, e.offsetY); - const d = formatSingle( formatter, data.total, @@ -123,7 +124,11 @@ export default function Tooltip(props: TooltipProps) { } case 'double': { - const data = props.xyToData(e.offsetX, e.offsetY); + if (props.format === 'single') { + throw new Error( + "props format is 'single' but it has been mapped to 'double'" + ); + } const d = formatDouble({ formatter, @@ -146,6 +151,8 @@ export default function Tooltip(props: TooltipProps) { default: throw new Error(`Unsupported format:'`); } + + setStyle(style); }, // these are the dependencies from props @@ -262,7 +269,6 @@ function formatDouble({ tooltipDiffColor = diffColorGreen.rgb().string(); } - // TODO unit test this let tooltipDiffText = ''; if (!totalLeft) { // this is a new function diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-also-zooms-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-also-zooms-1-snap.png new file mode 100644 index 0000000000..b2274aaeda Binary files /dev/null and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-also-zooms-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-in-the-beginning-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-in-the-beginning-1-snap.png new file mode 100644 index 0000000000..c01d64cad4 Binary files /dev/null and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-in-the-beginning-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-when-node-is-not-in-the-beginning-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-when-node-is-not-in-the-beginning-1-snap.png new file mode 100644 index 0000000000..5e22c838af Binary files /dev/null and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-when-node-is-not-in-the-beginning-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-zoom-top-level-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-zoom-top-level-1-snap.png new file mode 100644 index 0000000000..5d67332164 Binary files /dev/null and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-focused-renders-a-focused-node-zoom-top-level-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-complex-flamegraph-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-complex-flamegraph-1-snap.png index 711e80a8ab..bedea9f309 100644 Binary files a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-complex-flamegraph-1-snap.png and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-complex-flamegraph-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-double-diff-flamegraph-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-double-diff-flamegraph-1-snap.png index 8634d88e2f..ef7f4e72ea 100644 Binary files a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-double-diff-flamegraph-1-snap.png and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-double-diff-flamegraph-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-highlighted-flamegraph-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-highlighted-flamegraph-1-snap.png index 1fb25b0196..9dd71b4836 100644 Binary files a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-highlighted-flamegraph-1-snap.png and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-highlighted-flamegraph-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-simple-flamegraph-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-simple-flamegraph-1-snap.png index 3c6606d3c8..2945218d2c 100644 Binary files a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-simple-flamegraph-1-snap.png and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-simple-flamegraph-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-flamegraph-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-flamegraph-1-snap.png index 124f016076..b4653782eb 100644 Binary files a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-flamegraph-1-snap.png and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-flamegraph-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-with-fit-mode-tail-1-snap.png b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-with-fit-mode-tail-1-snap.png index c99a95546f..3bc030e3fa 100644 Binary files a/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-with-fit-mode-tail-1-snap.png and b/webapp/javascript/components/FlameGraph/FlameGraphComponent/__image_snapshots__/flamegraph-render-spec-tsx-render-group-snapshot-renders-a-zoomed-with-fit-mode-tail-1-snap.png differ diff --git a/webapp/javascript/components/FlameGraph/FlameGraphComponent/index.spec.tsx b/webapp/javascript/components/FlameGraph/FlameGraphComponent/index.spec.tsx index e758ebbe80..d34624d2cc 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphComponent/index.spec.tsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphComponent/index.spec.tsx @@ -1,11 +1,12 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Option } from 'prelude-ts'; import FlamegraphComponent from './index'; import TestData from './testData'; import { BAR_HEIGHT } from './constants'; -// the leafs have already been tested +// the leaves have already been tested // this is just to guarantee code is compiling // and the callbacks are being called correctly describe('FlamegraphComponent', () => { @@ -15,15 +16,16 @@ describe('FlamegraphComponent', () => { const onZoom = jest.fn(); const onReset = jest.fn(); const isDirty = jest.fn(); + const onFocusOnNode = jest.fn(); render( { const onZoom = jest.fn(); const onReset = jest.fn(); const isDirty = jest.fn(); + const onFocusOnNode = jest.fn(); render( { const onZoom = jest.fn(); const onReset = jest.fn(); const isDirty = jest.fn(); + const onFocusOnNode = jest.fn(); render( { }); describe('context menu', () => { - it('enables "reset view" menuitem when its dirty', () => { + it(`enables "reset view" menuitem when it's dirty`, async () => { const onZoom = jest.fn(); const onReset = jest.fn(); const isDirty = jest.fn(); + const onFocusOnNode = jest.fn(); const { rerender } = render( { userEvent.click(screen.getByTestId('flamegraph-canvas'), { button: 2, + clientX: 1, + clientY: 1, }); + // should not be available unless we zoom - expect( - screen.queryByRole('menuitem', { name: /Reset View/ }) - ).toHaveAttribute('aria-disabled', 'true'); + await waitFor(() => + expect( + screen.queryByRole('menuitem', { name: /Reset View/ }) + ).toHaveAttribute('aria-disabled', 'true') + ); // it's dirty now isDirty.mockReturnValue(true); @@ -127,11 +137,11 @@ describe('FlamegraphComponent', () => { rerender( { screen.queryByRole('menuitem', { name: /Reset View/ }) ).not.toHaveAttribute('aria-disabled', 'true'); }); + + it('triggers a highlight', () => { + const onZoom = jest.fn(); + const onReset = jest.fn(); + const isDirty = jest.fn(); + const onFocusOnNode = jest.fn(); + + render( + + ); + + // initially the context highlight is not visible + expect( + screen.getByTestId('flamegraph-highlight-contextmenu') + ).not.toBeVisible(); + + // then we click + userEvent.click(screen.getByTestId('flamegraph-canvas'), { button: 2 }); + + // should be visible now + expect( + screen.getByTestId('flamegraph-highlight-contextmenu') + ).toBeVisible(); + }); }); describe('header', () => { const onZoom = jest.fn(); const onReset = jest.fn(); const isDirty = jest.fn(); + const onFocusOnNode = jest.fn(); it('renders when type is single', () => { render( { render( [2]; - selectedLevel: ConstructorParameters[3]; - fitMode: ConstructorParameters[4]; - query: ConstructorParameters[5]; - zoom: ConstructorParameters[6]; // TODO call it zoom level? + focusedNode: ConstructorParameters[2]; + fitMode: ConstructorParameters[3]; + highlightQuery: ConstructorParameters[4]; + zoom: ConstructorParameters[5]; - onZoom: (i: number, j: number) => void; + onZoom: (bar: Option<{ i: number; j: number }>) => void; + onFocusOnNode: (i: number, j: number) => void; onReset: () => void; isDirty: () => boolean; @@ -32,74 +34,122 @@ interface FlamegraphProps { export default function FlameGraphComponent(props: FlamegraphProps) { const canvasRef = React.useRef(); const [flamegraph, setFlamegraph] = React.useState(); + const [rightClickedNode, setRightClickedNode] = React.useState( + Option.none<{ top: number; left: number; width: number }>() + ); - const { flamebearer, topLevel, selectedLevel, fitMode, query, zoom } = props; + const { flamebearer, focusedNode, fitMode, highlightQuery, zoom } = props; - const { onZoom, onReset, isDirty } = props; + const { onZoom, onReset, isDirty, onFocusOnNode } = props; const { ExportData } = props; // rerender whenever the canvas size changes // eg window resize, or simply changing the view // to display the flamegraph isolated from the table - useResizeObserver(canvasRef, () => { + useResizeObserver(canvasRef, (e) => { if (flamegraph) { renderCanvas(); } }); const onClick = (e: React.MouseEvent) => { - const { i, j } = flamegraph.xyToBar( + const opt = flamegraph.xyToBar( e.nativeEvent.offsetX, e.nativeEvent.offsetY ); - onZoom(i, j); + opt.match({ + // clicked on an invalid node + None: () => {}, + Some: (bar) => { + zoom.match({ + // there's no existing zoom + // so just zoom on the clicked node + None: () => { + onZoom(opt); + }, + + // it's already zoomed + Some: (z) => { + // TODO there mya be stale props here... + // we are clicking on the same node that's zoomed + if (bar.i === z.i && bar.j === z.j) { + // undo that zoom + onZoom(Option.none()); + } else { + onZoom(opt); + } + }, + }); + }, + }); }; const xyToHighlightData = (x: number, y: number) => { - const bar = flamegraph.xyToBarPosition(x, y); - - return { - left: canvasRef.current.offsetLeft + bar.x, - top: canvasRef.current.offsetTop + bar.y, - width: bar.width, - }; + const opt = flamegraph.xyToBar(x, y); + + return opt.map((bar) => { + return { + left: canvasRef.current.offsetLeft + bar.x, + top: canvasRef.current.offsetTop + bar.y, + width: bar.width, + }; + }); }; const xyToTooltipData = (x: number, y: number) => { - return flamegraph.xyToBarData(x, y); + return flamegraph.xyToBar(x, y); }; - // Context Menu stuff - const xyToContextMenuItems = (x: number, y: number) => { - const dirty = isDirty(); - - // - // this.focusOnNode(x, y)}> - // Focus - // , - return [ - - Reset View - , - ]; + const onContextMenuClose = () => { + setRightClickedNode(Option.none()); }; - // this level of indirection is required - // otherwise may get stale props - // eg. thinking that a zoomed flamegraph is not zoomed - const isWithinBounds = (x: number, y: number) => - flamegraph.isWithinBounds(x, y); + const onContextMenuOpen = (x: number, y: number) => { + setRightClickedNode(xyToHighlightData(x, y)); + }; + + // Context Menu stuff + const xyToContextMenuItems = useCallback( + (x: number, y: number) => { + const dirty = isDirty(); + const bar = flamegraph.xyToBar(x, y); + + const FocusItem = () => { + const hoveredOnValidNode = bar.map(() => true).getOrElse(false); + const onClick = bar + .map((f) => onFocusOnNode.bind(null, f.i, f.j)) + .getOrElse(() => {}); + + return ( + + Focus on this subtree + + ); + }; + + return [ + + Reset View + , + FocusItem(), + ]; + }, + [flamegraph] + ); React.useEffect(() => { if (canvasRef.current) { const f = new Flamegraph( flamebearer, canvasRef.current, - topLevel, - selectedLevel, + focusedNode, fitMode, - query, + highlightQuery, zoom ); @@ -108,10 +158,9 @@ export default function FlameGraphComponent(props: FlamegraphProps) { }, [ canvasRef.current, flamebearer, - topLevel, - selectedLevel, + focusedNode, fitMode, - query, + highlightQuery, zoom, ]); @@ -153,7 +202,12 @@ export default function FlameGraphComponent(props: FlamegraphProps) { barHeight={PX_PER_LEVEL} canvasRef={canvasRef} xyToHighlightData={xyToHighlightData} - isWithinBounds={isWithinBounds} + /> + )} + {flamegraph && ( + )} {flamegraph && ( @@ -161,7 +215,6 @@ export default function FlameGraphComponent(props: FlamegraphProps) { format={flamebearer.format} canvasRef={canvasRef} xyToData={xyToTooltipData as any /* TODO */} - isWithinBounds={isWithinBounds} numTicks={flamebearer.numTicks} sampleRate={flamebearer.sampleRate} leftTicks={flamebearer.format === 'double' && flamebearer.leftTicks} @@ -172,10 +225,14 @@ export default function FlameGraphComponent(props: FlamegraphProps) { /> )} - + {flamegraph && ( + + )}
); diff --git a/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx b/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx index 80ff94830c..53c2238fa3 100644 --- a/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx +++ b/webapp/javascript/components/FlameGraph/FlameGraphRenderer.jsx @@ -7,6 +7,7 @@ import React from 'react'; import clsx from 'clsx'; +import { Option } from 'prelude-ts'; import Graph from './FlameGraphComponent'; import TimelineChartWrapper from '../TimelineChartWrapper'; import ProfilerTable from '../ProfilerTable'; @@ -39,13 +40,8 @@ class FlameGraphRenderer extends React.Component { // TODO: this could come from some other state // eg localstorage initialFlamegraphState = { - selectedLevel: 0, - topLevel: 0, - - zoom: { - i: -1, - j: -1, - }, + focusedNode: Option.none(), + zoom: Option.none(), }; constructor(props) { @@ -168,18 +164,48 @@ class FlameGraphRenderer extends React.Component { }); }; - onFlamegraphZoom = (i, j) => { + onFlamegraphZoom = (bar) => { // zooming on the topmost bar is equivalent to resetting to the original state - if (i === 0 && j === 0) { + if (bar.isSome() && bar.get().i === 0 && bar.get().j === 0) { this.onReset(); return; } + // otherwise just pass it up to the state + // doesn't matter if it's some or none this.setState({ ...this.state, flamegraphConfigs: { ...this.state.flamegraphConfigs, - zoom: { i, j }, + zoom: bar, + }, + }); + }; + + onFocusOnNode = (i, j) => { + if (i === 0 && j === 0) { + this.onReset(); + return; + } + + let flamegraphConfigs = { ...this.state.flamegraphConfigs }; + + // reset zoom if we are focusing below the zoom + const { zoom } = this.state.flamegraphConfigs; + if (zoom.isSome()) { + if (zoom.get().i < i) { + flamegraphConfigs = { + ...flamegraphConfigs, + zoom: this.initialFlamegraphState.zoom, + }; + } + } + + this.setState({ + ...this.state, + flamegraphConfigs: { + ...flamegraphConfigs, + focusedNode: Option.some({ i, j }), }, }); }; @@ -311,11 +337,11 @@ class FlameGraphRenderer extends React.Component { query={this.state.highlightQuery} fitMode={this.state.fitMode} viewType={this.props.viewType} - topLevel={this.state.flamegraphConfigs.topLevel} zoom={this.state.flamegraphConfigs.zoom} - selectedLevel={this.state.flamegraphConfigs.selectedLevel} + focusedNode={this.state.flamegraphConfigs.focusedNode} label={this.props.query} onZoom={this.onFlamegraphZoom} + onFocusOnNode={this.onFocusOnNode} onReset={this.onReset} isDirty={this.isDirty} /> diff --git a/yarn.lock b/yarn.lock index 851d4e1051..5d0c9ce96a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5472,6 +5472,11 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +hamt_plus@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/hamt_plus/-/hamt_plus-1.0.2.tgz#e21c252968c7e33b20f6a1b094cd85787a265601" + integrity sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE= + handlebars@^4.7.6: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" @@ -7216,6 +7221,11 @@ lint-staged@^11.1.2: stringify-object "3.3.0" supports-color "8.1.1" +list@2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/list/-/list-2.0.19.tgz#370a3d7d3e24cfd5ced2c89cda2baf28e31e2830" + integrity sha512-nnVaRp4RaMAQkCpypTThsdxKqgPMiSwJq93eAm2/IbpUa8sd04XKBhkKu+bMk63HmdjK8b8Cuh4xARHWX2ye/Q== + listr2@^3.12.2, listr2@^3.8.3: version "3.12.2" resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.12.2.tgz#2d55cc627111603ad4768a9e87c9c7bb9b49997e" @@ -8568,6 +8578,14 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= +prelude-ts@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/prelude-ts/-/prelude-ts-1.0.3.tgz#61cd6b9f88a6a692e8387c2ec6e66c1c1e303c48" + integrity sha512-emZC/dMJmxUDQj4rDONVxEgNBca3WvCRQzXgCVkk9CnWDNfWPYsc6LoVdSqVI5puSesDh5yoauI2oi65ueAUAw== + dependencies: + hamt_plus "1.0.2" + list "2.0.19" + preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" @@ -10357,6 +10375,11 @@ trim-newlines@^3.0.0: resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== +ts-essentials@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.0.0.tgz#6196b7f390926429256c70951c8edd260e8e5097" + integrity sha512-pow5YBSknf/PyoDhr8vsb93+hZah/jSzhdHA3GMjSzUuZIDZH+rV7tvN5DCbN4hi7QJXdteDv8D9HdDCSwXBUw== + ts-jest@^27.0.5: version "27.0.5" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-27.0.5.tgz#0b0604e2271167ec43c12a69770f0bb65ad1b750"