summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBrian Cully <bjc@kublai.com>2021-01-03 18:34:19 -0500
committerBrian Cully <bjc@kublai.com>2021-02-06 10:39:32 -0500
commite92ad9f4b19a0670a80cdd293970c3a08c27a8b4 (patch)
treee8659dcdbf5f7ba3c55a118909d82dd8f0d0bcbd
downloadmolsim-e92ad9f4b19a0670a80cdd293970c3a08c27a8b4.tar.gz
molsim-e92ad9f4b19a0670a80cdd293970c3a08c27a8b4.zip
Initial commit.
-rw-r--r--Makefile4
-rw-r--r--desktop.css3
-rw-r--r--die.mjs64
-rw-r--r--error.mjs55
-rw-r--r--favicon.pngbin0 -> 61638 bytes
-rw-r--r--genome-list.mjs24
-rw-r--r--genome.mjs80
-rw-r--r--index.html92
-rw-r--r--main.mjs16
-rw-r--r--mobile.css123
-rw-r--r--nucleotide-selector.mjs71
-rw-r--r--nucleotide.mjs79
-rw-r--r--print.css29
-rw-r--r--rules.mjs361
-rw-r--r--style.css57
-rw-r--r--tablet.css23
-rw-r--r--utils.mjs48
17 files changed, 1129 insertions, 0 deletions
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..02db249
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,4 @@
+.PHONY: serve
+
+serve:
+ python -m http.server
diff --git a/desktop.css b/desktop.css
new file mode 100644
index 0000000..3372515
--- /dev/null
+++ b/desktop.css
@@ -0,0 +1,3 @@
+#instructions {
+ flex-basis: 33%
+}
diff --git a/die.mjs b/die.mjs
new file mode 100644
index 0000000..6b3d4fc
--- /dev/null
+++ b/die.mjs
@@ -0,0 +1,64 @@
+class Die {
+ constructor(elt) {
+ this.elt = elt
+
+ this.value = '--'
+ this._boundRollHandler = this.rollHandler.bind(this)
+ this.disable()
+ }
+
+ get valueElt() {
+ if (this._valueElt === undefined) {
+ this._valueElt = this.elt.querySelector('.value')
+ }
+ return this._valueElt
+ }
+
+ get value() {
+ return this.valueElt.innerText
+ }
+
+ set value(val) {
+ this.valueElt.innerText = val
+ }
+
+ get button() {
+ if (this._button === undefined) {
+ this._button = this.elt.querySelector('button')
+ }
+ return this._button
+ }
+
+ enable() {
+ this.elt.classList.add('enabled')
+ this.elt.classList.remove('disabled')
+ this.button.disabled = false
+ this.button.addEventListener('click', this._boundRollHandler)
+ }
+
+ disable() {
+ this.elt.classList.add('disabled')
+ this.elt.classList.remove('enabled')
+ this.button.disabled = true
+ this.button.removeEventListener('click', this._boundRollHandler)
+ }
+
+ get onChanged() {
+ if (this._onChanged !== undefined) {
+ return this._onChanged
+ }
+ return () => {}
+ }
+
+ set onChanged(fn) {
+ this._onChanged = fn
+ }
+
+ rollHandler() {
+ this.value = Math.floor(Math.random() * Die.size) + 1
+ this.onChanged(this.value)
+ }
+}
+Die.size = 20
+
+export default Die
diff --git a/error.mjs b/error.mjs
new file mode 100644
index 0000000..0c12f0e
--- /dev/null
+++ b/error.mjs
@@ -0,0 +1,55 @@
+class Error {
+ constructor(elt) {
+ this.elt = elt
+
+ this._boundClickHandler = this.clickHandler.bind(this)
+ this.button.addEventListener('click', this._boundClickHandler)
+ }
+
+ get errorElt() {
+ if (this._errorElt === undefined) {
+ this._errorElt = this.elt.querySelector('p')
+ }
+ return this._errorElt
+ }
+
+ get button() {
+ if (this._button === undefined) {
+ this._button = this.elt.querySelector('button')
+ }
+ return this._button
+ }
+
+ get innerHTML() {
+ return this.errorElt.tinnerHTML
+ }
+
+ set innerHTML(html) {
+ this.errorElt.innerHTML = html
+ }
+
+ get onClick() {
+ if (this._onClick !== undefined) {
+ return this._onClick
+ }
+ return () => {}
+ }
+
+ set onClick(fn) {
+ this._onClick = fn
+ }
+
+ show() {
+ this.elt.classList.remove('hidden')
+ }
+
+ hide() {
+ this.elt.classList.add('hidden')
+ }
+
+ clickHandler() {
+ this.onClick()
+ }
+}
+
+export default Error
diff --git a/favicon.png b/favicon.png
new file mode 100644
index 0000000..73c27ee
--- /dev/null
+++ b/favicon.png
Binary files differ
diff --git a/genome-list.mjs b/genome-list.mjs
new file mode 100644
index 0000000..0008ce8
--- /dev/null
+++ b/genome-list.mjs
@@ -0,0 +1,24 @@
+class GenomeList {
+ constructor(elt) {
+ this.genomes = [];
+ this.elt = elt;
+ }
+
+ push(genome) {
+ this.genomes.push(genome)
+ this.elt.appendChild(genome.elt)
+ window.genome = genome
+ console.log('scrolling into vuew', genome.elt)
+ genome.elt.scrollIntoView(false)
+ }
+
+ get last() {
+ if (this.genomes.length > 0) {
+ return this.genomes[this.genomes.length - 1]
+ } else {
+ return undefined
+ }
+ }
+}
+
+export default GenomeList
diff --git a/genome.mjs b/genome.mjs
new file mode 100644
index 0000000..fcab4ff
--- /dev/null
+++ b/genome.mjs
@@ -0,0 +1,80 @@
+import Nucleotide from './nucleotide.mjs'
+import Die from './die.mjs'
+import { randomItem } from './utils.mjs'
+
+class Genome {
+ static *randomBase() {
+ for (const i of [...Array(Genome.size)]) {
+ yield randomItem(Nucleotide.bases)
+ }
+ }
+
+ constructor(gen) {
+ const nucleotideList = document.createElement('ol')
+ this._boundNucleotideClickedHandler =
+ this.nucleotideClickedHandler.bind(this)
+ this.nucleotides = [...gen].map(base => {
+ const n = new Nucleotide(base)
+ n.onClick = this._boundNucleotideClickedHandler
+ nucleotideList.appendChild(n.elt)
+ return n
+ })
+ this.elt.appendChild(nucleotideList)
+ this.lock()
+ }
+
+ get elt() {
+ if (this._elt === undefined) {
+ this._elt = document.createElement('li')
+ this._elt.classList.add('genome')
+ }
+ return this._elt
+ }
+
+ get onSelectionChanged() {
+ if (this._onSelectionChanged !== undefined) {
+ return this._onSelectionChanged
+ }
+ return () => {}
+ }
+
+ set onSelectionChanged(fn) {
+ this._onSelectionChanged = fn
+ }
+
+ lock() {
+ this.elt.classList.add('locked')
+ this.nucleotides.forEach(n => n.lock())
+ }
+
+ unlock() {
+ this.elt.classList.remove('locked')
+ this.nucleotides.forEach(n => n.unlock())
+ }
+
+ clone() {
+ return new Genome(this.nucleotides.map(n => n.value))
+ }
+
+ get selectedNucleotide() {
+ return this._selectedNucleotide
+ }
+
+ set selectedNucleotide(nucleotide) {
+ if (this.selectedNucleotide !== undefined) {
+ this.selectedNucleotide.deselect()
+ }
+ this._selectedNucleotide = nucleotide
+ this._selectedNucleotide.select()
+
+ const i = this.nucleotides.indexOf(this._selectedNucleotide)
+ this.onSelectionChanged(this._selectedNucleotide, i)
+ }
+
+ nucleotideClickedHandler(nucleotide) {
+ this.selectedNucleotide = nucleotide
+ }
+}
+Genome.size = 18
+
+export default Genome
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5ab898a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<html lang='en'>
+ <head>
+ <meta charset='utf-8'>
+ <meta name='viewport'
+ content='width=device-width, initial-scale=1.0'>
+ <title>Molecular Evolution Simulator</title>
+ <link rel='shortcut icon' href='favicon.png' type='image/png'>
+ <link rel='stylesheet' href='style.css'>
+ <link rel='stylesheet' media='only screen'
+ href='mobile.css'>
+ <link rel='stylesheet' media='only screen and (min-width: 768px)'
+ href='tablet.css'>
+ <link rel='stylesheet' media='only screen and (min-width: 1024px)'
+ href='desktop.css'>
+ <link rel='stylesheet' media='only print'
+ href='print.css'>
+ </head>
+
+ <body>
+ <ol id='genome-history' start='0'>
+ </ol>
+
+ <div id='instructions'>
+ <div id='die'>
+ <div class='value'>--</div>
+ <button class='roll' disabled=''>Roll</button>
+ </div>
+
+ <span id='remaining-iterations'>--</span> more times:
+ <ol>
+ <li id='clone-nucleotide' class='step'>
+ <button id='clone' disabled=''>Clone</button> the genome to
+ start mutating it.
+ </li>
+
+ <li id='roll-for-nucleotide' class='step'>
+ Roll to find the nucleotide to mutate.
+ <ul>
+ <li>If the roll is between 1 through 18, inclusive, select
+ that nucleotide.</li>
+ <li>Otherwise, skip mutation and clone the genome
+ again.</li>
+ </ul>
+ </li>
+
+ <li id='nucleotide-select' class='step'>
+ Select the <span id='select-number'>rolled</span>
+ nucleotide in the sequence.
+ </li>
+
+ <li id='roll-for-mutation' class='step'>
+ Roll to see what kind of mutation to perform.
+ </li>
+
+ <li id='perform-mutation' class='step'>
+ Depending on the roll:
+
+ <ul>
+ <li>If the roll is between 1 through 14, inclusive, perform
+ a <em>transition</em> on the selected nucleotide.</li>
+
+ <li>If the roll is between 15 through 17, inclusive,
+ perform a <em>complementing transversion</em> on the selected
+ nucleotide to the base it pairs with.</li>
+
+ <li>Otherwise, perform the <em>other transversion</em> on
+ the selected nucleotide.</li>
+ </ul>
+ </li>
+ </ol>
+
+ <p id='print-results' class='step'>
+ <button id='print' disabled=''>Print</button> results.
+ </p>
+ </div>
+
+ <div id='errors' class='hidden'>
+ <p></p>
+ <button>OK</button>
+ </div>
+
+ <ul id='nucleotide-selector' class='hidden'>
+ <li>A</li>
+ <li>G</li>
+ <li>C</li>
+ <li>T</li>
+ </ul>
+
+ <script src='main.mjs' type='module'></script>
+ </body>
+</html>
diff --git a/main.mjs b/main.mjs
new file mode 100644
index 0000000..aed3c1a
--- /dev/null
+++ b/main.mjs
@@ -0,0 +1,16 @@
+import Rules from './rules.mjs'
+
+function init() {
+ const genomeList = document.querySelector('#genome-history')
+ const die = document.querySelector('#die')
+ const nucleotideSelector = document.querySelector('#nucleotide-selector')
+ const instructions = document.querySelector('#instructions')
+ const cloneButton = document.querySelector('#clone')
+ const remainingIterations = document.querySelector('#remaining-iterations')
+ const printButton = document.querySelector('#print')
+ const errors = document.querySelector('#errors')
+
+ const rules = new Rules(die, instructions, genomeList, nucleotideSelector, cloneButton, remainingIterations, printButton, errors)
+}
+
+init()
diff --git a/mobile.css b/mobile.css
new file mode 100644
index 0000000..3d721de
--- /dev/null
+++ b/mobile.css
@@ -0,0 +1,123 @@
+body {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ margin: 0;
+
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+#errors {
+ position: fixed;
+ display: block;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ background-color: #f99;
+ border: 1px solid #922;
+}
+
+#errors p {
+ padding: 1ex 1em;
+ margin: 0;
+}
+
+#errors button {
+ display: block;
+ margin-left: auto;
+ margin-right: 5px;
+ margin-bottom: 5px;
+}
+
+#genome-history {
+ margin: 0;
+ overflow: scroll;
+ align-self: stretch;
+ flex-basis: 50%;
+}
+
+#instructions {
+ padding: 1ex;
+ margin: 0;
+ overflow: scroll;
+ flex-basis: 50%;
+ border-style: solid;
+ border-top-width: 1px;
+ border-left-width: 0;
+ border-right-width: 0;
+ border-bottom-width: 0;
+}
+
+#instructions .step {
+ padding: 5px;
+}
+
+#instructions .step {
+ display: none;
+}
+
+#instructions .step.current {
+ display: list-item;
+}
+
+#print-results.step.current {
+ display: block;
+}
+
+#die {
+ display: block;
+ float: right;
+ padding: 2ex;
+}
+
+#die .value {
+ padding-bottom: 1ex;
+}
+
+#nucleotide-selector {
+ position: relative;
+ white-space: nowrap;
+ padding: 0;
+ word-spacing: -6px; /* TODO: 0 doesn't work. And for some reason whitespace is being renderded between <li> elements? */
+ width: 8em; /* TODO: this shouldn't be hard coded. */
+}
+
+#nucleotide-selector li {
+ display: inline-block;
+ width: 2em;
+ flex-basis: 2em;
+ text-align: center;
+ padding-top: 1ex;
+ padding-bottom: 1ex;
+}
+
+.genome {
+ margin: 0.5ex;
+}
+
+.genome>ol {
+ display: inline-flex;
+ flex-wrap: wrap;
+ padding: 0;
+}
+
+.genome .nucleotide {
+ display: inline-block;
+ height: 32px;
+ width: 32px;
+ font-size: 18px;
+ text-align: center;
+ flex-wrap: wrap;
+ justify-content: space-around;
+ align-items: center;
+}
+
+.genome .nucleotide span {
+ display: inline-block;
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
diff --git a/nucleotide-selector.mjs b/nucleotide-selector.mjs
new file mode 100644
index 0000000..69a94ab
--- /dev/null
+++ b/nucleotide-selector.mjs
@@ -0,0 +1,71 @@
+class NucleotideSelector {
+ constructor(elt) {
+ this.elt = elt
+ for (const elt of this.elt.querySelectorAll('li')) {
+ elt.addEventListener('click', this.select.bind(this))
+ }
+ }
+
+ get onItemSelected() {
+ if (this._onItemSelected !== undefined) {
+ return this._onItemSelected
+ }
+ return () => {}
+ }
+
+ set onItemSelected(fn) {
+ this._onItemSelected = fn
+ this._boundWindowResizeHandler =
+ this.windowResizeHandler.bind(this)
+ }
+
+ attach(nucleotide) {
+ this.nucleotide = nucleotide
+
+ this.nucleotide.elt.appendChild(this.elt)
+ this.elt.classList.remove('hidden')
+
+ this.adjustPosition()
+ window.addEventListener('resize', this._boundWindowResizeHandler)
+ }
+
+ detach() {
+ this.elt.classList.add('hidden')
+ window.removeEventListener('resize',
+ this._boundWindowResizeHandler)
+ }
+
+ windowResizeHandler() {
+ this.adjustPosition()
+ }
+
+ adjustPosition() {
+ const top =
+ this.nucleotide.elt.offsetTop +
+ this.nucleotide.elt.offsetHeight
+ const myWidth =
+ this.elt.getBoundingClientRect().width
+ const eltLeft =
+ this.nucleotide.elt.offsetLeft
+ const parentWidth =
+ this.nucleotide.elt.offsetParent.offsetWidth
+/*
+ this.elt.style.top = `${top}px`
+ if (eltLeft + myWidth > parentWidth) {
+ this.elt.style.left = 'auto'
+ this.elt.style.right = '0'
+ } else {
+ this.elt.style.left = `${eltLeft}px`
+ this.elt.style.right = 'auto'
+ }
+*/
+
+ this.elt.scrollIntoView(false)
+ }
+
+ select(evt) {
+ this.onItemSelected(evt.currentTarget.innerText)
+ }
+}
+
+export default NucleotideSelector
diff --git a/nucleotide.mjs b/nucleotide.mjs
new file mode 100644
index 0000000..d599fe1
--- /dev/null
+++ b/nucleotide.mjs
@@ -0,0 +1,79 @@
+import NucleotideSelector from './nucleotide-selector.mjs'
+
+class Nucleotide {
+ constructor(base) {
+ this.value = base
+ this._boundClickHandler = this.clickHandler.bind(this)
+ }
+
+ get elt() {
+ if (this._elt === undefined) {
+ this._elt = document.createElement('li')
+ this._elt.classList.add('nucleotide')
+ }
+ return this._elt
+ }
+
+ get valueElt() {
+ if (this._valueElt === undefined) {
+ this._valueElt = document.createElement('span')
+ this.elt.appendChild(this._valueElt)
+ }
+ return this._valueElt
+ }
+
+ get value() {
+ return this.valueElt.innerText
+ }
+
+ set value(val) {
+ this.valueElt.innerText = val
+ }
+
+ get onClick() {
+ if (this._onClick !== undefined) {
+ return this._onClick
+ }
+ return () => {}
+ }
+
+ set onClick(fn) {
+ this._onClick = fn
+ }
+
+ lock() {
+ this.elt.removeEventListener('click', this._boundClickHandler)
+ }
+
+ unlock() {
+ this.elt.addEventListener('click', this._boundClickHandler)
+ }
+
+ select() {
+ this._elt.classList.add('selected')
+ }
+
+ deselect() {
+ this._elt.classList.remove('selected')
+ }
+
+ clickHandler(evt) {
+ this.onClick(this)
+ }
+}
+Nucleotide.translation = {'A': 'G',
+ 'C': 'T',
+ 'G': 'A',
+ 'T': 'C'}
+Nucleotide.complementingTransversion = {'A': 'T',
+ 'C': 'G',
+ 'G': 'C',
+ 'T': 'A'}
+Nucleotide.defaultTransversion = {'A': 'C',
+ 'C': 'A',
+ 'G': 'T',
+ 'T': 'G'}
+Nucleotide.bases = Object.keys(Nucleotide.translation)
+
+
+export default Nucleotide
diff --git a/print.css b/print.css
new file mode 100644
index 0000000..cbbfff0
--- /dev/null
+++ b/print.css
@@ -0,0 +1,29 @@
+body {
+ background-color: white;
+}
+
+#instructions {
+ display: none;
+}
+
+#die {
+ display: none;
+}
+
+#errors {
+ display: none;
+}
+
+#nucleotide-selector {
+ display: none;
+}
+
+#genome-history {
+ top: 0;
+ left: 0;
+ width: 100%;
+}
+
+.genome .nucleotide.selected {
+ border: 2px solid black;
+}
diff --git a/rules.mjs b/rules.mjs
new file mode 100644
index 0000000..cc1363b
--- /dev/null
+++ b/rules.mjs
@@ -0,0 +1,361 @@
+import { randomItem, ordinalSuffix } from './utils.mjs'
+import Genome from './genome.mjs'
+import Nucleotide from './nucleotide.mjs'
+import Die from './die.mjs'
+import GenomeList from './genome-list.mjs'
+import NucleotideSelector from './nucleotide-selector.mjs'
+import Error from './error.mjs'
+
+class CloneNucleotide {
+ id = 'clone-nucleotide'
+
+ constructor(rules) {
+ this.rules = rules
+
+ this._boundClickHandler = this.clickHandler.bind(this)
+ }
+
+ enter() {
+ this.rules.cloneButton.addEventListener('click',
+ this._boundClickHandler)
+ this.rules.cloneButton.disabled = false
+ }
+
+ exit() {
+ this.rules.cloneButton.removeEventListener('click',
+ this._boundClickHandler)
+ this.rules.cloneButton.disabled = true
+ }
+
+ clickHandler(evt) {
+ const genome = this.rules.currentGenome.clone()
+ this.rules.genomeList.push(genome)
+ this.rules.next(new RollForNucleotide(this.rules))
+ }
+}
+
+class RollForNucleotide {
+ id = 'roll-for-nucleotide'
+
+ constructor(rules) {
+ this.rules = rules
+ }
+
+ enter() {
+ this.rules.die.value = '--'
+ this.rules.die.onChanged = this.handleDieRoll.bind(this)
+ this.rules.die.enable()
+ }
+
+ exit() {
+ this.rules.die.disable()
+ this.rules.die.onChanged = undefined
+ }
+
+ handleDieRoll() {
+ if (this.rules.die.value > Genome.size) {
+ this.rules.iterations--
+ if (this.rules.isLastIteration) {
+ this.rules.next(new DoNothing(this.rules))
+ } else {
+ this.rules.next(new CloneNucleotide(this.rules))
+ }
+ } else {
+ this.rules.next(new NucleotideSelect(this.rules))
+ }
+ }
+}
+
+class NucleotideSelect {
+ id = 'nucleotide-select'
+
+ constructor(rules) {
+ this.rules = rules
+ }
+
+ enter() {
+ this.want = this.rules.die.value
+ this.rules.instructions.querySelector('#select-number').innerHTML =
+ `${this.want}<sup>${ordinalSuffix(this.want)}</sup>`
+
+ this.rules.currentGenome.onSelectionChanged =
+ this.handleSelectionChanged.bind(this)
+ this.rules.currentGenome.unlock()
+ }
+
+ exit() {
+ this.rules.currentGenome.lock()
+ this.rules.currentGenome.onSelectionChanged = undefined;
+ }
+
+ handleSelectionChanged(nucleotide, i) {
+ i++;
+ if (i != this.rules.die.value) {
+ this.rules.error.innerHTML =
+ `You selected the ${i}<sup>${ordinalSuffix(i)}</sup> nucleotide. Please select the ${this.want}<sup>${ordinalSuffix(this.want)}</sup> one.`
+ this.rules.next(new ShowError(this.rules, this))
+ return
+ }
+ this.rules.next(new RollForMutation(this.rules))
+ }
+}
+
+class RollForMutation {
+ id = 'roll-for-mutation'
+
+ constructor(rules) {
+ this.rules = rules
+ }
+
+ enter() {
+ this.rules.die.value = '--'
+ this.rules.die.onChanged = this.handleDieRoll.bind(this)
+ this.rules.die.enable()
+ }
+
+ exit() {
+ this.rules.die.disable()
+ this.rules.die.onChanged = undefined
+ }
+
+ handleDieRoll() {
+ this.rules.next(new PerformMutation(this.rules))
+ }
+}
+
+class PerformMutation {
+ id = 'perform-mutation'
+
+ constructor(rules) {
+ this.rules = rules
+ }
+
+ enter() {
+ const selector = this.rules.nucleotideSelector
+ selector.onItemSelected = this.handleItemSelected.bind(this)
+ selector.attach(this.selectedNucleotide)
+ }
+
+ exit() {
+ this.rules.nucleotideSelector.detach()
+ }
+
+ validMutation(from, to) {
+ return to == this.expectedMutation[from]
+ }
+
+ get expectedMutation() {
+ if (this.rules.die.value <= 14) {
+ return Nucleotide.translation
+ } else if (this.rules.die.value <= 17) {
+ return Nucleotide.complementingTransversion
+ } else {
+ return Nucleotide.defaultTransversion
+ }
+ }
+
+ get selectedNucleotide() {
+ return this.rules.currentGenome.selectedNucleotide
+ }
+
+ get errorTranslationHTML() {
+ return `Select the base that corresponds to a <em>translation</em> of ${this.selectedNucleotide.value}.`
+ }
+ get errorComplementingTransversionHTML() {
+ return `Select the base that corresponds to a <em>complementing transversion</em> of ${this.selectedNucleotide.value}.`
+ }
+ get errorDefaultTransversionHTML() {
+ return `Select the base that corresponds to the <em>other transversion</em> of ${this.selectedNucleotide.value}.`
+ }
+
+ get errorHTML() {
+ if (this.expectedMutation == Nucleotide.translation) {
+ return this.errorTranslationHTML
+ } else if (this.expectedMutation == Nucleotide.complementingTransversion) {
+ return this.errorComplementingTransversionHTML
+ } else {
+ return this.errorDefaultTransversionHTML
+ }
+ }
+
+ handleItemSelected(base) {
+ if (!this.validMutation(this.selectedNucleotide.value, base)) {
+ this.rules.error.innerHTML = this.errorHTML
+ this.rules.next(new ShowError(this.rules, this))
+ return
+ }
+
+ this.selectedNucleotide.value = base
+ this.rules.iterations--
+ if (this.rules.isLastIteration) {
+ this.rules.next(new DoNothing(this.rules))
+ } else {
+ this.rules.next(new CloneNucleotide(this.rules))
+ }
+ }
+}
+
+class DoNothing {
+ id = 'print-results'
+
+ constructor(rules) {
+ this.rules = rules
+
+ this._boundClickHandler = this.clickHandler.bind(this)
+ }
+
+ enter() {
+ this.rules.printButton.addEventListener('click', this._boundClickHandler)
+ this.rules.printButton.disabled = false
+ }
+
+ exit() {
+ this.rules.printButton.disabled = true
+ this.rules.printButton.removeEventListener('click', this._boundClickHandler)
+ }
+
+ clickHandler() {
+ window.print()
+ }
+}
+
+class ShowError {
+ constructor(rules, nextState) {
+ this.rules = rules
+ this.nextState = nextState
+
+ this._boundClickHandler = this.clickHandler.bind(this)
+ }
+
+ enter() {
+ this.rules.error.onClick = this._boundClickHandler
+ this.rules.error.show()
+ }
+
+ exit() {
+ this.rules.error.hide()
+ this.rules.error.onClick = undefined
+ }
+
+ clickHandler() {
+ this.rules.next(this.nextState)
+ }
+}
+
+class Rules {
+ constructor(die, instructions, genomeList, nucleotideSelector, cloneButton, remainingIterations, printButton, errors) {
+ this.die = new Die(die)
+ this.instructions = instructions
+ this.genomeList = new GenomeList(genomeList)
+ this.nucleotideSelector = new NucleotideSelector(nucleotideSelector)
+ this.cloneButton = cloneButton
+ this.remainingIterations = remainingIterations
+ this.printButton = printButton
+ this.error = new Error(errors)
+
+ this.iterations = Rules.maxIterations
+ this.cloneButton.disabled = true
+ this.genomeList.push(new Genome(Rules.initialGenomeBases))
+
+ if (false) {
+ this._debugStartAtRollForMutation()
+ } else if (false) {
+ this._debugStartAtPerformMutation(4)
+ } else if (false) {
+ this._debugStartWithError()
+ } else {
+ this.currentState = new CloneNucleotide(this)
+ }
+ this.enterState()
+ }
+
+ get iterations() {
+ return Number(this.remainingIterations.innerText)
+ }
+
+ set iterations(val) {
+ this.remainingIterations.innerText = val
+ }
+
+ get isLastIteration() {
+ return this.iterations == 0
+ }
+
+ _debugStartAtRollForMutation() {
+ this.currentState = new RollForMutation(this)
+ const nucleotide = this.currentGenome.nucleotides[2]
+ this.currentGenome.selectedNucleotide = nucleotide
+ }
+
+ _debugStartAtPerformMutation(n) {
+ [...Array(n)].forEach(i => {
+ const n = randomItem(this.currentGenome.nucleotides)
+ n.value = randomItem(Nucleotide.bases)
+ this.currentGenome.selectedNucleotide = n
+ const g = this.currentGenome.clone()
+ window.g = g
+ console.log(g)
+ this.genomeList.push(g)
+ })
+
+ this.currentState = new PerformMutation(this)
+ this.die.value = 15
+ const nucleotide = this.currentGenome.nucleotides[15]
+ this.currentGenome.selectedNucleotide = nucleotide
+ }
+
+ _debugStartWithError() {
+ this.currentState = new ShowError(this, new CloneNucleotide(this))
+ this.error.innerHTML = 'test an error'
+ }
+
+ get currentGenome() {
+ return this.genomeList.last
+ }
+
+ showCurrent() {
+ if (this.currentState.id === undefined) {
+ return
+ }
+ const elt =
+ this.instructions.querySelector(`#${this.currentState.id}`)
+ elt.classList.add('current')
+ elt.scrollIntoView(true)
+ }
+
+ hideCurrent() {
+ if (this.currentState.id === undefined) {
+ return
+ }
+ const elt =
+ this.instructions.querySelector(`#${this.currentState.id}`)
+ elt.classList.remove('current')
+ }
+
+ enterState() {
+ this.currentState.enter()
+ this.showCurrent()
+ }
+
+ exitState() {
+ this.hideCurrent()
+ this.currentState.exit()
+ }
+
+ next(nextState) {
+ this.exitState()
+ this.currentState = nextState
+ this.enterState()
+ }
+}
+Rules.maxIterations = 10
+Rules.initialGenomeBases = [
+ 'G', 'C', 'A',
+ 'C', 'T', 'C',
+ 'G', 'G', 'A',
+ 'T', 'C', 'G',
+ 'A', 'A', 'T',
+ 'T', 'C', 'T'
+]
+
+export default Rules
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..1d4f71c
--- /dev/null
+++ b/style.css
@@ -0,0 +1,57 @@
+* {
+ box-sizing: border-box;
+}
+
+.hidden {
+ display: none !important;
+}
+
+body {
+ background-color: #eee;
+}
+
+#instructions {
+ background-color: #ddd;
+}
+
+#instructions .current {
+ background-color: yellow;
+}
+
+#die {
+ background-color: #fde;
+ border: 1px solid #aaa;
+}
+
+#die .value {
+ text-align: center;
+}
+
+#nucleotide-selector {
+ background-color: ivory;
+ border: 1px solid black;
+}
+
+.genome.locked {
+ cursor: text
+}
+
+.genome>ol {
+ border: 1px solid black;
+ background-color: white;
+}
+
+#nucleotide-selector,
+.genome:not(.locked) {
+ cursor: pointer;
+}
+
+#nucleotide-selector li:hover,
+.genome:not(.locked) .nucleotide:hover {
+ background-color: orange;
+}
+
+.genome .nucleotide.selected,
+.genome:not(.locked) .nucleotide.selected:hover {
+ background-color: red;
+}
diff --git a/tablet.css b/tablet.css
new file mode 100644
index 0000000..3774e6c
--- /dev/null
+++ b/tablet.css
@@ -0,0 +1,23 @@
+body {
+ flex-direction: row-reverse;
+}
+
+#instructions {
+ border-top-width: 0;
+ border-bottom-width: 0;
+ border-left-width: 0;
+ border-right-width: 1px;
+ flex-basis: 40%
+}
+
+#instructions .step {
+ display: list-item;
+}
+
+#print-results.step {
+ display: block;
+}
+
+#genome-history {
+ flex-grow: 4;
+}
diff --git a/utils.mjs b/utils.mjs
new file mode 100644
index 0000000..61e61b1
--- /dev/null
+++ b/utils.mjs
@@ -0,0 +1,48 @@
+function randomItem(array) {
+ return array[Math.floor(Math.random() * array.length)]
+}
+
+function ordinalSuffix(v) {
+ let n = Number(v)
+ if (n > 20) {
+ n = n % 10
+ }
+
+ switch (n) {
+ case 1:
+ return 'st'
+ case 2:
+ return 'nd'
+ case 3:
+ return 'rd'
+ default:
+ return 'th'
+ }
+}
+
+function testOrdinalSuffix() {
+ const tests = {1: 'st',
+ 2: 'nd',
+ 3: 'rd',
+ 4: 'th',
+ 10: 'th',
+ 11: 'th',
+ 12: 'th',
+ 13: 'th',
+ 14: 'th',
+ 20: 'th',
+ 21: 'st',
+ 32: 'nd',
+ 43: 'rd',
+ 44: 'th',
+ 100: 'th',
+ 101: 'st'}
+ for (const t of Object.keys(tests)) {
+ const got = ordinalSuffix(t)
+ const want = tests[t]
+ console.assert(got === want,
+ `bad suffix for ${t}: ${got} !== ${want}`)
+ }
+}
+
+export { ordinalSuffix, testOrdinalSuffix, randomItem }