diff --git a/stopwatch/src/index.tsx b/stopwatch/src/index.tsx new file mode 100644 index 0000000..4698a6c --- /dev/null +++ b/stopwatch/src/index.tsx @@ -0,0 +1,480 @@ +import { action, autorun, observable } from "mobx"; +import { observer } from "mobx-react"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface PossibleLap { + time: number; + lap: boolean; + comment: string; +} + +interface TimeAttack { + person: string; + durations: number[]; +} + +declare var is_kasse_i_kass: boolean; + +declare var time_attack: TimeAttack | null; + +declare var initial_state: { + elapsed_time: number; + durations: number[]; + state: number; + result: number; + result_display: string; + time_attack: TimeAttack | null; +} | null; + +const possible_laps: PossibleLap[] = []; +let form: HTMLFormElement | null = null; +let roundtrip_estimate = 0; +let fetch_interval: NodeJS.Timeout | null = null; + +function format_difference(total_milliseconds: number, n: number) { + // U+2212 = Minus Sign + const s = total_milliseconds > 0 ? "+" : "\u2212"; + if (total_milliseconds < 0) total_milliseconds = -total_milliseconds; + const seconds = (total_milliseconds / 1000) | 0; + const milliseconds = (total_milliseconds - 1000 * seconds) | 0; + return s + seconds + "." + ("000" + milliseconds).slice(-3, -3 + n); +} + +function format_timestamp(total_milliseconds: number, n: number) { + const total_seconds = (total_milliseconds / 1000) | 0; + const milliseconds = (total_milliseconds - 1000 * total_seconds) | 0; + const total_minutes = (total_seconds / 60) | 0; + const seconds = (total_seconds - 60 * total_minutes) | 0; + const total_hours = (total_minutes / 60) | 0; + const minutes = total_minutes - 60 * total_hours; + return ( + total_hours + + ":" + + ("00" + minutes).slice(-2) + + ":" + + ("00" + seconds).slice(-2) + + "." + + ("000" + milliseconds).slice(-3, -3 + n) + ); +} + +class State { + @observable + headerString = "Stopur"; + @observable + start_time: number | null = null; + @observable + total_milliseconds: number | null = null; + @observable + stopped = true; + @observable + laps: number[] = []; + + get durations() { + let prev = 0; + const v: number[] = []; + for (let i = 0; i < state.laps.length; ++i) { + const duration = (state.laps[i] - prev) | 0; + prev = state.laps[i]; + v.push(duration / 1000); + } + return v; + } +} + +const state = new State(); + +function update_time() { + if (state.stopped) { + state.total_milliseconds = + state.laps.length > 0 ? state.laps[state.laps.length - 1] : 0; + } else { + const now = new Date().getTime(); + state.total_milliseconds = (now - (state.start_time as number)) | 0; + window.requestAnimationFrame(update_time); + } +} + +@observer +class Stopwatch extends React.Component<{header: string}, {}> { + render() { + const button = state.stopped ? ( + + ) : ( + + ); + return ( + <> +

{this.props.header}

+
+ {format_timestamp( + state.total_milliseconds || 0, + state.stopped ? 2 : 1 + )} +
+
{button}
+ {} + + ); + } + + @action + onStart() { + if (state.laps.length == 0) { + // note, too large for signed 32-bit int + state.start_time = new Date().getTime() - (state.total_milliseconds || 0); + } + add_possible_lap(state.laps.length > 0 ? "Fortsæt" : "Start"); + state.stopped = false; + post_live_update(); + } + + @action + onLap(e: React.MouseEvent | React.TouchEvent) { + e.preventDefault(); + e.stopPropagation(); + if (state.start_time == null) return; + const n = new Date().getTime(); + try_add_lap((n - state.start_time) | 0); + } +} + +function Lap({ + index, + duration, + total, + difference +}: { + index: number; + duration: number; + total: number; + difference: number | null; +}) { + let lapTotal = format_timestamp(total, 2); + if (is_kasse_i_kass) { + const time = new Date(); + time.setTime(total + (state.start_time as number)); + const timeStr = time + ""; + const match = /(\d+:\d+:\d+)/.exec(timeStr); + lapTotal = match ? (match[1] as string) : ""; + } + let lapDiff: JSX.Element; + if (difference !== null) { + const c = difference <= 0 ? "negdiff" : "posdiff"; + lapDiff = ( +
{format_difference(difference, 2)}
+ ); + } else { + lapDiff =
; + } + return ( +
+
Øl {index}
+
{format_timestamp(duration, 2)}
+
{lapTotal}
+ {lapDiff} +
+ ); +} + +@observer +class Laps extends React.Component<{}, {}> { + render() { + const laps: JSX.Element[] = []; + + let prev = 0; + let ta_cumsum = 0; + const v = []; + for (let i = 0; i < state.laps.length; ++i) { + const duration = (state.laps[i] - prev) | 0; + prev = state.laps[i]; + v.push(duration / 1000); + if (time_attack) { + ta_cumsum += time_attack.durations[i] | 0; + } + let difference = null; + if (time_attack) { + if (time_attack.durations.length > i) { + difference = (state.laps[i] - ta_cumsum) | 0; + } else { + difference = null; + } + } + laps.push( + + ); + } + + const ta_len = time_attack ? time_attack.durations.length : 0; + if (time_attack && ta_len > state.laps.length) { + ta_cumsum += time_attack.durations[state.laps.length]; + laps.push( +
+
+
+
+ +
+ ); + } + // TODO(rav): Scroll laps if applicable + return ( +
8 ? "many" : ""}> + {laps} +
+ ); + } +} + +@observer +class TimeAttackCurrentDifference extends React.Component<{}, {}> { + render() { + if (state.stopped || !time_attack) return <>; + let ta_cumsum = 0; + const l = state.laps.length + 1; + for (let i = 0; i < l; ++i) ta_cumsum += time_attack.durations[i] | 0; + const d = (state.total_milliseconds || 0) - ta_cumsum; + const c = d <= 0 ? "negdiff" : "posdiff"; + return ( +
+ {format_difference(d, state.stopped ? 2 : 1)} +
+ ); + } +} + +function add_possible_lap(comment: string) { + if (state.start_time === null) return; + const n = new Date().getTime(); + const d = (n - state.start_time) | 0; + possible_laps.push({ + time: d, + lap: false, + comment: comment + }); +} + +function try_add_lap(d: number) { + let min_length = 1000; + if (state.laps.length === 0) min_length = 3000; + const prev_lap = + state.laps.length === 0 ? 0 : state.laps[state.laps.length - 1]; + if (d - prev_lap < min_length) { + possible_laps.push({ + time: d, + lap: false, + comment: "For kort (" + (d - prev_lap) + " < " + min_length + ")" + }); + } else { + possible_laps.push({ + time: d, + lap: true, + comment: "Tilføjet som Øl " + (1 + state.laps.length) + }); + state.laps.push(d); + post_live_update(); + } +} + +const stop = action((ev: Event) => { + if (state.laps.length === 0) return reset(ev); + add_possible_lap("Stop"); + ev.preventDefault(); + ev.stopPropagation(); + state.stopped = true; + post_live_update(); +}); + +const reset = action((ev: Event) => { + ev.preventDefault(); + ev.stopPropagation(); + state.stopped = true; + add_possible_lap("Reset"); + state.start_time = null; + state.laps = []; + for (const l of possible_laps) l.lap = false; + post_live_update(); +}); + +function window_click(_ev: Event) { + add_possible_lap("Tryk"); +} + +// getCookie from https://docs.djangoproject.com/en/1.4/ref/contrib/csrf/ +function getCookie(name: string) { + const cookies = (document.cookie || "").split(";").map(s => s.trim()); + for (const cookie of cookies) { + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === name + "=") + return decodeURIComponent(cookie.substring(name.length + 1)); + } + return null; +} +const csrftoken = getCookie("csrftoken"); + +declare var post_pk: number | null; +declare var fetch_pk: number | null; +declare function reverse(name: string, pk: number): string | null; +declare var $: any; + +function post_live_update() { + if (post_pk === null) return; + const url = reverse("timetrial_liveupdate", post_pk); + const now = new Date().getTime(); + let n; + if (state.start_time === null) { + n = 0; + } else { + n = (now - state.start_time) | 0; + } + const data = { + csrfmiddlewaretoken: csrftoken, + timetrial: post_pk, + durations: state.durations.join(" "), + elapsed_time: n / 1000, + roundtrip_estimate: roundtrip_estimate / 1000, + possible_laps: JSON.stringify(possible_laps), + state: state.stopped + ? state.laps.length > 0 + ? "stopped" + : "initial" + : "running" + }; + function measure_roundtrip() { + roundtrip_estimate = (new Date().getTime() - now) | 0; + console.log("roundtrip_estimate: " + roundtrip_estimate + " ms"); + } + $.post(url, data).always(measure_roundtrip); +} + +function fetch_state() { + const now = new Date().getTime(); + function success(data: any) { + roundtrip_estimate = (new Date().getTime() - now) | 0; + console.log("roundtrip_estimate: " + roundtrip_estimate + " ms"); + data["elapsed_time"] = data["elapsed_time"] + roundtrip_estimate / 2000; + console.log(data); + update_state(data); + } + function fail(_jqxhr: unknown, textStatus: string, error: any) { + const btn = document.getElementById("live"); + if (btn) btn.textContent = "Fejl"; + (document.getElementById("stopwatchlog") as Element).appendChild( + document.createTextNode(textStatus + ", " + error + "\n") + ); + } + $.getJSON(".", success).fail(fail); +} + +const update_state = action((remoteState: any) => { + const elapsed = (remoteState["elapsed_time"] * 1000) | 0; + state.start_time = new Date().getTime() - elapsed; + + state.laps = []; + let p = 0; + for (const duration of remoteState["durations"]) { + p += (1000 * duration) | 0; + state.laps.push(p); + } + + let button_label = "Live"; + if (remoteState["result"] === "") { + if (remoteState["state"] === "initial") { + state.stopped = true; + } else if (remoteState["state"] === "running") { + state.stopped = false; + update_time(); + } else if (remoteState["state"] === "stopped") { + state.stopped = true; + } + } else { + state.stopped = true; + if (fetch_interval !== null) { + clearInterval(fetch_interval); + fetch_interval = null; + } + button_label = remoteState["result_display"]; + } + + const btn = document.getElementById("live"); + if (btn) btn.textContent = button_label; +}); + +function takePictureChange(ev: { target: EventTarget | null }) { + const div_pictures = document.getElementById("pictures"); + if (!div_pictures) return console.log("No #pictures"); + + const showError = (s: string) => { + div_pictures.textContent = s; + }; + + if (typeof URL === "undefined") return showError("No File API support"); + const target = ev.target as HTMLFormElement; + if (!target.files) console.log("No target.files"); + const files = target.files || []; + if (files.length === 0) console.log("files is empty"); + div_pictures.innerHTML = ""; + for (const file of files) { + const imgURL = URL.createObjectURL(file); + const img = document.createElement("img"); + img.src = imgURL; + URL.revokeObjectURL(imgURL); + div_pictures.appendChild(img); + } +} + +function init() { + form = document.getElementById("stopwatch_form") as HTMLFormElement; + const btn_stop = document.getElementById("stop"); + if (btn_stop) { + btn_stop.addEventListener("click", stop, false); + } + const btn_reset = document.getElementById("reset"); + if (btn_reset) { + btn_reset.addEventListener("click", reset, false); + } + if (initial_state !== null) update_state(initial_state); + + if (fetch_pk !== null) { + fetch_interval = setInterval(fetch_state, 2000); + } + window.addEventListener("touchstart", window_click, false); + + const takePicture = document.getElementById("take-picture"); + if (takePicture !== null) { + takePicture.addEventListener("change", takePictureChange, false); + takePictureChange({ target: takePicture }); + } + + let headerString = "Stopur"; + const existingHeader = document.querySelector("#stopwatch h1"); + if (existingHeader) { + headerString = existingHeader.textContent || headerString; + } + ReactDOM.render(, document.getElementById("stopwatch")); + + autorun(() => { + if (!state.stopped) window.requestAnimationFrame(update_time); + }); + + autorun(() => { + const durationString = state.durations.join(" "); + const startTime = state.start_time ? state.start_time / 1000 : undefined; + if (form) { + form.durations.value = durationString; + form.start_time.value = startTime; + } + }); +} + +window.addEventListener("load", init, false);