diff options
-rw-r--r-- | public/main.css | 27 | ||||
-rw-r--r-- | src/chord.ts | 40 | ||||
-rw-r--r-- | src/main.ts | 8 | ||||
-rw-r--r-- | src/view/form.ts | 191 | ||||
-rw-r--r-- | src/view/options.ts | 70 | ||||
-rw-r--r-- | src/view/play.ts | 10 |
6 files changed, 253 insertions, 93 deletions
diff --git a/public/main.css b/public/main.css index 0214f03..de52e76 100644 --- a/public/main.css +++ b/public/main.css @@ -3,6 +3,7 @@ :root { --color-header: #333333; --color-button: #333333; + --color-background: #EEE; --spacing-mouse: 0.25rem; --spacing-cat: 0.5rem; @@ -100,6 +101,28 @@ header { align-items: center; } +.g-ChordsTable { + border-collapse: collapse; +} + +.g-ChordsTable td:first-child { + padding: 0 var(--spacing-horse); +} + +.g-ChordsTable td:nth-child(2) { + padding: var(--spacing-cat); +} + +.g-ChordsTable tr:nth-child(even) { + background-color: var(--color-background); +} + +.g-ChordsLine { + display: flex; + gap: var(--spacing-horse); + align-items: baseline; +} + .g-Form label { display: flex; align-items: baseline; @@ -108,7 +131,9 @@ header { } .g-ChordLabel { - font-family: chords; + font-family: monospace; + font-weight: bold; + padding: var(--spacing-cat); } .g-Form input[type="number"] { diff --git a/src/chord.ts b/src/chord.ts index 118f146..9393e96 100644 --- a/src/chord.ts +++ b/src/chord.ts @@ -1,22 +1,28 @@ -export type Options = { - major: boolean, - minor: boolean, - seventh: boolean, - minorSeventh: boolean, - majorSeventh: boolean, -} +export type Chord + = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' + | 'A♭' | 'B♭' | 'C♭' | 'D♭' | 'E♭' | 'F♭' | 'G♭' + | 'A♯' | 'B♯' | 'C♯' | 'D♯' | 'E♯' | 'F♯' | 'G♯' -export function generate(o: Options): [string, string] { - let base = all[Math.floor(Math.random() * all.length)] - let alterations: string[] = [] +export let plains: Array<Chord> = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G' ] +export let bemols: Array<Chord> = [ 'A♭', 'B♭', 'C♭', 'D♭', 'E♭', 'F♭', 'G♭' ] +export let sharps: Array<Chord> = [ 'A♯', 'B♯', 'C♯', 'D♯', 'E♯', 'F♯', 'G♯' ] - if (o.major) alterations = alterations.concat('') - if (o.minor) alterations = alterations.concat('-') - if (o.seventh) alterations = alterations.concat('7') - if (o.minorSeventh) alterations = alterations.concat('-7') - if (o.majorSeventh) alterations = alterations.concat('7') +export type Chords = Set<Chord> - return [base, alterations[Math.floor(Math.random() * alterations.length)]] +export type GenerateParams = { + major: Chords, + minor: Chords, + seventh: Chords, + minorSeventh: Chords, + majorSeventh: Chords, } -const all: string[] = [ 'C', 'D♭', 'D', 'E♭', 'E', 'F', 'G♭', 'G', 'A♭', 'A', 'B♭', 'B' ] +export function generate(o: GenerateParams): [string, string] { + const major: Array<[string, string]> = [...o.major].map(c => [c, '']) + const minor: Array<[string, string]> = [...o.minor].map(c => [c, '-']) + const seventh: Array<[string, string]> = [...o.seventh].map(c => [c, '7']) + const minorSeventh: Array<[string, string]> = [...o.minorSeventh].map(c => [c, '-7']) + const majorSeventh: Array<[string, string]> = [...o.majorSeventh].map(c => [c, '7']) + const all = [...major, ...minor, ...seventh, ...minorSeventh, ...majorSeventh] + return all[Math.floor(Math.random() * all.length)] +} diff --git a/src/main.ts b/src/main.ts index bba799e..e588aab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { h, withVar, mount } from 'lib/rx' +import { h, withState, mount } from 'lib/rx' import * as Form from 'view/form' import * as Play from 'view/play' import * as Options from 'view/options' @@ -9,9 +9,9 @@ enum Page { } mount( - withVar(Page.Form, (page, updatePage) => [ + withState(Page.Form, page => [ h('header', - { onclick: () => updatePage(_ => Page.Form) }, + { onclick: () => page.update(_ => Page.Form) }, 'Chords' ), page.map(p => @@ -20,7 +20,7 @@ mount( options: Options.load(), onSubmit: (options: Options.Model) => { Options.save(options) - updatePage(_ => Page.Play) + page.update(_ => Page.Play) } }) : Play.view({ diff --git a/src/view/form.ts b/src/view/form.ts index 5547e0c..e499b05 100644 --- a/src/view/form.ts +++ b/src/view/form.ts @@ -1,5 +1,6 @@ -import { h, withVar, Html, Rx } from 'lib/rx' +import { h, withState, Html, Rx, pure, RxAble } from 'lib/rx' import * as Options from 'view/options' +import * as Chord from 'chord' interface Params { options: Options.Model @@ -7,7 +8,7 @@ interface Params { } export function view({ options, onSubmit }: Params): Html { - return withVar(options, (opts, updateOptions) => + return withState(options, opts => h('form', { className: 'g-Form', onsubmit: opts.map(o => @@ -17,58 +18,71 @@ export function view({ options, onSubmit }: Params): Html { } ) }, - 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', + h('table', + { className: 'g-ChordsTable' }, + chordsGroup({ + title: 'Major', + chords: opts.map(o => o.major), + updateChords: (chords: Chord.Chords) => { + opts.update(o => { + o.major = chords + return o + }) + } + }), + chordsGroup({ + title: 'Minor', + chords: opts.map(o => o.minor), + updateChords: (chords: Chord.Chords) => { + opts.update(o => { + o.minor = chords + return o + }) + } + }), + chordsGroup({ + title: '7th', + chords: opts.map(o => o.seventh), + updateChords: (chords: Chord.Chords) => { + opts.update(o => { + o.seventh = chords + return o + }) + } + }), + chordsGroup({ + title: 'Minor 7th', + chords: opts.map(o => o.minorSeventh), + updateChords: (chords: Chord.Chords) => { + opts.update(o => { + o.minorSeventh = chords + return o + }) + } + }), + chordsGroup({ + title: 'Major 7th', + chords: opts.map(o => o.majorSeventh), + updateChords: (chords: Chord.Chords) => { + opts.update(o => { + o.majorSeventh = chords + return o + }) + } + }) + ), + numberInput({ + label: 'BPM', value: opts.map(o => o.bpm.toString()), - onChange: (n => updateOptions(o => { + onChange: (n => opts.update(o => { o.bpm = n return o })) }), - numberInput({ - label: 'Beats per Chord', + numberInput({ + label: 'Beats per Chord', value: opts.map(o => o.beatsPerChord.toString()), - onChange: (n => updateOptions(o => { + onChange: (n => opts.update(o => { o.beatsPerChord = n return o })) @@ -78,10 +92,85 @@ export function view({ options, onSubmit }: Params): Html { ) } +interface ChordsGroupParams { + title: string, + chords: Rx<Chord.Chords>, + updateChords: (chords: Chord.Chords) => void +} + +function chordsGroup({ title, chords, updateChords }: ChordsGroupParams): Html { + const line = + (lineChords: Array<Chord.Chord>, label: (chord: Chord.Chord) => string) => + h('div', + { className: 'g-ChordsLine' }, + chordCheckbox({ + checked: chords.map(cs => lineChords.every(c => cs.has(c))), + onCheck: chords.map(cs => + (checked: boolean) => { + if (checked) { + lineChords.forEach(c => cs.add(c)) + } else { + lineChords.forEach(c => cs.delete(c)) + } + updateChords(cs) + } + ) + }), + lineChords.map(chord => + chordCheckbox({ + label: label(chord), + checked: chords.map(cs => cs.has(chord)), + onCheck: chords.map(cs => + (checked: boolean) => { + if (checked) { + cs.add(chord) + } else { + cs.delete(chord) + } + updateChords(cs) + } + ) + }) + ) + ) + + return h('tr', + h('td', + chordCheckbox({ + label: title, + checked: chords.map(cs => + Chord.sharps.every(c => cs.has(c)) + && Chord.plains.every(c => cs.has(c)) + && Chord.bemols.every(c => cs.has(c)) + ), + onCheck: chords.map(cs => + (checked: boolean) => { + if (checked) { + Chord.sharps.forEach(c => cs.add(c)) + Chord.plains.forEach(c => cs.add(c)) + Chord.bemols.forEach(c => cs.add(c)) + } else { + Chord.sharps.forEach(c => cs.delete(c)) + Chord.plains.forEach(c => cs.delete(c)) + Chord.bemols.forEach(c => cs.delete(c)) + } + updateChords(cs) + } + ) + }) + ), + h('td', + line(Chord.sharps, () => '♯'), + line(Chord.plains, chord => chord), + line(Chord.bemols, () => '♭') + ) + ) +} + interface ChordCheckboxParams { - label: string + label?: string checked: Rx<boolean> - onCheck: (checked: boolean) => void + onCheck: Rx<(checked: boolean) => void> } function chordCheckbox({ label, checked, onCheck }: ChordCheckboxParams): Html { @@ -90,7 +179,7 @@ function chordCheckbox({ label, checked, onCheck }: ChordCheckboxParams): Html { h('input', { type: 'checkbox', checked, - onchange: (event: Event) => onCheck((event.target as HTMLInputElement).checked) + onchange: onCheck.map(f => (event: Event) => f((event.target as HTMLInputElement).checked)) } ), label diff --git a/src/view/options.ts b/src/view/options.ts index 31fd631..0782fe4 100644 --- a/src/view/options.ts +++ b/src/view/options.ts @@ -1,30 +1,70 @@ +import * as Chord from 'chord' + +// Model version: upgrade whenever the model is updated. Options are loaded +// only if versions match. +const version = '2' + export interface Model { - major: boolean - minor: boolean - seventh: boolean - minorSeventh: boolean - majorSeventh: boolean + major: Chord.Chords + minor: Chord.Chords + seventh: Chord.Chords + minorSeventh: Chord.Chords + majorSeventh: Chord.Chords bpm: number beatsPerChord: number } let init: Model = { - major: true, - minor: false, - seventh: false, - minorSeventh: false, - majorSeventh: false, + major: new Set(Chord.plains), + minor: new Set(), + seventh: new Set(), + minorSeventh: new Set(), + majorSeventh: new Set(), bpm: 90, beatsPerChord: 4 } -let key: string = 'options' - export function load(): Model { - let str = localStorage[key] - return str && JSON.parse(str) || init + let storedVersion = localStorage['version'] + + if (storedVersion === undefined || storedVersion !== version) { + return init + } else { + let str = localStorage['options'] + return str && fromString(str) || init + } } export function save(options: Model): void { - localStorage[key] = JSON.stringify(options) + localStorage['version'] = version + localStorage['options'] = toString(options) +} + +function toString(o: Model): string { + return JSON.stringify({ + major: [...o.major], + minor: [...o.minor], + seventh: [...o.seventh], + minorSeventh: [...o.minorSeventh], + majorSeventh: [...o.majorSeventh], + bpm: o.bpm, + beatsPerChord: o.beatsPerChord + }) +} + +function fromString(str: string): Model | undefined { + const o = JSON.parse(str) + if (o === undefined) { + return undefined + } else { + return { + major: new Set(o.major), + minor: new Set(o.minor), + seventh: new Set(o.seventh), + minorSeventh: new Set(o.minorSeventh), + majorSeventh: new Set(o.majorSeventh), + bpm: o.bpm, + beatsPerChord: o.beatsPerChord + } + } } diff --git a/src/view/play.ts b/src/view/play.ts index b85e505..4abc4ec 100644 --- a/src/view/play.ts +++ b/src/view/play.ts @@ -1,4 +1,4 @@ -import { h, withVar, Html } from 'lib/rx' +import { h, withState, Html } from 'lib/rx' import * as Options from 'view/options' import * as Chord from 'chord' @@ -9,19 +9,19 @@ interface ViewParams { export function view({ options }: ViewParams): Html { const initChords: Array<[string, string]> = [['', ''], Chord.generate(options), Chord.generate(options)] - return withVar(initChords, (chords, updateChords) => - withVar(undefined, (beat, updateBeat) => { + return withState(initChords, chords => + withState(undefined, beat => { let chordBeat = 1 const interval = setInterval(() => { if (chordBeat === options.beatsPerChord) { - updateChords(xs => { + chords.update(xs => { xs.shift() xs.push(Chord.generate(options)) return xs }) chordBeat = 1 } else { - updateBeat(_ => undefined) + beat.update(_ => undefined) chordBeat += 1 } }, 60 / options.bpm * 1000) |