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();
-
- //
- // ,
- return [
- ,
- ];
+ 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 (
+
+ );
+ };
+
+ return [
+ ,
+ 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"