From 221b6451fb4f8559a10e7fefebd13ce125ef29d0 Mon Sep 17 00:00:00 2001 From: Joris Date: Thu, 13 May 2021 14:50:51 +0200 Subject: Rewrite in TypeScript BuckleScript is no longer maintained. Choose a widely used techno that will still be maintained in the following years. --- src/Dom/CreateElement.ml | 72 --------------------- src/Dom/Document.ml | 14 ----- src/Dom/Element.ml | 44 ------------- src/Dom/Event.ml | 3 - src/Dom/EventTarget.ml | 4 -- src/Model/config.ml | 12 ---- src/Model/step.ml | 40 ------------ src/View/configView.ml | 83 ------------------------- src/View/timerView.ml | 123 ------------------------------------ src/animation.ml | 27 -------- src/arc.ml | 23 ------- src/arc.ts | 37 +++++++++++ src/audio.ml | 34 ---------- src/audio.ts | 19 ++++++ src/config.ts | 21 +++++++ src/duration.ml | 6 -- src/duration.ts | 9 +++ src/h.ts | 30 +++++++++ src/main.ml | 14 ----- src/main.ts | 21 +++++++ src/option.ml | 1 - src/router.ts | 55 +++++++++++++++++ src/state.ts | 63 +++++++++++++++++++ src/string.ml | 1 - src/view/form.ts | 47 ++++++++++++++ src/view/timer.ts | 158 +++++++++++++++++++++++++++++++++++++++++++++++ 26 files changed, 460 insertions(+), 501 deletions(-) delete mode 100644 src/Dom/CreateElement.ml delete mode 100644 src/Dom/Document.ml delete mode 100644 src/Dom/Element.ml delete mode 100644 src/Dom/Event.ml delete mode 100644 src/Dom/EventTarget.ml delete mode 100644 src/Model/config.ml delete mode 100644 src/Model/step.ml delete mode 100644 src/View/configView.ml delete mode 100644 src/View/timerView.ml delete mode 100644 src/animation.ml delete mode 100644 src/arc.ml create mode 100644 src/arc.ts delete mode 100644 src/audio.ml create mode 100644 src/audio.ts create mode 100644 src/config.ts delete mode 100644 src/duration.ml create mode 100644 src/duration.ts create mode 100644 src/h.ts delete mode 100644 src/main.ml create mode 100644 src/main.ts delete mode 100644 src/option.ml create mode 100644 src/router.ts create mode 100644 src/state.ts delete mode 100644 src/string.ml create mode 100644 src/view/form.ts create mode 100644 src/view/timer.ts (limited to 'src') diff --git a/src/Dom/CreateElement.ml b/src/Dom/CreateElement.ml deleted file mode 100644 index 8183a02..0000000 --- a/src/Dom/CreateElement.ml +++ /dev/null @@ -1,72 +0,0 @@ -(* Element creation *) - -let h tag ?(attributes = [||]) ?(eventListeners = [||]) ?(children = [||]) () : - Dom.element = - let element = - if tag == "svg" || tag == "path" then - Document.createElementNS "http://www.w3.org/2000/svg" tag - else Document.createElement tag - in - let () = - Js.Array.forEach - (fun (name, value) -> Element.setAttribute element name value) - attributes - in - let () = - Js.Array.forEach - (fun (name, eventListener) -> - Element.addEventListener element name eventListener) - eventListeners - in - let () = - Js.Array.forEach (fun child -> Element.appendChild element child) children - in - element - -(* Node creation *) - -let text = Document.createTextNode - -let div = h "div" - -let span = h "span" - -let header = h "header" - -let button = h "button" - -let section = h "section" - -let svg = h "svg" - -let path = h "path" - -let form = h "form" - -let label = h "label" - -let input_ = h "input" - -(* Attribute creation *) - -let id v = ("id", v) - -let className v = ("class", v) - -let viewBox v = ("viewBox", v) - -let d v = ("d", v) - -let type_ v = ("type", v) - -let min_ v = ("min", v) - -let value v = ("value", v) - -(* Event listeners *) - -let onClick f = ("click", f) - -let onInput f = ("input", f) - -let onSubmit f = ("submit", f) diff --git a/src/Dom/Document.ml b/src/Dom/Document.ml deleted file mode 100644 index 867e28c..0000000 --- a/src/Dom/Document.ml +++ /dev/null @@ -1,14 +0,0 @@ -external createElement : string -> Dom.element = "createElement" - [@@bs.val] [@@bs.scope "document"] - -external createElementNS : string -> string -> Dom.element = "createElementNS" - [@@bs.val] [@@bs.scope "document"] - -external querySelector : string -> Dom.element Js.Nullable.t = "querySelector" - [@@bs.val] [@@bs.scope "document"] - -let querySelectorUnsafe id = - querySelector id |> Js.Nullable.toOption |> Js.Option.getExn - -external createTextNode : string -> Dom.element = "createTextNode" - [@@bs.val] [@@bs.scope "document"] diff --git a/src/Dom/Element.ml b/src/Dom/Element.ml deleted file mode 100644 index 0b6c0bd..0000000 --- a/src/Dom/Element.ml +++ /dev/null @@ -1,44 +0,0 @@ -external setValue : Dom.element -> string -> unit = "value" [@@bs.set] - -external setTextContent : Dom.element -> string -> unit = "textContent" - [@@bs.set] - -external setStyle : Dom.element -> string -> unit = "style" [@@bs.set] - -external setClassName : Dom.element -> string -> unit = "className" [@@bs.set] - -external setScrollTop : Dom.element -> int -> unit = "scrollTop" [@@bs.set] - -external setAttribute : Dom.element -> string -> string -> unit = "setAttribute" - [@@bs.send] - -external setAttributeNS : Dom.element -> string -> string -> string -> unit - = "setAttributeNS" - [@@bs.send] - -external addEventListener : Dom.element -> string -> (Dom.event -> unit) -> unit - = "addEventListener" - [@@bs.send] - -external appendChild : Dom.element -> Dom.element -> unit = "appendChild" - [@@bs.send] - -external firstChild : Dom.element -> Dom.element Js.Nullable.t = "firstChild" - [@@bs.get] - -external removeChild : Dom.element -> Dom.element -> unit = "removeChild" - [@@bs.send] - -let removeFirstChild element = - match Js.toOption (firstChild element) with - | Some child -> - let () = removeChild element child in - true - | _ -> false - -let rec removeChildren element = - if removeFirstChild element then removeChildren element else () - -let mountOn base element = - let () = removeChildren base in - appendChild base element diff --git a/src/Dom/Event.ml b/src/Dom/Event.ml deleted file mode 100644 index bffd242..0000000 --- a/src/Dom/Event.ml +++ /dev/null @@ -1,3 +0,0 @@ -external preventDefault : Dom.event -> unit = "preventDefault" [@@bs.send] - -external target : Dom.event -> Dom.eventTarget = "target" [@@bs.get] diff --git a/src/Dom/EventTarget.ml b/src/Dom/EventTarget.ml deleted file mode 100644 index d1b0c02..0000000 --- a/src/Dom/EventTarget.ml +++ /dev/null @@ -1,4 +0,0 @@ -external nullableValue : Dom.eventTarget -> string Js.Nullable.t = "value" - [@@bs.get] - -let value eventTarget = nullableValue eventTarget |> Js.Nullable.toOption diff --git a/src/Model/config.ml b/src/Model/config.ml deleted file mode 100644 index 99e42d1..0000000 --- a/src/Model/config.ml +++ /dev/null @@ -1,12 +0,0 @@ -type config = { - prepare : int; - tabatas : int; - cycles : int; - work : int; - rest : int; -} - -let init = { prepare = 10; tabatas = 4; cycles = 8; work = 20; rest = 10 } - -let getDuration { prepare; tabatas; cycles; work; rest } = - tabatas * (prepare + (cycles * (work + rest))) diff --git a/src/Model/step.ml b/src/Model/step.ml deleted file mode 100644 index 02a110e..0000000 --- a/src/Model/step.ml +++ /dev/null @@ -1,40 +0,0 @@ -type step = Prepare | Work | Rest | End - -let prettyPrint step = - match step with - | Prepare -> "Prepare" - | Work -> "Work" - | Rest -> "Rest" - | End -> "End" - -type state = { step : step; remaining : int; tabata : int; cycle : int } - -let getAt (config : Config.config) elapsed = - let cycleDuration = config.work + config.rest in - let tabataDuration = config.prepare + (config.cycles * cycleDuration) in - if elapsed >= tabataDuration * config.tabatas then - { - step = End; - remaining = 0; - tabata = config.tabatas; - cycle = config.cycles; - } - else - let currentTabataElapsed = elapsed mod tabataDuration in - let step, remaining = - if currentTabataElapsed < config.prepare then - (Prepare, config.prepare - currentTabataElapsed) - else - let currentCycleElapsed = - (currentTabataElapsed - config.prepare) mod cycleDuration - in - if currentCycleElapsed < config.work then - (Work, config.work - currentCycleElapsed) - else (Rest, config.work + config.rest - currentCycleElapsed) - in - let tabata = (elapsed / tabataDuration) + 1 in - let cycle = - if currentTabataElapsed < config.prepare then 1 - else ((currentTabataElapsed - config.prepare) / cycleDuration) + 1 - in - { step; remaining; tabata; cycle } diff --git a/src/View/configView.ml b/src/View/configView.ml deleted file mode 100644 index 5db6ea5..0000000 --- a/src/View/configView.ml +++ /dev/null @@ -1,83 +0,0 @@ -open CreateElement -open Config - -let labelledInput labelValue minValue inputValue update writeDuration = - label - ~attributes:[| className "g-Form__Label" |] - ~eventListeners: - [| - onInput (fun e -> - match - EventTarget.value (Event.target e) - |> Option.flatMap Belt.Int.fromString - with - | Some n -> - let () = update n in - writeDuration () - | None -> ()); - |] - ~children: - [| - text labelValue; - input_ - ~attributes: - [| - className "g-Form__Input"; - type_ "number"; - min_ (Js.Int.toString minValue); - value (Js.Int.toString inputValue); - |] - (); - |] - () - -let render initialConfig onStart = - let config = ref initialConfig in - let duration = text (Duration.prettyPrint (getDuration !config)) in - let wd () = - Element.setTextContent duration (Duration.prettyPrint (getDuration !config)) - in - div - ~children: - [| - header - ~attributes:[| className "g-Layout__Header" |] - ~children:[| text "Tabata timer" |] - (); - form - ~attributes:[| className "g-Form" |] - ~eventListeners: - [| - onSubmit (fun e -> - let () = Event.preventDefault e in - onStart !config); - |] - ~children: - [| - labelledInput "prepare" 0 !config.prepare - (fun n -> config := { !config with prepare = n }) - wd; - labelledInput "tabatas" 1 !config.tabatas - (fun n -> config := { !config with tabatas = n }) - wd; - labelledInput "cycles" 1 !config.cycles - (fun n -> config := { !config with cycles = n }) - wd; - labelledInput "work" 5 !config.work - (fun n -> config := { !config with work = n }) - wd; - labelledInput "rest" 5 !config.rest - (fun n -> config := { !config with rest = n }) - wd; - div - ~attributes:[| className "g-Form__Duration" |] - ~children:[| text "duration"; div ~children:[| duration |] () |] - (); - button - ~attributes:[| className "g-Form__Start" |] - ~children:[| text "start" |] - (); - |] - (); - |] - () diff --git a/src/View/timerView.ml b/src/View/timerView.ml deleted file mode 100644 index 2384f85..0000000 --- a/src/View/timerView.ml +++ /dev/null @@ -1,123 +0,0 @@ -open CreateElement - -let render (config : Config.config) onStop = - let duration = Config.getDuration config in - (* State *) - let interval = ref None in - let elapsed = ref 0 in - let step = ref (Step.getAt config !elapsed) in - let isPlaying = ref true in - (* Elements *) - let stepElt = text (Step.prettyPrint !step.step) in - let durationElt = text (Duration.prettyPrint !step.remaining) in - let arcPathElt = path ~attributes:[| className "g-Timer__ArcProgress" |] () in - let tabataCurrentElt = text (Js.Int.toString !step.tabata) in - let cycleCurrentElt = text (Js.Int.toString !step.cycle) in - (* Update *) - let stop () = - let () = Belt.Option.forEach !interval Js.Global.clearInterval in - onStop config - in - let updateDom () = - let angle = Js.Int.toFloat !elapsed /. Js.Int.toFloat duration *. 360.0 in - let () = - Element.setAttribute arcPathElt "d" (Arc.describe 0.0 0.0 95.0 0.0 angle) - in - let step = Step.getAt config !elapsed in - let () = Element.setTextContent stepElt (Step.prettyPrint step.step) in - let () = - Element.setTextContent durationElt (Duration.prettyPrint step.remaining) - in - let () = - Element.setTextContent tabataCurrentElt (Js.Int.toString step.tabata) - in - let () = - Element.setTextContent cycleCurrentElt (Js.Int.toString step.cycle) - in - Audio.playFromStep config step - in - let update () = - if !isPlaying then - let () = elapsed := !elapsed + 1 in - let () = step := Step.getAt config !elapsed in - if !elapsed > duration then stop () else updateDom () - else () - in - (* Start timer *) - let () = interval := Some (Js.Global.setInterval update 1000) in - (* View *) - section - ~attributes:[| className "g-Timer" |] - ~children: - [| - button - ~attributes:[| className "g-Timer__Dial" |] - ~eventListeners:[| onClick (fun _ -> isPlaying := not !isPlaying) |] - ~children: - [| - svg - ~attributes: - [| className "g-Timer__Arc"; viewBox "-100 -100 200 200" |] - ~children: - [| - path - ~attributes: - [| - className "g-Timer__ArcTotal"; - d (Arc.describe 0.0 0.0 95.0 0.0 359.999); - |] - (); - arcPathElt; - |] - (); - div - ~attributes:[| className "g-Timer__Step" |] - ~children:[| stepElt |] (); - div - ~attributes:[| className "g-Timer__Duration" |] - ~children:[| durationElt |] (); - |] - (); - div - ~attributes:[| className "g-Timer__TabataAndCycle" |] - ~children: - [| - div - ~attributes:[| className "g-Timer__Tabata" |] - ~children: - [| - div ~children:[| text "Tabata" |] (); - span - ~attributes:[| className "g-Timer__TabataCurrent" |] - ~children:[| tabataCurrentElt |] (); - text "/"; - span - ~attributes:[| className "g-Timer__TabataTotal" |] - ~children:[| text (Js.Int.toString config.tabatas) |] - (); - |] - (); - div - ~attributes:[| className "g-Timer__Cycle" |] - ~children: - [| - div ~children:[| text "Cycle" |] (); - span - ~attributes:[| className "g-Timer__CycleCurrent" |] - ~children:[| cycleCurrentElt |] (); - text "/"; - span - ~attributes:[| className "g-Timer__CycleTotal" |] - ~children:[| text (Js.Int.toString config.cycles) |] - (); - |] - (); - |] - (); - div - ~attributes:[| className "g-Timer__Stop" |] - ~children:[| text "stop" |] - ~eventListeners:[| onClick (fun _ -> stop ()) |] - (); - |] - () diff --git a/src/animation.ml b/src/animation.ml deleted file mode 100644 index 35294dc..0000000 --- a/src/animation.ml +++ /dev/null @@ -1,27 +0,0 @@ -let isRunning = ref false - -let start base ~onStart ~onEnd = - if not !isRunning then - let () = isRunning := true in - let () = onStart () in - let () = Element.setClassName base "g-Animation" in - let delay = 400 in - let _ = - Js.Global.setTimeout - (fun () -> - let () = Element.setClassName base "" in - let () = onEnd () in - isRunning := false) - delay - in - () - else () - -let replaceChild scrollBase base mkChild = - start base - ~onStart:(fun _ -> - let () = Element.setScrollTop scrollBase 0 in - Element.appendChild base (mkChild ())) - ~onEnd:(fun _ -> - let _ = Element.removeFirstChild base in - ()) diff --git a/src/arc.ml b/src/arc.ml deleted file mode 100644 index 7a3195d..0000000 --- a/src/arc.ml +++ /dev/null @@ -1,23 +0,0 @@ -let polarToCartesian centerX centerY radius angleInDegrees = - let angleInRadians = (angleInDegrees -. 90.0) *. Js.Math._PI /. 180.0 in - ( centerX +. (radius *. Js.Math.cos angleInRadians), - centerY +. (radius *. Js.Math.sin angleInRadians) ) - -let describe x y radius startAngle endAngle = - let startX, startY = polarToCartesian x y radius endAngle in - let endX, endY = polarToCartesian x y radius startAngle in - let largeArcFlag = if endAngle -. startAngle <= 180.0 then "0" else "1" in - [| - "M"; - Js.Float.toString startX; - Js.Float.toString startY; - "A"; - Js.Float.toString radius; - Js.Float.toString radius; - "0"; - largeArcFlag; - "0"; - Js.Float.toString endX; - Js.Float.toString endY; - |] - |> Js.Array.joinWith " " diff --git a/src/arc.ts b/src/arc.ts new file mode 100644 index 0000000..d8e1e7d --- /dev/null +++ b/src/arc.ts @@ -0,0 +1,37 @@ +function polarToCartesian( + centerX: number, + centerY: number, + radius: number, + degreesAngle: number +): number[] { + const radianAngle = (degreesAngle - 90) * Math.PI / 180 + return [ + centerX + radius * Math.cos(radianAngle), + centerY + radius * Math.sin(radianAngle) + ] +} + +export function describe( + x: number, + y: number, + radius: number, + startAngle: number, + endAngle: number +): string { + const [startX, startY] = polarToCartesian(x, y, radius, endAngle) + const [endX, endY] = polarToCartesian(x, y, radius, startAngle) + const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1" + return [ + "M", + startX.toString(), + startY, + "A", + radius.toString(), + radius.toString(), + "0", + largeArcFlag, + "0", + endX.toString(), + endY.toString(), + ].join(" ") +} diff --git a/src/audio.ml b/src/audio.ml deleted file mode 100644 index 1446440..0000000 --- a/src/audio.ml +++ /dev/null @@ -1,34 +0,0 @@ -type audio - -external create : string -> audio = "Audio" [@@bs.new] - -external play : audio -> unit = "play" [@@bs.send] - -external currentTime : audio -> int = "currentTime" [@@bs.get] - -external setCurrentTime : audio -> int -> unit = "currentTime" [@@bs.set] - -let playOrReplay audio = - let () = if currentTime audio > 0 then setCurrentTime audio 0 else () in - play audio - -(* Sounds *) - -let c3 = create "sounds/c3.mp3" - -let c4 = create "sounds/c4.mp3" - -let c5 = create "sounds/c5.mp3" - -(* Play from step *) - -let playFromStep (config: Config.config) (step : Step.state) = - match step.step with - | Step.Prepare when step.remaining == config.prepare -> - playOrReplay c3 - | Step.Work when step.remaining == config.work -> - playOrReplay c5 - | Step.Rest when step.remaining == config.rest -> - playOrReplay c3 - | Step.End -> playOrReplay c3 - | _ -> if step.remaining <= 3 then playOrReplay c4 else () diff --git a/src/audio.ts b/src/audio.ts new file mode 100644 index 0000000..bdf64eb --- /dev/null +++ b/src/audio.ts @@ -0,0 +1,19 @@ +import * as Config from 'config' +import * as State from 'state' + +const start = new Audio('sound/start.mp3') +const stop = new Audio('sound/stop.mp3') +const endTabata = new Audio('sound/end-tabata.mp3') +const endTraining = new Audio('sound/end-training.mp3') + +export function playFromStep(config: Config.Config, state: State.State) { + if (state.step === State.Step.Work && state.remaining === config.work) { + start.play() + } else if (state.step === State.Step.Rest && state.remaining === config.rest) { + stop.play() + } else if (state.step === State.Step.Prepare && state.remaining === config.prepare) { + endTabata.play() + } else if (state.step === State.Step.End) { + endTraining.play() + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c20bff2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,21 @@ +export interface Config { + prepare : number; + tabatas : number; + cycles : number; + work : number; + rest : number; +} + +export function init(): Config { + return { + prepare: 10, + tabatas: 4, + cycles: 8, + work: 20, + rest: 10 + } +} + +export function getDuration(c: Config): number { + return c.tabatas * (c.prepare + (c.cycles * (c.work + c.rest))) +} diff --git a/src/duration.ml b/src/duration.ml deleted file mode 100644 index b0b119b..0000000 --- a/src/duration.ml +++ /dev/null @@ -1,6 +0,0 @@ -let prettyPrintNumber number = String.padStart (Js.Int.toString number) 2 "0" - -let prettyPrint duration = - let minutes = duration / 60 in - let seconds = duration mod 60 in - prettyPrintNumber minutes ^ ":" ^ prettyPrintNumber seconds diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..92952a7 --- /dev/null +++ b/src/duration.ts @@ -0,0 +1,9 @@ +function prettyPrintNumber(n: number): string { + return n.toString().padStart(2, "0") +} + +export function prettyPrint(duration: number): string { + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + return prettyPrintNumber(minutes) + ":" + prettyPrintNumber(seconds) +} diff --git a/src/h.ts b/src/h.ts new file mode 100644 index 0000000..bb21efd --- /dev/null +++ b/src/h.ts @@ -0,0 +1,30 @@ +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 +} diff --git a/src/main.ml b/src/main.ml deleted file mode 100644 index 003880b..0000000 --- a/src/main.ml +++ /dev/null @@ -1,14 +0,0 @@ -type view = Config of Config.config | Timer of Config.config - -let () = - let html = Document.querySelectorUnsafe "html" in - let main = Document.querySelectorUnsafe "main" in - let rec showView v = - Animation.replaceChild html main (fun _ -> - match v with - | Config config -> - ConfigView.render config (fun config -> showView (Timer config)) - | Timer config -> - TimerView.render config (fun config -> showView (Config config))) - in - showView (Config Config.init) diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..436a217 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,21 @@ +import * as Config from 'config' +import * as Form from 'view/form' +import * as Timer from 'view/timer' +import * as Router from 'router' + +export function showPage(route: Router.Route) { + if (route.kind === Router.Kind.Form) { + document.body.innerHTML = '' + document.body.appendChild(Form.view(route.config, showPage)) + } else if (route.kind === Router.Kind.Timer) { + document.body.innerHTML = '' + document.body.appendChild(Timer.view(route.config, showPage)) + } +} + +showPage(Router.from(document.location)) + +window.onpopstate = (event: Event) => { + Timer.clearInterval() + showPage(Router.from(document.location)) +} diff --git a/src/option.ml b/src/option.ml deleted file mode 100644 index 16047fd..0000000 --- a/src/option.ml +++ /dev/null @@ -1 +0,0 @@ -let flatMap f opt = Belt.Option.flatMapU opt (fun [@bs] x -> f x) diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..abbdc65 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,55 @@ +import * as Config from 'config' + +export enum Kind { + Form, + Timer, +} + +export interface Route { + kind: Kind, + config: Config.Config +} + +export function from(location: Location): Route { + const hash = location.hash.slice(1) + const parts = hash.split('?') + const path = parts[0] + const search = parts.length > 1 ? parts[1] : '' + + const kind = path.startsWith('/timer') ? Kind.Timer : Kind.Form + let config = Config.init() + if (search.length > 0) { + search.split('&').forEach(entry => { + const xs = entry.split('=') + if (xs.length === 2) { + const key = xs[0] + const value = parseInt(xs[1]) + if (key == 'prepare') config.prepare = value + else if (key == 'tabatas') config.tabatas = value + else if (key == 'cycles') config.cycles = value + else if (key == 'work') config.work = value + else if (key == 'rest') config.rest = value + } + }) + const params = search.split('&') + } + + return { kind, config } +} + +export function toString(route: Route): string { + const path = route.kind === Kind.Form ? '/' : '/timer' + let query = '' + if (route.config !== undefined) { + const { prepare, tabatas, cycles, work, rest } = route.config + const params = [ + `prepare=${prepare}`, + `tabatas=${tabatas}`, + `cycles=${cycles}`, + `work=${work}`, + `rest=${rest}`, + ].join('&') + query = `?${params}` + } + return `#${path}${query}` +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..3b390c5 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,63 @@ +import * as Config from 'config' + +export enum Step { + Prepare, + Work, + Rest, + End, +} + +export function prettyPrintStep(step: Step): string { + if (step === Step.Prepare) + return "Prepare" + else if (step === Step.Work) + return "Work" + else if (step === Step.Rest) + return "Rest" + else + return "End" +} + +export interface State { + step: Step, + remaining: number, + tabata: number, + cycle: number, +} + +export function getAt(config: Config.Config, elapsed: number): State { + const cycleDuration = config.work + config.rest + const tabataDuration = config.prepare + (config.cycles * cycleDuration) + if (elapsed >= tabataDuration * config.tabatas) { + return { + step: Step.End, + remaining: 0, + tabata: config.tabatas, + cycle: config.cycles, + } + } else { + const currentTabataElapsed = elapsed % tabataDuration + let step, remaining + if (currentTabataElapsed < config.prepare) { + step = Step.Prepare + remaining = config.prepare - currentTabataElapsed + } else { + const currentCycleElapsed = (currentTabataElapsed - config.prepare) % cycleDuration + if (currentCycleElapsed < config.work) { + step = Step.Work + remaining = config.work - currentCycleElapsed + } else { + step = Step.Rest + remaining = config.work + config.rest - currentCycleElapsed + } + } + + const tabata = Math.floor(elapsed / tabataDuration) + 1 + const cycle = + currentTabataElapsed < config.prepare + ? 1 + : Math.floor((currentTabataElapsed - config.prepare) / cycleDuration) + 1 + + return { step, remaining, tabata, cycle } + } +} diff --git a/src/string.ml b/src/string.ml deleted file mode 100644 index 335fdec..0000000 --- a/src/string.ml +++ /dev/null @@ -1 +0,0 @@ -external padStart : string -> int -> string -> string = "padStart" [@@bs.send] diff --git a/src/view/form.ts b/src/view/form.ts new file mode 100644 index 0000000..60e5f08 --- /dev/null +++ b/src/view/form.ts @@ -0,0 +1,47 @@ +import * as Config from 'config' +import h from 'h' +import * as Router from 'router' +import * as Duration from 'duration' + +function labelledInput( + labelValue: string, + min: number, + value: number, + update: (n: number) => void +): Element { + return h('label', + { className: 'g-Form__Label', + oninput: (e: Event) => { + if (e.target !== null) { + const target = e.target as HTMLInputElement + update(parseInt(target.value) || 0) + } + } + }, + labelValue, + h('input', { className: 'g-Form__Input', type: 'number', min, value })) +} + +export function view(config: Config.Config, showPage: (route: Router.Route) => void) { + const duration = document.createTextNode(Duration.prettyPrint(Config.getDuration(config))) + const wd = () => duration.textContent = Duration.prettyPrint(Config.getDuration(config)) + return h('div', + { className: 'g-Layout__Page' }, + h('header', { className: 'g-Layout__Header' }, 'Tabata timer'), + h('form', + { className: 'g-Form' + , onsubmit: (e: Event) => { + e.preventDefault() + const timerRoute = { kind: Router.Kind.Timer, config } + history.pushState({}, '', Router.toString(timerRoute)) + showPage(timerRoute) + }}, + labelledInput('prepare', 0, config.prepare, n => { config.prepare = n; wd()}), + labelledInput('tabatas', 1, config.tabatas, n => { config.tabatas = n; wd()}), + labelledInput('cycles', 1, config.cycles, n => { config.cycles = n; wd()}), + labelledInput('work', 5, config.work, n => { config.work = n; wd()}), + labelledInput('rest', 5, config.rest, n => { config.rest = n; wd()}), + h('div', { className: 'g-Form__Duration' }, 'duration', h('div', {}, duration)), + h('button', { className: 'g-Form__Start' }, 'start')) + ) +} diff --git a/src/view/timer.ts b/src/view/timer.ts new file mode 100644 index 0000000..ddcea71 --- /dev/null +++ b/src/view/timer.ts @@ -0,0 +1,158 @@ +import * as Config from 'config' +import * as State from 'state' +import * as Arc from 'arc' +import * as Router from 'router' +import * as Audio from 'audio' +import h from 'h' + +let interval: number | undefined = undefined + +export function clearInterval() { + if (interval !== undefined) { + window.clearInterval(interval) + interval = undefined + } +} + +export function view(config: Config.Config, showPage: (route: Router.Route) => void) { + + const formUrl = `${Router.toString({ kind: Router.Kind.Form, config })}` + const duration = Config.getDuration(config) + + // State + let isPlaying = true + let elapsed = 0 + let state = State.getAt(config, elapsed) + + // Elements + const section = h('section', { className: timerClass(state.step) }) + const stepElt = document.createTextNode(State.prettyPrintStep(state.step)) + const stepCountElt = document.createTextNode(stepCount(state)) + const arcPathElt = h('path', { class: 'g-Timer__ArcProgress' }) + + const updateDom = () => { + const angle = elapsed / duration * 360 + arcPathElt.setAttribute("d", Arc.describe(0, 0, 90, 0, angle)) + section.className = timerClass(state.step) + stepElt.textContent = State.prettyPrintStep(state.step) + stepCountElt.textContent = stepCount(state) + Audio.playFromStep(config, state) + } + + const quit = () => { + const formRoute = { kind: Router.Kind.Form, config } + history.pushState({}, '', Router.toString(formRoute)) + showPage(formRoute) + } + + const update = () => { + if (isPlaying) { + elapsed = elapsed + 1 + state = State.getAt(config, elapsed) + elapsed > duration + ? quit() + : updateDom() + } + } + + // Start timer + if (interval !== undefined) { + window.clearInterval(interval) + interval = undefined + } + interval = window.setInterval(update, 1000) + + section.append( + h('div', + { className: 'g-Timer__Dial' }, + h('svg', + { class: 'g-Timer__Arc', + viewBox: '-100 -100 200 200' + }, + h('path', + { class: 'g-Timer__ArcTotal', + d: Arc.describe(0, 0, 90.0, 0, 359.999) + } + ), + ...arcPaths(config), + arcPathElt + ), + h('div', { className: 'g-Timer__Step' }, stepElt), + h('div', {}, stepCountElt) + ), + h('div', + { className: 'g-Timer__Buttons' }, + h('button', + { className: 'g-Timer__Button', + onclick: (e: MouseEvent) => { + isPlaying = !isPlaying + const elt = e.target as HTMLElement + elt.textContent = isPlaying + ? 'pause' + : 'resume' + elt.className = isPlaying + ? 'g-Timer__Button' + : 'g-Timer__Button g-Timer__Button--Active' + } + }, + 'pause' + ), + h('a', + { className: 'g-Timer__Button', + href: formUrl + }, + 'quit' + ) + ) + ) + + return section +} + +function arcPaths(config: Config.Config): Element[] { + const paths = [] + + let t = 0 + const totalDuration = Config.getDuration(config) + + let arc = (kind: string, duration: number): Element => { + const startAngle = 360 * t / totalDuration + const endAngle = 360 * (t + duration) / totalDuration + + t += duration + + return h('path', + { class: `g-Timer__Arc${kind}`, + d: Arc.describe(0, 0, 90.0, startAngle, endAngle) + } + ) + } + + for (let tabata = 0; tabata < config.tabatas; tabata++) { + paths.push(arc('Prepare', config.prepare)) + for (let cycle = 0; cycle < config.cycles; cycle++) { + paths.push(arc('Work', config.work)) + paths.push(arc('Rest', config.rest)) + } + } + + return paths +} + +function timerClass(step: State.Step): string { + if (step === State.Step.Work) { + return 'g-Layout__Page g-Timer g-Timer--Work' + } else if (step === State.Step.Rest) { + return 'g-Layout__Page g-Timer g-Timer--Rest' + } else { + return 'g-Layout__Page g-Timer g-Timer--Prepare' + } +} + +function stepCount(state: State.State): string { + if (state.step === State.Step.Work || state.step === State.Step.Rest) { + return `#${state.tabata.toString()}.${state.cycle.toString()}` + } else { + return `#${state.tabata.toString()}` + } +} -- cgit v1.2.3