aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md19
-rwxr-xr-xbin/dev-server15
-rwxr-xr-xbin/watch16
-rw-r--r--public/main.css33
-rw-r--r--src/chord.ts4
-rw-r--r--src/lib/dom.ts15
-rw-r--r--src/lib/h.ts34
-rw-r--r--src/lib/rx.ts405
-rw-r--r--src/main.ts33
-rw-r--r--src/view/form.ts149
-rw-r--r--src/view/layout.ts13
-rw-r--r--src/view/options.ts22
-rw-r--r--src/view/play.ts89
-rw-r--r--tsconfig.json2
14 files changed, 639 insertions, 210 deletions
diff --git a/README.md b/README.md
index 1e0851e..89e2a62 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,15 @@
# Getting started
-Having nix installed, run:
+Enter nix shell:
-```sh
-bin/dev-server
-```
+ nix develop --command ./watch-command
-Then, open your browser at `http://localhost:8000`.
+Then run the dev-server:
-# Chord font
+ ./bin/dev-server path-to-books
-http://www.hummtunes.com/Fonts/chordsfont.html
+Finally, open your browser at `http://localhost:8000`.
-# TODO
+# Chord font
-- Pressing enter on the form page starts playing
-- New chords:
- - 7th chord
- - 7th major chord
+http://www.hummtunes.com/Fonts/chordsfont.html
diff --git a/bin/dev-server b/bin/dev-server
index e1faf14..82686ae 100755
--- a/bin/dev-server
+++ b/bin/dev-server
@@ -1,5 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
-cd `dirname "$0"`/..
-nix develop --command bin/watch
+# Run server
+
+python -m http.server --directory public 8000 &
+trap "fuser -k 8000/tcp" EXIT
+
+# Watch TypeScript
+
+CHECK="echo Checking TypeScript… && tsc --checkJs"
+BUILD="esbuild --bundle src/main.ts --target=es2017 --outdir=public"
+watchexec \
+ --clear \
+ --watch src \
+ -- "$CHECK && $BUILD"
diff --git a/bin/watch b/bin/watch
deleted file mode 100755
index 82686ae..0000000
--- a/bin/watch
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Run server
-
-python -m http.server --directory public 8000 &
-trap "fuser -k 8000/tcp" EXIT
-
-# Watch TypeScript
-
-CHECK="echo Checking TypeScript… && tsc --checkJs"
-BUILD="esbuild --bundle src/main.ts --target=es2017 --outdir=public"
-watchexec \
- --clear \
- --watch src \
- -- "$CHECK && $BUILD"
diff --git a/public/main.css b/public/main.css
index fca717d..0214f03 100644
--- a/public/main.css
+++ b/public/main.css
@@ -58,7 +58,7 @@ html {
/* Fix full width on small screens on chrome */
body, .g-Form, .g-Play {
- min-width: fit-content;
+ min-width: fit-content;
}
/* Fonts */
@@ -129,16 +129,17 @@ header {
/* Play */
.g-Play {
- display: flex;
- align-items: center;
height: calc(100vh - var(--header-height));
- position: relative;
font-family: chords;
font-size: 15vw;
- overflow-x: visible;
}
-.g-Play--Shift {
+.g-Chords {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ position: relative;
+ overflow-x: visible;
animation: Shift var(--shift-delay) ease-in-out;
}
@@ -152,18 +153,20 @@ header {
position: absolute;
margin-top: -10px; /* Move a bit higher for a better rendering */
transform: translateX(-50%);
- transition: opacity var(--shift-delay) ease-in-out;
}
-.g-Chord:nth-child(1) { left:-25%; }
-.g-Chord:nth-child(2) { left:25%; }
-.g-Chord:nth-child(3) { left:75%; }
-
.g-Chord:nth-child(1) {
- opacity: 0;
+ left:-25%;
+ animation: Disappear var(--shift-delay) ease-in-out;
+}
+
+@keyframes Disappear {
+ 0% {opacity: 1;}
+ 100% {opacity: 0;}
}
-.g-Chord--Beat {
+.g-Chord:nth-child(2) {
+ left:25%;
animation: Beat 0.2s linear var(--shift-delay);
}
@@ -172,3 +175,7 @@ header {
30% {transform: translateX(-50%) scale(120%);}
100% {transform: translateX(-50%) scale(100%);}
}
+
+.g-Chord:nth-child(3) {
+ left:75%;
+}
diff --git a/src/chord.ts b/src/chord.ts
index 0584727..118f146 100644
--- a/src/chord.ts
+++ b/src/chord.ts
@@ -7,11 +7,9 @@ export type Options = {
}
export function generate(o: Options): [string, string] {
-
let base = all[Math.floor(Math.random() * all.length)]
-
-
let alterations: string[] = []
+
if (o.major) alterations = alterations.concat('')
if (o.minor) alterations = alterations.concat('-')
if (o.seventh) alterations = alterations.concat('7')
diff --git a/src/lib/dom.ts b/src/lib/dom.ts
deleted file mode 100644
index 0b6a0ab..0000000
--- a/src/lib/dom.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 8b1abf3..0000000
--- a/src/lib/h.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-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/lib/rx.ts b/src/lib/rx.ts
new file mode 100644
index 0000000..3f3b8d9
--- /dev/null
+++ b/src/lib/rx.ts
@@ -0,0 +1,405 @@
+// [1.1.0] 2023-02-13
+
+// Html
+
+export type Html
+ = false
+ | undefined
+ | string
+ | number
+ | Tag
+ | WithVar<any>
+ | Array<Html>
+ | Rx<Html>
+
+interface Tag {
+ type: 'Tag'
+ tagName: string
+ attributes: Attributes
+ children?: Array<Html>
+ onmount?: (element: Element) => void
+ onunmount?: (element: Element) => void
+}
+
+interface WithVar<A> {
+ type: 'WithVar'
+ init: A
+ getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html
+}
+
+interface Attributes {
+ [key: string]: Rx<AttributeValue> | AttributeValue
+}
+
+type AttributeValue
+ = undefined
+ | string
+ | number
+ | boolean
+ | ((event: Event) => void)
+ | ((element: Element) => void)
+
+function isHtml(x: any): x is Html {
+ return (typeof x === 'string'
+ || typeof x === 'number'
+ || isTag(x)
+ || isWithVar(x)
+ || isRx(x)
+ || Array.isArray(x))
+}
+
+type ValueOrArray<T> = T | Array<ValueOrArray<T>>
+
+export function h(
+ tagName: string,
+ x?: Attributes | Html,
+ ...children: Array<Html>
+): Tag {
+ if (x === undefined || x === false) {
+ return {
+ type: 'Tag',
+ tagName,
+ attributes: {}
+ }
+ } else if (isHtml(x)) {
+ return {
+ type: 'Tag',
+ tagName,
+ attributes: {},
+ children: [x, ...children],
+ }
+ } else {
+ let attributes = x as Attributes
+ let onmount, onunmount
+ if ('onmount' in attributes) {
+ onmount = attributes['onmount'] as (element: Element) => void
+ delete attributes['onmount']
+ }
+ if ('onunmount' in attributes) {
+ onunmount = attributes['onunmount'] as (element: Element) => void
+ delete attributes['onunmount']
+ }
+ return {
+ type: 'Tag',
+ tagName,
+ attributes,
+ children,
+ onmount,
+ onunmount
+ }
+ }
+}
+
+export function withVar<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html): WithVar<A> {
+ return {
+ type: 'WithVar',
+ init,
+ getChildren
+ }
+}
+
+// Rx
+
+export type RxAble<A> = Rx<A> | A
+
+export class Rx<A> {
+ map<B>(f: (value: A) => B): Rx<B> {
+ return new Map<A, B>(this, f)
+ }
+
+ flatMap<B>(f: (value: A) => Rx<B>): Rx<B> {
+ return new FlatMap<A, B>(this, f)
+ }
+}
+
+class Var<A> extends Rx<A> {
+ readonly type: 'Var'
+ readonly id: string
+
+ constructor(id: string) {
+ super()
+ this.id = id
+ this.type = 'Var'
+ }
+}
+
+class Map<A, B> extends Rx<B> {
+ readonly type: 'Map'
+ readonly rx: Rx<A>
+ readonly f: (value: A) => B
+
+ constructor(rx: Rx<A>, f: (value: A) => B) {
+ super()
+ this.type = 'Map'
+ this.rx = rx
+ this.f = f
+ }
+}
+
+class FlatMap<A, B> extends Rx<B> {
+ readonly type: 'FlatMap'
+ readonly rx: Rx<A>
+ readonly f: (value: A) => Rx<B>
+
+ constructor(rx: Rx<A>, f: (value: A) => Rx<B>) {
+ super()
+ this.type = 'FlatMap'
+ this.rx = rx
+ this.f = f
+ }
+}
+
+// Mount
+
+export function mount(html: Html): Cancelable {
+ const state = new State()
+ let appendRes = appendChild(state, document.body, html)
+ return appendRes.cancel
+}
+
+interface StateEntry<A> {
+ value: A
+ subscribers: Array<(value: A) => void>
+}
+
+class State {
+ readonly state: {[key: string]: StateEntry<any>}
+ varCounter: bigint
+
+ constructor() {
+ this.state = {}
+ this.varCounter = BigInt(0)
+ }
+
+ register<A>(initValue: A) {
+ const v = new Var(this.varCounter.toString())
+ this.varCounter += BigInt(1)
+ this.state[v.id] = {
+ value: initValue,
+ subscribers: []
+ }
+ return v
+ }
+
+ unregister<A>(v: Var<A>) {
+ delete this.state[v.id]
+ }
+
+ get<A>(v: Var<A>) {
+ return this.state[v.id].value
+ }
+
+ update<A>(v: Var<A>, f: (value: A) => A) {
+ const value = f(this.state[v.id].value)
+ this.state[v.id].value = value
+ this.state[v.id].subscribers.forEach(notify => {
+ // Don’t notify if it has been removed from a precedent notifier
+ if (this.state[v.id].subscribers.indexOf(notify) !== -1) {
+ notify(value)
+ }
+ })
+ }
+
+ subscribe<A>(v: Var<A>, notify: (value: A) => void): Cancelable {
+ this.state[v.id].subscribers.push(notify)
+ return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify)
+ }
+}
+
+// Cancelable
+
+type Cancelable = () => void
+
+const voidCancel = () => {}
+
+// Removable
+
+type Removable = () => void
+
+const voidRemove = () => {}
+
+// Rx run
+
+function rxRun<A>(state: State, rx: Rx<A>, effect: (value: A) => void): Cancelable {
+ if (isVar(rx)) {
+ const cancel = state.subscribe(rx, effect)
+ effect(state.get(rx))
+ return cancel
+ } else if (isMap<A, any>(rx)) {
+ return rxRun(state, rx.rx, value => effect(rx.f(value)))
+ } else if (isFlatMap(rx)) {
+ let cancel1 = voidCancel
+ const cancel2 = rxRun(state, rx.rx, (value: A) => {
+ cancel1()
+ cancel1 = rxRun(state, rx.f(value), effect)
+ })
+ return () => {
+ cancel2()
+ cancel1()
+ }
+ } else {
+ throw new Error(`Unrecognized rx: ${rx}`)
+ }
+}
+
+function isRx<A>(x: any): x is Rx<A> {
+ return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap")
+}
+
+function isVar<A>(x: any): x is Var<A> {
+ return x.type === "Var"
+}
+
+function isMap<A, B>(x: any): x is Map<A, B> {
+ return x.type === "Map"
+}
+
+function isFlatMap<A, B>(x: any): x is FlatMap<A, B> {
+ return x.type === "FlatMap"
+}
+
+// Append
+
+interface AppendResult {
+ cancel: Cancelable
+ remove: Removable
+ lastAdded?: Node
+}
+
+function appendChild(state: State, element: Element, child: Html, lastAdded?: Node): AppendResult {
+ if (Array.isArray(child)) {
+ let cancels: Array<Cancelable> = []
+ let removes: Array<Removable> = []
+ child.forEach((o) => {
+ const appendResult = appendChild(state, element, o, lastAdded)
+ cancels.push(appendResult.cancel)
+ removes.push(appendResult.remove)
+ lastAdded = appendResult.lastAdded
+ })
+ return {
+ cancel: () => cancels.forEach((o) => o()),
+ remove: () => removes.forEach((o) => o()),
+ lastAdded
+ }
+ } else if (typeof child == "string") {
+ const node = document.createTextNode(child)
+ appendNode(element, node, lastAdded)
+ return {
+ cancel: voidCancel,
+ remove: () => element.removeChild(node),
+ lastAdded: node
+ }
+ } else if (typeof child == "number") {
+ return appendChild(state, element, child.toString(), lastAdded)
+ } else if (isTag(child)) {
+ const { tagName, attributes, children, onmount, onunmount } = child
+
+ const childElement = document.createElement(tagName)
+ const cancelAttributes = Object.entries(attributes).map(([key, value]) => {
+ if (isRx<AttributeValue>(value)) {
+ return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue))
+ } else {
+ setAttribute(state, childElement, key, value)
+ }
+ })
+
+ const appendChildrenRes = appendChild(state, childElement, children)
+
+ appendNode(element, childElement, lastAdded)
+
+ if (onmount !== undefined) {
+ onmount(childElement)
+ }
+
+ return {
+ cancel: () => {
+ cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {})
+ appendChildrenRes.cancel()
+ if (onunmount !== undefined) {
+ onunmount(childElement)
+ }
+ },
+ remove: () => element.removeChild(childElement),
+ lastAdded: childElement,
+ }
+ } else if (isWithVar(child)) {
+ const { init, getChildren } = child
+ const v = state.register(init)
+ const children = getChildren(v, f => state.update(v, f))
+ const appendRes = appendChild(state, element, children)
+ return {
+ cancel: () => {
+ appendRes.cancel()
+ state.unregister(v)
+ },
+ remove: () => appendRes.remove(),
+ lastAdded: appendRes.lastAdded
+ }
+ } else if (isRx(child)) {
+ const rxBase = document.createTextNode('')
+ appendNode(element, rxBase, lastAdded)
+ let appendRes: AppendResult = {
+ cancel: voidCancel,
+ remove: voidRemove,
+ lastAdded: rxBase
+ }
+ const cancelRx = rxRun(state, child, (value: Html) => {
+ appendRes.cancel()
+ appendRes.remove()
+ appendRes = appendChild(state, element, value, rxBase)
+ })
+ return {
+ cancel: () => {
+ appendRes.cancel()
+ cancelRx()
+ },
+ remove: () => {
+ appendRes.remove()
+ element.removeChild(rxBase)
+ },
+ lastAdded: appendRes.lastAdded,
+ }
+ } else if (child === undefined || child === false) {
+ return {
+ cancel: voidCancel,
+ remove: voidRemove,
+ lastAdded
+ }
+ } else {
+ throw new Error(`Unrecognized child: ${child}`)
+ }
+}
+
+function isTag<A>(x: any): x is Tag {
+ return x !== undefined && x.type === "Tag"
+}
+
+function isWithVar<A>(x: any): x is WithVar<A> {
+ return x !== undefined && x.type === "WithVar"
+}
+
+function appendNode(base: Element, node: Node, lastAdded?: Node) {
+ if (lastAdded !== undefined) {
+ base.insertBefore(node, lastAdded.nextSibling)
+ } else {
+ base.append(node)
+ }
+}
+
+function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) {
+ if (attribute === undefined || attribute === false) {
+ // Do nothing
+ } else if (attribute === true) {
+ // @ts-ignore
+ element[key] = "true"
+ } else if (typeof attribute === "number") {
+ // @ts-ignore
+ element[key] = attribute.toString()
+ } else if (typeof attribute === "string") {
+ // @ts-ignore
+ element[key] = attribute
+ } else {
+ // @ts-ignore
+ element[key] = (event: Event) => attribute(event)
+ }
+}
diff --git a/src/main.ts b/src/main.ts
index aecb754..bba799e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,31 @@
-import * as form from 'view/form'
-import * as dom from 'lib/dom'
+import { h, withVar, mount } from 'lib/rx'
+import * as Form from 'view/form'
+import * as Play from 'view/play'
+import * as Options from 'view/options'
-dom.show(form.view())
+enum Page {
+ Form,
+ Play
+}
+
+mount(
+ withVar(Page.Form, (page, updatePage) => [
+ h('header',
+ { onclick: () => updatePage(_ => Page.Form) },
+ 'Chords'
+ ),
+ page.map(p =>
+ p === Page.Form
+ ? Form.view({
+ options: Options.load(),
+ onSubmit: (options: Options.Model) => {
+ Options.save(options)
+ updatePage(_ => Page.Play)
+ }
+ })
+ : Play.view({
+ options: Options.load()
+ })
+ )
+ ])
+)
diff --git a/src/view/form.ts b/src/view/form.ts
index 77a8cb7..5547e0c 100644
--- a/src/view/form.ts
+++ b/src/view/form.ts
@@ -1,66 +1,121 @@
-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'
+import { h, withVar, Html, Rx } from 'lib/rx'
+import * as Options from 'view/options'
-// View
-
-export function view(): Element[] {
- let opts = options.load()
+interface Params {
+ options: Options.Model
+ onSubmit: (options: Options.Model) => void
+}
- return layout.view(
+export function view({ options, onSubmit }: Params): Html {
+ return withVar(options, (opts, updateOptions) =>
h('form',
- {
- className: 'g-Form',
- onsubmit
+ { className: 'g-Form',
+ onsubmit: opts.map(o =>
+ (event: Event) => {
+ event.preventDefault()
+ onSubmit(o)
+ }
+ )
},
- chordCheckbox({ name: 'major', label: '', checked: opts.major }),
- chordCheckbox({ name: 'minor', label: '-', checked: opts.minor }),
- chordCheckbox({ name: 'seventh', label: '7', checked: opts.seventh }),
- chordCheckbox({ name: 'minorSeventh', label: '-7', checked: opts.minorSeventh }),
- chordCheckbox({ name: 'majorSeventh', label: '7', checked: opts.majorSeventh }),
- labelInput({ type: 'number', name: 'bpm', label: 'BPM', value: opts.bpm.toString() }),
- labelInput({ type: 'number', name: 'beatsPerChord', label: 'Beats per Chord', value: opts.beatsPerChord.toString() }),
+ chordCheckbox({
+ label: '',
+ checked: opts.map(o => o.major),
+ onCheck: (checked => updateOptions(o => {
+ o.major = checked
+ return o
+ }))
+ }),
+ chordCheckbox({
+ label: '-',
+ checked: opts.map(o => o.minor),
+ onCheck: (checked => updateOptions(o => {
+ o.minor = checked
+ return o
+ }))
+ }),
+ chordCheckbox({
+ label: '7',
+ checked: opts.map(o => o.seventh),
+ onCheck: (checked => updateOptions(o => {
+ o.seventh = checked
+ return o
+ }))
+ }),
+ chordCheckbox({
+ label: '-7',
+ checked: opts.map(o => o.minorSeventh),
+ onCheck: (checked => updateOptions(o => {
+ o.minorSeventh = checked
+ return o
+ }))
+ }),
+ chordCheckbox({
+ label: '7',
+ checked: opts.map(o => o.majorSeventh),
+ onCheck: (checked => updateOptions(o => {
+ o.majorSeventh = checked
+ return o
+ }))
+ }),
+ numberInput({
+ label: 'BPM',
+ value: opts.map(o => o.bpm.toString()),
+ onChange: (n => updateOptions(o => {
+ o.bpm = n
+ return o
+ }))
+ }),
+ numberInput({
+ label: 'Beats per Chord',
+ value: opts.map(o => o.beatsPerChord.toString()),
+ onChange: (n => updateOptions(o => {
+ o.beatsPerChord = n
+ return o
+ }))
+ }),
h('input', { type: 'submit', value: 'Play' })
)
)
}
-function chordCheckbox({ name, label, checked }: any): Element {
- return labelInput({ className: 'g-ChordLabel', type: 'checkbox', name, label, checked })
+interface ChordCheckboxParams {
+ label: string
+ checked: Rx<boolean>
+ onCheck: (checked: boolean) => void
}
-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,
- seventh: input('seventh').checked,
- minorSeventh: input('minorSeventh').checked,
- majorSeventh: input('majorSeventh').checked,
- bpm: parseInt(input('bpm').value),
- beatsPerChord: parseInt(input('beatsPerChord').value)
- }
- options.save(opts)
- dom.show(play.view(opts))
+function chordCheckbox({ label, checked, onCheck }: ChordCheckboxParams): Html {
+ return h('label',
+ { className: 'g-ChordLabel' },
+ h('input',
+ { type: 'checkbox',
+ checked,
+ onchange: (event: Event) => onCheck((event.target as HTMLInputElement).checked)
+ }
+ ),
+ label
+ )
}
-type LabelInputParams = {
- className?: string,
- type: string,
- name: string,
- label: string,
- checked?: boolean,
- value?: string
+interface NumberInputParams {
+ label: string
+ value: Rx<string>
+ onChange: (n: number) => void
}
-function labelInput({ className, type, name, label, checked, value }: LabelInputParams) {
+function numberInput({ label, value, onChange }: NumberInputParams): Html {
return h('label',
- className !== undefined ? { className } : {},
- h('input', { type, name, checked, value }),
+ h('input',
+ { type: 'number',
+ value,
+ onchange: (event: Event) => {
+ const n = parseInt((event.target as HTMLInputElement).value)
+ if (typeof n === 'number') {
+ onChange(n)
+ }
+ }
+ }
+ ),
label
)
}
diff --git a/src/view/layout.ts b/src/view/layout.ts
deleted file mode 100644
index ac62290..0000000
--- a/src/view/layout.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-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
index 4a57f97..31fd631 100644
--- a/src/view/options.ts
+++ b/src/view/options.ts
@@ -1,14 +1,14 @@
-export type Options = {
- major: boolean,
- minor: boolean,
- seventh: boolean,
- minorSeventh: boolean,
- majorSeventh: boolean,
- bpm: number,
+export interface Model {
+ major: boolean
+ minor: boolean
+ seventh: boolean
+ minorSeventh: boolean
+ majorSeventh: boolean
+ bpm: number
beatsPerChord: number
}
-let defaultOptions: Options = {
+let init: Model = {
major: true,
minor: false,
seventh: false,
@@ -20,11 +20,11 @@ let defaultOptions: Options = {
let key: string = 'options'
-export function load(): Options {
+export function load(): Model {
let str = localStorage[key]
- return str && JSON.parse(str) || defaultOptions
+ return str && JSON.parse(str) || init
}
-export function save(options: Options): void {
+export function save(options: Model): void {
localStorage[key] = JSON.stringify(options)
}
diff --git a/src/view/play.ts b/src/view/play.ts
index f0340f7..b85e505 100644
--- a/src/view/play.ts
+++ b/src/view/play.ts
@@ -1,49 +1,58 @@
-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'
+import { h, withVar, Html } from 'lib/rx'
+import * as Options from 'view/options'
+import * as Chord from 'chord'
-export function view(options: Options): Element[] {
- let chords = h('div',
- { className: 'g-Play' },
- chordNode(),
- chordNode(options),
- chordNode(options)
- )
-
- let chordBeat = 1
+interface ViewParams {
+ options: Options.Model
+}
- dom.triggerAnimation(chords.children[1] 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[1] as HTMLElement, 'g-Chord--Beat')
- chordBeat = 1
- } else {
- dom.triggerAnimation(chords.children[1] as HTMLElement, 'g-Chord--Beat')
- chordBeat += 1
- }
- }, 60 / options.bpm * 1000)
+export function view({ options }: ViewParams): Html {
+ const initChords: Array<[string, string]> = [['', ''], Chord.generate(options), Chord.generate(options)]
- return layout.view(chords)
-}
+ return withVar(initChords, (chords, updateChords) =>
+ withVar(undefined, (beat, updateBeat) => {
+ let chordBeat = 1
+ const interval = setInterval(() => {
+ if (chordBeat === options.beatsPerChord) {
+ updateChords(xs => {
+ xs.shift()
+ xs.push(Chord.generate(options))
+ return xs
+ })
+ chordBeat = 1
+ } else {
+ updateBeat(_ => undefined)
+ chordBeat += 1
+ }
+ }, 60 / options.bpm * 1000)
-/* Shift chords and generate a new random one.
- */
-function shiftChords(chords: Element, options: Options) {
- chords.removeChild(chords.children[0])
- chords.appendChild(chordNode(options))
+ return h('div',
+ { className: 'g-Play',
+ onunmount: () => clearInterval(interval)
+ },
+ chords.map(xs =>
+ h('div',
+ { className: 'g-Chords' },
+ xs.map((chord, i) => {
+ if (i == 0) {
+ return viewChord(chord, 'g-Chord--Disappear')
+ } else if (i == 1) {
+ return beat.map(_ => viewChord(chord))
+ } else {
+ return viewChord(chord)
+ }
+ })
+ )
+ )
+ )
+ })
+ )
}
-function chordNode(options?: Options): Element {
- let [base, alteration] = options ? chord.generate(options) : ['', '']
-
- return h('div',
- { className: 'g-Chord' },
+function viewChord([base, alteration]: [string, string], className: string = ''): Html {
+ return h('div',
+ { className: `g-Chord ${className}` },
base,
- h('sup', {}, alteration)
+ h('sup', alteration)
)
}
diff --git a/tsconfig.json b/tsconfig.json
index 380eab3..6c9e683 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"module": "amd",
- "target": "es2017",
+ "target": "es2020",
"baseUrl": "src",
"outFile": "public/main.js",
"noImplicitAny": true,