summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBrian Cully <bjc@kublai.com>2021-02-17 21:40:03 -0500
committerBrian Cully <bjc@kublai.com>2021-02-17 21:40:28 -0500
commit4bb133a3515fa54be34b8ec50b80d6dcbe3a0b3d (patch)
tree35c929036f31e9e1c641ea1b007432aedf373166
downloadmolsim2-4bb133a3515fa54be34b8ec50b80d6dcbe3a0b3d.tar.gz
molsim2-4bb133a3515fa54be34b8ec50b80d6dcbe3a0b3d.zip
Initial commit.
-rw-r--r--Makefile4
-rw-r--r--NOTES.org201
-rw-r--r--amino-acid.mjs76
-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.mjs23
-rw-r--r--genome.mjs80
-rw-r--r--index.html92
-rw-r--r--main.mjs16
-rw-r--r--mobile.css161
-rw-r--r--nucleotide-selector.mjs71
-rw-r--r--nucleotide.mjs79
-rw-r--r--print.css43
-rw-r--r--rules.mjs358
-rw-r--r--style.css3
-rw-r--r--tablet.css23
-rw-r--r--utils.mjs48
19 files changed, 1400 insertions, 0 deletions
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..098fb4d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,4 @@
+.PHONY: serve
+
+serve:
+ python -m http.server 8080
diff --git a/NOTES.org b/NOTES.org
new file mode 100644
index 0000000..1ffa722
--- /dev/null
+++ b/NOTES.org
@@ -0,0 +1,201 @@
+#+title: Molecular Evolution Simulator for Amino Acids
+#+STARTUP: content hideblocks
+
+* Rules
+- If the nucleotide change causes an amino acid change, mark the change as lethal, and use the previous sequence for further mutations
+- if the nucleotide change doesn’t modify the amino acid, simply proceed with the change in place
+
+- Students must translate the codons to amino acids themselves (possibly only the ones changed)
+- have students mark lethality and proceed with forward movement
+- print out at the end
+
+** Text from Siobain
+#+begin_quote
+Translate your DNA sequence into amino acids. If the mutation is synonymous (despite the mutation there is no change in amino acid), then allow the mutation to survive to all subsequent rounds of mutation. If it is nonsynonymous, then the mutation was lethal, and you should mark the “lethal” box on the left. Revert to the last functional sequence (the ancestral one or one that has accumulated some synonymous mutation(s)) and use that to go to the next round of mutation.
+
+Continue to mutate for 10 rounds, though not all 10 of your sequences will survive. Then insert the pdf you get from the webpage into this word document on the next page and answer the questions about your mutated sequences.
+#+end_quote
+
+** Inverse codon table
+:PROPERTIES:
+:header-args: :noweb yes
+:END:
+ Found [[https://en.wikipedia.org/wiki/DNA_and_RNA_codon_tables#Inverse_DNA_codon_table][on Wikipedia]]:
+
+#+name: amino-acid-to-codon
+| Amino acid | Codon |
+|------------+-------------------------|
+| Ala | GCT GCC GCA GCG |
+| Arg | CGT CGC CGA CGG AGA AGG |
+| Asn | AAT AAC |
+| Asp | GAT GAC |
+| Cys | TGT TGC |
+| Gln | CAA CAG |
+| Glu | GAA GAG |
+| Gly | GGT GGC GGA GGG |
+| His | CAT CAC |
+| Ile | ATT ATC ATA |
+| Leu | CTT CTC CTA CTG TTA TTG |
+| Lys | AAA AAG |
+| Met | ATG |
+| Phe | TTT TTC |
+| Pro | CCT CCC CCA CCG |
+| Ser | TCT TCC TCA TCG AGT AGC |
+| Thr | ACT ACC ACA ACG |
+| Trp | TGG |
+| Tyr | TAT TAC |
+| Val | GTT GTC GTA GTG |
+| STOP | TAA TGA TAG |
+
+#+name: aa-table-to-form
+#+begin_src elisp :var table-name="amino-acid-to-codon" range="@<<$<..@>$>"
+ (let ((raw-data (org-table-get-remote-range table-name range)))
+ (seq-reduce (lambda (acc elt)
+ (set-text-properties 0 (length elt) nil elt)
+ (if (or (not acc) (cdar acc))
+ (cons (list elt) acc)
+ (cons (cons (caar acc) (split-string elt)) (cdr acc))))
+ raw-data
+ nil))
+#+end_src
+
+#+RESULTS: aa-table-to-form
+| STOP | TAA | TGA | TAG | | | |
+| Val | GTT | GTC | GTA | GTG | | |
+| Tyr | TAT | TAC | | | | |
+| Trp | TGG | | | | | |
+| Thr | ACT | ACC | ACA | ACG | | |
+| Ser | TCT | TCC | TCA | TCG | AGT | AGC |
+| Pro | CCT | CCC | CCA | CCG | | |
+| Phe | TTT | TTC | | | | |
+| Met | ATG | | | | | |
+| Lys | AAA | AAG | | | | |
+| Leu | CTT | CTC | CTA | CTG | TTA | TTG |
+| Ile | ATT | ATC | ATA | | | |
+| His | CAT | CAC | | | | |
+| Gly | GGT | GGC | GGA | GGG | | |
+| Glu | GAA | GAG | | | | |
+| Gln | CAA | CAG | | | | |
+| Cys | TGT | TGC | | | | |
+| Asp | GAT | GAC | | | | |
+| Asn | AAT | AAC | | | | |
+| Arg | CGT | CGC | CGA | CGG | AGA | AGG |
+| Ala | GCT | GCC | GCA | GCG | | |
+
+#+name: aa-table-inverted
+#+begin_src elisp :var table-name="amino-acid-to-codon" range="@<<$<..@>$>"
+ (let ((codon-alist (mapcar (lambda (aa-to-codons) (cons (cdr aa-to-codons) (car aa-to-codons)))
+ <<aa-table-to-form>>)))
+ (apply 'append
+ (mapcar (lambda (kvp)
+ (mapcar (lambda (codon)
+ (cons codon (cdr kvp)))
+ (car kvp)))
+ codon-alist)))
+#+end_src
+
+#+RESULTS: aa-table-inverted
+: ((TAA . STOP) (TGA . STOP) (TAG . STOP) (GTT . Val) (GTC . Val) (GTA . Val) (GTG . Val) (TAT . Tyr) (TAC . Tyr) (TGG . Trp) (ACT . Thr) (ACC . Thr) (ACA . Thr) (ACG . Thr) (TCT . Ser) (TCC . Ser) (TCA . Ser) (TCG . Ser) (AGT . Ser) (AGC . Ser) (CCT . Pro) (CCC . Pro) (CCA . Pro) (CCG . Pro) (TTT . Phe) (TTC . Phe) (ATG . Met) (AAA . Lys) (AAG . Lys) (CTT . Leu) (CTC . Leu) (CTA . Leu) (CTG . Leu) (TTA . Leu) (TTG . Leu) (ATT . Ile) (ATC . Ile) (ATA . Ile) (CAT . His) (CAC . His) (GGT . Gly) (GGC . Gly) (GGA . Gly) (GGG . Gly) (GAA . Glu) (GAG . Glu) (CAA . Gln) (CAG . Gln) (TGT . Cys) (TGC . Cys) (GAT . Asp) (GAC . Asp) (AAT . Asn) (AAC . Asn) (CGT . Arg) (CGC . Arg) (CGA . Arg) (CGG . Arg) (AGA . Arg) (AGG . Arg) (GCT . Ala) (GCC . Ala) (GCA . Ala) (GCG . Ala))
+
+#+name: tbl-to-json
+#+begin_src elisp :var table-name="amino-acid-to-codon" range="@<<$<..@>$>"
+ (let ((json-map (mapcar (lambda (kvp) (format "'%s': '%s'," (car kvp) (cdr kvp)))
+ <<aa-table-inverted>>)))
+ (format "{\n%s\n}" (string-join json-map "\n")))
+#+end_src
+
+#+RESULTS: tbl-to-json
+#+begin_example
+ {
+ 'TAA': 'STOP',
+ 'TGA': 'STOP',
+ 'TAG': 'STOP',
+ 'GTT': 'Val',
+ 'GTC': 'Val',
+ 'GTA': 'Val',
+ 'GTG': 'Val',
+ 'TAT': 'Tyr',
+ 'TAC': 'Tyr',
+ 'TGG': 'Trp',
+ 'ACT': 'Thr',
+ 'ACC': 'Thr',
+ 'ACA': 'Thr',
+ 'ACG': 'Thr',
+ 'TCT': 'Ser',
+ 'TCC': 'Ser',
+ 'TCA': 'Ser',
+ 'TCG': 'Ser',
+ 'AGT': 'Ser',
+ 'AGC': 'Ser',
+ 'CCT': 'Pro',
+ 'CCC': 'Pro',
+ 'CCA': 'Pro',
+ 'CCG': 'Pro',
+ 'TTT': 'Phe',
+ 'TTC': 'Phe',
+ 'ATG': 'Met',
+ 'AAA': 'Lys',
+ 'AAG': 'Lys',
+ 'CTT': 'Leu',
+ 'CTC': 'Leu',
+ 'CTA': 'Leu',
+ 'CTG': 'Leu',
+ 'TTA': 'Leu',
+ 'TTG': 'Leu',
+ 'ATT': 'Ile',
+ 'ATC': 'Ile',
+ 'ATA': 'Ile',
+ 'CAT': 'His',
+ 'CAC': 'His',
+ 'GGT': 'Gly',
+ 'GGC': 'Gly',
+ 'GGA': 'Gly',
+ 'GGG': 'Gly',
+ 'GAA': 'Glu',
+ 'GAG': 'Glu',
+ 'CAA': 'Gln',
+ 'CAG': 'Gln',
+ 'TGT': 'Cys',
+ 'TGC': 'Cys',
+ 'GAT': 'Asp',
+ 'GAC': 'Asp',
+ 'AAT': 'Asn',
+ 'AAC': 'Asn',
+ 'CGT': 'Arg',
+ 'CGC': 'Arg',
+ 'CGA': 'Arg',
+ 'CGG': 'Arg',
+ 'AGA': 'Arg',
+ 'AGG': 'Arg',
+ 'GCT': 'Ala',
+ 'GCC': 'Ala',
+ 'GCA': 'Ala',
+ 'GCG': 'Ala',
+ }
+#+end_example
+
+* work steps
+1. Use existing infrastructure to do codon mutation, but after mutation, need a place to select/display the amino acid from the codon group.
+2. Then need to verify student amino acid selection
+3. Then have student mark lethality
+4. Then clone either current or previous genome to next genome
+5. go back to 1
+
+Additionally, every codon in the first genome must be filled in by students
+
+* Misc
+** Cartesian product fun in lisp
+I don’t know why I did this when I knew I was just going to have to scrape a table anyway, but it was a fun exercise, and I don’t want to throw it away.
+#+BEGIN_SRC elisp
+ (let* ((builder (lambda (acc depth list)
+ (if (= depth 0)
+ (string-join acc)
+ (mapcar (lambda (e)
+ (funcall builder
+ (cons e acc) (1- depth) list))
+ list))))
+ (codons (flatten-list (funcall builder nil 3 '("A" "C" "T" "G")))))
+ (string-join (mapcar (lambda (c) (format "’%s’: ," c))
+ codons)
+ "\n"))
+#+END_SRC
diff --git a/amino-acid.mjs b/amino-acid.mjs
new file mode 100644
index 0000000..7df50d4
--- /dev/null
+++ b/amino-acid.mjs
@@ -0,0 +1,76 @@
+class AminoAcid {
+ // Create a protein from three nucleotides.
+ constructor(n1, n2, n3) {
+ this.codon = n1+n2+n3
+ this.value = AminoAcid.codonMap[this.codon]
+ }
+}
+
+AminoAcid.codonMap = {
+ 'TAA': 'STOP',
+ 'TGA': 'STOP',
+ 'TAG': 'STOP',
+ 'GTT': 'Val',
+ 'GTC': 'Val',
+ 'GTA': 'Val',
+ 'GTG': 'Val',
+ 'TAT': 'Tyr',
+ 'TAC': 'Tyr',
+ 'TGG': 'Trp',
+ 'ACT': 'Thr',
+ 'ACC': 'Thr',
+ 'ACA': 'Thr',
+ 'ACG': 'Thr',
+ 'TCT': 'Ser',
+ 'TCC': 'Ser',
+ 'TCA': 'Ser',
+ 'TCG': 'Ser',
+ 'AGT': 'Ser',
+ 'AGC': 'Ser',
+ 'CCT': 'Pro',
+ 'CCC': 'Pro',
+ 'CCA': 'Pro',
+ 'CCG': 'Pro',
+ 'TTT': 'Phe',
+ 'TTC': 'Phe',
+ 'ATG': 'Met',
+ 'AAA': 'Lys',
+ 'AAG': 'Lys',
+ 'CTT': 'Leu',
+ 'CTC': 'Leu',
+ 'CTA': 'Leu',
+ 'CTG': 'Leu',
+ 'TTA': 'Leu',
+ 'TTG': 'Leu',
+ 'ATT': 'Ile',
+ 'ATC': 'Ile',
+ 'ATA': 'Ile',
+ 'CAT': 'His',
+ 'CAC': 'His',
+ 'GGT': 'Gly',
+ 'GGC': 'Gly',
+ 'GGA': 'Gly',
+ 'GGG': 'Gly',
+ 'GAA': 'Glu',
+ 'GAG': 'Glu',
+ 'CAA': 'Gln',
+ 'CAG': 'Gln',
+ 'TGT': 'Cys',
+ 'TGC': 'Cys',
+ 'GAT': 'Asp',
+ 'GAC': 'Asp',
+ 'AAT': 'Asn',
+ 'AAC': 'Asn',
+ 'CGT': 'Arg',
+ 'CGC': 'Arg',
+ 'CGA': 'Arg',
+ 'CGG': 'Arg',
+ 'AGA': 'Arg',
+ 'AGG': 'Arg',
+ 'GCT': 'Ala',
+ 'GCC': 'Ala',
+ 'GCA': 'Ala',
+ 'GCG': 'Ala',
+}
+
+export default AminoAcid
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..535a956
--- /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..8969a8c
--- /dev/null
+++ b/genome-list.mjs
@@ -0,0 +1,23 @@
+class GenomeList {
+ constructor(elt) {
+ this.genomes = [];
+ this.elt = elt;
+ }
+
+ push(genome) {
+ this.genomes.push(genome)
+ this.elt.appendChild(genome.elt)
+ window.genome = genome
+ 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..7ea9a4a
--- /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 for Amino Acids</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..a6ddd29
--- /dev/null
+++ b/mobile.css
@@ -0,0 +1,161 @@
+body {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ margin: 0;
+
+ background-color: #eee;
+
+ 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%;
+
+ background-color: #ddd;
+
+ 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 .current {
+ background-color: yellow;
+}
+
+#instructions .step.current {
+ display: list-item;
+}
+
+#print-results.step.current {
+ display: block;
+}
+
+#die {
+ display: block;
+ float: right;
+ padding: 2ex;
+
+ background-color: #fde;
+ border: 1px solid #aaa;
+}
+
+#die .value {
+ text-align: center;
+ 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. */
+
+ background-color: ivory;
+ border: 1px solid black;
+}
+
+#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;
+}
+
+#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.locked {
+ cursor: text
+}
+
+.genome>ol {
+ display: inline-flex;
+ flex-wrap: wrap;
+ padding: 0;
+ border: 1px solid black;
+
+ background-color: white;
+}
+
+.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..13ed1b6
--- /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.transition = {'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.transition)
+
+
+export default Nucleotide
diff --git a/print.css b/print.css
new file mode 100644
index 0000000..bbbec05
--- /dev/null
+++ b/print.css
@@ -0,0 +1,43 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ display: flex;
+ background-color: white;
+}
+
+#instructions {
+ display: none;
+}
+
+#die {
+ display: none;
+}
+
+#errors {
+ display: none;
+}
+
+#nucleotide-selector {
+ display: none;
+}
+
+.genome>ol {
+ display: inline-flex;
+ flex-wrap: wrap;
+ padding: 0;
+ margin: 1ex;
+}
+
+.genome .nucleotide {
+ display: inline-flex;
+ height: 3ex;
+ width: 2em;
+ justify-content: space-around;
+ align-items: center;
+}
+
+.genome .nucleotide.selected {
+ border: 2px solid black;
+}
diff --git a/rules.mjs b/rules.mjs
new file mode 100644
index 0000000..d397bf0
--- /dev/null
+++ b/rules.mjs
@@ -0,0 +1,358 @@
+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 {
+ constructor(rules) {
+ this.rules = rules
+
+ this.id = 'clone-nucleotide'
+ 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 {
+ constructor(rules) {
+ this.rules = rules
+
+ this.id = 'roll-for-nucleotide'
+ }
+
+ 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 {
+ constructor(rules) {
+ this.rules = rules
+
+ this.id = 'nucleotide-select'
+ }
+
+ 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 {
+ constructor(rules) {
+ this.rules = rules
+
+ this.id = 'roll-for-mutation'
+ }
+
+ 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 {
+ constructor(rules) {
+ this.rules = rules
+
+ this.id = 'perform-mutation'
+ }
+
+ 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.transition
+ } else if (this.rules.die.value <= 17) {
+ return Nucleotide.complementingTransversion
+ } else {
+ return Nucleotide.defaultTransversion
+ }
+ }
+
+ get selectedNucleotide() {
+ return this.rules.currentGenome.selectedNucleotide
+ }
+
+ get errorTransitionHTML() {
+ return `Select the base that corresponds to a <em>transition</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.transition) {
+ return this.errorTransitionHTML
+ } 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 {
+ constructor(rules) {
+ this.rules = rules
+
+ this.id = 'print-results'
+ 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
+ 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..635d6ed
--- /dev/null
+++ b/style.css
@@ -0,0 +1,3 @@
+.hidden {
+ display: none !important;
+}
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 }