perfetto-ui: Integrate merging callsites with expanding

Clicking on callsite will expand the flamegraph and do merging afterward,
so that previously small callsites can be visible in zoomed in view.

Test trace: ?s=c3653414e9a8ae91eae74d2671f277034473ae822634f23a1e421746f5938

Change-Id: Id95eb79415d7e65f05de3c5f694beaf6f08007eb
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 56afebd..552a0b6 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -436,7 +436,7 @@
       kind: 'HEAP_PROFILE_FLAMEGRAPH',
       id: args.id,
       upid: args.upid,
-      ts: args.ts
+      ts: args.ts,
     };
   },
 
diff --git a/ui/src/frontend/flamegraph.ts b/ui/src/frontend/flamegraph.ts
index 125d9f8..5343a25 100644
--- a/ui/src/frontend/flamegraph.ts
+++ b/ui/src/frontend/flamegraph.ts
@@ -59,12 +59,6 @@
   private hoveredCallsite?: CallsiteInfo;
   private clickedCallsite?: CallsiteInfo;
 
-  // For each node, we use this map to get information about it's parent
-  // (total size of it, width and where it starts in graph) so we can
-  // calculate it's own position in graph.
-  // This one is store for base flamegraph when no clicking has happend.
-  private baseMap = new Map<number, Node>();
-
   constructor(flamegraphData: CallsiteInfo[]) {
     this.flamegraphData = flamegraphData;
     this.findMaxDepth();
@@ -101,12 +95,13 @@
    * Caller will have to call draw method ater updating data to have updated
    * graph.
    */
-  updateDataIfChanged(flamegraphData: CallsiteInfo[]) {
+  updateDataIfChanged(
+      flamegraphData: CallsiteInfo[], clickedCallsite?: CallsiteInfo) {
     if (this.flamegraphData === flamegraphData) {
       return;
     }
     this.flamegraphData = flamegraphData;
-    this.clickedCallsite = undefined;
+    this.clickedCallsite = clickedCallsite;
     this.findMaxDepth();
     // Finding total size of roots.
     this.totalSize = findRootSize(flamegraphData);
@@ -139,43 +134,24 @@
     ctx.fillRect(x, currentY, width, nodeHeight);
     currentY += nodeHeight;
 
-    const clickedNode = this.clickedCallsite !== undefined ?
-        this.baseMap.get(this.clickedCallsite.hash) :
-        undefined;
 
     for (let i = 0; i < this.flamegraphData.length; i++) {
       if (currentY > height) {
         break;
       }
       const value = this.flamegraphData[i];
-      const parentNode = nodesMap.get(value.parentHash);
+      const parentNode = nodesMap.get(value.parentId);
       if (parentNode === undefined) {
         continue;
       }
-      // If node is clicked, determine if we should draw current node.
-      let shouldDraw = true;
-      let isFullWidth = false;
-      let isGreyedOut = false;
 
-      const oldNode = this.baseMap.get(value.hash);
-      // We want to display full shape if it's thumbnail.
-      if (!this.isThumbnail && clickedNode !== undefined &&
-          this.clickedCallsite !== undefined && oldNode !== undefined) {
-        isFullWidth = value.depth <= this.clickedCallsite.depth;
-        isGreyedOut = value.depth < this.clickedCallsite.depth;
-        shouldDraw = isFullWidth ? (oldNode.x <= clickedNode.x) &&
-                ((oldNode.x + oldNode.width >=
-                  clickedNode.x + clickedNode.width)) :
-                                   (oldNode.x >= clickedNode.x) &&
-                ((oldNode.x + oldNode.width <=
-                  clickedNode.x + clickedNode.width));
-      }
+      const isClicked = !this.isThumbnail && this.clickedCallsite !== undefined;
+      const isFullWidth =
+          isClicked && value.depth <= this.clickedCallsite!.depth;
+      const isGreyedOut =
+          isClicked && value.depth < this.clickedCallsite!.depth;
 
-      if (!shouldDraw) {
-        continue;
-      }
-
-      const parent = value.parentHash;
+      const parent = value.parentId;
       const parentSize = parent === -1 ? this.totalSize : parentNode.size;
       // Calculate node's width based on its proportion in parent.
       const width =
@@ -189,14 +165,14 @@
       ctx.fillRect(currentX, currentY, width, nodeHeight);
 
       // Set current node's data in map for children to use.
-      nodesMap.set(value.hash, {
+      nodesMap.set(value.id, {
         width,
         nextXForChildren: currentX,
         size: value.totalSize,
         x: currentX
       });
       // Update next x coordinate in parent.
-      nodesMap.set(value.parentHash, {
+      nodesMap.set(value.parentId, {
         width: parentNode.width,
         nextXForChildren: currentX + width,
         size: parentNode.size,
@@ -241,10 +217,6 @@
       }
     }
 
-    if (clickedNode === undefined) {
-      this.baseMap = nodesMap;
-    }
-
     if (this.hoveredX > -1 && this.hoveredY > -1 && this.hoveredCallsite) {
       // Draw the tooltip.
       const line1 = this.getCallsiteName(this.hoveredCallsite);
@@ -288,12 +260,13 @@
     this.hoveredCallsite = undefined;
   }
 
-  onMouseClick({x, y}: {x: number, y: number}) {
+  // Returns id of clicked callsite if any.
+  onMouseClick({x, y}: {x: number, y: number}): number {
     if (this.isThumbnail) {
-      return true;
+      return -1;
     }
-    this.clickedCallsite = this.findSelectedCallsite(x, y);
-    return this.clickedCallsite !== undefined;
+    const clickedCallsite = this.findSelectedCallsite(x, y);
+    return clickedCallsite === undefined ? -1 : clickedCallsite.id;
   }
 
   private findSelectedCallsite(x: number, y: number): CallsiteInfo|undefined {
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 9ed553f..c78fc11 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -46,8 +46,8 @@
 }
 
 export interface CallsiteInfo {
-  hash: number;
-  parentHash: number;
+  id: number;
+  parentId: number;
   depth: number;
   name?: string;
   totalSize: number;
diff --git a/ui/src/tracks/heap_profile_flamegraph/common.ts b/ui/src/tracks/heap_profile_flamegraph/common.ts
index 8e0a820..1786681 100644
--- a/ui/src/tracks/heap_profile_flamegraph/common.ts
+++ b/ui/src/tracks/heap_profile_flamegraph/common.ts
@@ -20,9 +20,13 @@
 
 export interface Data extends TrackData {
   flamegraph: CallsiteInfo[];
+  clickedCallsite?: CallsiteInfo;
+  key: string;
 }
 
 export interface Config {
   upid: number;
+  ts: number;
   isMinimized: boolean;
+  expandedId: number;
 }
diff --git a/ui/src/tracks/heap_profile_flamegraph/controller.ts b/ui/src/tracks/heap_profile_flamegraph/controller.ts
index d679553..dc618cb 100644
--- a/ui/src/tracks/heap_profile_flamegraph/controller.ts
+++ b/ui/src/tracks/heap_profile_flamegraph/controller.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {Actions} from '../../common/actions';
 import {globals} from '../../controller/globals';
 import {
   TrackController,
@@ -29,6 +30,34 @@
 
 const MIN_PIXEL_DISPLAYED = 1;
 
+export function expandCallsites(
+    data: CallsiteInfo[], clickedCallsiteIndex: number): CallsiteInfo[] {
+  if (clickedCallsiteIndex === -1) return data;
+  const expandedCallsites: CallsiteInfo[] = [];
+  if (clickedCallsiteIndex >= data.length || clickedCallsiteIndex < -1) {
+    return expandedCallsites;
+  }
+  const clickedCallsite = data[clickedCallsiteIndex];
+  expandedCallsites.unshift(clickedCallsite);
+  // Adding parents
+  let parentId = clickedCallsite.parentId;
+  while (parentId > -1) {
+    expandedCallsites.unshift(data[parentId]);
+    parentId = data[parentId].parentId;
+  }
+  // Adding children
+  const parents: number[] = [];
+  parents.push(clickedCallsiteIndex);
+  for (let i = clickedCallsiteIndex + 1; i < data.length; i++) {
+    const element = data[i];
+    if (parents.includes(element.parentId)) {
+      expandedCallsites.push(element);
+      parents.push(element.id);
+    }
+  }
+  return expandedCallsites;
+}
+
 // Merge callsites that have approximately width less than
 // MIN_PIXEL_DISPLAYED. All small callsites in the same depth and with same
 // parent will be merged to one callsite with size of the biggest merged
@@ -40,11 +69,11 @@
     // When a small callsite is found, it will be merged with other small
     // callsites of the same depth. So if the current callsite has already been
     // merged we can skip it.
-    if (mergedCallsites.has(data[i].hash)) {
+    if (mergedCallsites.has(data[i].id)) {
       continue;
     }
     const copiedCallsite = copyCallsite(data[i]);
-    copiedCallsite.parentHash =
+    copiedCallsite.parentId =
         getCallsitesParentHash(copiedCallsite, mergedCallsites);
 
     // If current callsite is small, find other small callsites with same depth
@@ -53,11 +82,11 @@
       let j = i + 1;
       let nextCallsite = data[j];
       while (j < data.length && copiedCallsite.depth === nextCallsite.depth) {
-        if (copiedCallsite.parentHash ===
+        if (copiedCallsite.parentId ===
                 getCallsitesParentHash(nextCallsite, mergedCallsites) &&
             nextCallsite.totalSize <= minSizeDisplayed) {
           copiedCallsite.totalSize += nextCallsite.totalSize;
-          mergedCallsites.set(nextCallsite.hash, copiedCallsite.hash);
+          mergedCallsites.set(nextCallsite.id, copiedCallsite.id);
         }
         j++;
         nextCallsite = data[j];
@@ -70,8 +99,8 @@
 
 function copyCallsite(callsite: CallsiteInfo): CallsiteInfo {
   return {
-    hash: callsite.hash,
-    parentHash: callsite.parentHash,
+    id: callsite.id,
+    parentId: callsite.parentId,
     depth: callsite.depth,
     name: callsite.name,
     totalSize: callsite.totalSize
@@ -80,17 +109,21 @@
 
 function getCallsitesParentHash(
     callsite: CallsiteInfo, map: Map<number, number>): number {
-  return map.has(callsite.parentHash) ? map.get(callsite.parentHash)! :
-                                        callsite.parentHash;
+  return map.has(callsite.parentId) ? map.get(callsite.parentId)! :
+                                      callsite.parentId;
 }
 
-function getMinSizeDisplayed(flamegraphData: CallsiteInfo[]): number {
+function getMinSizeDisplayed(
+    flamegraphData: CallsiteInfo[], rootSize?: number): number {
   const timeState = globals.state.frontendLocalState.visibleState;
   const width = (timeState.endSec - timeState.startSec) / timeState.resolution;
-  const rootSize = findRootSize(flamegraphData);
+  if (rootSize === undefined) {
+    rootSize = findRootSize(flamegraphData);
+  }
   return MIN_PIXEL_DISPLAYED * rootSize / width;
 }
 
+const EMPTY_KEY = 'empty';
 class HeapProfileFlameraphTrackController extends
     TrackController<Config, Data> {
   static readonly kind = HEAP_PROFILE_FLAMEGRAPH_TRACK_KIND;
@@ -100,6 +133,7 @@
   private length = 0;
   private lastSelectedTs?: number;
   private lastSelectedId?: number;
+  private lastExpandedId?: number;
 
   private flamegraphDatasets: Map<string, CallsiteInfo[]> = new Map();
 
@@ -118,65 +152,106 @@
       end: -1,
       resolution: this.resolution,
       length: 0,
-      flamegraph: []
+      flamegraph: [],
+      key: EMPTY_KEY
     };
     return data;
   }
 
   run() {
-    super.run();
     const selection = globals.state.currentHeapProfileFlamegraph;
+
     if (selection && selection.kind === 'HEAP_PROFILE_FLAMEGRAPH') {
-      if (this.lastSelectedId === selection.id &&
-          this.lastSelectedTs === selection.ts) {
-        return;
+      if (this.lastSelectedId !== selection.id ||
+          this.lastSelectedTs !== selection.ts) {
+        const selectedId = selection.id;
+        const selectedUpid = selection.upid;
+        const selectedKind = selection.kind;
+        const selectedTs = selection.ts;
+        // If we opened new heap profile, we don't want to show it expanded, but
+        // if we are opening trace for the first time with existing state (ie.
+        // via link), we want to show it expanded.
+        this.lastExpandedId = !this.lastSelectedTs && this.config.expandedId ?
+            this.config.expandedId :
+            -1;
+        this.lastSelectedId = selection.id;
+        this.lastSelectedTs = selection.ts;
+
+        this.config.ts = selectedTs;
+        this.config.upid = selectedUpid;
+        this.config.expandedId = this.lastExpandedId;
+
+        const key = `${selectedUpid};${selectedTs}`;
+
+        // TODO(tneda): Prevent lots of flamegraph queries being queued if a
+        // user clicks lots of the markers quickly.
+        this.getFlamegraphData(key, selection.ts, selectedUpid)
+            .then(flamegraphData => {
+              if (flamegraphData !== undefined && selection &&
+                  selection.kind === selectedKind &&
+                  selection.id === selectedId && selection.ts === selectedTs) {
+                this.prepareAndMergeCallsites(flamegraphData, key);
+                globals.dispatch(Actions.updateTrackConfig(
+                    {id: this.trackState.id, config: this.config}));
+              }
+            });
+      } else if (this.config.expandedId !== this.lastExpandedId) {
+        const key = `${this.config.upid};${this.lastSelectedTs}`;
+        this.lastExpandedId = this.config.expandedId;
+        this.getFlamegraphData(key, this.lastSelectedTs, this.config.upid)
+            .then(flamegraphData => {
+              this.prepareAndMergeCallsites(flamegraphData, key);
+            });
       }
-      const selectedId = selection.id;
-      const selectedUpid = selection.upid;
-      const selectedKind = selection.kind;
-      const selectedTs = selection.ts;
-      this.lastSelectedId = selection.id;
-      this.lastSelectedTs = selection.ts;
-
-      // Sending empty data to show tha Loading state before we get an actual
-      // data.
+    } else {
       globals.publish(
-          'TrackData',
-          {id: HeapProfileFlamegraphKey, data: this.generateEmptyData()});
-
-      const key = `${selectedUpid};${selectedTs}`;
-
-      // TODO(tneda): Prevent lots of flamegraph queries being queued if a user
-      // clicks lots of the markers quickly.
-      this.getFlamegraphData(key, selection.ts, selectedUpid)
-          .then(flamegraphData => {
-            if (flamegraphData !== undefined && selection &&
-                selection.kind === selectedKind &&
-                selection.id === selectedId && selection.ts === selectedTs) {
-              // TODO(tneda): Remove before submitting.
-              const mergedFlamegraphData = mergeCallsites(
-                  flamegraphData, getMinSizeDisplayed(flamegraphData));
-
-              globals.publish('TrackData', {
-                id: HeapProfileFlamegraphKey,
-                data: {
-                  start: this.start,
-                  end: this.end,
-                  resolution: this.resolution,
-                  length: this.length,
-                  flamegraph: mergedFlamegraphData
-                }
-              });
-            }
-          });
+          'TrackData', {id: HeapProfileFlamegraphKey, data: undefined});
     }
   }
 
-  async getFlamegraphData(key: string, ts: number, upid: number) {
+  private publishEmptyData() {
+    globals.publish(
+        'TrackData',
+        {id: HeapProfileFlamegraphKey, data: this.generateEmptyData()});
+  }
+
+  private prepareAndMergeCallsites(
+      flamegraphData: CallsiteInfo[], key: string) {
+    const expandedFlamegraphData =
+        expandCallsites(flamegraphData, this.config.expandedId);
+    const expandedCallsite = this.config.expandedId === -1 ?
+        undefined :
+        flamegraphData[this.config.expandedId];
+
+    const rootSize =
+        expandedCallsite === undefined ? undefined : expandedCallsite.totalSize;
+    const mergedFlamegraphData = mergeCallsites(
+        expandedFlamegraphData,
+        getMinSizeDisplayed(expandedFlamegraphData, rootSize));
+
+    globals.publish('TrackData', {
+      id: HeapProfileFlamegraphKey,
+      data: {
+        start: this.start,
+        end: this.end,
+        resolution: this.resolution,
+        length: this.length,
+        flamegraph: mergedFlamegraphData,
+        key,
+        clickedCallsite: expandedCallsite
+      }
+    });
+  }
+
+  async getFlamegraphData(key: string, ts: number, upid: number):
+      Promise<CallsiteInfo[]> {
     let currentData;
     if (this.flamegraphDatasets.has(key)) {
-      currentData = this.flamegraphDatasets.get(key);
+      currentData = this.flamegraphDatasets.get(key)!;
     } else {
+      // Sending empty data to show Loading state before we get an actual
+      // data.
+      this.publishEmptyData();
       currentData = await this.getFlamegraphDataFromTables(ts, upid);
       this.flamegraphDatasets.set(key, currentData);
     }
@@ -258,19 +333,21 @@
         group by hash
         order by depth, parent_hash, size desc, name`);
     const flamegraphData: CallsiteInfo[] = [];
+    const hashToindex: Map<number, number> = new Map();
     for (let i = 0; i < callsites.numRecords; i++) {
       const hash = callsites.columns[0].longValues![i];
       const name = callsites.columns[1].stringValues![i];
       const parentHash = callsites.columns[2].longValues![i];
       const depth = callsites.columns[3].longValues![i];
       const totalSize = callsites.columns[4].longValues![i];
-      flamegraphData.push({
-        hash: +hash,
-        totalSize: +totalSize,
-        depth: +depth,
-        parentHash: +parentHash,
-        name
-      });
+      const parentId =
+          hashToindex.has(+parentHash) ? hashToindex.get(+parentHash)! : -1;
+      hashToindex.set(+hash, i);
+      // Instead of hash, we will store index of callsite in this original array
+      // as an id of callsite. That way, we have quicker access to parent and it
+      // will stay unique.
+      flamegraphData.push(
+          {id: i, totalSize: +totalSize, depth: +depth, parentId, name});
     }
     return flamegraphData;
   }
diff --git a/ui/src/tracks/heap_profile_flamegraph/controller_unittest.ts b/ui/src/tracks/heap_profile_flamegraph/controller_unittest.ts
index 63853f1..22db892 100644
--- a/ui/src/tracks/heap_profile_flamegraph/controller_unittest.ts
+++ b/ui/src/tracks/heap_profile_flamegraph/controller_unittest.ts
@@ -3,10 +3,10 @@
 
 test('zeroCallsitesMerged', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 8},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 4},
-    {hash: 4, parentHash: 2, name: 'B4', depth: 1, totalSize: 4},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 8},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 4},
+    {id: 4, parentId: 2, name: 'B4', depth: 1, totalSize: 4},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 5);
@@ -17,11 +17,11 @@
 
 test('zeroCallsitesMerged2', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 8},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 6},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 4},
-    {hash: 5, parentHash: 2, name: 'B5', depth: 1, totalSize: 8},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 8},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 6},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 4},
+    {id: 5, parentId: 2, name: 'B5', depth: 1, totalSize: 8},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 5);
@@ -32,36 +32,36 @@
 
 test('twoCallsitesMerged', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 5},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 5},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 5},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 5},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 6);
 
   expect(mergedCallsites).toEqual([
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 10},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 10},
   ]);
 });
 
 test('manyCallsitesMerged', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 5},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 3},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 1},
-    {hash: 5, parentHash: 1, name: 'A5', depth: 1, totalSize: 1},
-    {hash: 6, parentHash: 3, name: 'A36', depth: 2, totalSize: 1},
-    {hash: 7, parentHash: 4, name: 'A47', depth: 2, totalSize: 1},
-    {hash: 8, parentHash: 5, name: 'A58', depth: 2, totalSize: 1},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 5},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 3},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 1},
+    {id: 5, parentId: 1, name: 'A5', depth: 1, totalSize: 1},
+    {id: 6, parentId: 3, name: 'A36', depth: 2, totalSize: 1},
+    {id: 7, parentId: 4, name: 'A47', depth: 2, totalSize: 1},
+    {id: 8, parentId: 5, name: 'A58', depth: 2, totalSize: 1},
   ];
 
   const expectedMergedCallsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 5},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 5},
-    {hash: 6, parentHash: 3, name: 'A36', depth: 2, totalSize: 3},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 5},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 5},
+    {id: 6, parentId: 3, name: 'A36', depth: 2, totalSize: 3},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 4);
@@ -74,23 +74,23 @@
 
 test('manyCallsitesMergedWithoutChildren', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 5},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 5},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 3},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 1},
-    {hash: 5, parentHash: 1, name: 'A5', depth: 1, totalSize: 1},
-    {hash: 6, parentHash: 2, name: 'B6', depth: 1, totalSize: 5},
-    {hash: 7, parentHash: 4, name: 'A47', depth: 2, totalSize: 1},
-    {hash: 8, parentHash: 6, name: 'B68', depth: 2, totalSize: 1},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 5},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 5},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 3},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 1},
+    {id: 5, parentId: 1, name: 'A5', depth: 1, totalSize: 1},
+    {id: 6, parentId: 2, name: 'B6', depth: 1, totalSize: 5},
+    {id: 7, parentId: 4, name: 'A47', depth: 2, totalSize: 1},
+    {id: 8, parentId: 6, name: 'B68', depth: 2, totalSize: 1},
   ];
 
   const expectedMergedCallsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 5},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 5},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 5},
-    {hash: 6, parentHash: 2, name: 'B6', depth: 1, totalSize: 5},
-    {hash: 7, parentHash: 3, name: 'A47', depth: 2, totalSize: 1},
-    {hash: 8, parentHash: 6, name: 'B68', depth: 2, totalSize: 1},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 5},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 5},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 5},
+    {id: 6, parentId: 2, name: 'B6', depth: 1, totalSize: 5},
+    {id: 7, parentId: 3, name: 'A47', depth: 2, totalSize: 1},
+    {id: 8, parentId: 6, name: 'B68', depth: 2, totalSize: 1},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 4);
@@ -104,18 +104,18 @@
 
 test('smallCallsitesNotNextToEachOtherInArray', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 20},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 8},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 1},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 8},
-    {hash: 5, parentHash: 1, name: 'A5', depth: 1, totalSize: 3},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 20},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 8},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 1},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 8},
+    {id: 5, parentId: 1, name: 'A5', depth: 1, totalSize: 3},
   ];
 
   const expectedMergedCallsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 20},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 8},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 4},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 8},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 20},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 8},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 4},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 8},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 4);
@@ -129,9 +129,9 @@
 
 test('smallCallsitesNotMerged', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: 1, name: 'A2', depth: 1, totalSize: 2},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 2},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: 1, name: 'A2', depth: 1, totalSize: 2},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 2},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 1);
@@ -141,53 +141,53 @@
 
 test('mergingRootCallsites', () => {
   const callsites: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 10},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 2},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 10},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 2},
   ];
 
   const mergedCallsites = mergeCallsites(callsites, 20);
 
   expect(mergedCallsites).toEqual([
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 12},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 12},
   ]);
 });
 
 test('largerFlamegraph', () => {
   const data: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 60},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 40},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 25},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 15},
-    {hash: 5, parentHash: 1, name: 'A5', depth: 1, totalSize: 10},
-    {hash: 6, parentHash: 1, name: 'A6', depth: 1, totalSize: 10},
-    {hash: 7, parentHash: 2, name: 'B7', depth: 1, totalSize: 30},
-    {hash: 8, parentHash: 2, name: 'B8', depth: 1, totalSize: 10},
-    {hash: 9, parentHash: 3, name: 'A39', depth: 2, totalSize: 20},
-    {hash: 10, parentHash: 4, name: 'A410', depth: 2, totalSize: 10},
-    {hash: 11, parentHash: 4, name: 'A411', depth: 2, totalSize: 3},
-    {hash: 12, parentHash: 4, name: 'A412', depth: 2, totalSize: 2},
-    {hash: 13, parentHash: 5, name: 'A513', depth: 2, totalSize: 5},
-    {hash: 14, parentHash: 5, name: 'A514', depth: 2, totalSize: 5},
-    {hash: 15, parentHash: 7, name: 'A715', depth: 2, totalSize: 10},
-    {hash: 16, parentHash: 7, name: 'A716', depth: 2, totalSize: 5},
-    {hash: 17, parentHash: 7, name: 'A717', depth: 2, totalSize: 5},
-    {hash: 18, parentHash: 7, name: 'A718', depth: 2, totalSize: 5},
-    {hash: 19, parentHash: 9, name: 'A919', depth: 3, totalSize: 10},
-    {hash: 20, parentHash: 17, name: 'A1720', depth: 3, totalSize: 2},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 60},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 40},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 25},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 15},
+    {id: 5, parentId: 1, name: 'A5', depth: 1, totalSize: 10},
+    {id: 6, parentId: 1, name: 'A6', depth: 1, totalSize: 10},
+    {id: 7, parentId: 2, name: 'B7', depth: 1, totalSize: 30},
+    {id: 8, parentId: 2, name: 'B8', depth: 1, totalSize: 10},
+    {id: 9, parentId: 3, name: 'A39', depth: 2, totalSize: 20},
+    {id: 10, parentId: 4, name: 'A410', depth: 2, totalSize: 10},
+    {id: 11, parentId: 4, name: 'A411', depth: 2, totalSize: 3},
+    {id: 12, parentId: 4, name: 'A412', depth: 2, totalSize: 2},
+    {id: 13, parentId: 5, name: 'A513', depth: 2, totalSize: 5},
+    {id: 14, parentId: 5, name: 'A514', depth: 2, totalSize: 5},
+    {id: 15, parentId: 7, name: 'A715', depth: 2, totalSize: 10},
+    {id: 16, parentId: 7, name: 'A716', depth: 2, totalSize: 5},
+    {id: 17, parentId: 7, name: 'A717', depth: 2, totalSize: 5},
+    {id: 18, parentId: 7, name: 'A718', depth: 2, totalSize: 5},
+    {id: 19, parentId: 9, name: 'A919', depth: 3, totalSize: 10},
+    {id: 20, parentId: 17, name: 'A1720', depth: 3, totalSize: 2},
   ];
 
   const expectedData: CallsiteInfo[] = [
-    {hash: 1, parentHash: -1, name: 'A', depth: 0, totalSize: 60},
-    {hash: 2, parentHash: -1, name: 'B', depth: 0, totalSize: 40},
-    {hash: 3, parentHash: 1, name: 'A3', depth: 1, totalSize: 25},
-    {hash: 4, parentHash: 1, name: 'A4', depth: 1, totalSize: 35},
-    {hash: 7, parentHash: 2, name: 'B7', depth: 1, totalSize: 30},
-    {hash: 8, parentHash: 2, name: 'B8', depth: 1, totalSize: 10},
-    {hash: 9, parentHash: 3, name: 'A39', depth: 2, totalSize: 20},
-    {hash: 10, parentHash: 4, name: 'A410', depth: 2, totalSize: 25},
-    {hash: 15, parentHash: 7, name: 'A715', depth: 2, totalSize: 25},
-    {hash: 19, parentHash: 9, name: 'A919', depth: 3, totalSize: 10},
-    {hash: 20, parentHash: 15, name: 'A1720', depth: 3, totalSize: 2},
+    {id: 1, parentId: -1, name: 'A', depth: 0, totalSize: 60},
+    {id: 2, parentId: -1, name: 'B', depth: 0, totalSize: 40},
+    {id: 3, parentId: 1, name: 'A3', depth: 1, totalSize: 25},
+    {id: 4, parentId: 1, name: 'A4', depth: 1, totalSize: 35},
+    {id: 7, parentId: 2, name: 'B7', depth: 1, totalSize: 30},
+    {id: 8, parentId: 2, name: 'B8', depth: 1, totalSize: 10},
+    {id: 9, parentId: 3, name: 'A39', depth: 2, totalSize: 20},
+    {id: 10, parentId: 4, name: 'A410', depth: 2, totalSize: 25},
+    {id: 15, parentId: 7, name: 'A715', depth: 2, totalSize: 25},
+    {id: 19, parentId: 9, name: 'A919', depth: 3, totalSize: 10},
+    {id: 20, parentId: 15, name: 'A1720', depth: 3, totalSize: 2},
   ];
 
   // In this case, on depth 1, callsites A4, A5 and A6 should be merged and
diff --git a/ui/src/tracks/heap_profile_flamegraph/frontend.ts b/ui/src/tracks/heap_profile_flamegraph/frontend.ts
index a3d39a6..e1147db 100644
--- a/ui/src/tracks/heap_profile_flamegraph/frontend.ts
+++ b/ui/src/tracks/heap_profile_flamegraph/frontend.ts
@@ -55,7 +55,8 @@
     if (data === undefined) {
       this.flamegraph.updateDataIfChanged([]);
     } else {
-      this.flamegraph.updateDataIfChanged(data.flamegraph);
+      this.flamegraph.updateDataIfChanged(
+          data.flamegraph, data.clickedCallsite);
     }
   }
 
@@ -97,7 +98,9 @@
   }
 
   onMouseClick({x, y}: {x: number, y: number}): boolean {
-    this.flamegraph.onMouseClick({x, y});
+    this.config.expandedId = this.flamegraph.onMouseClick({x, y});
+    globals.dispatch(Actions.updateTrackConfig(
+        {id: this.trackState.id, config: this.config}));
     return true;
   }