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