aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoris2022-07-04 11:32:27 +0200
committerJoris2022-07-04 19:36:37 +0200
commitfade87173afbfdd51534646ed49844efa2d0e530 (patch)
tree54e8d5d81233fa5f3d1ba60fd8c3085252ebccc4 /src
parentce7722c901776ae8f6a64882e902e8ba851411e0 (diff)
Play random major and/or minor chords
Diffstat (limited to 'src')
-rw-r--r--src/chord.ts14
-rw-r--r--src/lib/dom.ts15
-rw-r--r--src/lib/h.ts34
-rw-r--r--src/main.ts4
-rw-r--r--src/view/form.ts55
-rw-r--r--src/view/layout.ts13
-rw-r--r--src/view/options.ts24
-rw-r--r--src/view/play.ts48
8 files changed, 207 insertions, 0 deletions
diff --git a/src/chord.ts b/src/chord.ts
new file mode 100644
index 0000000..b2645e2
--- /dev/null
+++ b/src/chord.ts
@@ -0,0 +1,14 @@
+export type Options = {
+ major: boolean,
+ minor: boolean
+}
+
+export function generate({ major, minor }: Options): string {
+ let choices: string[] = []
+ if (major) choices = choices.concat(all)
+ if (minor) choices = choices.concat(all.map(chord => `${chord}m`))
+
+ return choices[Math.floor(Math.random() * choices.length)]
+}
+
+const all: string[] = [ 'C', 'D♭', 'D', 'E♭', 'E', 'F', 'G♭', 'G', 'A♭', 'A', 'B♭', 'B' ]
diff --git a/src/lib/dom.ts b/src/lib/dom.ts
new file mode 100644
index 0000000..0b6a0ab
--- /dev/null
+++ b/src/lib/dom.ts
@@ -0,0 +1,15 @@
+export function show(elements: Element[]): void {
+ document.body.innerHTML = ''
+ elements.forEach(element => document.body.appendChild(element))
+}
+
+/* Trigger animation in any case.
+ *
+ * Trigger reflow between removing and adding the classname.
+ * See https://css-tricks.com/restart-css-animation/
+ */
+export function triggerAnimation(element: HTMLElement, animation: string) {
+ element.classList.remove(animation)
+ void element.offsetWidth
+ element.classList.add(animation)
+}
diff --git a/src/lib/h.ts b/src/lib/h.ts
new file mode 100644
index 0000000..8b1abf3
--- /dev/null
+++ b/src/lib/h.ts
@@ -0,0 +1,34 @@
+type Child = Element | Text | string | number
+
+export default function h(
+ tagName: string,
+ attrs: object,
+ ...children: Child[]
+): Element {
+ const isSvg = tagName === 'svg' || tagName === 'path'
+
+ let elem = isSvg
+ ? document.createElementNS('http://www.w3.org/2000/svg', tagName)
+ : document.createElement(tagName)
+
+ if (isSvg) {
+ Object.entries(attrs).forEach(([key, value]) => {
+ elem.setAttribute(key, value)
+ })
+ } else {
+ elem = Object.assign(elem, attrs)
+ }
+
+ for (const child of children) {
+ if (typeof child === 'number')
+ elem.append(child.toString())
+ else
+ elem.append(child)
+ }
+
+ return elem
+}
+
+export function classNames(obj: {[key: string]: boolean }): string {
+ return Object.keys(obj).filter(k => obj[k]).join(' ')
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..aecb754
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,4 @@
+import * as form from 'view/form'
+import * as dom from 'lib/dom'
+
+dom.show(form.view())
diff --git a/src/view/form.ts b/src/view/form.ts
new file mode 100644
index 0000000..a74a7de
--- /dev/null
+++ b/src/view/form.ts
@@ -0,0 +1,55 @@
+import h, { classNames } from 'lib/h'
+import * as dom from 'lib/dom'
+import * as play from 'view/play'
+import * as layout from 'view/layout'
+import * as chord from 'chord'
+import * as options from 'view/options'
+
+// View
+
+export function view(): Element[] {
+ let opts = options.load()
+
+ return layout.view(
+ h('form',
+ {
+ className: 'g-Form',
+ onsubmit
+ },
+ labelInput({ type: 'checkbox', name: 'major', label: 'Major', checked: opts.major }),
+ labelInput({ type: 'checkbox', name: 'minor', label: 'Minor', checked: opts.minor }),
+ labelInput({ type: 'number', name: 'bpm', label: 'BPM', value: opts.bpm.toString() }),
+ labelInput({ type: 'number', name: 'beatsPerChord', label: 'Beats per Chord', value: opts.beatsPerChord.toString() }),
+ h('input', { type: 'submit', value: 'Play' })
+ )
+ )
+}
+
+function onsubmit(event: Event): void {
+ event.preventDefault()
+ let input = (name: String) => document.querySelector(`input[name="${name}"]`) as HTMLInputElement
+ let opts = {
+ major: input('major').checked,
+ minor: input('minor').checked,
+ bpm: parseInt(input('bpm').value),
+ beatsPerChord: parseInt(input('beatsPerChord').value)
+ }
+ options.save(opts)
+ dom.show(play.view(opts))
+}
+
+type LabelInputParams = {
+ type: string,
+ name: string,
+ label: string,
+ checked?: boolean,
+ value?: string
+}
+
+function labelInput({ type, name, label, checked, value }: LabelInputParams) {
+ return h('label',
+ {},
+ h('input', { type, name, checked, value }),
+ label
+ )
+}
diff --git a/src/view/layout.ts b/src/view/layout.ts
new file mode 100644
index 0000000..ac62290
--- /dev/null
+++ b/src/view/layout.ts
@@ -0,0 +1,13 @@
+import h from 'lib/h'
+import * as dom from 'lib/dom'
+import * as form from 'view/form'
+
+export function view(main: Element): Element[] {
+ return [
+ h('header',
+ { onclick: () => dom.show(form.view()) },
+ 'Chords'
+ ),
+ main
+ ]
+}
diff --git a/src/view/options.ts b/src/view/options.ts
new file mode 100644
index 0000000..4c71be2
--- /dev/null
+++ b/src/view/options.ts
@@ -0,0 +1,24 @@
+export type Options = {
+ major: boolean,
+ minor: boolean,
+ bpm: number,
+ beatsPerChord: number
+}
+
+let defaultOptions: Options = {
+ major: true,
+ minor: false,
+ bpm: 90,
+ beatsPerChord: 4
+}
+
+let key: string = 'options'
+
+export function load(): Options {
+ let str = localStorage[key]
+ return str && JSON.parse(str) || defaultOptions
+}
+
+export function save(options: Options): void {
+ localStorage[key] = JSON.stringify(options)
+}
diff --git a/src/view/play.ts b/src/view/play.ts
new file mode 100644
index 0000000..26558cd
--- /dev/null
+++ b/src/view/play.ts
@@ -0,0 +1,48 @@
+import h, { classNames } from 'lib/h'
+import * as dom from 'lib/dom'
+import { Options } from 'view/options'
+import * as chord from 'chord'
+import * as layout from 'view/layout'
+
+export function view(options: Options): Element[] {
+ let chords = h('div',
+ { className: 'g-Play' },
+ chordNode(),
+ chordNode(),
+ chordNode(options),
+ chordNode(options)
+ )
+
+ let chordBeat = 1
+
+ dom.triggerAnimation(chords as HTMLElement, 'g-Play--Shift')
+ dom.triggerAnimation(chords.children[2] as HTMLElement, 'g-Chord--Beat')
+ setInterval(() => {
+ if (chordBeat == options.beatsPerChord) {
+ shiftChords(chords, options)
+ chords.children[0].classList.remove('g-Chord--Beat')
+ dom.triggerAnimation(chords as HTMLElement, 'g-Play--Shift')
+ dom.triggerAnimation(chords.children[2] as HTMLElement, 'g-Chord--Beat')
+ chordBeat = 1
+ } else {
+ dom.triggerAnimation(chords.children[2] as HTMLElement, 'g-Chord--Beat')
+ chordBeat += 1
+ }
+ }, 60 / options.bpm * 1000)
+
+ return layout.view(chords)
+}
+
+/* Shift chords and generate a new random one.
+ */
+function shiftChords(chords: Element, options: Options) {
+ chords.removeChild(chords.children[0])
+ chords.appendChild(chordNode(options))
+}
+
+function chordNode(options?: Options): Element {
+ return h('div',
+ { className: 'g-Chord' },
+ options ? chord.generate(options) : ''
+ )
+}