Mystery Master

View a Logic Puzzle in JavaScript

Michael Benson


Table of Contents

Detective

Introduction

This article explains how to view a logic grid puzzle in the JavaScript programming language. If you have not already done so, please read my article "Model a Logic Puzzle in JavaScript". This article will focus on the logic puzzle "Five Houses". Please look at this logic puzzle before continuing. You should have a basic knowledge of JavaScript, know some of the newer constructs, and understand how classes work in JavaScript.

User Interface

One of the main concerns of any application is to have a responsive User Interface (UI). If the UI is unresponsive, the user will think the application has stopped working. A C# application can get around this problem by using the async/await keywords. Until these keywords are common in JavaScript, I use the following two technologies: (1) the setTimeout keyword, and (2) Web Workers. The setTimeout keyword is discussed towards the end of this article. Web Workers will be discussed in a future article. The component that manages the UI is called the Viewer.

Viewer

The Viewer component is the UI manager for Mystery Master. Because this is is a web application, it must manage HTML, CSS, and Javascript. Some of this is done using PHP. For instance, I use PHP to display common headers and footers for most of my pages. But the two most important visual components for solving a logic puzzle are the dashboard (aka Board), and the tabbed-interface (aka Tabby). These two components are displayed on the logic puzzle page after the text.

Note: Given everything I've said so far, I should probably take a look at React.

The Viewer Class

/* global Q, nextPuzzle, Helper, Board, Setup, Tabby */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Viewer class.
 * @description The Viewer class manages the User Interface (UI).
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-07-01
 * @returns {Viewer} Viewer.
 */
function Viewer() {
	this.toString = function () { return "Viewer"; };
	this.asString = function () { return this.toString(); };

	/** Puzzle object. */
	let puzzle = null;
	
	/** Finder status. */
	let okFinder = false;
	
	/** Lawyer status. */
	let okLawyer = false;

	/**
	 * Updates UI when a puzzle is loaded/unloaded. Called by head.php.
	 * @param {Puzzle} apuzzle Puzzle object.
	 */
	this.setPuzzle = function (apuzzle) {
		puzzle = apuzzle;
		if (board !== null) board.setPuzzle(puzzle, okFinder);
		if (tabby !== null) tabby.setPuzzle(puzzle);
		this.resetWork();

		worker.postMessage({"key": "loadPuzzle", "name": puzzle.myName});
	};

	/** Updates UI when work is reset. Called by setPuzzle, doWork, board.btnQuit_OnClick. */
	this.resetWork = function() {
		if (puzzle !== null) puzzle.resetWork();

		// Reset the tasks.
		workFlag = false;
		numTasks = maxTasks = 0;
		tasks = [];
		
		pauseFlag = false;
		time1 = null;

		if (board !== null) board.reset();
		if (tabby !== null) tabby.reset();

		worker.postMessage({"key": "resetWork"});
	};

	/** Updates UI when work is started. Called when the user clicks the Solve button. */
	this.doWork = function () {
		this.resetWork();
		time1 = new Date();
		let msg = Helper.getStartedMsg(time1);
		if (board !== null) board.updateMessage(msg);
		print(msg);
		worker.postMessage({"key": "doWork"});
	};
	
	// ---------------------------------------------------------------
	// Tasks. Each message received from WW is stored in a queue.

	/** Queue of tasks. */
	let tasks = [];

	/** Number of tasks performed. */
	let numTasks = 0;

	/** Number of tasks in the queue. */
	let maxTasks = 0;
	
	/** Work flag. */
	let workFlag = false;
	
	/** Pause flag. */
	let pauseFlag = false;
	
	/** Start time. */
	let time1 = null;

	/**
	 * Performs each task. Calls itself via setTimeout until all tasks are done.
	 * The worker.onmessage event needs to call this when there are more tasks to do.
	 * The cases in the switch statement are sorted by data.key.
	 */
	function doTasks() {
		//if (numTasks > 0) print("viewer.doTasks numTasks=" + numTasks + " maxTasks=" + maxTasks + " numMarks=" + puzzle.numMarks + " last task data=" + Q + tasks[numTasks-1] + Q + " data.key=" + Q + tasks[numTasks-1].key + Q);
		
		// Return if the pause flag is true, or there are no more tasks to perform.
		workFlag = false;
		if (pauseFlag) return;
		if (numTasks === maxTasks) return;
		
		workFlag = true;
		let mark, rule, msg;
		let time2;

		let data = tasks[numTasks++];
		switch (data.key) {
			case "addMarkByObjLit":
				puzzle.addMarkByObjLit(data.obj);
				break;
			case "addPlaceholder":
				mark = puzzle.marks[data.mark - 1];
				rule = puzzle.rules[data.rule - 1];
				let noun = puzzle.getNoun(data.nounType, data.noun);
				//print("viewer.doTasks addPlaceholder mark=" + mark.num + " rule=" + rule.num + " noun=" + noun + " value=" + data.value);
				mark.addPlaceholder(rule, noun, data.value);
				break;
			case "quitWork":
				time2 = new Date();
				msg = Helper.getStoppedMsg(time1, time2);
				sayMessage(msg);
				if (board !== null) board.quitWork();
				break;
			case "sayContradiction":
				sayMessage(data.msg);
				pauseFlag = pauseFlag || okPauseViolation;
				break;
			case "sayError":
				sayError(data.msg);
				break;
			case "sayLawViolation":
				mark = puzzle.marks[data.mark - 1];
				sayMessage(data.msg);
				pauseFlag = pauseFlag || okPauseViolation;
				break;
			case "sayLevel":
				sayMessage("Level " + data.num);
				pauseFlag = pauseFlag || okPauseLevel;
				break;
			case "sayMessage":
				sayMessage(data.msg);
				pauseFlag = pauseFlag || okPauseAll;
				break;
			case "sayRuleViolation":
				mark = puzzle.marks[data.mark - 1];
				rule = puzzle.rules[data.rule - 1];
				updateOnRule(rule);
				sayMessage(data.msg);
				pauseFlag = pauseFlag || okPauseViolation;
				break;
			case "saySolution":
				time2 = new Date();
				let numSolutions = data.num;
				msg = Helper.getSolutionMsg(time1, time2, numSolutions);
				addMessage(msg);
				if (tabby !== null) tabby.updateOnSolution();
				if (nextPuzzle !== null) document.location = nextPuzzle;
				pauseFlag = pauseFlag || okPauseSolution;
				break;
			case "saySolverState":
				okFinder = data.okFinder;
				okLawyer = data.okLawyer;
				print(data.key + " okFinder=" + okFinder + " okLawyer=" + okLawyer);
				break;
			case "undoAssumption":
				puzzle.undoAssumption(removeMark);
				pauseFlag = pauseFlag || okPauseAll;
				break;
			case "undoUserMark":
				puzzle.undoUserMark(removeMark);
				break;
		}

		// Exit if the pause flag is true. Call resumeTasks when the Resume button is pressed.
		pauseFlag = pauseFlag || okPauseAll;
		if (pauseFlag) {
			if (board !== null) {
				board.pauseWork();
				return;
			}
		}

		setTimeout(function () { doTasks(); }, ms);
	}
	
	/** Resumes the doTasks method. Called by the btnWork_OnClick method. */
	this.resumeTasks = function () {
		pauseFlag = false;
		setTimeout(function () { doTasks(); }, ms);
	};
	
	/**
	 * Displays the message in the board. Called by doTasks.
	 * @param {string} msg Message.
	 */
	function sayMessage(msg) {
		if (board !== null) board.updateMessage(msg);
	}
	
	/**
	 * Appends the message in the board. Called by doTasks.
	 * @param {string} msg Message.
	 */
	function addMessage(msg) {
		if (board !== null) board.appendMessage(msg);
	}
	
	/**
	 * Displays the error message in the board. Called by doTasks.
	 * @param {string} msg Message.
	 */
	function sayError(msg) {
		if (board !== null) board.updateMessage(msg);
		pauseFlag = true;
	}

	/**
	 * Updates UI when a mark is undone. Passed to undoAssumption, undoUserMark.
	 * @param {Mark} mark Mark.
	 */
	function removeMark(mark) {
		let msg = "I undid mark " + mark.num + ".";
		if (board !== null) board.updateOnMark(mark, false, msg);
		if (tabby !== null) tabby.updateOnMark(mark, false);
	}
	
	// ---------------------------------------------------------------
	// Methods called by puzzle.addMark.

	/**
	 * Updates UI when a mark is entered. Called by puzzle.addMark.
	 * @param {Mark} mark Mark.
	 */
	this.updateOnMark = function (mark) {
		let msg = mark.num + ") " + mark.reason;
		if (board !== null) board.updateOnMark(mark, true, msg);
		if (tabby !== null) tabby.updateOnMark(mark, true);
		pauseFlag = pauseFlag || okPauseMark || (okPauseAssumption && mark.guess);
	};

	/**
	 * Updates UI when a mark references a fact. Called by puzzle.addMark.
	 * @param {Fact} fact Fact.
	 */
	this.updateOnFact = function (fact) {
		if (board !== null) board.updateOnFact(fact);
		if (tabby !== null) tabby.updateOnFact(fact);
	};

	/**
	 * Updates UI when a mark references a rule. Called by sayRuleViolation.
	 * @param {Rule} rule Rule.
	 */
	function updateOnRule(rule) {
		if (board !== null) board.updateOnRule(rule);
		if (tabby !== null) tabby.updateOnRule(rule);
	}
	
	// Called by puzzle.addMark.
	this.updateOnRule = updateOnRule;

	// ---------------------------------------------------------------
	
	/** Number of milliseconds for setTimeout. */
	const ms = 1;
	
	print("viewer create WebWorker");
	let worker = new Worker("js/WebWorker.js");

	/**
	 * Event handler to receive a message from the WebWorker thread.
	 * @param {Event} event Event.
	 */
	worker.onmessage = function (event) {
		let data = event.data;
		//print("worker.onmessage data=" + Q + data + Q + " data.key=" + Q + data.key + Q);
		tasks.push(data);
		++maxTasks;
		if (!workFlag) setTimeout(function() { doTasks(); }, ms);
	};
	
	/**
	 * Event handler to handle errors from the WebWorker thread.
	 * @param {Event} err Error.
	 */
	worker.onerror = function (err) {
		sayError(err.message);
	};
	
	/**
	 * Posts the message to the WebWorker thread.
	 * @param {string} msg Message.
	 */
	this.postToWorker = function (msg) {
		worker.postMessage(msg);
	};
	
	// ---------------------------------------------------------------
	// Links Form.

	// These grid styles are shared with Links and Grids. Use "style.cssText" to set these.
	// The style attribute is read-only and assignment is an error in Edge/IE!
	let gridCellStyles = ["background-color:white;", "background-color:yellow;"];
	let gridHeadStyles = ["background-color:white;", "background-color:lightgray;"];

	/**
	 * Handles the onmouseover/onmouseout events in the link grid when the user moves the mouse.
	 * @param {number} en Number where 0=mouse out, 1=mouse over.
	 * @param {number} ln Zero-based number of link.
	 * @param {number} n1 Zero-based index of noun 1.
	 * @param {number} n2 Zero-based index of noun 2.
	 */
	this.doLinkGridCell = function (en, ln, n1, n2) {
		if (tabby === null) return;
		let cells = tabby.getLinkGridCells(ln, n1, n2);
		cells[0].style.cssText = gridCellStyles[en];
		cells[1].style.cssText = gridHeadStyles[en];
		cells[2].style.cssText = gridHeadStyles[en];
	};

	// ---------------------------------------------------------------
	// Facts Form.

	/**
	 * Handles the onclick event when the user clicks a fact's checkbox.
	 * @param {number} num One-based number of fact.
	 */
	this.enableFact = function (num) {
		let fact = puzzle.facts[num - 1];
		fact.enabled = !fact.enabled;
		print("UI fact " + num + " is " + fact.enabled);
		worker.postMessage({"key": "updateFact", "num": num, "enabled": fact.enabled});
	};

	// ---------------------------------------------------------------
	// Rules Form.

	/**
	 * Handles the onclick event when the user clicks a rule's checkbox.
	 * @param {number} num One-based number of rule.
	 */
	this.enableRule = function (num) {
		let rule = puzzle.rules[num - 1];
		rule.enabled = !rule.enabled;
		print("UI rule " + num + " is " + rule.enabled);
		worker.postMessage({"key": "updateRule", "num": num, "enabled": rule.enabled});
	};

	/**
	 * Updates the given rule in the Rules form.
	 * @param {Rule} rule Rule.
	 */
	this.updateRule = function (rule) {
		if (tabby !== null) tabby.updateOnRule(rule);
	};

	// ---------------------------------------------------------------
	// Chart Form.

	/**
	 * Handles the onclick event when the user clicks a header cell.
	 * @param {number} icol Zero-based index of column.
	 */
	this.updateChartCol1 = function (icol) {
		if (tabby !== null) tabby.updateChartCol1(icol);
	};

	// ---------------------------------------------------------------
	// Grids Form.

	/**
	 * Determines if the user can enter the requested mark. Called by a grid cell's onclick event.
	 * @param {number} t1 Zero-based index of noun type 1.
	 * @param {number} n1 Zero-based index of noun 1.
	 * @param {number} t2 Zero-based index of noun type 2.
	 * @param {number} n2 Zero-based index of noun 2.
	 * @param {number} v Number of verb. Should be -1 or 1.
	 */
	this.clickGridCell = function (t1, n1, t2, n2, v) {
		if (puzzle === null) return;

		// Allow mark if the current cell holds the possible verb.
		if (v !== 0) return;

		// Get the verb to enter. Should be the negative or positive verb.
		if (tabby === null) return;
		let verb = tabby.getGridVerb();

		let noun1 = puzzle.nounTypes[t1].nouns[n1];
		let noun2 = puzzle.nounTypes[t2].nouns[n2];

		let msg = "You entered '" + verb.code + "' for " + noun1 + " and " + noun2;
		print(msg);
		if (board !== null) board.updateMessage(msg);
		addMarkByUser(noun1, verb, noun2);
	};

	/**
	 * Enters the mark by the user. Called by viewer.clickGridCell ONLY on the UI thread.
	 * Note: Need to (1) send mark from UI to WW, and then (2) send mark from WW to UI.
	 * @param {Noun} noun1 Noun 1.
	 * @param {Verb} verb Verb.
	 * @param {Noun} noun2 Noun 2.
	 * @returns {number} Status.
	 */
	function addMarkByUser(noun1, verb, noun2) {
		let rs = 0;
		print("exec viewer.addMarkByUser: noun1=" + Q + noun1 + Q + " verb=" + Q + verb + Q + " noun2=" + Q + noun2 + Q);

		let obj = {
			'noun1TypeNum': noun1.type.num,
			'noun1Num': noun1.num,
			'verbNum': verb.num,
			'noun2TypeNum': noun2.type.num,
			'noun2Num': noun2.num
		};
		worker.postMessage({"key": "addMarkByObjLit", "obj": obj});

		print("done viewer.addMarkByUser");
		return rs;
	}

	/**
	 * Handles the onmouseover/onmouseoff events when the user moves the mouse over a grid cell.
	 * Un[highlights] the cell, row header, and column header.
	 * @param {number} en Number where 0=mouse out, 1=mouse over.
	 * @param {number} t1 Zero-based index of noun type 1.
	 * @param {number} n1 Zero-based index of noun 1.
	 * @param {number} t2 Zero-based index of noun type 2.
	 * @param {number} n2 Zero-based index of noun 2.
	 */
	this.doGridCell = function (en, t1, n1, t2, n2) {
		if (tabby === null) return;
		let cells = tabby.getGridCells(t1, n1, t2, n2);
		cells[0].style.cssText = gridCellStyles[en];
		cells[1].style.cssText = gridHeadStyles[en];
		cells[2].style.cssText = gridHeadStyles[en];
	};
	
	// ---------------------------------------------------------------
	// Setup Form.

	/** Autoload puzzle when page is loaded. NOT USED. */
	this.okAutoload = false;
	
	/** Autorun puzzle when page is loaded. */
	this.okAutorun = false;
	
	/** Reorder chart when a pair is entered/removed. */
	this.okReorderChart = false;
	
	/** Pause on any message. */
	let okPauseAll = false;
	
	/** Pause when the level changes. */
	let okPauseLevel = false;
	
	/** Pause when a solution is found. */
	let okPauseSolution = true;
	
	/** Pause when a violation occurs. */
	let okPauseViolation = false;
	
	/** Pause when a mark is entered. */
	let okPauseMark = false;
	
	/** Pause when an assumption is made. */
	let okPauseAssumption = false;
	
	/**
	 * Updates variables affected by the Setup form.
	 * @param {string} key Key.
	 * @param {boolean} val Value.
	 */
	this.updateOption = function (key, val) {
		//print("viewer.updateOption key=" + Q + key + Q + " val=" + Q + val + Q);
		switch (key) {
			// General.
			case "chkAutorun": this.okAutorun = val; break;
			case "chkReorderChart": this.okReorderChart = val; break;
			
			// Pause.
			case "chkPauseAll": okPauseAll = val; break;
			case "chkPauseLevel": okPauseLevel = val; break;
			case "chkPauseSolution": okPauseSolution = val; break;
			case "chkPauseViolation": okPauseViolation = val; break;
			case "chkPauseMark": okPauseMark = val; break;
			case "chkPauseAssumption": okPauseAssumption = val; break;
				
			// Post all solver, finder, and lawyer options to WW.
			default: worker.postMessage({"key": key, "val": val});
		}
	};

	// ---------------------------------------------------------------
	// Initialize all managers last. Note: setup is not referenced.
	
	/** Board Form manager. */
	let board = new Board(this);
	
	/** Tabbed-interface manager. */
	let tabby = new Tabby(this);
	
	/** Setup Form manager. */
	let setup = new Setup(this);
}

Board

The Board form displays information about the puzzle. Below is what the board looks like when there is no puzzle. If you want to know more about this form or any other form, please take a look at the Mystery Master Guide.

Facts of   Hits   Pairs of   Level
Rules of   Hits   Marks of   Guess

The board is initially displayed via the board.php file.

board.php

<table id="tblBoard" class="clsBoard">
	<tr>
		<td>Facts</td>
		<td><input type="text" readonly id="txtNumFacts" /></td>
		<th>of</th>
		<td><input type="text" readonly id="txtMaxFacts" /></td>
		<td>&nbsp; Hits</td>
		<td><input type="text" readonly id="txtNumFactHits" /></td>
		<td>&nbsp; Pairs</td>
		<td><input type="text" readonly id="txtNumPairs" /></td>
		<th>of</th>
		<td><input type="text" readonly id="txtMaxPairs" /></td>
		<td>&nbsp; Level</td>
		<td><input type="text" readonly id="txtLevel" /></td>
		<td><button disabled id="btnWork">Work</button></td>
	</tr>
	<tr>
		<td>Rules</td>
		<td><input type="text" readonly id="txtNumRules" /></td>
		<th>of</th>
		<td><input type="text" readonly id="txtMaxRules" /></td>
		<td>&nbsp; Hits</td>
		<td><input type="text" readonly id="txtNumRuleHits" /></td>
		<td>&nbsp; Marks</td>
		<td><input type="text" readonly id="txtNumMarks" /></td>
		<th>of</th>
		<td><input type="text" readonly id="txtMaxMarks" /></td>
		<td>&nbsp; Guess</td>
		<td><input type="text" readonly id="txtNumGuesses" /></td>
		<td><button disabled id="btnQuit">Quit</button></td>
	</tr>
	<tr>
		<td colspan="13">
			<textarea rows="4" readonly id="txtMsg">
			</textarea>
		</td>
	</tr>
</table>

The board is updated by the Board class.

The Board Class

/* global NL, Is */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Board class.
 * @description The Board Form.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-06-17
 * @param {Viewer} viewer Viewer.
 * @returns {Board} Board.
 */
function Board(viewer) {
	this.toString = function () { return "Board"; };
	this.asString = function () { return this.toString(); };
	
	/** Puzzle object. */
	let puzzle = null;

	/**
	 * Sets the puzzle. Called by viewer.setPuzzle.
	 * @param {Puzzle} apuzzle Puzzle object.
	 * @param {boolean} okFinder Finder flag.
	 */
	this.setPuzzle = function (apuzzle, okFinder) {
		puzzle = apuzzle;
		btnWork.disabled = puzzle === null || !okFinder;
	};

	/** Manually click the Solver button. Called by viewer.setPuzzle when the autorun option is true. */
	this.doWork = function () {
		btnWork_OnClick();
	};

	/** Resets the buttons when the Worker is done. Called by viewer.quitWork. */
	this.quitWork = function () {
		btnWork.innerText = btnWorkCaptions[0];
		btnQuit.innerText = btnQuitCaptions[1];
	};

	/** Updates the buttons when a pause is requested by the user. Called by viewer.pauseWork. */
	this.pauseWork = function () {
		btnWork.innerText = btnWorkCaptions[2];
	};

	/**
	 * Updates the Board. Called by viewer.updateOnMark, viewer.removeMark.
	 * @param {Mark} mark Mark.
	 * @param {boolean} b Flag.
	 * @param {string} msg Message.
	 */
	this.updateOnMark = function (mark, b, msg) {
		// 1. Update number of pairs.
		if (mark.verb === Is) txtNumPairs.value = puzzle.numPairs;

		// 2. Update number of marks. Use puzzle.numMarks in case mark was removed.
		txtNumMarks.value = puzzle.numMarks;

		// 3. Update number of guesses only if mark was entered.
		if (mark.guess && b) txtNumGuesses.value = puzzle.numGuesses;

		// 4. Update the level. If b is false, need to get the last mark in the stack.
		if (!b) {
			let lastMark = puzzle.getLastMark();
			if (lastMark !== null) mark = lastMark;
		}

		let tmp = "" + mark.levelNum + (mark.levelSub === ' ' ? "" : mark.levelSub);
		if (level !== tmp) {
			level = tmp;
			txtLevel.value = level;
			msg = "Level " + level + NL + msg;
		}

		// Display message.
		txtMsg.value = msg;
	};

	/**
	 * Updates the fact fields. Called by viewer.updateOnFact.
	 * @param {Fact} fact Fact object.
	 */
	this.updateOnFact = function (fact) {
		txtNumFacts.value = puzzle.numFacts;
		txtNumFactHits.value = puzzle.numFactHits;
	};

	/**
	 * Updates the rule fields. Called by viewer.updateOnRule.
	 * @param {Rule} rule Rule object.
	 */
	this.updateOnRule = function (rule) {
		txtNumRules.value = puzzle.numRules;
		txtNumRuleHits.value = puzzle.numRuleHits;
	};

	// ---------------------------------------------------------------
	// Event Handlers.

	/** Handles the event when the user clicks the Work button. */
	function btnWork_OnClick() {
		let caption = btnWork.innerText;
		//print("exec board.btnWork_OnClick: You pressed the " + caption + " button.");

		switch (caption) {
			case btnWorkCaptions[0]: // Solve
				btnWork.innerText = btnWorkCaptions[1];
				btnQuit.disabled = false;
				viewer.doWork();
				break;
			case btnWorkCaptions[1]: // Pause
				btnWork.innerText = btnWorkCaptions[2];
				break;
			case btnWorkCaptions[2]: // Resume
				btnWork.innerText = btnWorkCaptions[1];
				viewer.resumeTasks();
				break;
		}
		//print("done board.btnWork_OnClick");
	}

	/** Handles the event when the user clicks the Quit button. */
	function btnQuit_OnClick() {
		let caption = btnQuit.innerText;
		//print("exec board.btnQuit_OnClick: You pressed the " + caption + " button.");

		switch (caption) {
			case btnQuitCaptions[0]: // Quit
				btnQuit.innerText = btnQuitCaptions[1];
				viewer.postToWorker({"key":"quitWork"});
				break;
			case btnQuitCaptions[1]: // Reset
				btnQuit.innerText = btnQuitCaptions[0];
				btnQuit.disabled = true;
				viewer.resetWork();
				break;
		}
		//print("done board.btnQuit_OnClick");
	}

	// ---------------------------------------------------------------
	// Update Message Field.

	/**
	 * Updates the message field with the given message.
	 * @param {string} msg Message.
	 */
	this.updateMessage = function (msg) {
		txtMsg.value = msg;
	};

	/**
	 * Updates the message field by appending the given message.
	 * @param {string} msg Message.
	 */
	this.appendMessage = function (msg) {
		txtMsg.value += NL + msg;
	};

	// ---------------------------------------------------------------

	/** Current level. */
	let level = "";

	/** Resets the board. Called by viewer.resetWork. */
	this.reset = function () {
		level = "";
		let b = puzzle === null;

		txtMaxFacts.value = b ? "" : puzzle.maxFacts;
		txtMaxRules.value = b ? "" : puzzle.maxRules;
		txtMaxPairs.value = b ? "" : puzzle.maxPairs;
		txtMaxMarks.value = b ? "" : puzzle.maxMarks;

		txtNumFacts.value = b ? "" : puzzle.numFacts;
		txtNumRules.value = b ? "" : puzzle.numRules;
		txtNumPairs.value = b ? "" : puzzle.numPairs;
		txtNumMarks.value = b ? "" : puzzle.numMarks;

		txtNumFactHits.value = b ? "" : puzzle.numFactHits;
		txtNumRuleHits.value = b ? "" : puzzle.numRuleHits;

		txtNumGuesses.value = b ? "" : puzzle.numGuesses;

		txtLevel.value = level;
		txtMsg.value = "";

		btnWork.innerText = btnWorkCaptions[0];
		btnQuit.innerText = btnQuitCaptions[0];
	};

	// ---------------------------------------------------------------
	// Board Fields.

	const txtNumFacts = document.getElementById("txtNumFacts");
	const txtNumRules = document.getElementById("txtNumRules");
	const txtNumPairs = document.getElementById("txtNumPairs");
	const txtNumMarks = document.getElementById("txtNumMarks");

	const txtMaxFacts = document.getElementById("txtMaxFacts");
	const txtMaxRules = document.getElementById("txtMaxRules");
	const txtMaxPairs = document.getElementById("txtMaxPairs");
	const txtMaxMarks = document.getElementById("txtMaxMarks");

	const txtNumFactHits = document.getElementById("txtNumFactHits");
	const txtNumRuleHits = document.getElementById("txtNumRuleHits");

	const txtLevel = document.getElementById("txtLevel");
	const txtNumGuesses = document.getElementById("txtNumGuesses");
	const txtMsg = document.getElementById("txtMsg");

	const btnWork = document.getElementById("btnWork");
	const btnQuit = document.getElementById("btnQuit");

	const btnWorkCaptions = ["Solve", "Pause", "Resume"];
	const btnQuitCaptions = ["Quit", "Reset"];

	// ---------------------------------------------------------------
	// Initialization. Do this last.

	btnWork.innerText = btnWorkCaptions[0];
	btnQuit.innerText = btnQuitCaptions[0];

	btnWork.disabled = true;
	btnQuit.disabled = true;

	// Add listeners for the Work and Quit buttons.
	btnWork.addEventListener('click', btnWork_OnClick, false);
	btnQuit.addEventListener('click', btnQuit_OnClick, false);
}

Tabby

The tabbed-interface controller, affectionately called tabby, displays one of many forms at a time. Here is what tabby looks like when the Setup form is displayed.


Tabby is initially displayed via the tabby.php file.

tabby.php

	
	

The Tabby Class

/* global Filer, Locker, Puzzler, Is, Stats, nextPuzzle */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Tabby class.
 * @description The tabbed interface control displays one form at a time.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-07-01
 * @param {Viewer} viewer Viewer.
 * @returns {Tabby} Tabby.
 */
function Tabby(viewer) {
	this.toString = function () { return "Tabby"; };
	this.asString = function () { return this.toString(); };

	// Create the managers.
	let stats = new Stats();

	/**
	 * Displays the content of the tab given by its zero-based number (tabNum1). Called by init and selectTab.
	 * @param {boolean} flag Flag.
	 */
	function update(flag = false) {
		//print("exec tabby.update flag=" + flag);
		// Unset each tab and hide its contents.
		for (let i = 0; i < maxTabs; i++) {
			btns[i].style.color = "black";
			divs[i].style.display = "none";
		}

		if (flag) {
			// Update contents of selected tab. Applies to Facts, Rules, Marks, Chart, Grids, Stats.
			if (tabNum1 > 2 && tabNum1 < maxTabs - 1) updateFs[tabNum1]();
		}

		// Set the current tab and show its contents.
		btns[tabNum1].style.color = "blue";
		divs[tabNum1].style.display = "block";
		//print("done tabby.update");
	}

	/** Puzzle object. */
	let puzzle = null;

	/**
	 * Sets the puzzle. Called by viewer.setPuzzle.
	 * @param {Puzzle} apuzzle Puzzle object.
	 */
	this.setPuzzle = function (apuzzle) {
		puzzle = apuzzle;

		// Grid verb.
		let num = (gridVerb === null) ? 0 : gridVerb.num + 1;
		let tmp = Locker.getNumber("gridVerb", num);
		if (typeof tmp !== "number" || tmp < 0 || tmp > 2) tmp = 0;
		gridVerb = puzzle === null ? null : puzzle.verbs[tmp];

		// The Developer version creates files based on the puzzle.
		if (nextPuzzle !== null) {
			Filer.saveNounsToFile(puzzle, false);
			Filer.saveVerbsToFile(puzzle);
			Filer.saveLinksToFile(puzzle);
			Filer.saveFactsToFile(puzzle, false);
			Filer.saveRulesToFile(puzzle, false);
			Filer.saveChartToFile(puzzle, false);
			Filer.saveGridsToFile(puzzle, gridVerb);
		}
	};

	/**
	 * Refreshes the tabbed content when a puzzle is loaded or unloaded.
	 * Called by viewer.resetWork.
	 */
	this.reset = function () {
		//print("exec tabby.reset");
		updateNouns();
		updateVerbs();
		updateLinks();
		updateFacts();
		updateRules();
		updateMarks();
		updateChart();
		updateGrids();

		stats.reset();
		updateStats();
		//print("done tabby.reset");
	};

	/**
	 * Updates the UI when a fact is referenced. Called by viewer.updateOnFact.
	 * @param {Fact} fact Fact.
	 */
	this.updateOnFact = function (fact) {
		if (tabNum1 === 3) updateFact(fact);
	};

	/**
	 * Updates the UI when a rule is referenced. Called by viewer.updateOnRule.
	 * @param {Rule} rule Rule.
	 */
	this.updateOnRule = function (rule) {
		if (tabNum1 === 4) updateRule(rule);
	};

	/**
	 * Updates the UI when a mark is entered/removed. Called by viewer.updateOnMark.
	 * @param {Mark} mark Mark. 
	 * @param {boolean} b Flag.
	 */
	this.updateOnMark = function (mark, b) {
		if (mark.verb === Is) {
			// Update the chart if the first column is the same as the noun type for noun1 OR noun2.
			let i = mark.noun1.type.num - 1;
			if (viewer.okReorderChart) chartCol1 = i;
			if (tabNum1 === 6 && chartCol1 === i || chartCol1 === mark.noun2.type.num - 1) {
				updateChart();
			}
		}

		if (tabNum1 === 5) updateMarks();
		if (tabNum1 === 7) updateGrids();

		stats.updateOnMark(mark, b);
		if (tabNum1 === 8) updateStats();
	};

	/** Updates the UI when a solution is found. Called by viewer.saySolution. */
	this.updateOnSolution = function () {
		chartCol1 = 0;
		updateChart();

		// The Developer version creates files based on the solution.
		if (nextPuzzle !== null) {
			Filer.saveNounsToFile(puzzle, true);
			Filer.saveFactsToFile(puzzle, true);
			Filer.saveRulesToFile(puzzle, true);
			Filer.saveMarksToFile(puzzle);
			Filer.saveChartToFile(puzzle, true);
			Filer.saveGridsToFile(puzzle, gridVerb);
			Filer.saveStatsToFile(puzzle, stats);
		}
	};

	/** Zero-based number of the current tab. */
	let tabNum1 = 6;
	
	tabNum1 = Locker.getNumber("tabNum1", tabNum1);

	/** Tab names. */
	const tabNames = ["Nouns", "Verbs", "Links", "Facts", "Rules", "Marks", "Chart", "Grids", "Stats", "Setup"];

	/** Number of tabs. */
	const maxTabs = tabNames.length;

	/**
	 * Update functions. All but the Setup form needs to be updated when the user clicks on its tab.
	 * Note: In updateOnMark, update the Marks and Grids form only if it is the current tab.
	 */
	const updateFs = [updateNouns, updateVerbs, updateLinks, updateFacts, updateRules, updateMarks, updateChart, updateGrids, updateStats];

	/**
	 * Returns the field with the given ID and event listener.
	 * @param {string} id ID.
	 * @returns {Element} Field with the given ID.
	 */
	function getFieldWithEvent(id) {
		let fld = document.getElementById(id);
		fld.addEventListener('click', selectTab, false);
		return fld;
	}

	/** Tab buttons. */
	let btns = new Array(maxTabs);
	for (let i = 0; i < maxTabs; i++) {
		let id = "btn" + tabNames[i];
		btns[i] = getFieldWithEvent(id);
	}

	/** Tab containers. */
	let divs = new Array(maxTabs);
	let i = 0;
	const divNouns = document.getElementById("divNouns"); divs[i++] = divNouns;
	const divVerbs = document.getElementById("divVerbs"); divs[i++] = divVerbs;
	const divLinks = document.getElementById("divLinks"); divs[i++] = divLinks;
	const divFacts = document.getElementById("divFacts"); divs[i++] = divFacts;
	const divRules = document.getElementById("divRules"); divs[i++] = divRules;
	const divMarks = document.getElementById("divMarks"); divs[i++] = divMarks;
	const divChart = document.getElementById("divChart"); divs[i++] = divChart;
	const divGrids = document.getElementById("divGrids"); divs[i++] = divGrids;
	const divStats = document.getElementById("divStats"); divs[i++] = divStats;
	const divSetup = document.getElementById("divSetup"); divs[i++] = divSetup;

	/**
	 * Displays the content of the selected tab. Called when the tab button is clicked.
	 * @param {Event} evt Event.
	 */
	function selectTab(evt) {
		let id = evt.target.id;

		// Determine the number of the selected tab.
		for (let i = 0; i < maxTabs; i++) {
			if (id === "btn" + tabNames[i]) {
				tabNum1 = i;
				break;
			}
		}

		// Remember the number of the current tab.
		Locker.setValue("tabNum1", tabNum1);
		update(true);
	}

	// ---------------------------------------------------------------
	// Nouns form. The loaded form is unchanged.
	
	/** Updates the Nouns form. Called by reset. */
	function updateNouns() {
		divNouns.innerHTML = Puzzler.getNounsAsHtml(puzzle);
	}

	// ---------------------------------------------------------------
	// Verbs form. The loaded form is unchanged.
	
	/** Updates the Verbs form. Called by reset. */
	function updateVerbs() {
		divVerbs.innerHTML = Puzzler.getVerbsAsHtml(puzzle);
	}

	// ---------------------------------------------------------------
	// Links form. The loaded form is unchanged.
	
	/**
	 * Updates the Links form. Link grid cells are highlighted in mouse events. Called by reset.
	 */
	function updateLinks() {
		divLinks.innerHTML = Puzzler.getLinksAsHtml(puzzle);
	}

	/**
	 * Returns the HTML table cells for the mark, noun1 (row header), and noun2 (column header).
	 * Called by viewer.doLinkGridCell
	 * @param {number} ln Zero-based number of link.
	 * @param {number} n1 Zero-based index of noun 1.
	 * @param {number} n2 Zero-based index of noun 2.
	 * @returns {Array} Array of HTML table cells.
	 */
	this.getLinkGridCells = function (ln, n1, n2) {
		let tbl = divLinks.getElementsByTagName("table")[ln + 1];
		let irow = n1 + 1;
		let icol = n2 + 1;

		let cell = tbl.rows[irow].cells[icol];
		let hrow = tbl.rows[irow].cells[0];
		let hcol = tbl.rows[0].cells[icol];
		return [cell, hrow, hcol];
	};

	// ---------------------------------------------------------------
	// Facts form. Unchanged except for checkbox and number of hits.
	
	/** Updates the Facts form. Called by reset. */
	function updateFacts() {
		divFacts.innerHTML = Puzzler.getFactsAsHtml(puzzle);
	}

	/**
	 * Updates the given fact in the Facts form. Called by updateOnFact.
	 * Note: The first row contains the column headers.
	 * @param {Fact} fact Fact.
	 */
	function updateFact(fact) {
		let tbl = divFacts.getElementsByTagName("table")[0];
		let irow = fact.num;

		// Update the checkbox.
		let cell = tbl.rows[irow].cells[1];
		let fld = cell.children[0];
		fld.checked = fact.enabled;

		// Update the number of hits.
		cell = tbl.rows[irow].cells[2];
		cell.innerHTML = fact.hits;
	}

	// ---------------------------------------------------------------
	// Rules form. Unchanged except for checkbox and number of hits.
	
	/** Update the Rules form. Called by reset. */
	function updateRules() {
		divRules.innerHTML = Puzzler.getRulesAsHtml(puzzle);
	}

	/**
	 * Updates the given rule in the Rules form. Called by updateOnRule.
	 * Note: The first row contains the column headers.
	 * @param {Rule} rule Rule.
	 */
	function updateRule(rule) {
		let tbl = divRules.getElementsByTagName("table")[0];
		let irow = rule.num;

		// DON"T Update the checkbox.
		//let cell = tbl.rows[irow].cells[1];
		//let fld = cell.children[0];
		//fld.checked = !fld.checked;

		// Update the number of hits.
		let cell = tbl.rows[irow].cells[2];
		cell.innerHTML = rule.hits;
	}

	/** Updates the Marks form when a mark is entered or removed. Called by reset, updateOnMark. */
	function updateMarks() {
		divMarks.innerHTML = Puzzler.getMarksAsHtml(puzzle);
	}

	// ---------------------------------------------------------------
	// Chart form. Updated when a positive mark is added or removed,
	// or when the user clicks a column header.
	
	/** First noun type to display in the Chart form. */
	let chartCol1 = 0;

	/**
	 * Updates the chart with a different noun type in the first column.
	 * Called by viewer.setChartCol1.
	 * @param {number} icol Zero-based index of column.
	 */
	this.updateChartCol1 = function (icol) {
		if (icol === 0) return;
		chartCol1 = (icol > chartCol1 ? icol : icol - 1);
		updateChart();
	};

	/**
	 * Updates the chart.
	 * Called by updateChartCol1, reset, updateOnMark, updateOnSolution.
	 */
	function updateChart() {
		divChart.innerHTML = Puzzler.getChartAsHtml(puzzle, chartCol1, false);
	}

	// ---------------------------------------------------------------
	// Grids form. Updated when marks are entered/removed, and when
	// user clicks: (1) Grid Verb button, (2) Undo button or (3) grid cell.

	/** Grid verb. */
	let gridVerb = null;

	/**
	 * Returns the grid verb. Called by viewer.clickGridCell.
	 * @returns {Verb} Verb.
	 */
	this.getGridVerb = function () {
		return gridVerb;
	};

	/**
	 * Sets the grid verb field with its code (mark character). Called by init and updateGridVerb.
	 */
	function setGridVerb() {
		let fld = document.getElementById("btnGridVerb");
		if (fld) fld.innerHTML = gridVerb === null ? "" : gridVerb.code;
	}

	/**
	 * Toggles the grid verb between negative and positive.
	 * Called by the onclick event when the user clicks the grid verb.
	 */
	function updateGridVerb() {
		//print("You clicked the grid verb");
		let i = gridVerb.num < 0 ? 2 : 0;
		gridVerb = puzzle.verbs[i];
		setGridVerb();
		Locker.setValue("gridVerb", i);
	}

	/**
	 * Undo all marks back to and including the last mark entered by the user.
	 * Called by the onclick event of the Undo button.
	 */
	function undoUserMark() {
		print("tabby.undoUserMark You clicked the undo button.");
		viewer.postToWorker({"key":"undoUserMark"});
	}

	/** Updates the Grids form. Called by reset, updateOnMark. */
	function updateGrids() {
		//print("tabby.updateGrids gridVerb=" + Q + gridVerb + Q);
		divGrids.innerHTML = Puzzler.getGridsAsHtml(puzzle, gridVerb);

		// Need to add listener to Verb button each time.
		let fld1 = document.getElementById("btnGridVerb");
		fld1.addEventListener('click', updateGridVerb, false);

		// Need to add listener to Undo button each time.
		let fld2 = document.getElementById("btnGridUndo");
		fld2.addEventListener('click', undoUserMark, false);
	}

	/**
	 * Returns the HTML table cells for the mark, noun1 (row header), and noun2 (column header).
	 * Called by viewer.doGridCell.
	 * @param {number} t1 Zero-based index of noun type 1.
	 * @param {number} n1 Zero-based index of noun 1.
	 * @param {number} t2 Zero-based index of noun type 2.
	 * @param {number} n2 Zero-based index of noun 2.
	 * @returns {Array} Array of HTML table cells.
	 */
	this.getGridCells = function (t1, n1, t2, n2) {
		if (puzzle === null) return null;

		let m = puzzle.nounTypes.length;
		let n = puzzle.nounTypes[0].nouns.length;
		let tbl = divGrids.getElementsByTagName("table")[0];

		//console.log([t1,n1,t2,n2]);
		let irow = 0, icol = 0;
		if (t1 < 1) {
			irow = n1 + 2;
			icol = (t2 - 1) * n + n2 + 1;
		}
		else if (t1 < t2) {
			irow = (m - t2) * (n + 1) + n2 + 2;
			icol = (t1 - 1) * n + n1 + 1;
		}
		else {
			irow = (m - t1) * (n + 1) + n1 + 2;
			icol = (t2 - 1) * n + n2 + 1;
		}

		let cell = tbl.rows[irow].cells[icol];
		let hrow = tbl.rows[irow].cells[0];
		let hcol = tbl.rows[1].cells[icol];
		return [cell, hrow, hcol];
	};

	// ---------------------------------------------------------------
	// Stats form.

	/**
	 * Updates the Statistics form when a mark is added or removed.
	 * Called by reset, updateOnMark.
	 */
	function updateStats() {
		divStats.innerHTML = Puzzler.getStatsAsHtml(stats);
	}

	// Initialization.
	setGridVerb();
	update(false);
}

The forms for the nouns, verbs, links, facts, and rules are shown in my article "Model a Logic Puzzle in JavaScript". The forms for the marks, chart, and grids will be discussed in a future article. So that means the Stats form and the Setup form are discussed in this article.

Stats

The Statistics form displays information about how the puzzle was solved. It tells you how many marks and pairs were entered by level, law, and rule. Here are the statistics after our example puzzle has been solved.

The Stats Class

/* global Mark, Solver */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Stats class.
 * @description The Stats class collects statistics while the puzzle is being solved.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-03-27
 * @returns {Stats} Stats.
 */
function Stats() {
	this.toString = function () { return "Stats"; };
	this.asString = function () { return this.toString(); };

	/**
	 * Returns the array of level counters for the pairs. Called by Helper.getStatsAsHtml.
	 * @returns {Array} Array of level counters for the pairs.
	 */
	this.getLevelPairs = function () { return levelPairs; };
	
	/**
	 * Returns the array of level counters for the Marks. Called by Helper.getStatsAsHtml.
	 * @returns {Array} Array of level counters for the marks.
	 */
	this.getLevelMarks = function () { return levelMarks; };

	/** Resets the level counters for the Marks and Pairs. Called by tabby.reset. */
	this.reset = function () {
		resetLevelCounters(levelPairs);
		resetLevelCounters(levelMarks);
	};

	/**
	 * Resets the given level counters. Called by reset.
	 * @param {LevelCounter[]} levelCounters Level counters.
	 */
	function resetLevelCounters(levelCounters) {
		for (let levelCounter of levelCounters) {
			levelCounter.reset();
		}
	}

	/**
	 * Updates the statistics based on the new/old mark, where b is True=increment, False=decrement.
	 * Called by tabby.updateOnMark.
	 * @param {Mark} mark Mark.
	 * @param {boolean} b Flag where True=increment, False=decrement.
	 */
	this.updateOnMark = function (mark, b = true) {
		let d = b ? 1 : -1;
		//print("stats.updateOnMark " + mark.num +" b=" + b + " d=" + d + " levelNum=" + mark.levelNum + " type=" + Q + mark.type.name + Q);
		let levelNum = mark.levelNum;
		if (levelNum < 1) return;

		let levelMark = levelMarks[levelNum - 1];
		let levelPair = levelPairs[levelNum - 1];

		// Checks if the verb is positive using its number.
		let isPositive = mark.verb.num > 0;

		// Update sums for the specific level and in total.
		levelMark.sum += d;
		totalMark.sum += d;
		if (isPositive) {
			levelPair.sum += d;
			totalPair.sum += d;
		}

		// Update counters for the type of mark: Level or User, Rule, or Law.
		switch (mark.type) {
			case Mark.Type.Level:
			case Mark.Type.User:
				levelMark.levelHits += d;
				totalMark.levelHits += d;
				if (isPositive) {
					levelPair.levelHits += d;
					totalPair.levelHits += d;
				}
				break;
			case Mark.Type.Rule:
				levelMark.ruleHits += d;
				totalMark.ruleHits += d;
				if (isPositive) {
					levelPair.ruleHits += d;
					totalPair.ruleHits += d;
				}
				break;
			case Mark.Type.Law:
				let j = mark.refNum - 1;
				levelMark.lawsHits[j] += d;
				totalMark.lawsHits[j] += d;
				if (isPositive) {
					levelPair.lawsHits[j] += d;
					totalPair.lawsHits[j] += d;
				}
				break;
			default:
				print("stats.updateOnMark bad mark.type!");
		}
	};

	// ---------------------------------------------------------------

	/** Level Counter class. */
	function LevelCounter() {
		let levelCounter = this;
		levelCounter.levelHits = 0;
		levelCounter.ruleHits = 0;
		levelCounter.lawsHits = new Array(Solver.MaxLaws);
		levelCounter.sum = 0;

		levelCounter.reset = function () {
			levelCounter.levelHits = 0;
			levelCounter.ruleHits = 0;
			for (let j = 0; j < levelCounter.lawsHits.length; j++) levelCounter.lawsHits[j] = 0;
			levelCounter.sum = 0;
		};

		levelCounter.reset();
	}

	/**
	 * Returns initialized array of level counters where the last one is for the totals.
	 * @returns {Array} Array of level counters.
	 */
	function getLevelCounters() {
		let levelCounters = new Array(Solver.MaxLevels + 1);
		for (let i = 0; i < levelCounters.length; i++) {
			levelCounters[i] = new LevelCounter();
		}
		return levelCounters;
	}

	// ---------------------------------------------------------------
	// Initialization.

	/** Array of level counters for the pairs. */
	let levelPairs = getLevelCounters();
	
	/** Array of level counters for the marks. */
	let levelMarks = getLevelCounters();

	/** Number of rows in the level counters. */
	let nrows = levelMarks.length;
	
	/** Totals for marks. This is the last row in the levelMarks. */
	let totalMark = levelMarks[nrows - 1];
	
	/** Totals for pairs. This is the last row in the levelPairs. */
	let totalPair = levelPairs[nrows - 1];
}

Setup

The Setup form displays options for the user that control how the application solves a logic puzzle. Here is what setup looks like when there is no puzzle.

Setup
General





Pause






Solver



Finder





Lawyer







The Setup form is initially displayed via the setup.php file.

setup.php

<table class="clsAppTable clsSetup">
	<caption>Setup</caption>
	<tr>
		<td>
			<div class="clsSetupHeader">General</div>
			<label><input type="checkbox" id="chkAutoload">Autoload</label><br />
			<label><input type="checkbox" id="chkAutorun">Autorun</label><br />
			<label><input type="checkbox" id="chkReorderChart">Reorder Chart</label><br />
			<input type="range" id="sldNumRows" min="10" max="30" step="1" value="12" style="width:100px;"><br />
			<button id="btnReset">Reset</button><br />
		</td>
		<td>
			<div class="clsSetupHeader">Pause</div>
			<label><input type="checkbox" id="chkPauseAll">All</label><br />
			<label><input type="checkbox" id="chkPauseLevel">Level</label><br />
			<label><input type="checkbox" id="chkPauseSolution">Solution</label><br />
			<label><input type="checkbox" id="chkPauseViolation">Violation</label><br />
			<label><input type="checkbox" id="chkPauseMark">Mark</label><br />
			<label><input type="checkbox" id="chkPauseAssumption">Assumption</label><br />
		</td>
		<td>
			<div class="clsSetupHeader">Solver</div>
			<label><input type="checkbox" id="chkFacts">Facts</label><br />
			<label><input type="checkbox" id="chkRules">Rules</label><br />
			<label><input type="checkbox" id="chkTriggers">Triggers</label><br />
		</td>
		<td>
			<div class="clsSetupHeader">Finder</div>
			<label><input type="checkbox" id="chkLevel0">All</label><br />
			<label><input type="checkbox" id="chkLevel1">Level 1</label><br />
			<label><input type="checkbox" id="chkLevel2">Level 2</label><br />
			<label><input type="checkbox" id="chkLevel3">Level 3</label><br />
			<label><input type="checkbox" id="chkLevel4">Level 4</label><br />
		</td>
		<td>
			<div class="clsSetupHeader">Lawyer</div>
			<label><input type="checkbox" id="chkLaw0">All</label><br />
			<label><input type="checkbox" id="chkLaw1">Law 1</label><br />
			<label><input type="checkbox" id="chkLaw2">Law 2</label><br />
			<label><input type="checkbox" id="chkLaw3">Law 3</label><br />
			<label><input type="checkbox" id="chkLaw4">Law 4</label><br />
			<label><input type="checkbox" id="chkLaw5">Law 5</label><br />
		</td>
	</tr>
</table>

The Setup Class

/* global UIX, Solver */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Setup class.
 * @description The Setup form.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-07-01
 * @param {Viewer} viewer Viewer.
 * @returns {Setup} Setup.
 */
function Setup(viewer) {
	this.toString = function () { return "Setup"; };
	this.asString = function () { return this.toString(); };

	/**
	 * Updates the appropriate variable. Called when the user clicks/changes a setup option.
	 * @param {Event} evt
	 */
	function updateOption(evt) {
		let fld = evt.target;
		updateField(fld);
	}

	/**
	 * Handles the event when the user clicks the Reset button.
	 * @param {Event} evt
	 */
	function btnReset_OnClick(evt) {
		reset(0);
	}

	/**
	 * Returns the field with the given id and event listener (default is 'click').
	 * @param {string} id
	 * @param {string} eventType
	 * @returns {Field}
	 */
	function getFieldWithEvent(id, eventType = 'click') {
		let fld = document.getElementById(id);
		fld.addEventListener(eventType, updateOption, false);
		return fld;
	}

	// General.
	let chkAutorun = getFieldWithEvent("chkAutorun");
	let chkReorderChart = getFieldWithEvent("chkReorderChart");

	let btnReset = document.getElementById("btnReset");
	btnReset.addEventListener('click', btnReset_OnClick, false);

	// Pause.
	let chkPauseAll = getFieldWithEvent("chkPauseAll");
	let chkPauseLevel = getFieldWithEvent("chkPauseLevel");
	let chkPauseSolution = getFieldWithEvent("chkPauseSolution");
	let chkPauseViolation = getFieldWithEvent("chkPauseViolation");
	let chkPauseMark = getFieldWithEvent("chkPauseMark");
	let chkPauseAssumption = getFieldWithEvent("chkPauseAssumption");

	// Rules.
	let chkRules = getFieldWithEvent("chkRules");
	let chkTriggers = getFieldWithEvent("chkTriggers");

	// Finder.
	let chkLevels = [];
	chkLevels[0] = getFieldWithEvent("chkLevel0");
	for (let i = 1; i <= Solver.MaxLevels; i++) {
		chkLevels[i] = getFieldWithEvent("chkLevel" + i);
	}

	// Lawyer.
	let chkLaws = [];
	chkLaws[0] = getFieldWithEvent("chkLaw0");
	for (let i = 1; i <= Solver.MaxLaws; i++) {
		chkLaws[i] = getFieldWithEvent("chkLaw" + i);
	}

	/**
	 * Resets the setup options. Called by init (flag=1), and when the Reset button is pushed (flag=0).
	 * @param {boolean} flag
	 */
	function reset(flag) {
		// General.
		updateField(chkAutorun, false, flag);
		updateField(chkReorderChart, false, flag);

		// Pause.
		updateField(chkPauseAll, false, flag);
		updateField(chkPauseLevel, false, flag);
		updateField(chkPauseSolution, true, flag);
		updateField(chkPauseViolation, false, flag);
		updateField(chkPauseMark, false, flag);
		updateField(chkPauseAssumption, false, flag);

		// Rules.
		updateField(chkRules, true, flag);
		updateField(chkTriggers, true, flag);

		// Finder.
		updateField(chkLevels[0], true, flag);
		for (let i = 1; i < chkLevels.length; i++) {
			updateField(chkLevels[i], true, flag);
		}

		// Lawyer.
		updateField(chkLaws[0], true, flag);
		for (let i = 1; i < chkLaws.length; i++) {
			updateField(chkLaws[i], true, flag);
		}
	}

	/**
	 * Updates the appropriate variable to the value of the field, where the field is first set to the value (if given).
	 * If dir is 0: default val -> field  -> var -  > locker
	 * if dir is 1: default val -> locker -> field -> var
	 * Called by updateOption, reset.
	 * @param {Field} fld
	 * @param {type} val
	 * @param {number} dir
	 */
	function updateField(fld, val, dir = 0) {
		//print("setup.updateField fld.id=" + Q + fld.id + Q + " fld.type=" + Q + fld.type + Q + " fld.checked=" + Q + fld.checked + Q + " fld.value=" + Q + fld.value + Q + " val=" + Q + val + Q + " dir=" + Q + dir + Q);

		if (dir !== 0)
			UIX.setFieldFromLocker(fld, val);
		else
			UIX.setField(fld, val);

		//print("setup.updateField fld.id=" + Q + fld.id + Q + " fld.checked=" + fld.checked);
		viewer.updateOption(fld.id, fld.checked);
		if (dir === 0) UIX.putFieldInLocker(fld);
	}

	// Initialization.
	reset(1);
}

Locker

The Locker static class is a wrapper for HTML 5 Storage.

The Locker Class

/* global Helper */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Locker class.
 * @description The HTML 5 Storage class.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-06-15
 */
function Locker() {
	throw new Error("Locker is a static class!");
}

// HTML5 Local Storage.
// localstorage.getItem(key) returns the item as string or null.
// localstorage.removeItem(key) removes the item

/**
 * Determines if HTML 5 Storage is available.
 * @returns {boolean} True if HTML 5 Storage is supported, otherwise false.
 */
Locker.supportHtml5Storage = function () {
	try {
		return 'localStorage' in window && window['localStorage'] !== null;
	} catch (e) {
		return false;
	}
};

Locker.ok = Locker.supportHtml5Storage();

/**
 * Puts the value into storage organized by its key.
 * @param {string} key Key.
 * @param {type} val Value.
 */
Locker.setValue = function (key, val) {
	if (Locker.ok) localStorage.setItem(key, val);
};

/**
 * Returns the string value in storage given its key, or the default.
 * @param {string} key Key.
 * @param {string} def Default value.
 * @returns {string} Value.
 */
Locker.getString = function (key, def) {
	let val = def;
	if (Locker.ok) {
		let tmp = localStorage.getItem(key);
		if (tmp !== null) val = tmp;
	}
	return val;
};

/**
 * Returns the numeric value in storage given its key, or the default.
 * @param {string} key Key.
 * @param {number} def Default value.
 * @returns {number} Value.
 */
Locker.getNumber = function (key, def) {
	let val = def;
	if (Locker.ok) {
		let tmp = localStorage.getItem(key);
		tmp = parseInt(tmp, 10);
		if (Helper.isNumber(tmp)) val = tmp;
	}
	return val;
};

/**
 * Returns the boolean value in storage given its key, or the default.
 * @param {string} key Key.
 * @param {boolean} def Default value.
 * @returns {number} Value.
 */
Locker.getBoolean = function (key, def) {
	let val = def;
	if (Locker.ok) {
		let tmp = localStorage.getItem(key);
		if (tmp !== null) val = tmp;
		val = Helper.getBoolean(val);
	}
	return val;
};

Puzzler

The Puzzler static class contains helpful methods that return HTML as a string. For example, these methods are called to populate most of the forms in the tabbed-interface component.

The Puzzler Class

/* global Helper, Q, NL, Puzzle, Solver */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class Puzzler class.
 * @description The Puzzler static class contains methods for the Puzzle and/or Solver objects.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-07-01
 */
function Puzzler() {
	throw new Error("Puzzler is a static class!");
}

/**
 * Returns the nouns as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @returns {string} String.
 */
Puzzler.getNounsAsHtml = function (puzzle) {
	let txt = "";
	if (puzzle === null) return txt;

	txt = "<table class=" + Q + "clsAppTable clsNouns" + Q + ">" + NL + "<caption>Nouns</caption>" + NL + "<thead>" + NL + "<tr><th>#</th>";
	let n = 0;
	for (let nounType of puzzle.nounTypes) {
		txt += "<th>" + nounType.name + "</th>";
		if (nounType.num === 1) n = nounType.nouns.length;
	}
	txt += "</tr>" + NL + "</thead>" + NL + "<tbody>" + NL;

	for (let j = 0; j < n; j++) {
		txt += "<tr><td>" + (j + 1) + "</td>";
		for (let nounType of puzzle.nounTypes) {
			let noun = nounType.nouns[j];
			txt += "<td>" + noun.title + "</td>";
		}
		txt += "</tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	return txt;
};

/**
 * Returns the verbs as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @returns {string} String.
 */
Puzzler.getVerbsAsHtml = function (puzzle) {
	let txt = "";
	if (puzzle === null) return txt;

	txt = "<table class=" + Q + "clsAppTable clsVerbs" + Q + ">" + NL + "<caption>Verbs</caption>" + NL + "<thead>" +
		NL + "<tr><th>#</th><th>Type</th><th>Name</th><th>Code</th></tr>" + NL + "</thead>" +
		NL + "<tbody>" + NL;
	for (let verb of puzzle.verbs) {
		txt += "<tr><td>" + verb.num + "</td><td>" + verb.type + "</td><td>" + verb.name + "</td><td>" + verb.getCode() + "</td></tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	return txt;
};

/**
 * Returns the links as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @returns {string} String.
 */
Puzzler.getLinksAsHtml = function (puzzle) {
	let txt = "";
	if (puzzle === null) return txt;

	// Display the Links table.
	txt = "<table class=" + Q + "clsAppTable clsLinks" + Q + ">" + NL + "<caption>Links</caption>" + NL + "<thead>" + NL +
		"<tr><th>#</th><th>Noun Type</th><th>Name</th><th>1:1</th></tr>" + NL + "</thead>" + NL +
		"<tbody>" + NL;
	for (let link of puzzle.links) {
		txt += "<tr><td>" + link.num + "</td><td>" + link.nounType.name + "</td><td><input type=" + Q + "text" + Q + " value=" + Q + link.name + Q + " readonly></td><td>" + "<input type=" + Q + "checkbox" + Q + (link.oneToOne ? " checked" : "") + " /></td></tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	// Display the grids for each link.
	for (let link of puzzle.links) {
		let i = link.num;
		let nounType = link.nounType;
		txt += "<br />" + NL + "<table class=" + Q + "clsAppTable clsLinkGrid" + Q + ">" +
			NL + "<caption>" + link.name + "</caption>" + NL + "<thead>" +
			NL + "<tr><th style=" + Q + "font-weight:bold; text-align:center;" + Q + ">" + nounType.name + "</th>";
		for (let noun of nounType.nouns) {
			txt += "<th>" + noun.title + "</th>";
		}
		txt += "</tr>" + NL + "</thead>" + NL + "<tbody>" + NL;
		for (let nounj of nounType.nouns) {
			let j = nounj.num - 1;
			txt += "<tr><th>" + nounj.title + "</th>";
			for (let nounk of nounType.nouns) {
				let k = nounk.num - 1;
				let verb = link.f(nounj, nounk);
				let evt1 = "onmouseout=" + Q + "viewer.doLinkGridCell(0," + i + "," + j + "," + k + ");" + Q;
				let evt2 = "onmouseover=" + Q + "viewer.doLinkGridCell(1," + i + "," + j + "," + k + ");" + Q;
				txt += "<td " + evt1 + " " + evt2 + ">" + verb.getCode() + "</td>";
			}
			txt += "</tr>" + NL;
		}
		txt += "</tbody>" + NL + "</table>" + NL;
	}

	return txt;
};

/**
 * Returns the facts as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @returns {string} String.
 */
Puzzler.getFactsAsHtml = function (puzzle) {
	let txt = "";
	if (puzzle === null) return txt;

	txt = "<table class=" + Q + "clsAppTable clsFacts" + Q + ">" + NL + "<caption>Facts</caption>" + NL + "<thead>" +
		NL + "<tr><th>#</th><th>X</th><th>Hits</th><th>Name</th></tr>" + NL + "</thead>" + NL + "<tbody>" + NL;
	for (let fact of puzzle.facts) {
		txt += "<tr><td>" + fact.num + "</td><td><input type=" + Q + "checkbox" + Q + " onclick=" + Q + "viewer.enableFact(" + fact.num + ")" + Q + (fact.enabled ? " checked" : "") + " /></td><td>" + fact.hits + "</td><td><input type=" + Q + "text" + Q + " value=" + Q + fact.name + Q + " readonly></td></tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	return txt;
};

/**
 * Returns the rules as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @returns {string} String.
 */
Puzzler.getRulesAsHtml = function (puzzle) {
	let txt = "";
	if (puzzle === null) return txt;

	txt = "<table class=" + Q + "clsAppTable clsRules" + Q + ">" + NL + "<caption>Rules</caption>" + NL + "<thead>" + NL + "<tr><th>#</th><th>X</th><th>Hits</th><th>Name</th></tr>" + NL + "</thead>" + NL + "<tbody>" + NL;
	for (let rule of puzzle.rules) {
		txt += "<tr><td>" + rule.num + "</td><td><input type=" + Q + "checkbox" + Q + " onclick=" + Q + "viewer.enableRule(" + rule.num + ")" + Q + (rule.enabled ? " checked" : "") + " /></td>" + "<td>" + rule.hits + "</td><td><input type=" + Q + "text" + Q + " value=" + Q + rule.name + Q + " readonly></td></tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	return txt;
};

/**
 * Returns the marks as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @returns {string} String.
 */
Puzzler.getMarksAsHtml = function (puzzle) {
	let txt = "";
	if (puzzle === null) return txt;

	txt = "<table class=" + Q + "clsAppTable clsMarks" + Q + ">" + NL + "<caption>Marks</caption>" + NL + "<thead>" +
		NL + "<tr><th>#</th><th>L</th><th>Type</th><th>Reason</th></tr>" +
		NL + "</thead>" + NL + "<tbody>" + NL;
	for (let mark of puzzle.marks) {
		if (mark.num > puzzle.numMarks) break;
		txt += "<tr><td>" + mark.num + "<td>" + mark.levelNum + mark.levelSub + "</td><td>" + mark.type.name + "</td><td><input type=" + Q + "text" + Q + " value=" + Q + Helper.getMsgAsOneLine(mark.reason) + Q + " readonly></td></tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	return txt;
};

/**
 * Returns the chart as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @param {number} chartCol1 Number of first column to display.
 * @param {boolean} isSolution Solution flag.
 * @returns {string} String.
 */
Puzzler.getChartAsHtml = function (puzzle, chartCol1, isSolution) {
	let txt = "";
	if (puzzle === null) return txt;

	let caption = isSolution ? "Solution" : "Chart";
	txt = "<table class=" + Q + "clsAppTable clsChart" + Q + ">" + NL + "<caption>" + caption + "</caption>" + NL + "<thead>" + NL + "<tr>" + NL;

	let t = chartCol1;
	let nounTypes = puzzle.nounTypes;
	let nounType1 = nounTypes[t];

	let maxNounTypes = nounTypes.length;
	let maxNouns = nounType1.nouns.length;

	let i = 0, j = 0, k = 0;
	for (j = 0; j < maxNounTypes; j++) {
		if (k === t)++k;
		let nounType = (j === 0 ? nounType1 : nounTypes[k++]);
		txt += (isSolution ? "<th>" : "<th onclick=" + Q + "viewer.updateChartCol1(" + j + ")" + Q + ">");
		txt += nounType.name + "</th>" + NL;
	}
	txt += "</tr>" + NL + "</thead>" + NL + "<tbody>" + NL;
	for (i = 0; i < maxNouns; i++) {
		txt += "<tr>" + NL;
		k = 0;
		for (j = 0; j < maxNounTypes; j++) {
			if (k === t) ++k;
			let noun1 = nounType1.nouns[i];
			if (j === 0)
				txt += "<td>" + noun1.title + "</td>";
			else {
				let noun2 = Puzzle.getPairNoun(noun1, nounTypes[k]);
				if (noun2 === null)
					txt += "<td>&nbsp;</td>";
				else
					txt += "<td>" + noun2.title + "</td>";
				++k;
			}
		}
		txt += NL + "</tr>" + NL;
	}
	txt += "</tbody>" + NL + "</table>" + NL;

	return txt;
};

/**
 * Returns the grids as an HTML table.
 * @param {Puzzle} puzzle Puzzle object.
 * @param {Verb} gridVerb Grid verb.
 * @returns {string} String.
 */
Puzzler.getGridsAsHtml = function (puzzle, gridVerb) {
	let txt = "";
	if (puzzle === null) return txt;

	function getVerticalName(name) {
		let txt = "";
		for (let i = 0; i < name.length; i++) {
			txt += name[i] + "<br />";
		}
		return txt;
	}

	// "<button id=" + Q + "btnGridVerb" + Q + " alt=" + Q + "Mark" + Q + " onclick=" + Q + "viewer.updateGridVerb();" + Q + " title=" + Q +
	// "<button alt=" + Q + "Undo" + Q + " onclick=" + Q + "viewer.undoUserMark()" + Q + "; title=" + Q + "Undo your last mark" + Q + "><img src=" + Q +

	let gridControls = "<div class=" + Q + "clsGridControls" + Q + ">" +
		"Verb<br />" +
		"<button id=" + Q + "btnGridVerb" + Q + " alt=" + Q + "Verb" + Q + " title=" + Q + "Click to change verb" + Q + ">" + gridVerb.getCode() + "</button>" +
		"<br />Undo<br />" +
		"<button id=" + Q + "btnGridUndo" + Q + " alt=" + Q + "Undo" + Q + " title=" + Q + "Undo your last mark" + Q + "><img src=" + Q +
		"/img/arrow-undo.png" + Q + " style=" + Q + "vertical-align:middle;" + Q + " /></button>" +
		"</div>";

	txt = "<table class=" + Q + "clsAppTable clsBigGrid" + Q + ">" + NL + "<caption>Grids</caption" + NL;

	// do-while loop decrements m, but n is unchanged.
	let nounTypes = puzzle.nounTypes;
	let m = nounTypes.length;
	let n = nounTypes[0].nouns.length;

	// Row headers are t1. Noun type indexes: 0, then m-1, m-2, ... where t1 > 1
	let t1 = 0;
	do {
		let nounType1 = nounTypes[t1];

		// Display column header nouns once.
		if (t1 < 1) {
			txt += "<thead>" + NL + "<tr>";
			txt += "<th colspan=" + Q + n + Q + ">&nbsp;</th>";
			for (let t2 = 1; t2 < m; t2++) {
				let type2 = nounTypes[t2];
				txt += "<th colspan=" + Q + n + Q + ">" + type2.name + "</th>";
				// Column separator between noun types. Was: ((m - t2)*n - t2 - 1)
				if (t2 < m - 1) {
					txt += "<th class=" + Q + "clsGridSep" + Q + " rowspan=" + Q + ((m - t2 - 1) * (n + 1) + 1) + Q + "></th>";
				}
			}
			txt += "</thead>" + NL + "</tr>" + NL;

			txt += "<tr style=" + Q + "vertical-align:bottom;" + Q + ">";
			txt += "<th colspan=" + Q + n + Q + ">" + gridControls + "<br />" + nounType1.name + "</th>";
			for (let t2 = 1; t2 < m; t2++) {
				let type2 = nounTypes[t2];
				for (let n2 = 0; n2 < n; n2++) {
					let noun2 = type2.nouns[n2];
					txt += "<td>" + getVerticalName(noun2.title) + "</td>";
				}
			}
			txt += "</tr>" + NL;
		}
		else {
			txt += "<tr><th colspan=" + Q + n + Q + ">" + nounType1.name + "</th></tr>";
		}

		// Display nouns for row header in first column, then marks for each row/col noun pair.
		for (let n1 = 0; n1 < n; n1++) {
			let noun1 = nounType1.nouns[n1];
			txt += "<tr>";
			txt += "<td colspan=" + Q + n + Q + ">" + noun1.title + "</td>";
			for (let t2 = 1; t2 < m; t2++) {
				let type2 = nounTypes[t2];
				for (let n2 = 0; n2 < n; n2++) {
					let noun2 = type2.nouns[n2];
					let verb = puzzle.getGridVerb(noun1, noun2);
					let evt1 = "onmouseout=" + Q + "viewer.doGridCell(0," + t1 + "," + n1 + "," + t2 + "," + n2 + ");" + Q;
					let evt2 = "onmouseover=" + Q + "viewer.doGridCell(1," + t1 + "," + n1 + "," + t2 + "," + n2 + ");" + Q;
					let evt3 = "onclick=" + Q + "viewer.clickGridCell(" + t1 + "," + n1 + "," + t2 + "," + n2 + "," + verb.num + ");" + Q;
					txt += "<td " + evt1 + " " + evt2 + " " + evt3 + ">" + verb.getCode() + "</td>";
				}
			}
			txt += "</tr>" + NL;
		}

		if (t1 < 1) t1 = m;
	} while (--t1 > 1 && --m > 0);

	txt += "</table>" + NL;

	return txt;
};

/**
 * Returns the stats as an HTML table.
 * @param {Stats} stats Stats object.
 * @returns {string} String.
 */
Puzzler.getStatsAsHtml = function (stats) {
	// Returns the HTML table for either the pairs or the marks.
	function getStatsTable(levelCounters, label) {
		//print("WTF1 levelCounters.length=" + Q + levelCounters.length + Q + " label=" + Q + label + Q);
		let txt = "";

		txt = "<table class=" + Q + "clsAppTable clsStats" + Q + ">" + NL + "<caption>" + label + " By Level</caption>" +
			NL + "<thead>" + NL + "<tr><th>#</th><th>Facts</th><th>Rules</th>";
		for (let j = 0; j < Solver.MaxLaws; j++) {
			txt += "<th>Law " + (j + 1) + "</th>";
		}
		txt += "<th>Total</th></tr>" + NL + "</thead>" + NL + "<tbody>" + NL;

		for (let i = 0; i < Solver.MaxLevels; i++) {
			let lc = levelCounters[i];
			txt += "<tr><th>" + (i + 1) + "</th><td>" + lc.levelHits + "</td><td>" + lc.ruleHits + "</td>";
			for (let j = 0; j < Solver.MaxLaws; j++) {
				txt += "<td>" + lc.lawsHits[j] + "</td>";
			}
			txt += "<td>" + lc.sum + "</td></tr>" + NL;
		}

		// Display the totals.
		let totals = levelCounters[Solver.MaxLevels];
		txt += "<tr><th>Total</th>" + "<td>" + totals.levelHits + "</td><td>" + totals.ruleHits + "</td>";
		for (let j = 0; j < Solver.MaxLaws; j++) {
			txt += "<td>" + totals.lawsHits[j] + "</td>";
		}
		txt += "<td>" + totals.sum + "</td></tr>" + NL + "</tbody>" + NL + "</table>" + NL;
		return txt;
	}

	let txt = getStatsTable(stats.getLevelPairs(), "Pairs") + "<br />" + NL + getStatsTable(stats.getLevelMarks(), "Marks");
	return txt;
};

UIX

The User Interface and eXperience (UIX) static class for all pages. It has methods to show/hide the "fun" images that float on the right side of my pages.

The UIX Class

/* global Locker */
/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * @class UIX class.
 * @description The User Interface and eXperience (UIX) static class is common for all web pages.
 * @author Michael Benson <michael.benson@mysterymaster.com>
 * @copyright Mystery Master
 * @version 2017-07-01
 */
function UIX() {
	throw new Error("UIX is a static class!");
}

// ---------------------------------------------------------------
// Style Sheet methods.

/** Key to find the Fun Image display value stored in the locker. */
UIX.imgFunKey = "imgFun";

/** Style for the Fun Image display rule. */
UIX.imgFunStyle = null;

/** Initializes the Fun Image style. */
UIX.initImgFun = function () {
	let key = UIX.imgFunKey;
	let style = UIX.addStyle();
		
	let val = Locker.getString(key, "inline");
	if (val !== "none") val = "inline";
	console.log("initImgFun val=" + val);
	Locker.setValue(key, val);
		
	UIX.addImgFunRule(style, val);
	UIX.imgFunStyle = style;
};

/** Toggles the display of the fun images between "none" and "inline". */
UIX.toggleImgFun = function () {
	let key = UIX.imgFunKey;
	let style = UIX.imgFunStyle;
		
	// Select/Update value from storage.
	let val = Locker.getString(key, "inline");
	if (val !== "inline") val = "none";
	val = (val === "inline" ? "none" : "inline");
	Locker.setValue(key, val);
	console.log("toggleImgFun val=" + val);
		
	if (style === null)
		style = UIX.addStyle();
	else
		UIX.deleteRules(style);
	UIX.addImgFunRule(style, val);
	UIX.imgFunStyle = style;		
};

/**
 * Creates style element and add to the document's head, and returns the style element.
 * @returns {Element} Style element.
 */
UIX.addStyle = function () {
	let style = document.createElement("style");
	document.head.appendChild(style);
	return style;
};

/**
 * Deletes all of the rules for the style element.
 * @param {Element} style Style element.
 */
UIX.deleteRules = function (style) {
	if (style === null) return;
	for (let i = style.sheet.cssRules.length - 1; i > -1; i--) {
		style.sheet.deleteRule(i);
	}
};
	
/**
 * Adds the Fun Image rule to the style element.
 * @param {Element} style Style element.
 * @param {string} val String value.
 */
UIX.addImgFunRule = function (style, val) {
	if (style === null) return;
	style.sheet.insertRule(".imgFun { display: " + val + "; }", 0);
};

// ---------------------------------------------------------------
// Field Methods.

/**
 * Sets the value of the field to the given value.
 * Called by setup.updateField.
 * @param {Field} fld Field.
 * @param {type} val Value.
 */
UIX.setField = function (fld, val) {
	switch (fld.type) {
		case "checkbox":
			if (val !== undefined) fld.checked = val;
			break;
		case "range":
			if (val !== undefined) fld.value = val;
			break;
	}
};

/**
 * Sets the value of the field from the locker (if found), or the default value.
 * Called by setup.updateField.
 * @param {Field} fld Field.
 * @param {type} val Value.
 */
UIX.setFieldFromLocker = function (fld, val) {
	if (!Locker.ok) return;
	// Get value from locker.
	switch (fld.type) {
		case "checkbox":
			val = Locker.getBoolean(fld.id, val);
			if (val !== undefined) fld.checked = val;
			break;
		case "range":
			val = Locker.getNumber(fld.id, val);
			if (val !== undefined) fld.value = val;
			break;
	}
};

/**
 * Puts the field and its value into the locker, optionally assigning a value to it first.
 * Called by setup.updateField.
 * @param {Field} fld Field.
 * @param {type} val Value.
 */
UIX.putFieldInLocker = function (fld, val) {
	if (!Locker.ok) return;
	switch (fld.type) {
		case "checkbox":
			if (val !== undefined) fld.checked = val;
			Locker.setValue(fld.id, fld.checked);
			break;
		case "range":
			if (val !== undefined) fld.value = val;
			Locker.setValue(fld.id, fld.value);
			break;
	}
};

setTimeout

Here is an interesting article concerning setTimeout. To wit, JavaScript has a concurrency model based on an "event loop", and calling setTimeout adds a message to the queue. This technique avoids the dreaded "a script is taking too long to run" dialog.

Using setTimeout has its consequences - it turns a procedure-based program into an event-driven one. If the UI could be updated everytime a mark was entered, this would not be a problem. But there is one thing we must take into account - the user may want the program to pause for certain events, like when a mark is entered. If we say that each event places one or more tasks into a queue, we need a method that can process each task in order. Below is a simplified version of the doTasks method found in the Viewer class.

	function doTasks() {
		if (numTasks === maxTasks) return;
		let data = tasks[numTasks++];

		// Perform the task based on the key.
		switch (data.key) {
		}

		// Exit if the pause flag is true. Call resumeTasks when the Resume button is pressed.
		if (pauseFlag) {
			board.pauseWork();
			return;
		}

		// Recursively call doTasks via setTimeout.
		setTimeout(function () { doTasks(); }, ms);
	}
	

Sharp-minded people will notice this is similar to the Producer-Consumer problem. After the consumer performs a task, it must see if it needs to wait. If the consumer does not need to wait, it simply moves on to the next task in the queue. But if the consumer needs to wait, instead of spinning CPU cycles in a loop until the user presses the Resume button, the consumer simply stops running. This is the Not Runnable state. Now the only way for the consumer to resume is to have the click event of the Resume button tell the consumer to "wake up" and put the consumer into the Runnable state. I'm probably mixing metaphors here, but you get the idea.

Pausing

The user may request the program to pause in the situations given below.

Board Buttons

Here are all of the possible states for the Work and Quit buttons. Note that a button's caption may change for a specific state. For simplicity, the following refers to a button by its current caption.

  1. When there is no solver or no puzzle, both buttons are disabled.
  2. When the puzzle is loaded, the Work button is enabled and says "Solve".
  3. When the Solve button is pressed, the Solve button says "Pause" and the Quit button is enabled.
  4. When the Pause button is pressed, the Pause button says "Resume".
  5. When the Resume button is pressed, the Resume button says "Pause".
  6. When the Quit button is pressed, the Pause button says "Solve" and the Quit button says "Reset".
  7. When the Reset button is pressed, the Reset button says "Quit" and is disabled.

Warning: For DOM objects with an id, most browsers will auto-create JavaScript variables with the id as its name! I don't like this because I do not want the viewer to access fields that are managed by subcomponents.

Conclusion

I hope you enjoyed reading this article. My motivation for writing this article is that you will try to model a logic puzzle on your own. Then together we can find better ways to model and/or solve logic puzzles. Thank you.

About the Author

Michael Benson Michael Benson

My current position is zookeeper of the Mystery Master website at http://www.mysterymaster.com. My favorite languages are C#, Java, and JavaScript. When I'm not at my computer, I am travelling far more than I like to places far more exciting than I can handle.