diff --git a/parts/designing-surveys/designing-surveys.pdf b/parts/designing-surveys/designing-surveys.pdf index 26226f0..41e3751 100644 Binary files a/parts/designing-surveys/designing-surveys.pdf and b/parts/designing-surveys/designing-surveys.pdf differ diff --git a/parts/designing-surveys/figs/unnamed-chunk-24-1.png b/parts/designing-surveys/figs/unnamed-chunk-24-1.png index 4efaf69..36ff2bd 100644 Binary files a/parts/designing-surveys/figs/unnamed-chunk-24-1.png and b/parts/designing-surveys/figs/unnamed-chunk-24-1.png differ diff --git a/parts/designing-surveys/figs/unnamed-chunk-26-1.png b/parts/designing-surveys/figs/unnamed-chunk-26-1.png index 1f67554..77d998d 100644 Binary files a/parts/designing-surveys/figs/unnamed-chunk-26-1.png and b/parts/designing-surveys/figs/unnamed-chunk-26-1.png differ diff --git a/parts/designing-surveys/index.Rmd b/parts/designing-surveys/index.Rmd index 7822ab4..98160e0 100755 --- a/parts/designing-surveys/index.Rmd +++ b/parts/designing-surveys/index.Rmd @@ -720,6 +720,19 @@ background-size: contain class: inverse +```{r} +#| echo: false + +countdown::countdown( + minutes = 10, + warn_when = 30, + update_every = 1, + top = 0, + right = 0, + font_size = '2em' +) +``` + # Your turn - Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip). diff --git a/parts/designing-surveys/index.html b/parts/designing-surveys/index.html index d689122..45c5a1e 100644 --- a/parts/designing-surveys/index.html +++ b/parts/designing-surveys/index.html @@ -29,6 +29,8 @@ + + @@ -304,12 +306,12 @@ ``` #> profileID respID qID altID obsID price type freshness -#> 1 55 1 1 1 1 3.5 Gala Excellent -#> 2 15 1 1 2 1 1.0 Honeycrisp Poor -#> 3 16 1 1 3 1 1.5 Honeycrisp Poor -#> 4 6 1 2 1 2 3.5 Fuji Poor -#> 5 35 1 2 2 2 4.0 Gala Average -#> 6 57 1 2 3 2 1.0 Honeycrisp Excellent +#> 1 53 1 1 1 1 2.5 Gala Excellent +#> 2 45 1 1 2 1 2.0 Fuji Excellent +#> 3 33 1 1 3 1 3.0 Gala Average +#> 4 19 1 2 1 2 3.0 Honeycrisp Poor +#> 5 14 1 2 2 2 4.0 Gala Poor +#> 6 28 1 2 3 2 4.0 Fuji Average ``` --- @@ -336,12 +338,12 @@ ``` #> profileID respID qID altID obsID price type_Fuji type_Gala type_Honeycrisp freshness_Poor freshness_Average freshness_Excellent no_choice -#> 1 33 1 1 1 1 3.0 0 1 0 0 1 0 0 -#> 2 62 1 1 2 1 3.5 0 0 1 0 0 1 0 -#> 3 48 1 1 3 1 3.5 1 0 0 0 0 1 0 +#> 1 6 1 1 1 1 3.5 1 0 0 1 0 0 0 +#> 2 1 1 1 2 1 1.0 1 0 0 1 0 0 0 +#> 3 27 1 1 3 1 3.5 1 0 0 0 1 0 0 #> 4 0 1 1 4 1 0.0 0 0 0 0 0 0 1 -#> 5 15 1 2 1 2 1.0 0 0 1 1 0 0 0 -#> 6 23 1 2 2 2 1.5 1 0 0 0 1 0 0 +#> 5 48 1 2 1 2 3.5 1 0 0 0 0 1 0 +#> 6 1 1 2 2 2 1.0 1 0 0 1 0 0 0 ``` --- @@ -370,12 +372,12 @@ ``` #> profileID respID qID altID obsID price type freshness -#> 1 26 1 1 1 1 3 Fuji Average -#> 2 31 1 1 2 1 2 Gala Average -#> 3 19 1 1 3 1 3 Honeycrisp Poor -#> 4 1 1 2 1 2 1 Fuji Poor -#> 5 8 1 2 2 2 1 Gala Poor -#> 6 57 1 2 3 2 1 Honeycrisp Excellent +#> 1 22 1 1 1 1 1.0 Fuji Average +#> 2 55 1 1 2 1 3.5 Gala Excellent +#> 3 63 1 1 3 1 4.0 Honeycrisp Excellent +#> 4 28 1 2 1 2 4.0 Fuji Average +#> 5 54 1 2 2 2 3.0 Gala Excellent +#> 6 57 1 2 3 2 1.0 Honeycrisp Excellent ``` --- @@ -421,12 +423,12 @@ ``` #> respID qID altID obsID price type freshness -#> 1 1 1 1 1 3 Fuji Average -#> 2 1 1 2 1 2 Gala Average -#> 3 1 1 3 1 3 Honeycrisp Poor -#> 4 1 2 1 2 1 Fuji Poor -#> 5 1 2 2 2 1 Gala Poor -#> 6 1 2 3 2 1 Honeycrisp Excellent +#> 1 1 1 1 1 1.0 Fuji Average +#> 2 1 1 2 1 3.5 Gala Excellent +#> 3 1 1 3 1 4.0 Honeycrisp Excellent +#> 4 1 2 1 2 4.0 Fuji Average +#> 5 1 2 2 2 3.0 Gala Excellent +#> 6 1 2 3 2 1.0 Honeycrisp Excellent ``` --- @@ -549,12 +551,12 @@ ``` #> profileID respID qID altID obsID price type freshness choice -#> 1 26 1 1 1 1 3 Fuji Average 0 -#> 2 31 1 1 2 1 2 Gala Average 1 -#> 3 19 1 1 3 1 3 Honeycrisp Poor 0 -#> 4 1 1 2 1 2 1 Fuji Poor 0 -#> 5 8 1 2 2 2 1 Gala Poor 0 -#> 6 57 1 2 3 2 1 Honeycrisp Excellent 1 +#> 1 22 1 1 1 1 1.0 Fuji Average 0 +#> 2 55 1 1 2 1 3.5 Gala Excellent 0 +#> 3 63 1 1 3 1 4.0 Honeycrisp Excellent 1 +#> 4 28 1 2 1 2 4.0 Fuji Average 1 +#> 5 54 1 2 2 2 3.0 Gala Excellent 0 +#> 6 57 1 2 3 2 1.0 Honeycrisp Excellent 0 ``` --- @@ -705,13 +707,13 @@ ``` ``` -#> sampleSize coef est se -#> 1 30 price -0.0395841 0.09664295 -#> 2 30 typeGala 0.3198033 0.18958184 -#> 3 30 typeHoneycrisp 0.1728158 0.19532138 -#> 4 30 freshnessAverage 0.6564963 0.22311590 -#> 5 30 freshnessExcellent -0.1699275 0.24271214 -#> 6 60 price -0.1187266 0.06587854 +#> sampleSize coef est se +#> 1 30 price -0.18969925 0.09519838 +#> 2 30 typeGala -0.03074145 0.19389450 +#> 3 30 typeHoneycrisp 0.19956629 0.18165533 +#> 4 30 freshnessAverage 0.46713033 0.23479744 +#> 5 30 freshnessExcellent 0.47712173 0.22886577 +#> 6 60 price -0.13185249 0.06576991 ``` ] @@ -724,13 +726,13 @@ ``` ``` -#> sampleSize coef est se -#> 45 270 freshnessExcellent -0.1221717 0.07561697 -#> 46 300 price -0.1388063 0.02949868 -#> 47 300 typeGala 0.1638605 0.06015893 -#> 48 300 typeHoneycrisp 0.2682089 0.05873098 -#> 49 300 freshnessAverage 0.1894352 0.06988738 -#> 50 300 freshnessExcellent -0.1025510 0.07192388 +#> sampleSize coef est se +#> 45 270 freshnessExcellent -0.14791440 0.07498391 +#> 46 300 price -0.11983143 0.02896963 +#> 47 300 typeGala 0.08577075 0.05984688 +#> 48 300 typeHoneycrisp 0.22142284 0.05810356 +#> 49 300 freshnessAverage 0.17092085 0.07083180 +#> 50 300 freshnessExcellent -0.11784026 0.07093520 ``` ] @@ -805,6 +807,11 @@ class: inverse +
+
+10:00 +
+ # Your turn - Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip). diff --git a/parts/designing-surveys/libs/countdown/countdown.css b/parts/designing-surveys/libs/countdown/countdown.css new file mode 100644 index 0000000..b438be5 --- /dev/null +++ b/parts/designing-surveys/libs/countdown/countdown.css @@ -0,0 +1,144 @@ +.countdown { + background: inherit; + position: absolute; + cursor: pointer; + font-size: 2em; + line-height: 1; + border-color: #ddd; + border-width: 3px; + border-style: solid; + border-radius: 15px; + box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4); + -webkit-box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4); + margin: 0.6em; + padding: 10px 15px; + text-align: center; + z-index: 10; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.countdown { + display: flex; + align-items: center; + justify-content: center; +} +.countdown .countdown-time { + background: none; + font-size: 100%; + padding: 0; +} +.countdown-digits { + color: inherit; +} +.countdown.running { + border-color: #2A9B59FF; + background-color: #43AC6A; +} +.countdown.running .countdown-digits { + color: #002F14FF; +} +.countdown.finished { + border-color: #DE3000FF; + background-color: #F04124; +} +.countdown.finished .countdown-digits { + color: #4A0900FF; +} +.countdown.running.warning { + border-color: #CEAC04FF; + background-color: #E6C229; +} +.countdown.running.warning .countdown-digits { + color: #3A2F02FF; +} + +.countdown.running.blink-colon .countdown-digits.colon { + opacity: 0.1; +} + +/* ------ Controls ------ */ +.countdown:not(.running) .countdown-controls { + display: none; +} + +.countdown-controls { + position: absolute; + top: -0.5rem; + right: -0.5rem; + left: -0.5rem; + display: flex; + justify-content: space-between; + margin: 0; + padding: 0; +} + +.countdown-controls > button { + font-size: 1.5rem; + width: 1rem; + height: 1rem; + display: inline-block; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: monospace; + padding: 10px; + margin: 0; + background: inherit; + border: 2px solid; + border-radius: 100%; + transition: 50ms transform ease-in-out, 150ms opacity ease-in; + --countdown-transition-distance: 10px; +} + +.countdown .countdown-controls > button:last-child { + transform: translate(calc(-1 * var(--countdown-transition-distance)), var(--countdown-transition-distance)); + opacity: 0; + color: #002F14FF; + background-color: #43AC6A; + border-color: #2A9B59FF; +} + +.countdown .countdown-controls > button:first-child { + transform: translate(var(--countdown-transition-distance), var(--countdown-transition-distance)); + opacity: 0; + color: #4A0900FF; + background-color: #F04124; + border-color: #DE3000FF; +} + +.countdown.running:hover .countdown-controls > button, +.countdown.running:focus-within .countdown-controls > button{ + transform: translate(0, 0); + opacity: 1; +} + +.countdown.running:hover .countdown-controls > button:hover, +.countdown.running:focus-within .countdown-controls > button:hover{ + transform: translate(0, calc(var(--countdown-transition-distance) / -2)); + box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4); + -webkit-box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4); +} + +.countdown.running:hover .countdown-controls > button:active, +.countdown.running:focus-within .countdown-controls > button:active{ + transform: translate(0, calc(var(--coutndown-transition-distance) / -5)); +} + +/* ----- Fullscreen ----- */ +.countdown.countdown-fullscreen { + z-index: 0; +} + +.countdown-fullscreen.running .countdown-controls { + top: 1rem; + left: 0; + right: 0; + justify-content: center; +} + +.countdown-fullscreen.running .countdown-controls > button + button { + margin-left: 1rem; +} diff --git a/parts/designing-surveys/libs/countdown/countdown.js b/parts/designing-surveys/libs/countdown/countdown.js new file mode 100644 index 0000000..a058ad8 --- /dev/null +++ b/parts/designing-surveys/libs/countdown/countdown.js @@ -0,0 +1,478 @@ +/* globals Shiny,Audio */ +class CountdownTimer { + constructor (el, opts) { + if (typeof el === 'string' || el instanceof String) { + el = document.querySelector(el) + } + + if (el.counter) { + return el.counter + } + + const minutes = parseInt(el.querySelector('.minutes').innerText || '0') + const seconds = parseInt(el.querySelector('.seconds').innerText || '0') + const duration = minutes * 60 + seconds + + function attrIsTrue (x) { + if (x === true) return true + return !!(x === 'true' || x === '' || x === '1') + } + + this.element = el + this.duration = duration + this.end = null + this.is_running = false + this.warn_when = parseInt(el.dataset.warnWhen) || -1 + this.update_every = parseInt(el.dataset.updateEvery) || 1 + this.play_sound = attrIsTrue(el.dataset.playSound) + this.blink_colon = attrIsTrue(el.dataset.blinkColon) + this.startImmediately = attrIsTrue(el.dataset.startImmediately) + this.timeout = null + this.display = { minutes, seconds } + + if (opts.src_location) { + this.src_location = opts.src_location + } + + this.addEventListeners() + } + + addEventListeners () { + const self = this + + if (this.startImmediately) { + if (window.remark && window.slideshow) { + // Remark (xaringan) support + const isOnVisibleSlide = () => { + return document.querySelector('.remark-visible').contains(self.element) + } + if (isOnVisibleSlide()) { + self.start() + } else { + let started_once = 0 + window.slideshow.on('afterShowSlide', function () { + if (started_once > 0) return + if (isOnVisibleSlide()) { + self.start() + started_once = 1 + } + }) + } + } else if (window.Reveal) { + // Revealjs (quarto) support + const isOnVisibleSlide = () => { + const currentSlide = document.querySelector('.reveal .slide.present') + return currentSlide ? currentSlide.contains(self.element) : false + } + if (isOnVisibleSlide()) { + self.start() + } else { + const revealStartTimer = () => { + if (isOnVisibleSlide()) { + self.start() + window.Reveal.off('slidechanged', revealStartTimer) + } + } + window.Reveal.on('slidechanged', revealStartTimer) + } + } else if (window.IntersectionObserver) { + // All other situtations use IntersectionObserver + const onVisible = (element, callback) => { + new window.IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.intersectionRatio > 0) { + callback(element) + observer.disconnect() + } + }) + }).observe(element) + } + onVisible(this.element, el => el.countdown.start()) + } else { + // or just start the timer as soon as it's initialized + this.start() + } + } + + function haltEvent (ev) { + ev.preventDefault() + ev.stopPropagation() + } + function isSpaceOrEnter (ev) { + return ev.code === 'Space' || ev.code === 'Enter' + } + function isArrowUpOrDown (ev) { + return ev.code === 'ArrowUp' || ev.code === 'ArrowDown' + } + + ;['click', 'touchend'].forEach(function (eventType) { + self.element.addEventListener(eventType, function (ev) { + haltEvent(ev) + self.is_running ? self.stop() : self.start() + }) + }) + this.element.addEventListener('keydown', function (ev) { + if (ev.code === "Escape") { + self.reset() + haltEvent(ev) + } + if (!isSpaceOrEnter(ev) && !isArrowUpOrDown(ev)) return + haltEvent(ev) + if (isSpaceOrEnter(ev)) { + self.is_running ? self.stop() : self.start() + return + } + + if (!self.is_running) return + + if (ev.code === 'ArrowUp') { + self.bumpUp() + } else if (ev.code === 'ArrowDown') { + self.bumpDown() + } + }) + this.element.addEventListener('dblclick', function (ev) { + haltEvent(ev) + if (self.is_running) self.reset() + }) + this.element.addEventListener('touchmove', haltEvent) + + const btnBumpDown = this.element.querySelector('.countdown-bump-down') + ;['click', 'touchend'].forEach(function (eventType) { + btnBumpDown.addEventListener(eventType, function (ev) { + haltEvent(ev) + if (self.is_running) self.bumpDown() + }) + }) + btnBumpDown.addEventListener('keydown', function (ev) { + if (!isSpaceOrEnter(ev) || !self.is_running) return + haltEvent(ev) + self.bumpDown() + }) + + const btnBumpUp = this.element.querySelector('.countdown-bump-up') + ;['click', 'touchend'].forEach(function (eventType) { + btnBumpUp.addEventListener(eventType, function (ev) { + haltEvent(ev) + if (self.is_running) self.bumpUp() + }) + }) + btnBumpUp.addEventListener('keydown', function (ev) { + if (!isSpaceOrEnter(ev) || !self.is_running) return + haltEvent(ev) + self.bumpUp() + }) + this.element.querySelector('.countdown-controls').addEventListener('dblclick', function (ev) { + haltEvent(ev) + }) + } + + remainingTime () { + const remaining = this.is_running + ? (this.end - Date.now()) / 1000 + : this.remaining || this.duration + + let minutes = Math.floor(remaining / 60) + let seconds = Math.ceil(remaining - minutes * 60) + + if (seconds > 59) { + minutes = minutes + 1 + seconds = seconds - 60 + } + + return { remaining, minutes, seconds } + } + + start () { + if (this.is_running) return + + this.is_running = true + + if (this.remaining) { + // Having a static remaining time indicates timer was paused + this.end = Date.now() + this.remaining * 1000 + this.remaining = null + } else { + this.end = Date.now() + this.duration * 1000 + } + + this.reportStateToShiny('start') + + this.element.classList.remove('finished') + this.element.classList.add('running') + this.update(true) + this.tick() + } + + tick (run_again) { + if (typeof run_again === 'undefined') { + run_again = true + } + + if (!this.is_running) return + + const { seconds: secondsWas } = this.display + this.update() + + if (run_again) { + const delay = (this.end - Date.now() > 10000) ? 1000 : 250 + this.blinkColon(secondsWas) + this.timeout = setTimeout(this.tick.bind(this), delay) + } + } + + blinkColon (secondsWas) { + // don't blink unless option is set + if (!this.blink_colon) return + // warn_when always updates the seconds + if (this.warn_when > 0 && Date.now() + this.warn_when > this.end) { + this.element.classList.remove('blink-colon') + return + } + const { seconds: secondsIs } = this.display + if (secondsIs > 10 || secondsWas !== secondsIs) { + this.element.classList.toggle('blink-colon') + } + } + + update (force) { + if (typeof force === 'undefined') { + force = false + } + + const { remaining, minutes, seconds } = this.remainingTime() + + const setRemainingTime = (selector, time) => { + const timeContainer = this.element.querySelector(selector) + if (!timeContainer) return + time = Math.max(time, 0) + timeContainer.innerText = String(time).padStart(2, 0) + } + + if (this.is_running && remaining < 0.25) { + this.stop() + setRemainingTime('.minutes', 0) + setRemainingTime('.seconds', 0) + this.playSound() + return + } + + const should_update = force || + Math.round(remaining) < this.warn_when || + Math.round(remaining) % this.update_every === 0 + + if (should_update) { + this.element.classList.toggle('warning', remaining <= this.warn_when) + this.display = { minutes, seconds } + setRemainingTime('.minutes', minutes) + setRemainingTime('.seconds', seconds) + } + } + + stop () { + const { remaining } = this.remainingTime() + if (remaining > 1) { + this.remaining = remaining + } + this.element.classList.remove('running') + this.element.classList.remove('warning') + this.element.classList.remove('blink-colon') + this.element.classList.add('finished') + this.is_running = false + this.end = null + this.reportStateToShiny('stop') + this.timeout = clearTimeout(this.timeout) + } + + reset () { + this.stop() + this.remaining = null + this.update(true) + this.reportStateToShiny('reset') + this.element.classList.remove('finished') + this.element.classList.remove('warning') + } + + setValues (opts) { + if (typeof opts.warn_when !== 'undefined') { + this.warn_when = opts.warn_when + } + if (typeof opts.update_every !== 'undefined') { + this.update_every = opts.update_every + } + if (typeof opts.blink_colon !== 'undefined') { + this.blink_colon = opts.blink_colon + if (!opts.blink_colon) { + this.element.classList.remove('blink-colon') + } + } + if (typeof opts.play_sound !== 'undefined') { + this.play_sound = opts.play_sound + } + if (typeof opts.duration !== 'undefined') { + this.duration = opts.duration + if (this.is_running) { + this.reset() + this.start() + } + } + this.reportStateToShiny('update') + this.update(true) + } + + bumpTimer (val, round) { + round = typeof round === 'boolean' ? round : true + const { remaining } = this.remainingTime() + let newRemaining = remaining + val + if (newRemaining <= 0) { + this.setRemaining(0) + this.stop() + return + } + if (round && newRemaining > 10) { + newRemaining = Math.round(newRemaining / 5) * 5 + } + this.setRemaining(newRemaining) + this.reportStateToShiny(val > 0 ? 'bumpUp' : 'bumpDown') + this.update(true) + } + + bumpUp (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.bumpTimer( + val || this.bumpIncrementValue(), + typeof val === 'undefined' + ) + } + + bumpDown (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.bumpTimer( + val || -1 * this.bumpIncrementValue(), + typeof val === 'undefined' + ) + } + + setRemaining (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.end = Date.now() + val * 1000 + this.update(true) + } + + playSound () { + let url = this.play_sound + if (!url) return + if (typeof url === 'boolean') { + const src = this.src_location + ? this.src_location.replace('/countdown.js', '') + : 'libs/countdown' + url = src + '/smb_stage_clear.mp3' + } + const sound = new Audio(url) + sound.play() + } + + bumpIncrementValue (val) { + val = val || this.remainingTime().remaining + if (val <= 30) { + return 5 + } else if (val <= 300) { + return 15 + } else if (val <= 3000) { + return 30 + } else { + return 60 + } + } + + reportStateToShiny (action) { + if (!window.Shiny) return + + const inputId = this.element.id + const data = { + event: { + action, + time: new Date().toISOString() + }, + timer: { + is_running: this.is_running, + end: this.end ? new Date(this.end).toISOString() : null, + remaining: this.remainingTime() + } + } + + function shinySetInputValue () { + if (!window.Shiny.setInputValue) { + setTimeout(shinySetInputValue, 100) + return + } + window.Shiny.setInputValue(inputId, data) + } + + shinySetInputValue() + } +} + +(function () { + const CURRENT_SCRIPT = document.currentScript.getAttribute('src') + + document.addEventListener('DOMContentLoaded', function () { + const els = document.querySelectorAll('.countdown') + if (!els || !els.length) { + return + } + els.forEach(function (el) { + el.countdown = new CountdownTimer(el, { src_location: CURRENT_SCRIPT }) + }) + + if (window.Shiny) { + Shiny.addCustomMessageHandler('countdown:update', function (x) { + if (!x.id) { + console.error('No `id` provided, cannot update countdown') + return + } + const el = document.getElementById(x.id) + el.countdown.setValues(x) + }) + + Shiny.addCustomMessageHandler('countdown:start', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.start() + }) + + Shiny.addCustomMessageHandler('countdown:stop', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.stop() + }) + + Shiny.addCustomMessageHandler('countdown:reset', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.reset() + }) + + Shiny.addCustomMessageHandler('countdown:bumpUp', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.bumpUp() + }) + + Shiny.addCustomMessageHandler('countdown:bumpDown', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.bumpDown() + }) + } + }) +})() diff --git a/parts/designing-surveys/libs/countdown/smb_stage_clear.mp3 b/parts/designing-surveys/libs/countdown/smb_stage_clear.mp3 new file mode 100644 index 0000000..da2ddc2 Binary files /dev/null and b/parts/designing-surveys/libs/countdown/smb_stage_clear.mp3 differ diff --git a/parts/estimating-models/estimating-models.pdf b/parts/estimating-models/estimating-models.pdf index fb8e812..1049902 100644 Binary files a/parts/estimating-models/estimating-models.pdf and b/parts/estimating-models/estimating-models.pdf differ diff --git a/parts/estimating-models/index.Rmd b/parts/estimating-models/index.Rmd index 7cf5f81..8b6ab91 100755 --- a/parts/estimating-models/index.Rmd +++ b/parts/estimating-models/index.Rmd @@ -890,6 +890,19 @@ predict( class: inverse +```{r} +#| echo: false + +countdown::countdown( + minutes = 10, + warn_when = 30, + update_every = 1, + top = 0, + right = 0, + font_size = '2em' +) +``` + # Your turn - Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip). diff --git a/parts/estimating-models/index.html b/parts/estimating-models/index.html index 531f521..0436188 100644 --- a/parts/estimating-models/index.html +++ b/parts/estimating-models/index.html @@ -29,6 +29,8 @@ + + @@ -269,9 +271,9 @@ ``` #> Estimate Std. Error z-value Pr(>|z|) -#> brandhiland -8.01982 0.45951 -17.4529 < 2.2e-16 *** -#> brandyoplait 3.72173 0.15882 23.4330 < 2.2e-16 *** -#> branddannon 1.65734 0.16723 9.9105 < 2.2e-16 *** +#> brandhiland -8.01982 0.46096 -17.3980 < 2.2e-16 *** +#> brandyoplait 3.72173 0.15890 23.4214 < 2.2e-16 *** +#> branddannon 1.65734 0.16832 9.8463 < 2.2e-16 *** #> --- #> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1 ``` @@ -465,10 +467,10 @@ ``` #> Estimate Std. Error z-value Pr(>|z|) -#> scalePar 0.388626 0.024254 16.0230 < 2.2e-16 *** -#> brandhiland -8.019815 0.459511 -17.4529 < 2.2e-16 *** -#> brandyoplait 3.721731 0.158824 23.4330 < 2.2e-16 *** -#> branddannon 1.657345 0.167231 9.9105 < 2.2e-16 *** +#> scalePar 0.388626 0.024399 15.9280 < 2.2e-16 *** +#> brandhiland -8.019815 0.460961 -17.3980 < 2.2e-16 *** +#> brandyoplait 3.721731 0.158903 23.4214 < 2.2e-16 *** +#> branddannon 1.657345 0.168321 9.8463 < 2.2e-16 *** #> --- #> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1 ``` @@ -996,6 +998,11 @@ class: inverse +
+
+10:00 +
+ # Your turn - Be sure to have downloaded and unzipped the [practice code](https://jhelvy.github.io/2023-qux-conf-conjoint/practice/2023-qux-conf-conjoint.zip). diff --git a/parts/estimating-models/libs/countdown/countdown.css b/parts/estimating-models/libs/countdown/countdown.css new file mode 100644 index 0000000..b438be5 --- /dev/null +++ b/parts/estimating-models/libs/countdown/countdown.css @@ -0,0 +1,144 @@ +.countdown { + background: inherit; + position: absolute; + cursor: pointer; + font-size: 2em; + line-height: 1; + border-color: #ddd; + border-width: 3px; + border-style: solid; + border-radius: 15px; + box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4); + -webkit-box-shadow: 0px 4px 10px 0px rgba(50, 50, 50, 0.4); + margin: 0.6em; + padding: 10px 15px; + text-align: center; + z-index: 10; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.countdown { + display: flex; + align-items: center; + justify-content: center; +} +.countdown .countdown-time { + background: none; + font-size: 100%; + padding: 0; +} +.countdown-digits { + color: inherit; +} +.countdown.running { + border-color: #2A9B59FF; + background-color: #43AC6A; +} +.countdown.running .countdown-digits { + color: #002F14FF; +} +.countdown.finished { + border-color: #DE3000FF; + background-color: #F04124; +} +.countdown.finished .countdown-digits { + color: #4A0900FF; +} +.countdown.running.warning { + border-color: #CEAC04FF; + background-color: #E6C229; +} +.countdown.running.warning .countdown-digits { + color: #3A2F02FF; +} + +.countdown.running.blink-colon .countdown-digits.colon { + opacity: 0.1; +} + +/* ------ Controls ------ */ +.countdown:not(.running) .countdown-controls { + display: none; +} + +.countdown-controls { + position: absolute; + top: -0.5rem; + right: -0.5rem; + left: -0.5rem; + display: flex; + justify-content: space-between; + margin: 0; + padding: 0; +} + +.countdown-controls > button { + font-size: 1.5rem; + width: 1rem; + height: 1rem; + display: inline-block; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: monospace; + padding: 10px; + margin: 0; + background: inherit; + border: 2px solid; + border-radius: 100%; + transition: 50ms transform ease-in-out, 150ms opacity ease-in; + --countdown-transition-distance: 10px; +} + +.countdown .countdown-controls > button:last-child { + transform: translate(calc(-1 * var(--countdown-transition-distance)), var(--countdown-transition-distance)); + opacity: 0; + color: #002F14FF; + background-color: #43AC6A; + border-color: #2A9B59FF; +} + +.countdown .countdown-controls > button:first-child { + transform: translate(var(--countdown-transition-distance), var(--countdown-transition-distance)); + opacity: 0; + color: #4A0900FF; + background-color: #F04124; + border-color: #DE3000FF; +} + +.countdown.running:hover .countdown-controls > button, +.countdown.running:focus-within .countdown-controls > button{ + transform: translate(0, 0); + opacity: 1; +} + +.countdown.running:hover .countdown-controls > button:hover, +.countdown.running:focus-within .countdown-controls > button:hover{ + transform: translate(0, calc(var(--countdown-transition-distance) / -2)); + box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4); + -webkit-box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4); +} + +.countdown.running:hover .countdown-controls > button:active, +.countdown.running:focus-within .countdown-controls > button:active{ + transform: translate(0, calc(var(--coutndown-transition-distance) / -5)); +} + +/* ----- Fullscreen ----- */ +.countdown.countdown-fullscreen { + z-index: 0; +} + +.countdown-fullscreen.running .countdown-controls { + top: 1rem; + left: 0; + right: 0; + justify-content: center; +} + +.countdown-fullscreen.running .countdown-controls > button + button { + margin-left: 1rem; +} diff --git a/parts/estimating-models/libs/countdown/countdown.js b/parts/estimating-models/libs/countdown/countdown.js new file mode 100644 index 0000000..a058ad8 --- /dev/null +++ b/parts/estimating-models/libs/countdown/countdown.js @@ -0,0 +1,478 @@ +/* globals Shiny,Audio */ +class CountdownTimer { + constructor (el, opts) { + if (typeof el === 'string' || el instanceof String) { + el = document.querySelector(el) + } + + if (el.counter) { + return el.counter + } + + const minutes = parseInt(el.querySelector('.minutes').innerText || '0') + const seconds = parseInt(el.querySelector('.seconds').innerText || '0') + const duration = minutes * 60 + seconds + + function attrIsTrue (x) { + if (x === true) return true + return !!(x === 'true' || x === '' || x === '1') + } + + this.element = el + this.duration = duration + this.end = null + this.is_running = false + this.warn_when = parseInt(el.dataset.warnWhen) || -1 + this.update_every = parseInt(el.dataset.updateEvery) || 1 + this.play_sound = attrIsTrue(el.dataset.playSound) + this.blink_colon = attrIsTrue(el.dataset.blinkColon) + this.startImmediately = attrIsTrue(el.dataset.startImmediately) + this.timeout = null + this.display = { minutes, seconds } + + if (opts.src_location) { + this.src_location = opts.src_location + } + + this.addEventListeners() + } + + addEventListeners () { + const self = this + + if (this.startImmediately) { + if (window.remark && window.slideshow) { + // Remark (xaringan) support + const isOnVisibleSlide = () => { + return document.querySelector('.remark-visible').contains(self.element) + } + if (isOnVisibleSlide()) { + self.start() + } else { + let started_once = 0 + window.slideshow.on('afterShowSlide', function () { + if (started_once > 0) return + if (isOnVisibleSlide()) { + self.start() + started_once = 1 + } + }) + } + } else if (window.Reveal) { + // Revealjs (quarto) support + const isOnVisibleSlide = () => { + const currentSlide = document.querySelector('.reveal .slide.present') + return currentSlide ? currentSlide.contains(self.element) : false + } + if (isOnVisibleSlide()) { + self.start() + } else { + const revealStartTimer = () => { + if (isOnVisibleSlide()) { + self.start() + window.Reveal.off('slidechanged', revealStartTimer) + } + } + window.Reveal.on('slidechanged', revealStartTimer) + } + } else if (window.IntersectionObserver) { + // All other situtations use IntersectionObserver + const onVisible = (element, callback) => { + new window.IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.intersectionRatio > 0) { + callback(element) + observer.disconnect() + } + }) + }).observe(element) + } + onVisible(this.element, el => el.countdown.start()) + } else { + // or just start the timer as soon as it's initialized + this.start() + } + } + + function haltEvent (ev) { + ev.preventDefault() + ev.stopPropagation() + } + function isSpaceOrEnter (ev) { + return ev.code === 'Space' || ev.code === 'Enter' + } + function isArrowUpOrDown (ev) { + return ev.code === 'ArrowUp' || ev.code === 'ArrowDown' + } + + ;['click', 'touchend'].forEach(function (eventType) { + self.element.addEventListener(eventType, function (ev) { + haltEvent(ev) + self.is_running ? self.stop() : self.start() + }) + }) + this.element.addEventListener('keydown', function (ev) { + if (ev.code === "Escape") { + self.reset() + haltEvent(ev) + } + if (!isSpaceOrEnter(ev) && !isArrowUpOrDown(ev)) return + haltEvent(ev) + if (isSpaceOrEnter(ev)) { + self.is_running ? self.stop() : self.start() + return + } + + if (!self.is_running) return + + if (ev.code === 'ArrowUp') { + self.bumpUp() + } else if (ev.code === 'ArrowDown') { + self.bumpDown() + } + }) + this.element.addEventListener('dblclick', function (ev) { + haltEvent(ev) + if (self.is_running) self.reset() + }) + this.element.addEventListener('touchmove', haltEvent) + + const btnBumpDown = this.element.querySelector('.countdown-bump-down') + ;['click', 'touchend'].forEach(function (eventType) { + btnBumpDown.addEventListener(eventType, function (ev) { + haltEvent(ev) + if (self.is_running) self.bumpDown() + }) + }) + btnBumpDown.addEventListener('keydown', function (ev) { + if (!isSpaceOrEnter(ev) || !self.is_running) return + haltEvent(ev) + self.bumpDown() + }) + + const btnBumpUp = this.element.querySelector('.countdown-bump-up') + ;['click', 'touchend'].forEach(function (eventType) { + btnBumpUp.addEventListener(eventType, function (ev) { + haltEvent(ev) + if (self.is_running) self.bumpUp() + }) + }) + btnBumpUp.addEventListener('keydown', function (ev) { + if (!isSpaceOrEnter(ev) || !self.is_running) return + haltEvent(ev) + self.bumpUp() + }) + this.element.querySelector('.countdown-controls').addEventListener('dblclick', function (ev) { + haltEvent(ev) + }) + } + + remainingTime () { + const remaining = this.is_running + ? (this.end - Date.now()) / 1000 + : this.remaining || this.duration + + let minutes = Math.floor(remaining / 60) + let seconds = Math.ceil(remaining - minutes * 60) + + if (seconds > 59) { + minutes = minutes + 1 + seconds = seconds - 60 + } + + return { remaining, minutes, seconds } + } + + start () { + if (this.is_running) return + + this.is_running = true + + if (this.remaining) { + // Having a static remaining time indicates timer was paused + this.end = Date.now() + this.remaining * 1000 + this.remaining = null + } else { + this.end = Date.now() + this.duration * 1000 + } + + this.reportStateToShiny('start') + + this.element.classList.remove('finished') + this.element.classList.add('running') + this.update(true) + this.tick() + } + + tick (run_again) { + if (typeof run_again === 'undefined') { + run_again = true + } + + if (!this.is_running) return + + const { seconds: secondsWas } = this.display + this.update() + + if (run_again) { + const delay = (this.end - Date.now() > 10000) ? 1000 : 250 + this.blinkColon(secondsWas) + this.timeout = setTimeout(this.tick.bind(this), delay) + } + } + + blinkColon (secondsWas) { + // don't blink unless option is set + if (!this.blink_colon) return + // warn_when always updates the seconds + if (this.warn_when > 0 && Date.now() + this.warn_when > this.end) { + this.element.classList.remove('blink-colon') + return + } + const { seconds: secondsIs } = this.display + if (secondsIs > 10 || secondsWas !== secondsIs) { + this.element.classList.toggle('blink-colon') + } + } + + update (force) { + if (typeof force === 'undefined') { + force = false + } + + const { remaining, minutes, seconds } = this.remainingTime() + + const setRemainingTime = (selector, time) => { + const timeContainer = this.element.querySelector(selector) + if (!timeContainer) return + time = Math.max(time, 0) + timeContainer.innerText = String(time).padStart(2, 0) + } + + if (this.is_running && remaining < 0.25) { + this.stop() + setRemainingTime('.minutes', 0) + setRemainingTime('.seconds', 0) + this.playSound() + return + } + + const should_update = force || + Math.round(remaining) < this.warn_when || + Math.round(remaining) % this.update_every === 0 + + if (should_update) { + this.element.classList.toggle('warning', remaining <= this.warn_when) + this.display = { minutes, seconds } + setRemainingTime('.minutes', minutes) + setRemainingTime('.seconds', seconds) + } + } + + stop () { + const { remaining } = this.remainingTime() + if (remaining > 1) { + this.remaining = remaining + } + this.element.classList.remove('running') + this.element.classList.remove('warning') + this.element.classList.remove('blink-colon') + this.element.classList.add('finished') + this.is_running = false + this.end = null + this.reportStateToShiny('stop') + this.timeout = clearTimeout(this.timeout) + } + + reset () { + this.stop() + this.remaining = null + this.update(true) + this.reportStateToShiny('reset') + this.element.classList.remove('finished') + this.element.classList.remove('warning') + } + + setValues (opts) { + if (typeof opts.warn_when !== 'undefined') { + this.warn_when = opts.warn_when + } + if (typeof opts.update_every !== 'undefined') { + this.update_every = opts.update_every + } + if (typeof opts.blink_colon !== 'undefined') { + this.blink_colon = opts.blink_colon + if (!opts.blink_colon) { + this.element.classList.remove('blink-colon') + } + } + if (typeof opts.play_sound !== 'undefined') { + this.play_sound = opts.play_sound + } + if (typeof opts.duration !== 'undefined') { + this.duration = opts.duration + if (this.is_running) { + this.reset() + this.start() + } + } + this.reportStateToShiny('update') + this.update(true) + } + + bumpTimer (val, round) { + round = typeof round === 'boolean' ? round : true + const { remaining } = this.remainingTime() + let newRemaining = remaining + val + if (newRemaining <= 0) { + this.setRemaining(0) + this.stop() + return + } + if (round && newRemaining > 10) { + newRemaining = Math.round(newRemaining / 5) * 5 + } + this.setRemaining(newRemaining) + this.reportStateToShiny(val > 0 ? 'bumpUp' : 'bumpDown') + this.update(true) + } + + bumpUp (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.bumpTimer( + val || this.bumpIncrementValue(), + typeof val === 'undefined' + ) + } + + bumpDown (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.bumpTimer( + val || -1 * this.bumpIncrementValue(), + typeof val === 'undefined' + ) + } + + setRemaining (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.end = Date.now() + val * 1000 + this.update(true) + } + + playSound () { + let url = this.play_sound + if (!url) return + if (typeof url === 'boolean') { + const src = this.src_location + ? this.src_location.replace('/countdown.js', '') + : 'libs/countdown' + url = src + '/smb_stage_clear.mp3' + } + const sound = new Audio(url) + sound.play() + } + + bumpIncrementValue (val) { + val = val || this.remainingTime().remaining + if (val <= 30) { + return 5 + } else if (val <= 300) { + return 15 + } else if (val <= 3000) { + return 30 + } else { + return 60 + } + } + + reportStateToShiny (action) { + if (!window.Shiny) return + + const inputId = this.element.id + const data = { + event: { + action, + time: new Date().toISOString() + }, + timer: { + is_running: this.is_running, + end: this.end ? new Date(this.end).toISOString() : null, + remaining: this.remainingTime() + } + } + + function shinySetInputValue () { + if (!window.Shiny.setInputValue) { + setTimeout(shinySetInputValue, 100) + return + } + window.Shiny.setInputValue(inputId, data) + } + + shinySetInputValue() + } +} + +(function () { + const CURRENT_SCRIPT = document.currentScript.getAttribute('src') + + document.addEventListener('DOMContentLoaded', function () { + const els = document.querySelectorAll('.countdown') + if (!els || !els.length) { + return + } + els.forEach(function (el) { + el.countdown = new CountdownTimer(el, { src_location: CURRENT_SCRIPT }) + }) + + if (window.Shiny) { + Shiny.addCustomMessageHandler('countdown:update', function (x) { + if (!x.id) { + console.error('No `id` provided, cannot update countdown') + return + } + const el = document.getElementById(x.id) + el.countdown.setValues(x) + }) + + Shiny.addCustomMessageHandler('countdown:start', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.start() + }) + + Shiny.addCustomMessageHandler('countdown:stop', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.stop() + }) + + Shiny.addCustomMessageHandler('countdown:reset', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.reset() + }) + + Shiny.addCustomMessageHandler('countdown:bumpUp', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.bumpUp() + }) + + Shiny.addCustomMessageHandler('countdown:bumpDown', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.bumpDown() + }) + } + }) +})() diff --git a/parts/estimating-models/libs/countdown/smb_stage_clear.mp3 b/parts/estimating-models/libs/countdown/smb_stage_clear.mp3 new file mode 100644 index 0000000..da2ddc2 Binary files /dev/null and b/parts/estimating-models/libs/countdown/smb_stage_clear.mp3 differ