
//--------------------------------------------------------------------*/
//--- DHAT: a Dynamic Heap Analysis Tool                dh_view.js ---*/
//--------------------------------------------------------------------*/

/*
   This file is part of DHAT, a Valgrind tool for profiling the
   heap usage of programs.

   Copyright (C) 2018 Mozilla Foundation

   This program is free software; you can redistribute it and/or
   modify it under the terms of the GNU General Public License as
   published by the Free Software Foundation; either version 2 of the
   License, or (at your option) any later version.

   This program is distributed in the hope that it will be useful, but
   WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with this program; if not, see <http://www.gnu.org/licenses/>.

   The GNU General Public License is contained in the file COPYING.
*/

/*
   Parts of this file are derived from Firefox, copyright Mozilla Foundation,
   and may be redistributed under the terms of the Mozilla Public License
   Version 2.0, as well as under the license of this project.  A copy of the
   Mozilla Public License Version 2.0 is available at at
   https://www.mozilla.org/en-US/MPL/2.0/.
*/

// Test this file by loading dh_view.html?test=1. That runs the tests in
// dh_test.js and gives pass/fail indicators.

"use strict";

//------------------------------------------------------------//
//--- Globals                                              ---//
//------------------------------------------------------------//

// Important HTML elements.
let gInput;
let gSelect;
let gHeaderDiv, gTestingDiv, gMainDiv, gLegendDiv, gTimingsDiv;

// The name of the loaded file.
let gFilename;

// The object extracted from the JSON input.
let gData;

// The root of the radix tree build from gData. A radix tree is a
// space-optimized prefix tree in which each node that is the only child is
// merged with its parent.
let gRoot;

// Data relating to the sort metrics.
//
// - isDefault: True for the default sort metric.
// - label: Used in the drop-down menu.
// - bolds: Which fields to highlight in the output.
// - cmpField: Field used to sort the radix tree.
// - sig: Significance function used to determine aggregate nodes.
// - sigLabel: Significance threshold description function.
//
const gSelectData = [
  {
    label: "Total (bytes)",
    bolds: { "totalTitle": 1, "totalBytes": 1 },
    cmpField: "_totalBytes",
    sig: (aT) => aT._totalBytes >= 0.01 * gRoot._totalBytes,
    sigLabel: () => `\
total >= ${bytesAndPerc(0.01 * gRoot._totalBytes, gRoot._totalBytes)}`
  },
  {
    isDefault: true,
    label: "Total (blocks)",
    bolds: { "totalTitle": 1, "totalBlocks": 1 },
    cmpField: "_totalBlocks",
    sig: (aT) => aT._totalBlocks >= 0.01 * gRoot._totalBlocks,
    sigLabel: () => `\
total >= ${blocksAndPerc(0.01 * gRoot._totalBlocks, gRoot._totalBlocks)}`
  },
  // No "Total (bytes), tiny" because it's extremely unlikely that an AP with a
  // tiny average size will take up a significant number of bytes.
  {
    label: "Total (blocks), tiny",
    bolds: { "totalTitle": 1, "totalBlocks": 1, "totalAvgSizeBytes": 1 },
    cmpField: "_totalBlocks",
    sig: (aT) => aT._totalBlocks >= 0.005 * gRoot._totalBlocks &&
                 aT._totalAvgSizeBytes() <= 16,
    sigLabel: () => `\
(total >= ${blocksAndPerc(0.005 * gRoot._totalBlocks, gRoot._totalBlocks)}) && \
(total avg size <= ${bytes(16)})`
  },
  // No "Total (bytes), short-lived", because an AP with few large, short-lived
  // blocks is unlikely. (In contrast, "Total (blocks), short-lived" is useful,
  // because an AP with many small, short-lived blocks *is* likely.) And if
  // such an AP existed, it'll probably show up in "Total (bytes), zero reads
  // or zero writes" or "Total (bytes), low-access" anyway, because there's
  // little time for accesses in 500 instructions.
  {
    label: "Total (blocks), short-lived",
    bolds: { "totalTitle": 1, "totalBlocks": 1, "totalAvgLifetimeInstrs": 1 },
    cmpField: "_totalBlocks",
    sig: (aT) => aT._totalBlocks >= 0.005 * gRoot._totalBlocks &&
                 aT._totalAvgLifetimeInstrs() <= 500,
    sigLabel: () => `\
(total >= ${blocksAndPerc(0.005 * gRoot._totalBlocks, gRoot._totalBlocks)}) && \
(total avg lifetime <= ${instrs(500)})`
  },
  {
    label: "Total (bytes), zero reads or zero writes",
    bolds: { "totalTitle": 1, "totalBytes": 1,
             "readsTitle": 1, "readsBytes": 1,
             "writesTitle": 1, "writesBytes": 1,
           },
    cmpField: "_totalBytes",
    sig: (aT) => aT._totalBytes >= 0.005 * gRoot._totalBytes &&
                 (aT._readsBytes === 0 || aT._writesBytes === 0),
    sigLabel: () => `\
(total >= ${bytesAndPerc(0.005 * gRoot._totalBytes, gRoot._totalBytes)}) && \
((reads == ${bytes(0)}) || (writes == ${bytes(0)}))`
  },
  {
    label: "Total (blocks), zero reads or zero writes",
    bolds: { "totalTitle": 1, "totalBlocks": 1,
             "readsTitle": 1, "readsBytes": 1,
             "writesTitle": 1, "writesBytes": 1,
           },
    cmpField: "_totalBlocks",
    sig: (aT) => aT._totalBlocks >= 0.005 * gRoot._totalBlocks &&
                 (aT._readsBytes === 0 || aT._writesBytes === 0),
    sigLabel: () => `\
(total >= ${blocksAndPerc(0.005 * gRoot._totalBlocks, gRoot._totalBlocks)}) && \
((reads == ${bytes(0)}) || (writes == ${bytes(0)}))`
  },
  {
    label: "Total (bytes), low-access",
    bolds: { "totalTitle": 1, "totalBytes": 1,
             "readsTitle": 1, "readsAvgPerByte": 1,
             "writesTitle": 1, "writesAvgPerByte": 1,
           },
    cmpField: "_totalBytes",
    sig: (aT) => aT._totalBytes >= 0.005 * gRoot._totalBytes &&
                 aT._readsBytes !== 0 &&
                 aT._writesBytes !== 0 &&
                 (aT._readsAvgPerByte() <= 0.4 ||
                  aT._writesAvgPerByte() <= 0.4),
    sigLabel: () => `\
(total >= ${bytesAndPerc(0.005 * gRoot._totalBytes, gRoot._totalBytes)}) && \
(reads != ${bytes(0)}) && \
(writes != ${bytes(0)}) && \
((reads <= ${perByte(0.4)}) || (writes <= ${perByte(0.4)}))`
  },
  {
    label: "Total (blocks), low-access",
    bolds: { "totalTitle": 1, "totalBlocks": 1,
             "readsTitle": 1, "readsAvgPerByte": 1,
             "writesTitle": 1, "writesAvgPerByte": 1,
           },
    cmpField: "_totalBlocks",
    sig: (aT) => aT._totalBlocks >= 0.005 * gRoot._totalBlocks &&
                 aT._readsBytes !== 0 &&
                 aT._writesBytes !== 0 &&
                 (aT._readsAvgPerByte() <= 0.4 ||
                  aT._writesAvgPerByte() <= 0.4),
    sigLabel: () => `\
(total >= ${blocksAndPerc(0.005 * gRoot._totalBlocks, gRoot._totalBlocks)}) && \
(reads != ${bytes(0)}) && \
(writes != ${bytes(0)}) && \
((reads <= ${perByte(0.4)}) || (writes <= ${perByte(0.4)}))`
  },
  // No "Total (avg size bytes)": not interesting.
  // No "Total (avg lifetime instrs)": covered by "Total (blocks), short-lived".
  // No "Max (bytes)": not interesting, and unclear how to sort.
  // No "Max (blocks)": not interesting, and unclear how to sort.
  // No "Max (avg size bytes)": not interesting, and unclear how to sort.
  {
    label: "At t-gmax (bytes)",
    bolds: { "atTGmaxTitle": 1, "atTGmaxBytes": 1 },
    cmpField: "_atTGmaxBytes",
    sig: (aT) => aT._atTGmaxBytes >= 0.01 * gRoot._atTGmaxBytes,
    sigLabel: () => `\
at-t-gmax >= ${bytesAndPerc(0.01 * gRoot._atTGmaxBytes, gRoot._atTGmaxBytes)}`
  },
  // No "At t-gmax (blocks)": not interesting.
  // No "At t-gmax (avg size bytes)": not interesting.
  {
    label: "At t-end (bytes)",
    bolds: { "atTEndTitle": 1, "atTEndBytes": 1 },
    cmpField: "_atTEndBytes",
    sig: (aT) => aT._atTEndBytes >= 0.01 * gRoot._atTEndBytes,
    sigLabel: () => `\
at-t-end >= ${bytesAndPerc(0.01 * gRoot._atTEndBytes, gRoot._atTEndBytes)}`
  },
  // No "At t-end (blocks)": not interesting.
  // No "At t-end (avg size bytes)": not interesting.
  {
    label: "Reads (bytes)",
    bolds: { "readsTitle": 1, "readsBytes": 1 },
    cmpField: "_readsBytes",
    sig: (aT) => aT._readsBytes >= 0.01 * gRoot._readsBytes,
    sigLabel: () => `\
reads >= ${bytesAndPerc(0.01 * gRoot._readsBytes, gRoot._readsBytes)}`
  },
  {
    label: "Reads (bytes), high-access",
    bolds: { "readsTitle": 1, "readsBytes": 1, "readsAvgPerByte": 1 },
    cmpField: "_readsBytes",
    sig: (aT) => aT._readsBytes >= 0.005 * gRoot._readsBytes &&
                 (aT._readsAvgPerByte() >= 1000 ||
                  aT._writesAvgPerByte() >= 1000),
    sigLabel: () => `\
(reads >= ${bytesAndPerc(0.005 * gRoot._readsBytes, gRoot._readsBytes)}) && \
((reads >= ${perByte(1000)}) || (writes >= ${perByte(1000)}))`
  },
  // No "Reads (avg per byte)": covered by other access-related ones.
  {
    label: "Writes (bytes)",
    bolds: { "writesTitle": 1, "writesBytes": 1 },
    cmpField: "_writesBytes",
    sig: (aT) => aT._writesBytes >= 0.01 * gRoot._writesBytes,
    sigLabel: () => `\
writes >= ${bytesAndPerc(0.01 * gRoot._writesBytes, gRoot._writesBytes)}`
  },
  {
    label: "Writes (bytes), high-access",
    bolds: { "writesTitle": 1, "writesBytes": 1, "writesAvgPerByte": 1 },
    cmpField: "_writesBytes",
    sig: (aT) => aT._writesBytes >= 0.005 * gRoot._writesBytes &&
                 (aT._readsAvgPerByte() >= 1000 ||
                  aT._writesAvgPerByte() >= 1000),
    sigLabel: () => `\
(writes >= ${bytesAndPerc(0.005 * gRoot._writesBytes, gRoot._writesBytes)}) && \
((reads >= ${perByte(1000)}) || (writes >= ${perByte(1000)}))`
  }
  // No "Writes (avg per byte)": covered by other access-related ones.
];

//------------------------------------------------------------//
//--- Utilities                                            ---//
//------------------------------------------------------------//

// Assertion. Fails if aMsg is missing.
function assert(aCond, aMsg) {
  if (!aCond || !aMsg) {
    throw new Error(`assertion failed: ${aMsg}`);
  }
}

// Division function that returns 0 instead of NaN for 0/0, which is what we
// always want.
function div(aNum, aDenom) {
  return aNum === 0 && aDenom === 0 ? 0 : aNum / aDenom;
}

// Execute a function, printing any exception to the page.
function tryFunc(aFunc) {
  try {
    aFunc();
  } catch (ex) {
    // Clear gRoot, so that any old or partially-built new value doesn't hang
    // around if after this exception is thrown.
    gRoot = undefined;
    clearMainDivWithText(ex.toString(), "error");
    throw ex;
  }
}

// Put some text in a div at the bottom of the page. Useful for debugging.
function debug(x) {
  let section = appendElement(document.body, "div", "section");
  appendElementWithText(section, "div", JSON.stringify(x), "debug noselect");
}

//------------------------------------------------------------//
//--- Radix tree building                                  ---//
//------------------------------------------------------------//

// Notes about the TreeNode kinds:
//
// --------------------------------------------------------------------
//                              Leaf          Internal        Aggregate
// --------------------------------------------------------------------
// Has this._kids?              No            Yes             No
// Has this._max*?              Yes           No              No
// Has this._accesses?          Maybe         Maybe           No
// Allowed this._sig values?    Self,None     Self,Desc,None  None
// How many this._add() calls?  1             1+              1+
// --------------------------------------------------------------------
//
const kLeaf     = 1;
const kInternal = 2;
const kAgg      = 3;

function TreeNode(aKind, aFrames) {
  this._kind = aKind;

  this._totalBytes = 0;
  this._totalBlocks = 0;

  this._totalLifetimesInstrs = 0;

  // These numbers only make sense for leaf nodes. Unlike total stats, which
  // can be summed, _maxBytes/_maxBlocks for two APs can't be easily combined
  // because the maxes may have occurred at different times.
  if (this._kind === kLeaf) {
    this._maxBytes = 0;
    this._maxBlocks = 0;
  }

  this._atTGmaxBytes = 0;
  this._atTGmaxBlocks = 0;

  this._atTEndBytes = 0;
  this._atTEndBlocks = 0;

  this._readsBytes = 0;
  this._writesBytes = 0;

  // this._accesses is left undefined. It will be added if necessary.
  // The possible values have the following meanings:
  // - undefined means "unset accesses" (i.e. new node, never been set)
  // - length==0 means "no accesses" (i.e. some kids have accesses and some
  //   don't, or all kids have accesses but in different sizes)
  // - length>0 means "accesses" (i.e. all kids have accesses and all the same
  //   size)

  // If a node would only have a single child, we instead effectively inline it
  // in the parent. Therefore a node can have multiple frames.
  this._frames = aFrames;

  // this._kids is left undefined. It will be added if necessary.

  // this._sig is added later, by sigTree().
}

TreeNode.prototype = {
  _add(aTotalBytes, aTotalBlocks, aTotalLifetimesInstrs, aMaxBytes,
       aMaxBlocks, aAtTGmaxBytes, aAtTGmaxBlocks, aAtTEndBytes,
       aAtTEndBlocks, aReadsBytes, aWritesBytes, aAccesses) {

    // We ignore this._kind, this._frames, and this._kids.

    this._totalBytes += aTotalBytes;
    this._totalBlocks += aTotalBlocks;
    this._totalLifetimesInstrs += aTotalLifetimesInstrs;

    if (this._kind === kLeaf) {
      // Leaf nodes should only be added to once, because DHAT currently
      // produces records with unique locations. If we remove addresses from
      // frames in the future then something must be done here to sum non-zero
      // _maxBytes and _maxBlocks values, but it's unclear exactly what. Range
      // arithmetic is a (complicated) possibility.
      assert(this._maxBytes === 0, "bad _maxBytes: " + this._maxBytes);
      assert(this._maxBlocks === 0, "bad _maxBlocks: " + this._maxBlocks);
      this._maxBytes += aMaxBytes;
      this._maxBlocks += aMaxBlocks;
    }

    this._atTGmaxBytes += aAtTGmaxBytes;
    this._atTGmaxBlocks += aAtTGmaxBlocks;

    this._atTEndBytes += aAtTEndBytes;
    this._atTEndBlocks += aAtTEndBlocks;

    this._readsBytes += aReadsBytes;
    this._writesBytes += aWritesBytes;

    if (this._kind !== kAgg) {
      if (!this._accesses && aAccesses) {
        // unset accesses += accesses --> has accesses (must clone the array)
        this._accesses = aAccesses.slice();
      } else if (this._accesses && aAccesses &&
                 this._accesses.length === aAccesses.length) {
        // accesses += accesses (with matching lengths) --> accesses
        for (let i = 0; i < this._accesses.length; i++) {
          this._accesses[i] += aAccesses[i];
        }
      } else {
        // any other combination --> no accesses
        this._accesses = [];
      }
    } else {
      assert(!this._accesses, "agg nodes cannot have accesses");
    }
  },

  _addAP(aAP) {
    this._add(aAP.tb, aAP.tbk, aAP.tli, aAP.mb, aAP.mbk, aAP.gb, aAP.gbk,
              aAP.fb, aAP.fbk, aAP.rb, aAP.wb, aAP.acc);
  },

  // This is called in two cases.
  // - Splitting a node, where we are adding to a fresh node (i.e. effectively
  //   cloning a node).
  // - Aggregating multiple nodes.
  _addNode(aT) {
    this._add(aT._totalBytes, aT._totalBlocks, aT._totalLifetimesInstrs,
              aT._maxBytes, aT._maxBlocks, aT._atTGmaxBytes, aT._atTGmaxBlocks,
              aT._atTEndBytes, aT._atTEndBlocks,
              aT._readsBytes, aT._writesBytes, aT._accesses);
  },

  // Split the node after the aTi'th internal frame. The inheriting kid will
  // get the post-aTi frames; the new kid will get aNewFrames.
  _split(aTi, aAP, aNewFrames) {
    // kid1 inherits t's kind and values.
    let inheritedFrames = this._frames.splice(aTi + 1);
    let kid1 = new TreeNode(this._kind, inheritedFrames);
    if (this._kids) {
      kid1._kids = this._kids;
    }
    kid1._addNode(this);

    // Put all remaining frames into kid2.
    let kid2 = new TreeNode(kLeaf, aNewFrames);
    kid2._addAP(aAP);

    // Update this.
    if (this._kind === kLeaf) {
      // Convert to an internal node.
      this._kind = kInternal;
      assert(this.hasOwnProperty("_maxBytes"), "missing _maxBytes");
      assert(this.hasOwnProperty("_maxBlocks"), "missing _maxBlocks");
      delete this._maxBytes;
      delete this._maxBlocks;
    }
    this._kids = [kid1, kid2];
    this._addAP(aAP);
  },

  _totalAvgSizeBytes() {
    return div(this._totalBytes, this._totalBlocks);
  },

  _totalAvgLifetimeInstrs() {
    return div(this._totalLifetimesInstrs, this._totalBlocks);
  },

  _maxAvgSizeBytes() {
    assert(this._kind === kLeaf, "non-leaf node");
    return div(this._maxBytes, this._maxBlocks);
  },

  _atTGmaxAvgSizeBytes() {
    return div(this._atTGmaxBytes, this._atTGmaxBlocks);
  },

  _atTEndAvgSizeBytes() {
    return div(this._atTEndBytes, this._atTEndBlocks);
  },

  _readsAvgPerByte() {
    return div(this._readsBytes, this._totalBytes);
  },

  _writesAvgPerByte() {
    return div(this._writesBytes, this._totalBytes);
  }
}

// Check if the fields in `aFields` are present in `aObj`.
function checkFields(aObj, aFields) {
  for (let f of aFields) {
    if (!aObj.hasOwnProperty(f)) {
      throw new Error(`data file is missing a field: ${f}`);
    }
  }
}

// Do basic checking of an AP read from file.
function checkAP(aAP) {
  let fields = ["tb", "tbk", "tli",
                "mb", "mbk",
                "gb", "gbk",
                "fb", "fbk",
                "rb", "wb",
                "fs"];
  checkFields(aAP, fields);
}

// Access counts latch as 0xffff. Treating 0xffff as Infinity gives us exactly
// the behaviour we want, e.g. Infinity + 1 = Infinity.
function normalizeAccess(aAcc) {
  if (aAcc < 0xffff) {
    return aAcc;
  }
  if (aAcc === 0xffff) {
    return Infinity;
  }
  assert(false, "too-large access value");
}

const kExpectedFileVersion = 1;

// Build gRoot from gData.
function buildTree() {
  // Check global values.
  let fields = ["dhatFileVersion",
                "cmd", "pid",
                "mi", "ei",
                "aps", "ftbl"];
  checkFields(gData, fields);
  if (gData.dhatFileVersion != kExpectedFileVersion) {
      throw Error(`data file has version number ${gData.dhatFileVersion}, ` +
                  `expected version number ${kExpectedFileVersion}`);
  }

  // Build the radix tree. Nodes are in no particular order to start with. The
  // algorithm is tricky because we need to use internal frames when possible.
  gRoot = new TreeNode(kLeaf, [0]);   // Frame 0 is always "[root]".

  for (let [i, ap] of gData.aps.entries()) {
    checkAP(ap);

    // Decompress the run-length encoding in `acc`, if present.
    if (ap.acc) {
      let acc = [];
      for (let i = 0; i < ap.acc.length; i++) {
        if (ap.acc[i] < 0) {
          // A negative number encodes a repeat count. The following entry has
          // the value to be repeated.
          let reps = -ap.acc[i++];
          let val = ap.acc[i];
          for (let j = 0; j < reps; j++) {
            acc.push(normalizeAccess(val));
          }
        } else {
          acc.push(normalizeAccess(ap.acc[i]));
        }
      }
      ap.acc = acc;
    }

    // The first AP is a special case, because we have to build gRoot.
    if (i === 0) {
      gRoot._frames.push(...ap.fs);
      gRoot._addAP(ap);
      continue;
    }

    let t = gRoot;  // current node
    let ti = 0;     // current frame index within t
    let done = false;

    // In the examples below, tree nodes have the form `abcd:N-Xs`, where
    // `abcd` is a frame sequence (and `-` is an empty sequence), `N` is a node
    // value, and `Xs` are the node's children.

    for (let [j, kidFrame] of ap.fs.entries()) {

      // Search for kidFrame among internal frames.
      if (ti + 1 < t._frames.length) {
        // t has an internal frame at the right index.

        if (t._frames[ti + 1] === kidFrame) {
          // The internal frame matches. Move to t's next internal frame.
          ti++;
        } else {
          // The internal frame doesn't match. Split the node.
          //
          // E.g. abcd:20-[] + abef:10 => ab:30-[cd:20-[], ef:10-[]]
          t._split(ti, ap, ap.fs.slice(j));
          done = true;
          break;
        }

      } else {
        // We've run out of internal frames in t. Consider t's kids.

        if (!t._kids) {
          // No kids; this must be a supersequence of an existing sequence.
          // Split t; the inheriting kid will get no frames, the new kid will
          // get the leftover frames.
          //
          // E.g. ab:20-[] + abcd:10 => ab:30-[-:20-[], cd:10-[]]
          t._split(ti, ap, ap.fs.slice(j));
          done = true;
          break;
        }

        t._addAP(ap);

        // Search for the frame among the kids.
        let kid;
        for (let k of t._kids) {
          if (k._frames[0] === kidFrame) {
            kid = k;
            break;
          }
        }
        if (kid) {
          // Found it. Move to it.
          t = kid;
          ti = 0;
        } else {
          // Didn't find it. Put all remaining frames into a new leaf node.
          //
          // E.g. ab:20-[c:10-Xs, d:10-Ys] + abef:10 =>
          //      ab:30-[c:10-Xs, d:10-Ys, ef:10-[]]
          kid = new TreeNode(kLeaf, ap.fs.slice(j));
          kid._addAP(ap);
          t._kids.push(kid);
          done = true;
          break;
        }
      }
    }

    if (!done) {
      // If we reach here, either:
      // - ap's frames match an existing frame sequence, in which case we
      //   just need to _addAP(); or
      // - ap's frames are a subsequence of an existing sequence, in which
      //   case we must split.

      if (ti + 1 < t._frames.length) {
        // A subsequence of an existing sequence that ends within t's internal
        // frames. Split, creating an empty node.
        //
        // E.g. abcd:20-Xs + ab:10 => ab:30-[cd:20-Xs, -:10-[]]
        t._split(ti, ap, []);

      } else if (!t._kids) {
        // This is impossible because DHAT currently produces records with
        // unique locations. If we remove addresses from frames in the future
        // then duplicate locations will occur, and the following code is how
        // it must be handled.
        throw Error(`data file contains a repeated location`);

        // Matches an existing sequence that doesn't end in node with empty
        // frames. Add the AP.
        //
        // E.g. ab:20-[] + ab:10 => ab:30-[]
        t._addAP(ap);

      } else {
        // Look for a kid with empty frames.
        let emptyKid;
        for (let k of t._kids) {
          if (k._frames.length === 0) {
            emptyKid = k;
            break;
          }
        }

        if (emptyKid) {
          // This is impossible because DHAT currently produces records with
          // unique locations. If we remove addresses from frames in the future
          // then duplicate locations will occur, and the following code is how
          // it must be handled.
          throw Error(`data file contains a repeated location`);

          // Matches an existing sequence that ends in a node with empty
          // frames. Add the AP.
          //
          // E.g. ab:20-[c:10-Xs, -:10-[]] + ab:10 => ab:30-[c:10-Xs, -:20-[]]
          t._addAP(ap);
          emptyKid._addAP(ap);

        } else {
          // A subsequence of an existing sequence that ends at the end of t's
          // internal frames. Append an empty node.
          //
          // E.g. ab:20-[c:10-Xs, d:10-Ys] + ab:10 =>
          //      ab:30-[c:10-Xs, d:10-Ys, -:10-[]]
          let newKid = new TreeNode(kLeaf, []);
          newKid._addAP(ap);

          t._kids.push(newKid);
          t._addAP(ap);
        }
      }
    }

  }
}

//------------------------------------------------------------//
//--- Pretty printers                                      ---//
//------------------------------------------------------------//

// Using Intl.NumberFormat makes things faster than using toLocaleString()
// repeatedly.
const kPFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, style: "percent" });
const kDFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }); // decimal
const kTFormat = new Intl.NumberFormat(); // time

function perc(aNum, aDenom) {
  return kPFormat.format(div(aNum, aDenom));
}

function perMinstr(aN) {
  return `${kDFormat.format(div(1000000 * aN, gData.ei))}/Minstr`;
}

function bytes(aN) {
  return `${kDFormat.format(aN)} bytes`;
}

function bytesAndPerc(aN, aTotalN) {
  return `${bytes(aN)} (${perc(aN, aTotalN)})`;
}

function bytesAndPercAndRate(aN, aTotalN) {
  return `${bytes(aN)} (${perc(aN, aTotalN)}, ${perMinstr(aN)})`;
}

function blocks(aN) {
  return `${kDFormat.format(aN)} blocks`;
}

function blocksAndPerc(aN, aTotalN) {
  return `${blocks(aN)} (${perc(aN, aTotalN)})`;
}

function blocksAndPercAndRate(aN, aTotalN) {
  return `${blocks(aN)} (${perc(aN, aTotalN)}, ${perMinstr(aN)})`;
}

function avgSizeBytes(aN) {
  return `avg size ${bytes(aN)}`;
}

function perByte(aN) {
  return `${kDFormat.format(aN)}/byte`;
}

function instrs(aN) {
  return `${kDFormat.format(aN)} instrs`;
}

function avgLifetimeInstrs(aN) {
  return `avg lifetime ${instrs(aN)}`;
}

function accesses(aAccesses) {
  // Make zero stand out.
  if (aAccesses === 0) {
    return "-";
  }

  if (aAccesses === Infinity) {
    return "∞";
  }

  // Don't use toLocaleString() -- in this case the values rarely reach
  // 100,000, and the grid formatting means the separators tend to make the
  // numbers harder to read. (And locales such as fr-FR use ' ' as the
  // separator, which conflicts with our use of ' ' between values!)
  return aAccesses.toString();
}

function ms(aNum) {
  // This function is called only a handful of times, so there is no need to
  // use Intl.NumberFormat.
  return aNum !== undefined ? `${kTFormat.format(aNum)}ms` : "n/a";
}

//------------------------------------------------------------//
//--- DOM manipulation                                     ---//
//------------------------------------------------------------//

const kDocumentTitle = "DHAT Viewer";

document.title = kDocumentTitle;

function appendElement(aP, aTagName, aClassName) {
  let e = document.createElement(aTagName);
  if (aClassName) {
    e.className = aClassName;
  }
  aP.appendChild(e);
  return e;
}

function appendElementWithText(aP, aTagName, aText, aClassName) {
  let e = appendElement(aP, aTagName, aClassName);
  e.textContent = aText;
  return e;
}

function appendText(aP, aText) {
  let e = document.createTextNode(aText);
  aP.appendChild(e);
  return e;
}

function clearDiv(aDiv) {
  // Replace aDiv with an empty node.
  assert(aDiv, "no div given");
  let tmp = aDiv.cloneNode(/* deep = */ false);
  aDiv.parentNode.replaceChild(tmp, aDiv);
  return tmp;
}

function clearMainDiv() {
  gMainDiv = clearDiv(gMainDiv);
}

function clearTimingsDiv() {
  gTimingsDiv = clearDiv(gTimingsDiv);
}

function clearMainDivWithText(aText, aClassName) {
  clearMainDiv();
  appendElementWithText(gMainDiv, "span", aText, aClassName);
}

function appendInvocationAndTimes(aP) {
  let v, v1, v2;

  v = "Invocation {\n";
  v += `  Command: ${gData.cmd}\n`;
  v += `  PID:     ${gData.pid}\n`;
  v += "}\n\n";

  appendElementWithText(aP, "span", v, "invocation");

  v = "Times {\n";

  v1 = perc(gData.mi, gData.ei);
  v += `  t-gmax: ${instrs(gData.mi)} (${v1} of program duration)\n`;
  v += `  t-end:  ${instrs(gData.ei)}\n`;

  v += "}\n\n";

  appendElementWithText(aP, "span", v, "times");
}

// Arrows indicating what state a node is in.
const kNoKidsArrow      = "─ ";     // cannot change
const kHidingKidsArrow  = "▶ ";     // expandible
const kShowingKidsArrow = "▼ ";     // collapsible

// HTML doesn't have a tree element, so we fake one with text. One nice
// consequence is that you can copy and paste the output. The non-ASCII chars
// used (for arrows and tree lines) usually reproduce well when pasted into
// textboxes.
//
// - aT: The sub-tree to append.
// - aP: Parent HTML element to append to.
// - aBolds: Which fields to highlight in the output.
// - aPc: The percentage function.
// - aCmp: The comparison function.
// - aSig: The significance function.
// - aNodeIdNums: The node ID numbers, e.g. [1,2,3], which is printed "1.2.3".
// - aNumSibs: The number of siblings that aT has.
// - aOldFrames: Frames preceding this node's frames.
// - aTlFirst: Treeline for the first line of the node.
// - aTlRest: Treeline for the other lines of the node, and its kids.
//
function appendTreeInner(aT, aP, aBolds, aCmp, aPc, aSig, aNodeIdNums,
                         aNumSibs, aOldFrames, aTlFirst, aTlRest) {
  // The primary element we'll be appending to.
  let p;

  // We build up text fragments in up to seven groups:
  // - pre-Bold1 (multiple)
  // - Bold1 (single)
  // - post-Bold1 (multiple)
  // - Bold2 (single)
  // - post-Bold2 (multiple)
  // - Bold3 (single)
  // - post-Bold3 (multiple)
  //
  // This is so that up to 3 bold sequences can be highlighted per line.
  let frags, fi;

  // Clear the text fragments.
  function clear() {
    frags = [[], undefined, [], undefined, [], undefined, []];
    fi = 0;
  }

  // Add a fragment.
  // - aShowIfInsig: should we show this even in an insignificant node?
  // - aIsBold: if this is shown, should it be bold? If undefined (as is
  //   common) it takes the same value as aShowIfInsig.
  function fr(aStr, aShowIfInsig, aIsBold) {
    if (!aShowIfInsig && aT._sig !== kSigSelf) {
      return;
    }

    if (aIsBold === undefined) {
      aIsBold = aShowIfInsig;
    }

    if (aIsBold) {
      assert(fi === 0 || fi === 2 || fi === 4, "bad fragIndex (1)");
      assert(frags[fi + 1] === undefined, "bold already here");
      frags[fi + 1] = aStr;
      fi += 2;
    } else {
      assert(fi === 0 || fi === 2 || fi === 4 || fi === 6, "bad fragIndex (2)");
      frags[fi].push(aStr);
    }
  }

  // Add a newline fragment (with a following treeline, unless aIsLast==true).
  // - aShowIfInsig: should we show this even in an insignificant node?
  // - aIsLast: is this the last newline for the node?
  function nl(aShowIfInsig, aIsLast) {
    assert(fi === 0 || fi === 2 || fi === 4 || fi === 6, "bad fragIndex (3)");
    if (!aShowIfInsig && aT._sig !== kSigSelf) {
      return;
    }

    frags[fi].push("\n");

    // Alternate the non-bold fragments (each in a text node) and bold
    // fragments (each in a span).
    if (frags[0].length > 0) {
      appendText(p, frags[0].join(""));
    }
    if (frags[1] !== undefined) {
      appendElementWithText(p, "span", frags[1], "bold");
    }
    if (frags[2].length > 0) {
      appendText(p, frags[2].join(""));
    }
    if (frags[3] !== undefined) {
      appendElementWithText(p, "span", frags[3], "bold");
    }
    if (frags[4].length > 0) {
      appendText(p, frags[4].join(""));
    }
    if (frags[5] !== undefined) {
      appendElementWithText(p, "span", frags[5], "bold");
    }
    if (frags[6].length > 0) {
      appendText(p, frags[6].join(""));
    }

    if (!aIsLast) {
      appendElementWithText(p, "span", aTlRest, "treeline");
      clear();
    }
  }

  clear();

  // Traverse the kids, aggregating insignificant nodes.
  let kids;
  if (aT._kids) {
    kids = [];
    let agg, nAgg = 0;

    for (let kid of aT._kids) {
      assert(kid._sig === kSigSelf || kid._sig === kSigDesc ||
             kid._sig === kSigNone, "kid _sig not set");

      if (kid._sig !== kSigNone) {
        // `kid` is at least partially significant. Just push it as-is.
        kids.push(kid);
      } else {
        // `kid` is insignificant. Aggregate it.
        if (!agg) {
          // We fill in ._frames below, once we know how many kids were
          // aggregated.
          agg = new TreeNode(kAgg, undefined);
          agg._sig = kSigNone;
          kids.push(agg);
        }
        nAgg++;
        agg._addNode(kid);
      }
    }

    if (agg) {
      // Fill in agg._frames.
      let insigFrame = `[${nAgg} insignificant]`;
      agg._frames = [insigFrame];
    }

    kids.sort(aCmp);
  }
  // Note: need to use `kids` for the rest of this function, not `aT._kids`.

  // Put the percentage into a colour band. The obvious way to do this is
  // with equal-sized bands (e.g. 0--20%, 20--40%, ...) but that doesn't work
  // well because in practice we have few nodes with mid-to-high percentages,
  // and many nodes with small percentages. So we use a logarithmic
  // distribution instead, so small values are better distinguished. (This is
  // reasonable in a way: a 2% node is twice as important as a 1%, a 4% node
  // is twice as important as a 2% node, etc.)
  let pc = aPc(aT);
  let lt = (aT._sig !== kSigSelf) ? "insig" // insignificant nodes
         : (pc <  1) ? "lt1"                //  0% to   0.999%
         : (pc <  2) ? "lt2"                //  1% to   1.999%
         : (pc <  4) ? "lt4"                //  2% to   3.999%
         : (pc <  8) ? "lt8"                //  4% to   7.999%
         : (pc < 16) ? "lt16"               //  8% to  15.999%
         : (pc < 32) ? "lt32"               // 16% to  31.999%
         :             "lt100";             // 32% to 100%

  // Append the primary element.
  let arrow;
  if (kids) {
    p = appendElement(aP, "span", lt + " internal expanded");
    p.onclick = toggleClass;
    arrow = kShowingKidsArrow;
  } else {
    p = appendElement(aP, "span", lt + " leaf");
    arrow = kNoKidsArrow;
  }

  // Node start: treeline and arrow.
  appendElementWithText(p, "span", aTlFirst, "treeline");
  appendElementWithText(p, "span", arrow, "arrow");

  let v1, v2, v3, v4, v5;

  // "AP" + node ID + kid count.
  v1 = aNodeIdNums.join('.');
  v2 = aNumSibs + 1;
  v3 = kids ? `(${kids.length} children) ` : "";
  fr(`AP ${v1}/${v2} ${v3}{`, true, false);
  nl(true);

  // "Total".
  v1 = bytesAndPercAndRate(aT._totalBytes, gRoot._totalBytes);
  v2 = blocksAndPercAndRate(aT._totalBlocks, gRoot._totalBlocks);
  v3 = avgSizeBytes(aT._totalAvgSizeBytes());
  v4 = avgLifetimeInstrs(aT._totalAvgLifetimeInstrs());
  v5 = perc(aT._totalAvgLifetimeInstrs(), gData.ei);
  fr("  Total:     ", aBolds.totalTitle);
  fr(v1, aBolds.totalBytes);
  fr(" in ");
  fr(v2, aBolds.totalBlocks);
  fr(", ", aBolds.totalAvgSizeBytes, false);
  fr(v3, aBolds.totalAvgSizeBytes);
  fr(", ", aBolds.totalAvgLifetimeInstrs, false);
  fr(`${v4} (${v5} of program duration)`, aBolds.totalAvgLifetimeInstrs);
  nl(aBolds.totalTitle);

  // "Max".
  if (aT !== gRoot && aT._kind === kLeaf) {
    assert(!kids, "leaf node has children");
    // These percentages are relative to the local totals, not the root
    // totals.
    v1 = bytes(aT._maxBytes);
    v2 = blocks(aT._maxBlocks);
    v3 = avgSizeBytes(aT._maxAvgSizeBytes());
    fr(`  Max:       ${v1} in ${v2}, ${v3}`);
    nl();
  }

  // "At t-gmax".
  v1 = bytesAndPerc(aT._atTGmaxBytes, gRoot._atTGmaxBytes);
  v2 = blocksAndPerc(aT._atTGmaxBlocks, gRoot._atTGmaxBlocks);
  v3 = avgSizeBytes(aT._atTGmaxAvgSizeBytes());
  fr("  At t-gmax: ", aBolds.atTGmaxTitle);
  fr(v1, aBolds.atTGmaxBytes);
  fr(` in ${v2}, ${v3}`);
  nl(aBolds.atTGmaxTitle);

  // "At t-end".
  v1 = bytesAndPerc(aT._atTEndBytes, gRoot._atTEndBytes);
  v2 = blocksAndPerc(aT._atTEndBlocks, gRoot._atTEndBlocks);
  v3 = avgSizeBytes(aT._atTEndAvgSizeBytes());
  fr("  At t-end:  ", aBolds.atTEndTitle);
  fr(v1, aBolds.atTEndBytes);
  fr(` in ${v2}, ${v3}`);
  nl(aBolds.atTEndTitle);

  // "Reads".
  v1 = bytesAndPercAndRate(aT._readsBytes, gRoot._readsBytes);
  v2 = perByte(aT._readsAvgPerByte());
  fr("  Reads:     ", aBolds.readsTitle);
  fr(v1, aBolds.readsBytes);
  fr(", ", aBolds.readsBytes && aBolds.readsAvgPerByte, false);
  fr(v2, aBolds.readsAvgPerByte);
  nl(aBolds.readsTitle);

  // "Writes".
  v1 = bytesAndPercAndRate(aT._writesBytes, gRoot._writesBytes);
  v2 = perByte(aT._writesAvgPerByte());
  fr("  Writes:    ", aBolds.writesTitle);
  fr(v1, aBolds.writesBytes);
  fr(", ", aBolds.writesBytes && aBolds.writesAvgPerByte, false);
  fr(v2, aBolds.writesAvgPerByte);
  nl(aBolds.writesTitle);

  // "Accesses". We show 32 per line (but not on aggregate nodes).
  if (aT._accesses && aT._accesses.length > 0) {
    let v = "  Accesses: {";
    let prevN;
    for (let [i, n] of aT._accesses.entries()) {
      if ((i % 32) === 0) {
        fr(v);
        nl();
        v1 = i.toString().padStart(3, ' ');
        v = `    [${v1}]  `;
        v += `${accesses(n)} `;
      } else {
        // Use a ditto mark for repeats.
        v += (n === prevN && n !== 0) ? "〃 " : `${accesses(n)} `;
      }
      prevN = n;
    }
    fr(v);
    nl();

    fr("  }");
    nl();
  }

  // "Allocated at".
  fr("  Allocated at {", true, false);
  nl(true);
  if (aT._kind === kAgg) {
    // Don't print ancestor frames; just print the "insignificant" frame.
    let isInsigFrame = (aFrm) => aFrm.indexOf(" insignificant]") >= 0;
    assert(aT._frames.length === 1 && isInsigFrame(aT._frames[0]),
           "bad aggregate node");
    fr(`    ${aT._frames[0]}`, true, false);
    nl(true);
  } else {
    // Start numbering frames from #1, unless it's the root node, in which case
    // we show "#0: [root]".
    let i = (aT === gRoot) ? 0 : 1;

    // Maybe show frames from ancestor nodes, excluding "[root]" (by starting
    // at j=1).
    for (let j = 1; j < aOldFrames.length; j++, i++) {
      fr(`    ^${i}: ${gData.ftbl[aOldFrames[j]]}`);
      nl(false);
    }
    // Show frames from this node.
    for (let j = 0; j < aT._frames.length; j++, i++) {
      fr(`    #${i}: ${gData.ftbl[aT._frames[j]]}`, true, false);
      nl(true);
    }
  }
  fr("  }", true, false);
  nl(true);

  // End of node.
  fr(`}`, true, false);
  nl(true, true);

  // Do the kids.
  if (kids) {
    assert(aT._kind !== kLeaf, "leaf node has children");

    p = appendElement(aP, "span", "kids");

    // tlFirstFor{Most,Last} are shorter than tlRestFor{Most,Last} to allow
    // space for the arrow.
    let tlFirstForMost;
    let tlRestForMost;
    if (kids.length > 1) {
      tlFirstForMost = aTlRest + "├─";
      tlRestForMost  = aTlRest + "│   ";
    }
    let tlFirstForLast = aTlRest + "└─";
    let tlRestForLast  = aTlRest + "    ";

    for (let [i, kid] of kids.entries()) {
      let n = aT._frames.length;
      aOldFrames.push(...aT._frames); // append aT._frames to aOldFrames
      aNodeIdNums.push(i + 1);
      let isLast = i === kids.length - 1;
      appendTreeInner(kid, p, aBolds, aCmp, aPc, aSig, aNodeIdNums,
                      kids.length - 1, aOldFrames,
                      !isLast ? tlFirstForMost : tlFirstForLast,
                      !isLast ? tlRestForMost : tlRestForLast);
      aNodeIdNums.pop(i);
      aOldFrames.splice(-n);         // remove aT._frames from aOldFrames
    }
  }
}

// Node significance.
// - kSigSelf: the node itself is significant. It will be shown in full.
// - kSigDesc: the node itself is insignificant, but it has one or more
//   significant descendants. (This is not possible for the straightforward
//   additive sort metrics like total-bytes, but it is possible for the
//   non-additive ones like "Total (bytes), short-lived", "Total (bytes),
//   low-access", etc.) It will be shown abbreviated.
// - kSigNone: the node itself is insignificant, and it has no significant
//   descendants. It will be aggregated.
const kSigSelf = 3;
const kSigDesc = 2;
const kSigNone = 1;

// Fill in the ._sig field of all tree nodes.
function sigTree(aT, aSig) {
  let sig = false;
  if (aT._kids) {
    for (let kid of aT._kids) {
      sig |= sigTree(kid, aSig);
    }
  }

  if (aSig(aT)) {
    aT._sig = kSigSelf;
    return true;
  }
  if (sig) {
    aT._sig = kSigDesc;
    return true;
  }
  aT._sig = kSigNone;
  return false;
}

function appendTree(aP, aBolds, aCmp, aPc, aSig) {
  sigTree(gRoot, aSig);

  appendTreeInner(gRoot, aP, aBolds, aCmp, aPc, aSig, [1], 0, [], "", "  ");
}

function appendSignificanceThreshold(aP, aSigLabel) {
  let v = `\nAP significance threshold: ${aSigLabel()}\n`;
  appendElementWithText(aP, "span", v, "threshold");
}

// Check that aElem's class list contains at least one name from aClassNames.
function classListContains(aElem, aClassNames) {
  for (let className of aClassNames) {
    if (aElem.classList.contains(className)) {
      return true;
    }
  }
  return false;
}

function assertClassListContains(aElem, aClassNames) {
  assert(aElem, "undefined elem");
  assert(classListContains(aElem, aClassNames),
         `none of ${JSON.stringify(aClassNames)} found in class list`);
}

// Called when a node with kids is clicked on.
function toggleClass(aEvent) {
  let clickedNode = aEvent.target;
  let hasKidsNode;
  if (classListContains(clickedNode, ["expanded", "collapsed"])) {
    // The click must have been on a text node, so clickedNode is the node
    // to toggle.
    hasKidsNode = clickedNode;
  } else {
    // The click must have been on a span element, so the parent node is
    // the node to toggle.
    hasKidsNode = clickedNode.parentNode;
    assertClassListContains(hasKidsNode, ["expanded", "collapsed"]);
  }
  hasKidsNode.classList.toggle("expanded");
  hasKidsNode.classList.toggle("collapsed");

  // Element order: 0: treeline span, 1: arrow span, ...
  let arrowSpan = hasKidsNode.childNodes[1];
  assertClassListContains(arrowSpan, ["arrow"]);
  if (arrowSpan.textContent === kHidingKidsArrow) {
    arrowSpan.textContent = kShowingKidsArrow;
  } else if (arrowSpan.textContent === kShowingKidsArrow) {
    arrowSpan.textContent = kHidingKidsArrow;
  } else {
    assert(false, `bad arrowSpan textContent`);
  }

  // Toggle visibility of the span containing this node's kids.
  let kidsSpan = hasKidsNode.nextSibling;
  assertClassListContains(kidsSpan, ["kids"]);
  kidsSpan.classList.toggle("hidden");
}

//------------------------------------------------------------//
//--- Top-level stuff                                      ---//
//------------------------------------------------------------//

// These arguments will be `undefined` when displayTree() is called without
// having read a file (e.g. when redisplaying with a different sort metric).
function displayTree(aTRead, aTParse, aTBuild) {
  let tRead = aTRead === undefined ? 0 : aTRead;
  let tParse = aTParse === undefined ? 0 : aTParse;
  let tBuild = aTBuild === undefined ? 0 : aTBuild;

  // Get details relating to the chosen sort metrics.
  let data = gSelectData[gSelect.selectedIndex];
  let bolds = data.bolds;
  let label = data.label;
  let cmpField = data.cmpField;
  let sig = data.sig;
  let sigLabel = data.sigLabel;
  let cmp = (aT1, aT2) => {
    // Try the specified sort metric. If that doesn't distinguish them, sort by
    // _totalBytes.
    let s1 = aT2[cmpField] - aT1[cmpField];
    return (s1 !== 0) ? s1 : aT2._totalBytes - aT1._totalBytes;
  };
  let pc = (aT) => div(aT[cmpField], gRoot[cmpField]) * 100;

  // Update the page title.
  document.title = `${kDocumentTitle} - ${gFilename} - ${label}`;

  // Build the main part of the page.
  let now = performance.now();
  clearMainDiv();
  let pre = appendElement(gMainDiv, "pre");
  appendInvocationAndTimes(pre);
  appendTree(pre, bolds, cmp, pc, sig);
  appendSignificanceThreshold(pre, sigLabel);
  let tDisplay = performance.now() - now;

  let tTotal = tRead + tParse + tBuild + tDisplay;
  clearTimingsDiv();
  let timings = `\
Processing time: \
read:${ms(aTRead)} + \
parse:${ms(aTParse)} + \
build:${ms(aTBuild)} + \
display:${ms(tDisplay)} = \
total:${ms(tTotal)}\
`;
  appendElementWithText(gTimingsDiv, "p", timings);
}

function loadFile() {
  clearMainDivWithText("Loading...");

  let now = performance.now();
  let file = gInput.files[0];
  gFilename = file.name;

  // Update the title. This will likely be overwritten very shortly, unless
  // there's a file loading problem, in which case it's nice to have the
  // correct filename in the title.
  document.title = `${kDocumentTitle} - ${gFilename}`;

  let reader = new FileReader();
  reader.onload = function(aEvent) {
    tryFunc(() => {
      let tRead = performance.now() - now;

      let data = aEvent.target.result;

      now = performance.now();
      gData = JSON.parse(data);
      let tParse = performance.now() - now;

      now = performance.now();
      buildTree();
      let tBuild = performance.now() - now;

      displayTree(tRead, tParse, tBuild);
    });
  };

  reader.onerror = function(aEvent) {
    clearMainDivWithText("Error loading file", "error");
  };

  reader.readAsText(file);
}

function changeSortMetric() {
  // If we have a tree, redisplay it for the new sort metric.
  if (gRoot) {
    tryFunc(() => {
      displayTree();
    });
  }
}

// Top-level setup when the page is first loaded.
function onLoad() {
  // Check if tests should be run.
  let params = new URLSearchParams(document.location.search.substring(1));
  let test = params.get("test");

  // The header div.
  gHeaderDiv = appendElement(document.body, "div", "section");

  // The (hidden) input element.
  let inputDiv = appendElement(gHeaderDiv, "div", "header");
  appendElementWithText(inputDiv, "div", "File");
  gInput = appendElement(inputDiv, "input", "hidden");
  gInput.type = "file";
  gInput.onchange = loadFile;

  // The button that triggers the hidden input element.
  let b = appendElementWithText(inputDiv, "button", "Load…");
  b.onclick = () => gInput.click();

  // The sort metric menu.
  let selectDiv = appendElement(gHeaderDiv, "div", "header");
  appendElementWithText(selectDiv, "div", "Sort metric");
  gSelect = appendElement(selectDiv, "select");
  gSelect.onchange = changeSortMetric;
  for (let [i, data] of gSelectData.entries()) {
    let option = appendElementWithText(gSelect, "option", data.label);
    option.value = i;
    if (data.isDefault) {
      option.selected = true;
    }
  }

  // The testing div, if necessary.
  if (test) {
    gTestingDiv = appendElement(document.body, "div", "testing");
  }

  // The main div.
  gMainDiv = appendElement(document.body, "div", "section");
  appendElementWithText(gMainDiv, "span", "Load a DHAT data file to begin");

  // The legend div. We show it even before loading a file so that new users
  // are immediately aware that it exists.
  gLegendDiv = appendElement(document.body, "div", "legend noselect");
  let p = appendElementWithText(gLegendDiv, "p", "Legend:");
  let ul = appendElement(p, "ul");
  appendElementWithText(ul, "li", "'t-gmax': time of global heap maximum " +
                                  "(as measured in bytes)");
  appendElementWithText(ul, "li", "'t-end': time of program end");
  appendElementWithText(ul, "li", "'instrs': instructions");
  appendElementWithText(ul, "li", "'Minstr': mega-instruction, i.e. one " +
                                  "million instructions");
  appendElementWithText(ul, "li", "'AP': allocation point");
  appendElementWithText(ul, "li", "'avg': average");
  appendElementWithText(ul, "li", "'-' (in accesses): zero");
  appendElementWithText(ul, "li", "'∞' (in accesses): leaf AP counts max out " +
                                  "at 65534; larger counts are treated as " +
                                  "infinity");
  appendElementWithText(ul, "li", "'〃' (in accesses): same as previous entry");

  // The timings div.
  gTimingsDiv = appendElement(document.body, "div", "timings noselect");

  if (test) {
    appendElementWithText(gHeaderDiv, "div", "TEST MODE", "header");
    var script = document.createElement("script");
    script.src = "dh_test.js";
    document.body.appendChild(script);
  }
}

