From fade87173afbfdd51534646ed49844efa2d0e530 Mon Sep 17 00:00:00 2001 From: Joris Date: Mon, 4 Jul 2022 11:32:27 +0200 Subject: Play random major and/or minor chords --- src/chord.ts | 14 ++++++++++++++ src/lib/dom.ts | 15 +++++++++++++++ src/lib/h.ts | 34 +++++++++++++++++++++++++++++++++ src/main.ts | 4 ++++ src/view/form.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/view/layout.ts | 13 +++++++++++++ src/view/options.ts | 24 +++++++++++++++++++++++ src/view/play.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 207 insertions(+) create mode 100644 src/chord.ts create mode 100644 src/lib/dom.ts create mode 100644 src/lib/h.ts create mode 100644 src/main.ts create mode 100644 src/view/form.ts create mode 100644 src/view/layout.ts create mode 100644 src/view/options.ts create mode 100644 src/view/play.ts (limited to 'src') 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) : '' + ) +} -- cgit v1.2.3