From 66c2cfaed11c70abc58feec9459a15d359859a8f Mon Sep 17 00:00:00 2001
From: Brady James Garvin <bgarvin@cse.unl.edu>
Date: Fri, 6 Dec 2019 09:10:25 -0600
Subject: [PATCH] Added an overlay that shows the transit systems' forecasted
 ridership based on the passengers' current plans.

---
 css/index.css                 |  10 ++
 js/leafy_tree.js              | 329 ++++++++++++++++++++++++++++++++++
 js/transit.js                 |  64 +++++++
 js/utility.js                 |   4 +
 js/visualization.js           |   6 +
 unit_tests/test_leafy_tree.js | 234 ++++++++++++++++++++++++
 unit_tests/unit_tests.html    |   1 +
 7 files changed, 648 insertions(+)
 create mode 100644 js/leafy_tree.js
 create mode 100644 unit_tests/test_leafy_tree.js

diff --git a/css/index.css b/css/index.css
index 6c8ca4a..dedd1db 100644
--- a/css/index.css
+++ b/css/index.css
@@ -27,6 +27,16 @@ body {
   text-align: center;
 }
 
+.visualization .overlay {
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 1;
+  padding: 0.5em;
+  font-size: 18pt;
+  background: rgba(0, 255, 255, 0.25);
+}
+
 .visualization svg {
   position: absolute;
   width: 100%;
diff --git a/js/leafy_tree.js b/js/leafy_tree.js
new file mode 100644
index 0000000..2d0840b
--- /dev/null
+++ b/js/leafy_tree.js
@@ -0,0 +1,329 @@
+import './utility.js';
+
+// Besides the usual identity (identitySummary) and binary operator (combineSummaries) that every monoid has, here we also include a function (summarizeValue)
+// to convert values in a leafy tree's leaves into summaries.  That way, leafy trees can have one type for their elements and a different type for their
+// summaries.  (And when different types are not required, summarizeValue can just be set to the identity function.)
+export class Monoid {
+  constructor(identitySummary, summarizeValue, combineSummaries) {
+    this.identitySummary = identitySummary;
+    this.summarizeValue = summarizeValue;
+    this.combineSummaries = combineSummaries;
+  }
+}
+
+// Likewise, we not only give an ordered monoid a way to compare its summaries (combineSummaries), we also include a function (summarizePosition) to convert
+// from a position type to the summary type.  That way, leafy trees can have one type for positions and a different type for summaries.  (Yet again, if
+// different types are not required, then summarizePosition can just be set to the identity function.)
+//
+// Note that, by convention, compareSummaries should be defined so that compareSummaries(x, y) is true iff x ≼ y.
+export class OrderedMonoid extends Monoid {
+  constructor(identitySummary, summarizeValue, combineSummaries, summarizePosition, compareSummaries) {
+    super(identitySummary, summarizeValue, combineSummaries);
+    this.summarizePosition = summarizePosition;
+    this.compareSummaries = compareSummaries;
+  }
+}
+
+// An AnnotatedMonoid is a product monoid that is ordered only by its left coordinate.  (So its positions are exactly the positions of the left monoid.)  An
+// AnnotatedMonoid is normally used to annotate the summaries in an ordered monoid with additional summary information from an unordered monoid on the right.
+export class AnnotatedMonoid extends OrderedMonoid {
+  constructor(orderedMonoid, unorderedMonoid) {
+    super(
+      [orderedMonoid.identitySummary, unorderedMonoid.identitySummary],
+      (value) => [orderedMonoid.summarizeValue(value), unorderedMonoid.summarizeValue(value)],
+      ([left, leftAnnotation], [right, rightAnnotation]) =>
+        [orderedMonoid.combineSummaries(left, right), unorderedMonoid.combineSummaries(leftAnnotation, rightAnnotation)],
+      (position) => [orderedMonoid.summarizePosition(position), undefined],
+      ([left, leftAnnotation], [right, rightAnnotation]) => orderedMonoid.compareSummaries(left, right) // eslint-disable-line no-unused-vars
+    );
+  }
+}
+
+// A KeyValueMonoid is a Monoid for leafy trees that store key/value pairs sorted by key.  (Hence, the keys must belong to an ordered monoid, and the values
+// usually belong to a different, not necessarily ordered monoid.)  When specifying positions in a leafy tree using a KeyValueMonoid, it is only necessary to
+// give a position for the key monoid.
+export class KeyValueMonoid extends AnnotatedMonoid {
+  constructor(keyMonoid, valueMonoid) {
+    super(
+      new OrderedMonoid(
+        keyMonoid.identitySummary,
+        ([key, value]) => keyMonoid.summarizeValue(key), // eslint-disable-line no-unused-vars
+        keyMonoid.combineSummaries,
+        keyMonoid.summarizePosition,
+        keyMonoid.compareSummaries
+      ),
+      new Monoid(
+        valueMonoid.identitySummary,
+        ([key, value]) => valueMonoid.summarizeValue(value), // eslint-disable-line no-unused-vars
+        valueMonoid.combineSummaries
+      )
+    );
+  }
+}
+
+// COUNT_MONOID is a monoid meant to be used as the ordered part of an annotated monoid for leafy trees that act like lists.  It's positions can be used to
+// index the tree's leaves, with indices counting from zero from left to right.
+export const COUNT_MONOID = new OrderedMonoid(
+  0,
+  (value) => 1, // eslint-disable-line no-unused-vars
+  (left, right) => left + right,
+  (index) => index + 1,
+  (left, right) => left <= right
+);
+
+// RANGE_MONOID is a monoid meant to be used as the ordered part of an annotated monoid for leafy trees that act like ordered sets or as the key part of a
+// key/value monoid for leafy trees that act like ordered dictionaries.  It's positions can be used to index the set or dictionary much like a dictionary key.
+export const RANGE_MONOID = new OrderedMonoid(
+  [Infinity, -Infinity],
+  (value) => [value, value], // eslint-disable-line no-unused-vars
+  ([leftMinimum, leftMaximum], [rightMinimum, rightMaximum]) => [Math.min(leftMinimum, rightMinimum), Math.max(leftMaximum, rightMaximum)],
+  (position) => [position, position],
+  ([leftMinimum, leftMaximum], [rightMinimum, rightMaximum]) => leftMaximum <= rightMaximum // eslint-disable-line no-unused-vars
+);
+
+class LeafyTreeNode {
+  constructor(tree, parent) {
+    this.tree = tree;
+    this.parent = parent;
+  }
+
+  summarize(summaryOfBeginPosition, summaryOfEndPosition, summaryOfLeftSide) {
+    const orderedMonoid = this.tree.orderedMonoid;
+    const minimum = orderedMonoid.combineSummaries(summaryOfLeftSide, this.summaryOfLeftmostLeaf);
+    const maximum = orderedMonoid.combineSummaries(summaryOfLeftSide, this.summary);
+    // containment case:
+    if (orderedMonoid.compareSummaries(summaryOfBeginPosition, minimum) &&
+        !orderedMonoid.compareSummaries(summaryOfEndPosition, maximum)) {
+      return this.summary;
+    }
+    // disjointness case:
+    if (!orderedMonoid.compareSummaries(summaryOfBeginPosition, maximum) ||
+        orderedMonoid.compareSummaries(summaryOfEndPosition, minimum)) {
+      return orderedMonoid.identitySummary;
+    }
+    // partial overlap case:
+    console.assert(this.children !== undefined, 'Tried to summarize part of a leaf in a leafy tree.');
+    let result = orderedMonoid.identitySummary;
+    let accumulator = summaryOfLeftSide;
+    for (const child of this.children) {
+      result = orderedMonoid.combineSummaries(result, child.summarize(summaryOfBeginPosition, summaryOfEndPosition, accumulator));
+      accumulator = orderedMonoid.combineSummaries(accumulator, child.summary);
+    }
+    return result;
+  }
+}
+
+class Branch extends LeafyTreeNode {
+  constructor(tree, parent, children) {
+    super(tree, parent);
+    console.assert(children.every((child) => child.height === 0), 'Tried to create a new branch above height one in a leafy tree.');
+    if (parent !== undefined) {
+      for (let index = parent.children.length; index--;) {
+        if (children.includes(parent.children[index])) {
+          parent.children[index] = this;
+        }
+      }
+      console.assert(
+        parent.children.filter((child) => child === this).length === 1,
+        'Tried to create a leafy tree branch that does not replace exactly one node.'
+      );
+    } else {
+      console.assert(
+        children.includes(this.tree._root), // eslint-disable-line no-underscore-dangle, (read by friend)
+        'Tried to add a second root to a leafy tree.'
+      );
+      this.tree._root = this; // eslint-disable-line no-underscore-dangle, (write by friend)
+    }
+    this.children = children;
+    for (const child of children) {
+      child.parent = this;
+    }
+    this._updateSummaries();
+  }
+
+  _updateSummaries() {
+    console.assert(this.children.length > 0, 'Found a branch-type vertex in a leafy tree with no children.');
+    if (this.children.length === 1) {
+      if (this.parent !== undefined) {
+        const ownIndex = this.parent.children.indexOf(this);
+        console.assert(ownIndex >= 0, 'Found a branch in a leafy tree that is not a child of its parent.');
+        this.parent.children[ownIndex] = this.children[0];
+        this.children[0].parent = this.parent;
+      } else {
+        this.tree._root = this.children[0]; // eslint-disable-line no-underscore-dangle, (write by friend)
+        this.children[0].parent = undefined;
+      }
+    } else {
+      this.height = 1 + Math.max(...this.children.map((child) => child.height));
+      this.summaryOfLeftmostLeaf = this.children[0].summaryOfLeftmostLeaf;
+      this.summary = this.children.reduce((left, right) => this.tree.orderedMonoid.combineSummaries(left.summary, right.summary));
+    }
+    if (this.parent !== undefined) {
+      this.parent._updateSummaries(); // eslint-disable-line no-underscore-dangle, (call from friend)
+    }
+  }
+
+  findLeafAndSummaryOfLeftSide(positionSummary, summaryOfLeftSide) {
+    const orderedMonoid = this.tree.orderedMonoid;
+    let accumulator = summaryOfLeftSide;
+    for (const child of this.children.slice(0, -1)) {
+      const candidate = orderedMonoid.combineSummaries(accumulator, child.summary);
+      if (orderedMonoid.compareSummaries(positionSummary, candidate)) {
+        return child.findLeafAndSummaryOfLeftSide(positionSummary, accumulator);
+      }
+      accumulator = candidate;
+    }
+    return this.children.top().findLeafAndSummaryOfLeftSide(positionSummary, accumulator);
+  }
+
+  dump(indentation) {
+    const newIndentation = indentation + 2;
+    const children = this.children.map((child) => `${child.dump(newIndentation)}\n`).join('');
+    return `${' '.repeat(indentation)}Branch (height=${this.height}) ` +
+      `{\n${' '.repeat(newIndentation)}summary: ${this.summary}\n${children}${' '.repeat(indentation)}}`;
+  }
+}
+
+class Leaf extends LeafyTreeNode {
+  constructor(tree, value) {
+    super(tree, undefined);
+    this.value = value;
+    this.summary = tree.orderedMonoid.summarizeValue(value);
+  }
+
+  get _successor() {
+    let [child, current] = [this, this.parent];
+    while (current !== undefined) {
+      const childIndex = current.children.indexOf(child);
+      if (childIndex + 1 < current.children.length) {
+        for (current = current.children[childIndex + 1]; // eslint-disable-line curly
+          current.children !== undefined;
+          current = current.children[0]);
+        return current;
+      }
+      [child, current] = [current, current.parent];
+    }
+    return undefined;
+  }
+
+  get height() { // eslint-disable-line class-methods-use-this
+    return 0;
+  }
+
+  get summaryOfLeftmostLeaf() {
+    return this.summary;
+  }
+
+  findLeafAndSummaryOfLeftSide(positionSummary, summaryOfLeftSide) {
+    return [this, summaryOfLeftSide];
+  }
+
+  add(positionSummary, value, summaryOfLeftSide) {
+    const orderedMonoid = this.tree.orderedMonoid;
+    const sibling = new Leaf(this.tree, value);
+    // For now, only use binary branching:
+    if (orderedMonoid.compareSummaries(positionSummary, orderedMonoid.combineSummaries(summaryOfLeftSide, this.summary))) {
+      new Branch(this.tree, this.parent, [sibling, this]); // eslint-disable-line no-new
+    } else {
+      new Branch(this.tree, this.parent, [this, sibling]); // eslint-disable-line no-new
+    }
+    return sibling;
+  }
+
+  _matchesPosition(positionSummary, summaryOfLeftSide) {
+    const orderedMonoid = this.tree.orderedMonoid;
+    const summaryOfLeftSideAndSelf = orderedMonoid.combineSummaries(summaryOfLeftSide, this.summary);
+    return orderedMonoid.compareSummaries(positionSummary, summaryOfLeftSideAndSelf) &&
+      orderedMonoid.compareSummaries(summaryOfLeftSideAndSelf, positionSummary);
+  }
+
+  _delete() {
+    if (this.parent !== undefined) {
+      this.parent.children.delete(this);
+      this.parent._updateSummaries(); // eslint-disable-line no-underscore-dangle, (call from friend)
+    } else {
+      this.tree._root = undefined; // eslint-disable-line no-underscore-dangle, (write by friend)
+    }
+  }
+
+  delete(positionSummary, summaryOfLeftSide) {
+    if (this._matchesPosition(positionSummary, summaryOfLeftSide)) {
+      this._delete();
+      return true;
+    }
+    return false;
+  }
+
+  deleteMatch(positionSummary, criterion, summaryOfLeftSide) {
+    if (this._matchesPosition(positionSummary, summaryOfLeftSide)) {
+      const orderedMonoid = this.tree.orderedMonoid;
+      if (criterion(this.value)) {
+        this._delete();
+        return true;
+      }
+      const successor = this._successor;
+      if (successor !== undefined) {
+        return successor.deleteMatch(positionSummary, criterion, orderedMonoid.combineSummaries(summaryOfLeftSide, this.summary));
+      }
+    }
+    return false;
+  }
+
+  dump(indentation) {
+    return `${' '.repeat(indentation)}Leaf { value: ${this.value}, summary: ${this.summary} }`;
+  }
+}
+
+export class LeafyTree {
+  constructor(orderedMonoid) {
+    this.orderedMonoid = orderedMonoid;
+    this._root = undefined;
+  }
+
+  summarize(begin, end) {
+    if (this._root !== undefined) {
+      return this._root.summarize(this.orderedMonoid.summarizePosition(begin), this.orderedMonoid.summarizePosition(end), this.orderedMonoid.identitySummary);
+    }
+    return this.orderedMonoid.identitySummary;
+  }
+
+  add(position, value) {
+    if (this._root !== undefined) {
+      const positionSummary = this.orderedMonoid.summarizePosition(position);
+      const [leaf, accumulation] = this._root.findLeafAndSummaryOfLeftSide(positionSummary, this.orderedMonoid.identitySummary);
+      return leaf.add(positionSummary, value, accumulation);
+    }
+    this._root = new Leaf(this, value);
+    return this._root;
+  }
+
+  // syntactic sugar for dictionary-like leafy trees where we want to store key/value pairs, not just values
+  set(position, value) {
+    this.add(position, [position, value]);
+  }
+
+  delete(position) {
+    if (this._root !== undefined) {
+      const positionSummary = this.orderedMonoid.summarizePosition(position);
+      const [leaf, accumulation] = this._root.findLeafAndSummaryOfLeftSide(positionSummary, this.orderedMonoid.identitySummary);
+      return leaf.delete(positionSummary, accumulation);
+    }
+    return false;
+  }
+
+  deleteMatch(position, criterion) {
+    if (this._root !== undefined) {
+      const positionSummary = this.orderedMonoid.summarizePosition(position);
+      const [leaf, accumulation] = this._root.findLeafAndSummaryOfLeftSide(positionSummary, this.orderedMonoid.identitySummary);
+      return leaf.deleteMatch(positionSummary, criterion, accumulation);
+    }
+    return false;
+  }
+
+  toString() {
+    if (this._root !== undefined) {
+      return `LeafyTree {\n${this._root.dump(2)}\n}`;
+    }
+    return 'LeafyTree {}';
+  }
+}
diff --git a/js/transit.js b/js/transit.js
index 0d90d55..3f3a95e 100644
--- a/js/transit.js
+++ b/js/transit.js
@@ -1,6 +1,17 @@
 import './utility.js';
 import {Simulation, Decision, Agent} from './simulation.js';
 import {shortestUndirectedPath} from './undirected_graph.js';
+import {Monoid, KeyValueMonoid, RANGE_MONOID, LeafyTree} from './leafy_tree.js';
+
+const SERIES_MAX_MONOID = new Monoid(
+  [-Infinity, 0],
+  (value) => [value, value],
+  ([leftMaximum, leftSum], [rightMaximum, rightSum]) => [
+    Math.max(leftMaximum, leftSum + rightMaximum),
+    leftSum + rightSum,
+  ]
+);
+const KEYED_SERIES_MAX_MONOID = new KeyValueMonoid(RANGE_MONOID, SERIES_MAX_MONOID);
 
 export class Vertex {
   constructor(name) {
@@ -38,6 +49,25 @@ export class City extends Simulation {
     this._passengers = [];
     this._inBulkEdit = false;
     this._needsRestartAfterBulkEdit = false;
+    this._forecast = new LeafyTree(KEYED_SERIES_MAX_MONOID);
+  }
+
+  get passengerCount() {
+    return this._passengers.length;
+  }
+
+  get forecastedRidership() {
+    const [[_, __], forecast] = this._forecast.summarize(-Infinity, Infinity);
+    const [maximum, ___] = SERIES_MAX_MONOID.combineSummaries(SERIES_MAX_MONOID.summarizeValue(0), forecast);
+    return maximum;
+  }
+
+  addForecastedRidershipChange(time, change) {
+    this._forecast.set(time, change);
+  }
+
+  deleteForecastedRidershipChange(time, change) {
+    this._forecast.deleteMatch(time, ([key, value]) => value === change); // eslint-disable-line no-unused-vars
   }
 
   chooseRandomWalkVertex() {
@@ -604,6 +634,7 @@ export class Passenger extends Agent {
     this._arrivalTime = undefined;
     this.inactiveTime = inactiveTime;
     this.plan = [new PlanningVertex(undefined, this.vertex, this.inactiveTime)];
+    this.ridershipForecast = [];
     this.vertex = vertex;
   }
 
@@ -746,9 +777,38 @@ export class Passenger extends Agent {
     this.vertex = this.immediateDestination;
   }
 
+  _deleteOldPlanFromForecast() {
+    for (const [time, change] of this.ridershipForecast) {
+      this.simulation.deleteForecastedRidershipChange(time, change);
+    }
+    this.ridershipForecast = [];
+  }
+
+  _addNewPlanToForecast() {
+    this.ridershipForecast = [];
+    let eta = 0;
+    let previousTime = -Infinity;
+    for (const vertex of this.plan) {
+      if (vertex.route !== undefined) {
+        if (previousTime === this.simulation.currentTime + eta) {
+          this.ridershipForecast.pop();
+        } else {
+          this.ridershipForecast.push([this.simulation.currentTime + eta, 1]);
+        }
+        previousTime = this.simulation.currentTime + vertex.eta;
+        this.ridershipForecast.push([previousTime, -1]);
+      }
+      eta = vertex.eta;
+    }
+    for (const [time, change] of this.ridershipForecast) {
+      this.simulation.addForecastedRidershipChange(time, change);
+    }
+  }
+
   _plan() {
     this._departureTime = undefined;
     this._arrivalTime = undefined;
+    this._deleteOldPlanFromForecast();
     console.assert(this.vertex !== undefined,
       `Attempted to plan a route for the passenger ${this} using _plan while they are still in transit (use _planFromBus instead).`);
     if (this.destination === undefined) {
@@ -764,6 +824,7 @@ export class Passenger extends Agent {
         console.assert(discarded === starter, `The computed plan ${discarded}, ${this.plan} does not begin at the vertex ${this.vertex}.`);
         console.assert(this.plan.top().destination === this.destination,
           `The computed plan ${discarded}, ${this.plan} does not end at the destination ${this.destination}.`);
+        this._addNewPlanToForecast();
         return;
       }
     }
@@ -773,6 +834,7 @@ export class Passenger extends Agent {
   _planFromBus() {
     console.assert(this.vertex === undefined,
       `Attempted to plan a route for the passenger ${this} using _planFromBus while they are not in transit (use _plan instead).`);
+    this._deleteOldPlanFromForecast();
     const nextStop = this.bus.vertex || this.bus.arc.next.originalSource.vertex;
     const starter = new PlanningVertex(this.bus.arc.route, nextStop, this.bus.getETA(nextStop), true);
     this.plan = shortestUndirectedPath(new PlanningGraph(this.simulation), starter, (vertex) => vertex.destination === this.destination,
@@ -783,9 +845,11 @@ export class Passenger extends Agent {
         console.assert(discarded === starter, `The computed plan ${discarded}, ${this.plan} does not begin at the source ${nextStop}.`);
       }
       console.assert(this.plan.top().destination === this.destination, `The computed plan ${this.plan} does not end at the destination ${this.destination}.`);
+      this._addNewPlanToForecast();
       return;
     }
     this.plan = [starter];
+    this._addNewPlanToForecast();
   }
 
   _decide() {
diff --git a/js/utility.js b/js/utility.js
index efcfaf9..6948f5d 100644
--- a/js/utility.js
+++ b/js/utility.js
@@ -32,3 +32,7 @@ Array.prototype.delete = function(element) { // eslint-disable-line func-names,
   this.remove(this.indexOf(element));
   return this;
 };
+
+Array.prototype.sum = function sum() {
+  return this.reduce((left, right) => left + right, 0);
+};
diff --git a/js/visualization.js b/js/visualization.js
index e0f158a..f57e45e 100644
--- a/js/visualization.js
+++ b/js/visualization.js
@@ -309,6 +309,8 @@ $.widget('transit.visualization', {
         this._trigger('ready');
       },
     });
+    this.overlay = $('<div class="overlay"></div>');
+    this.element.append(this.overlay);
   },
 
   _populate() {
@@ -397,6 +399,10 @@ $.widget('transit.visualization', {
     for (const dot of this._passengerDots.values()) {
       dot.refresh();
     }
+    const ridership = this._city.forecastedRidership;
+    const passengerCount = this._city.passengerCount;
+    const ridershipPercent = 100 * ridership / passengerCount;
+    this.overlay.text(`Ridership Forecast: ${ridership}/${passengerCount} passenger(s) (${ridershipPercent.toFixed(0)}%)`);
   },
 
   getSlowness() {
diff --git a/unit_tests/test_leafy_tree.js b/unit_tests/test_leafy_tree.js
new file mode 100644
index 0000000..ce1c350
--- /dev/null
+++ b/unit_tests/test_leafy_tree.js
@@ -0,0 +1,234 @@
+import {identity} from '../js/utility.js';
+import {Monoid, AnnotatedMonoid, KeyValueMonoid, COUNT_MONOID, RANGE_MONOID, LeafyTree} from '../js/leafy_tree.js';
+
+/* globals QUnit */
+QUnit.module('test_leafy_tree.js');
+/* eslint-disable no-magic-numbers, no-underscore-dangle */
+
+// Purposely use a noncommutative monoid to better catch ordering bugs.
+const HOLD = 'HOLD'; // do nothing to the lightswitch
+const OFF = 'OFF'; // turn the lightswitch off
+const ON = 'ON'; // turn the lightswitch on
+const FLIP = 'FLIP'; // toggle the lightswitch's state
+const OPPOSITES = new Map([[HOLD, FLIP], [OFF, ON], [ON, OFF], [FLIP, HOLD]]);
+const LIGHTSWITCH_MONOID = new Monoid(
+  HOLD,
+  identity,
+  (left, right) => {
+    switch (right) {
+    case HOLD:
+      return left;
+    case OFF:
+    case ON:
+      return right;
+    case FLIP:
+      return OPPOSITES.get(left);
+    default:
+      console.assert(false, 'Tried to apply the lightswitch monoid to an invalid lightswitch action (check use of `add` vs. `set`).');
+      return undefined;
+    }
+  }
+);
+const INDEXED_LIGHTSWITCH_MONOID = new AnnotatedMonoid(COUNT_MONOID, LIGHTSWITCH_MONOID);
+const KEYED_LIGHTSWITCH_MONOID = new KeyValueMonoid(RANGE_MONOID, LIGHTSWITCH_MONOID);
+
+QUnit.test('summarize an empty tree as the identity', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [0, HOLD]);
+});
+
+QUnit.test('summarize a one-element tree as that element', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, OFF);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [1, OFF]);
+});
+
+QUnit.test('summarize subranges of a one-element tree', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, OFF);
+  assert.deepEqual(tree.summarize(-Infinity, 0), [0, HOLD]);
+  assert.deepEqual(tree.summarize(0, 0), [0, HOLD]);
+  assert.deepEqual(tree.summarize(0, 1), [1, OFF]);
+  assert.deepEqual(tree.summarize(1, Infinity), [0, HOLD]);
+});
+
+QUnit.test('summarize subranges of a two-element tree', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, OFF);
+  tree.add(1, FLIP);
+  assert.deepEqual(tree.summarize(-Infinity, 0), [0, HOLD]);
+  assert.deepEqual(tree.summarize(0, 1), [1, OFF]);
+  assert.deepEqual(tree.summarize(1, 2), [1, FLIP]);
+  assert.deepEqual(tree.summarize(0, 2), [2, ON]);
+  assert.deepEqual(tree.summarize(2, Infinity), [0, HOLD]);
+});
+
+QUnit.test('break position-ordering ties by placing new elements to the left', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, FLIP);
+  tree.add(0, OFF);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [2, ON]);
+});
+
+QUnit.test('summarize subranges of a two-element tree where the elements are tied in the order', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(0, OFF);
+  assert.deepEqual(tree.summarize(-Infinity, 0), [[Infinity, -Infinity], HOLD]);
+  assert.deepEqual(tree.summarize(0, 0), [[Infinity, -Infinity], HOLD]);
+  assert.deepEqual(tree.summarize(0, 1), [[0, 0], ON]);
+  assert.deepEqual(tree.summarize(1, Infinity), [[Infinity, -Infinity], HOLD]);
+});
+
+QUnit.test('summarize subranges of a many-element tree', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, FLIP);
+  tree.add(1, OFF);
+  tree.add(2, FLIP);
+  tree.add(3, FLIP);
+  tree.add(4, OFF);
+  tree.add(5, ON);
+  assert.deepEqual(tree.summarize(-1, 0), [0, HOLD]);
+  assert.deepEqual(tree.summarize(0, 1), [1, FLIP]);
+  assert.deepEqual(tree.summarize(0, 2), [2, OFF]);
+  assert.deepEqual(tree.summarize(2, 4), [2, HOLD]);
+  assert.deepEqual(tree.summarize(1, 4), [3, OFF]);
+  assert.deepEqual(tree.summarize(3, 6), [3, ON]);
+  assert.deepEqual(tree.summarize(0, 6), [6, ON]);
+});
+
+QUnit.test('summarize subranges of a many-element tree built out of order', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, FLIP); // [FLIP]
+  tree.add(1, OFF); // [FLIP, OFF]
+  tree.add(0, OFF); // [OFF, FLIP, OFF]
+  tree.add(3, ON); // [OFF, FLIP, OFF, ON]
+  tree.add(0, FLIP); // [FLIP, OFF, FLIP, OFF, ON]
+  tree.add(2, FLIP); // [FLIP, OFF, FLIP, FLIP, OFF, ON]
+  assert.deepEqual(tree.summarize(-1, 0), [0, HOLD]);
+  assert.deepEqual(tree.summarize(0, 1), [1, FLIP]);
+  assert.deepEqual(tree.summarize(0, 2), [2, OFF]);
+  assert.deepEqual(tree.summarize(2, 4), [2, HOLD]);
+  assert.deepEqual(tree.summarize(1, 4), [3, OFF]);
+  assert.deepEqual(tree.summarize(3, 6), [3, ON]);
+  assert.deepEqual(tree.summarize(0, 6), [6, ON]);
+});
+
+QUnit.test('summarize subranges of a many-element tree built out of order using nonconsecutive keys', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(12, FLIP); // [12: FLIP]
+  tree.set(18, OFF); // [12: FLIP, 18: OFF]
+  tree.set(2, OFF); // [2: OFF, 12: FLIP, 18: OFF]
+  tree.set(31, ON); // [2: OFF, 12: FLIP, 18: OFF, 31: ON]
+  tree.set(-1, FLIP); // [-1: FLIP, 2: OFF, 12: FLIP, 18: OFF, 31: ON]
+  tree.set(9, FLIP); // [-1: FLIP, 2: OFF, 9: FLIP, 12: FLIP, 18: OFF, 31: ON]
+  assert.deepEqual(tree.summarize(-10, -1), [[Infinity, -Infinity], HOLD]);
+  assert.deepEqual(tree.summarize(-1, 1), [[-1, -1], FLIP]);
+  assert.deepEqual(tree.summarize(-4, 3), [[-1, 2], OFF]);
+  assert.deepEqual(tree.summarize(8, 18), [[9, 12], HOLD]);
+  assert.deepEqual(tree.summarize(1, 14), [[2, 12], OFF]);
+  assert.deepEqual(tree.summarize(11, 32), [[12, 31], ON]);
+  assert.deepEqual(tree.summarize(-64, 64), [[-1, 31], ON]);
+});
+
+QUnit.test('summarize a tree as elements are removed', (assert) => {
+  const tree = new LeafyTree(INDEXED_LIGHTSWITCH_MONOID);
+  tree.add(0, FLIP);
+  tree.add(1, OFF);
+  tree.add(2, ON);
+  tree.add(3, FLIP);
+  tree.add(4, OFF);
+  tree.add(5, FLIP);
+  let result = tree.delete(4);
+  assert.deepEqual(result, true);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [5, ON]);
+  result = tree.delete(2);
+  assert.deepEqual(result, true);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [4, OFF]);
+  result = tree.delete(1);
+  assert.deepEqual(result, true);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [3, FLIP]);
+});
+
+QUnit.test('break position-ordering ties by deleting elements from the left', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(0, OFF);
+  tree.delete(0);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [[0, 0], FLIP]);
+});
+
+QUnit.test('break a position-ordering tie during a deletion by matching an element', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(0, OFF);
+  tree.deleteMatch(0, ([key, value]) => value === FLIP); // eslint-disable-line no-unused-vars
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [[0, 0], OFF]);
+});
+
+QUnit.test('break multiple position-ordering ties during a deletion by matching an element', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(0, OFF);
+  tree.set(0, HOLD);
+  tree.deleteMatch(0, ([key, value]) => value === FLIP); // eslint-disable-line no-unused-vars
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [[0, 0], OFF]);
+});
+
+QUnit.test('empty a tree by deleting its elements', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(0, OFF);
+  tree.delete(0);
+  tree.delete(0);
+  assert.deepEqual(tree.summarize(-Infinity, Infinity), [[Infinity, -Infinity], HOLD]);
+});
+
+QUnit.test('assign heights to vertices in a one-element tree', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  assert.deepEqual(tree._root.height, 0);
+});
+
+QUnit.test('assign heights to vertices in a two-element tree', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(1, OFF);
+  assert.deepEqual(tree._root.height, 1);
+  assert.deepEqual(tree._root.children[0].height, 0);
+  assert.deepEqual(tree._root.children[1].height, 0);
+});
+
+QUnit.test('assign heights to vertices in a two-element tree', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  tree.set(0, FLIP);
+  tree.set(1, OFF);
+  tree.set(2, HOLD);
+  assert.deepEqual(tree._root.height, 2);
+  assert.deepEqual(tree._root.children[0].height, 0);
+  assert.deepEqual(tree._root.children[1].height, 1);
+  assert.deepEqual(tree._root.children[1].children[0].height, 0);
+  assert.deepEqual(tree._root.children[1].children[1].height, 0);
+});
+
+QUnit.test('assign heights to vertices in a many-element tree', (assert) => {
+  const tree = new LeafyTree(KEYED_LIGHTSWITCH_MONOID);
+  // intentionally build the tree balanced, so that we're not also testing rotation code:
+  tree.set(2, FLIP);
+  tree.set(3, FLIP);
+  tree.set(1, OFF);
+  tree.set(4, OFF);
+  tree.set(0, FLIP);
+  tree.set(5, ON);
+  assert.deepEqual(tree._root.height, 3);
+  assert.deepEqual(tree._root.children[0].height, 2);
+  assert.deepEqual(tree._root.children[0].children[0].height, 1);
+  assert.deepEqual(tree._root.children[0].children[0].children[0].height, 0);
+  assert.deepEqual(tree._root.children[0].children[0].children[1].height, 0);
+  assert.deepEqual(tree._root.children[0].children[1].height, 0);
+  assert.deepEqual(tree._root.children[1].height, 2);
+  assert.deepEqual(tree._root.children[1].children[0].height, 0);
+  assert.deepEqual(tree._root.children[1].children[1].height, 1);
+  assert.deepEqual(tree._root.children[1].children[1].children[0].height, 0);
+  assert.deepEqual(tree._root.children[1].children[1].children[1].height, 0);
+});
diff --git a/unit_tests/unit_tests.html b/unit_tests/unit_tests.html
index 4869bb5..88a6be1 100644
--- a/unit_tests/unit_tests.html
+++ b/unit_tests/unit_tests.html
@@ -27,6 +27,7 @@
   <script type="module" src="test_priority_queue.js"></script>
   <script type="module" src="test_simulation.js"></script>
   <script type="module" src="test_undirected_graph.js"></script>
+  <script type="module" src="test_leafy_tree.js"></script>
   <script type="module" src="test_transit.js"></script>
   <script type="module" src="test_patching.js"></script>
   <script type="module" src="test_heat_map.js"></script>
-- 
GitLab