From 2fc3cee13926258243f86a791c2e175cc9ceb07b Mon Sep 17 00:00:00 2001 From: stephenPspackman <93166870+stephenPspackman@users.noreply.github.com> Date: Thu, 2 Dec 2021 11:27:53 -0800 Subject: [PATCH] Create app.js --- apps/pooqroman/app.js | 764 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 764 insertions(+) create mode 100644 apps/pooqroman/app.js diff --git a/apps/pooqroman/app.js b/apps/pooqroman/app.js new file mode 100644 index 000000000..a5de57210 --- /dev/null +++ b/apps/pooqroman/app.js @@ -0,0 +1,764 @@ +// pooqRoman +// +// Copyright (c) 2021 Stephen P Spackman +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Notes: +// +// This only works for Bangle 2. + +////////////////////////////////////////////////////////////////////////////// +/* System integration */ + +const storage = require('Storage'); + +const settings = storage.readJSON("setting.json", true) || {}; + +const alarms = storage.readJSON('alarm.json', true) || []; + +/* + { on : true, + hr : 6.5, // hours + minutes/60 + msg : "Eat chocolate", + last : 0, // last day of the month we alarmed on - so we don't alarm twice in one day! + rp : true, // repeat + as : false, // auto snooze + timer : 5, // OPTIONAL - if set, this is a timer and it's the time in minutes + } +*/ + +////////////////////////////////////////////////////////////////////////////// +/* Face-specific options */ + +class Options { + // Protocol: subclasses must have static id and defaults fields. + // Only fields named in the defaults will be saved. + constructor() { + this.id = this.constructor.id; + this.file = `${this.id}.opt`; + this.backing = storage.readJSON(this.file, true) || {}; + this.defaults = this.constructor.defaults; + Object.keys(this.defaults).forEach(k => this.bless(k)); + } + + writeBack(delay) { + if (this.timeout) clearTimeout(this.timeout); + this.timeout = setTimeout( + () => { + this.timeout = null; + storage.writeJSON(this.file, this.backing); + }, + delay + ); + } + + bless(k) { + Object.defineProperty(this, k, { + get: () => this.backing[k] == null ? this.defaults[k] : this.backing[k], + set: v => { + this.backing[k] = v; + // Ten second writeback delay, since the user will roll values up and down. + this.writeBack(10000); + return v; + } + }); + } + + showMenu(m) { + if (m) { + for (const k in m) if ('init' in m[k]) m[k].value = m[k].init(); + m[''].selected = -1; // Workaround for self-selection bug. + } + E.showMenu(m); + } + + reset() { + this.backing = {}; + this.writeBack(0); + } + + interact() {this.showMenu(this.menu);} +} + +class RomanOptions extends Options { + constructor() { + super(); + this.menu = { + '': {title: '* face options *'}, + '< Back': _ => {this.showMenu(); this.emit('done');}, + Ticks: { + init: _ => this.resolution, + min: 0, max: 3, + onchange: x => this.resolution = x, + format: x => ['seconds', 'seconds (up)', 'minutes', 'hours'][x] + }, + 'Display': { + init: _ => this.o24h == null ? 0 : 1 + this.o24h, + min: 0, max: 2, + onchange: x => this.o24h = [null, 0, 1][x], + format: x => ['system', '12h', '24h'][x] + }, + 'Day of Week': { + init: _ => this.dow, + onchange: x => this.dow = x + }, + Calendar: { + init: _ => this.calendric, + min: 0, max: 2, + onchange: x => this.calendric = x, + format: x => ['none', 'day', 'date'][x] + }, + Defaults: _ => {this.reset();} + }; + } +} + +RomanOptions.id = 'pooqroman'; + +RomanOptions.defaults = { + resolution: 1, + dow: true, + calendric: 2, + o24h: !settings["12hour"], + bg: g.theme.bg, + fg: g.theme.fg, + barBg: g.theme.fg, + barFg: g.theme.bg, + hourFg: g.theme.fg, + minuteFg: g.theme.fg, + secondFg: g.theme.fg2, + rectFg: g.theme.fg, + hubFg: g.theme.fg, + alarmFg: '#f00', + timerFg: '#0f0', + active: g.theme.fg2, +}; + +////////////////////////////////////////////////////////////////////////////// +/* Assets (generated by resourcer.js, in this directory) */ + +const heatshrink = require('heatshrink'); +const dec = x => E.toString(heatshrink.decompress(atob(x))); +const romanPartsF = [ + dec( + 'wEBsEB3//7//9//+0AjUAguAg3AgYQJjfAgv+gH/8Fg/0gh/AgP4gf2h/j/+BCAP' + + 'wgFggEggEQgEMgEHwEDEIIyDuED3kD7+H9vn2k/hEPgMP4Xevd+j4QB7kA9kAmkA' + + 'hUGgOH8Hn3le4+GgH32PuvfGj+CCAMDgXD4dz+evt9DgcL7fXn87h8NCAMP+Ef/0' + + 'eg+egPugF2j0bCAPAh3wh88h8P/8BNwI' + ), 97, dec('gUDgUGgUJgYFBhsBhMJhgA=='), 17 +];const fontF = [ + dec( + 'AAUwAIM/4F/8HguHAmABBAoIJBBoIUBkEwsEw//wAIIdDBoUQBoIfC+HB+Hj2F/m' + + 'E+CIXAoHEsHMuHcmH8mHuuHH8GBGIUAwEBwEHwH/wH5+EBAIILCCAP8oH8EYXMmA' + + 'BB5wjCgYjCAYMP8E+uF8mHsCIWHCIgCBAIXw4fw54tBgBsBGgUAnKLC99w40wAII' + + 'FBBIINBCIM8gF+iHnmHDuHD8HnDYMAjizEMYJJBn+A+OAAYIHBBYKjDXYKvDYZYP' + + 'D40AAIYMBZYgkC4Hg4DnDuH/8H/BYIVCv/wnEAjwBCAoIJBEIYRFh0Ag8AgPAEYQ' + + 'RCJIJNBfYRXKnFAvlg9ihE8dwsfgkLFHMYgJF8DNCh+AUYWAA4ILBAAJGB/4PB+D' + + '9CgADCEoIPCJobbBB4IBBAoJdDEgXggvwhuwAIcH8EDRIh/BhkwAIMOuAPCMYQDB' + + 'A4ILBCIcGsECoAPLU4oPDH42ggeAB4XEg/mh1zhkzh03g/+h/4J4nwg0AhjbDRII' + + 'vCt/wAIIVFAoKTBCYIXBDIYHHEIYVFGJJxHSI8P/8H/6hLF44BBM4IABg8gh6NEh' + + 'vwgngBoITBv/Av7PBV4kAsArCfYIVBuEABYNwA4I3BD4cPL4UAM4IXBBYQfC4kP8' + + '0AucAmcAu8PXogA=' + ), 32, dec('gINMgUAhMHhIAGCQ0KAQIKBgwEBgcIBAQVEhIJBhAeIBQIADAoUDEQULBQcHg4FD' + + 'CII='), 16 +]; +const lockI = dec('iMSwMAgfwgf8geHgeB4PA8HguFwnH//9//+4gPf//v//3gE7//9//+8EHCAO///A'); +const batteryI = dec('h8SwMAgPggfAv/4//x//j//H/+P/8f/0//gOOA=='); +const chargeI = dec('h0MwIEBkEBwEMgFwgeAj/w/+AjkA8EDgEYgFAA=='); +const GPSI = dec('iUQwMAhEAgsAgUggFEgEKvEBn0Aj+AgfgglygsJosgxNGiNIgWJ4FBEoM4gA'); +const HRMI = dec('iMRwMAnken8fzfd7v+/3/v9/38/z+b5tiiM3/eP/+D/+AAIM/wEPwEDwEAAIIA=='); +const compassI = dec('iMRwMAgfgg/8g8ng0Q40ImcOjcHg+DwfB4Ph2Hw7FsolmkUxwEwuFwj/wEIMAA=='); + +////////////////////////////////////////////////////////////////////////////// +/* Squeezable strings */ + +class Formattable { + width(g) {return this.w != null ? this.w : (this.w = g.stringWidth(this.text));} + print(g, x, y) {g.drawString(this.text, x, y); return this.width();} +} + +class Fixed extends Formattable { + constructor(text) { + super(); + this.text = text; + } + squeeze() {return false;} +} + +class Squeezable extends Formattable { + constructor(named, index) { + super(); + this.named = named; + this.index = index; + this.end = index + named.forms; + } + squeeze() { + if (this.index >= this.end) return false; + this.index++; + this.w = null; + return true; + } + get text() {return this.named.table[this.index];} +} + +class Named { + constructor(forms, table) { + this.forms = forms; + this.table = table; + } + on(index) {return new Squeezable(this, this.forms * index);} +} + +////////////////////////////////////////////////////////////////////////////// +/* Face */ + +// Static geometry +const barW = 26, barH = g.getHeight(), barX = g.getWidth() - barW, barY = 0; +const faceW = g.getWidth() - barW, faceH = g.getHeight(); +const faceX = 0, faceY = 0, faceCX = faceW / 2, faceCY = faceH / 2; +const rectX = faceX + 35, rectY = faceY + 24, rectW = 80, rectH = 128; + +// Extended-Roman-numeral labels +const layout = E.toUint8Array( + 75, 23, // XII + 132, 24, // I + 132, 61, // II + 132, 97, // III + 132, 133, // IV + 132, 170, // V + 75, 171, // VI + 18, 170, // VII + 18, 133, // VIII + 18, 97, // IX + 18, 61, // X + 18, 24 // XI +); + +const numeral = (n, options) => [ + 'n', // 0 + 'abc', // I + 'abdc', // II + 'abddc', // III + 'abefg', // IV + 'hfg', // V + 'hfibc', // VI + 'hfibdc', // VII + 'hfibddc', // VIII + 'abjk', // IX + 'kjk', // X + 'kjbc', // XI + 'kjbdc', // XII + 'kjbddc', // XIII + 'kjbefg', // XIV + 'kjefg', // XV + 'labc', // XVI + 'labdc', // XVII + 'labddc', // XVIII + 'kjbjk', // XIX + 'kjjk', // XX + 'mabc', // XXI + 'mabdc', // XXII + 'mabddc', // XXIII +][options.o24h ? n % 24 : (n + 11) % 12 + 1]; + +const formatMonth = new Named(4, [ + 'January', 'Jan.', 'Jan', 'I', + 'February', 'Feb.', 'Feb', 'II', + 'March', 'Mar.', 'Mar', 'III', + 'April', 'Apr.', 'Apr', 'IV', + 'May', 'May', 'May', 'V', + 'June', 'June', 'Jun', 'VI', + 'July', 'July', 'Jul', 'VII', + 'August', 'Aug.', 'Aug', 'VIII', // VIII *is* narrower than Aug, our I is thin. + 'September', 'Sept.', 'Sep', 'IX', + 'October', 'Oct.', 'Oct', 'X', + 'November', 'Nov.', 'Nov', 'XI', + 'December', 'Dec.', 'Dec', 'XII' +]); +const formatDom = { + on: d => new Fixed(d.toString()) +}; +const formatDow = new Named(4, [ + 'Sunday', 'Sun.', 'Sun', 'Su', + 'Monday', 'Mon.', 'Mon', 'M', + 'Tuesday', 'Tues.', 'Tue', 'Tu', + 'Wednesday', 'Weds.', 'Wed', 'W', + 'Thursday', 'Thurs.', 'Thu', 'Th', + 'Friday', 'Fri.', 'Fri', 'F', + 'Saturday', 'Sat.', 'Sat', 'Sa' +]); + +const isString = x => typeof x == 'string'; +const imageWidth = i => isString(i) ? i.charCodeAt(0) : i.width; +const imageHeight = i => isString(i) ? i.charCodeAt(1) : i.height; + +const events = { + // Items are {time: number, wall: boolean, priority: number, + // past: bool, future: bool, precision: number, + // colour: colour, dramatic?: bool, event?: any} + fixed: [{time: Number.POSITIVE_INFINITY}], // indexed by ms absolute + wall: [{time: Number.POSITIVE_INFINITY}], // indexed by nominal ms + TZ ms + + clean: function(now, l) { + let o = now.getTimezoneOffset() * 60000; + let tf = now.getTime() + l, tw = tf - o; + // Discard stale events: + while (this.wall[0].time <= tw) this.wall.shift(); + while (this.fixed[0].time <= tf) this.fixed.shift(); + }, + + scan: function(now, from, to, f) { + result = Infinity; + let o = now.getTimezoneOffset() * 60000; + let t = now.getTime() - o; + let c, p, i, l = from - o, h = to - o; + for (i = 0; (c = this.wall[i]).time < l; i++) ; + for (; (c = this.wall[i]).time < h; i++) { + if ((p = c.time < t) ? c.past : c.future) + result = Math.min(result, f(c, new Date(c.time + o), p)); + } + l += o; h += o; t += o; + for (i = 0; (c = this.fixed[i]).time < l; i++) ; + for (; (c = this.fixed[i]).time < h; i++) { + if ((p = c.time < t) ? c.past : c.future) + result = Math.min(f(c, new Date(c.time), p)); + } + return result; + }, + + span: function(now, width) { + let o = now.getTimezoneOffset() * 60000; + let t = now.getTime() - o; + let lfence = [], rfence = []; + this.scan(now, now - width, now + width, (e, d, p) => { + if (p) { + for (let j = 0; j <= e.priority; j++) { + if (e.time < (lfence[e.priority] || t)) lfence[e.priority] = e.time; + } + } else { + for (let j = 0; j <= e.priority; j++) { + if (e.time > (rfence[e.priority] || t)) rfence[e.priority] = e.time; + } + } + }); + for (let j = 0; ; j += 0.5) { + if ((rfence[Math.ceil(j)] - lfence[Math.floor(j)] || 0) <= width) { + return [lfence[Math.floor(j)] || t, rfence[Math.ceil(j)] || t]; + } + } + }, + + insert: function(t, wall, e) { + let v = wall ? this.wall : this.fixed; + e.time = t = t - (wall ? t.getTimezoneOffset() * 60000 : 0); + v.splice(v.findIndex(x => x.time > t), 0, e); + }, + + loadFromSystem: function(options) { + alarms.forEach(x => { + if (x.on) { + const t = new Date(); + let h = x.hr; + let m = h % 1 * 60; + let s = m % 1 * 60; + let ms = s % 1 * 1000; + t.setHours(h - h % 1, m - m % 1, s - s % 1, ms); + // There's a race condition here, but I'm not sure what we can do about it. + if (t < Date.now() || x.last === t.getDate()) t.setDate(t.getDate() + 1); + this.insert(t, true, { + priority: 0, + past: false, // System alarms seem uninteresting if past? + future: true, + precision: x.timer ? 1000 : 60000, + colour: x.timer ? options.timerFg : options.alarmFg, + event: x + }); + } + }); + return this; + }, +}; + +////////////////////////////////////////////////////////////////////////////// +/* The main face logic */ + +class Sidebar { + constructor(g, x, y, w, h, options) { + this.g = g; + this.options = options; + this.x = x; + this.y = this.initY = y; + this.h = h; + this.rate = Infinity; + this.doLocked = Sidebar.status(_ => Bangle.isLocked(), lockI); + this.doHRM = Sidebar.status(_ => Bangle.isHRMOn(), HRMI); + this.doGPS = Sidebar.status(_ => Bangle.isGPSOn(), GPSI, Sidebar.gpsColour(options)); + } + reset(rate) {this.y = this.initY; this.rate = rate; return this;} + print(t) { + this.y += 4 + t.print( + this.g.setColor(this.options.barFg).setFontAlign(-1, 1, 1), + this.x + 3, this.y + 4 + ); + return this; + } + pad(n) {this.y += n; return this;} + free() {return this.h - this.y;} + static status(p, i, c) { + return function() { + if (p()) { + this.g.setColor(c ? c() : this.options.barFg) + .drawImage(i, this.x + 4, this.y += 4); + this.y += imageHeight(i); + } + return this; + }; + } + static gpsColour(o) { + const fix = Bangle.getGPSFix(); + return fix && fix.fix ? o.active : o.barFg; + } + doPower() { + const c = Bangle.isCharging(); + const b = E.getBattery(); + if (c || b < 50) { + let g = this.g, x = this.x, y = this.y, options = this.options; + g.setColor(options.barFg).drawImage(batteryI, x + 4, y + 4); + g.setColor(b <= 10 ? '#f00' : b <= 30 ? '#ff0' : '#0f0'); + const h = 13 * (100 - b) / 100; + g.fillRect(x + 8, y + 7 + h, x + 17, y + 20); + // Espruino disallows blank leading rows in icons, for some reason. + if (c) g.setColor(options.barBg).drawImage(chargeI, x + 4, y + 8); + this.y = y + imageHeight(batteryI) + 4; + } + return this; + } + doCompass() { + if (Bangle.isCompassOn()) { + const c = Bangle.getCompass(); + const a = c && this.rate <= 1000; + this.g.setColor(a ? this.options.active : this.options.barFg).drawImage( + compassI, + this.x + 4 + imageWidth(compassI) / 2, + this.y + 4 + imageHeight(compassI) / 2, + a ? {rotate: c.heading / 180 * Math.PI} : undefined + ); + this.y += 4 + imageHeight(compassI); + } + return this; + } +} + +class Roman { + constructor(g, events) { + this.g = g; + this.state = {}; + const options = this.options = new RomanOptions(); + this.events = events.loadFromSystem(this.options); + this.timescales = [1000, [1000, 60000], 60000, 3600000]; + this.sidebar = new Sidebar(g, barX, barY, barW, barH, options); + this.hours = Roman.hand(g, 3, 0.5, 12, _ => options.hourFg); + this.minutes = Roman.hand(g, 2, 0.9, 60, _ => options.minuteFg); + this.seconds = Roman.hand(g, 1, 0.9, 60, _ => options.secondFg); + } + + reset() {this.state = {}; this.g.clear(true);} + + doIcons(which) {this.state.iconsOk = null;} + + // Watch hands. These could be improved, graphically. + // If we restricted them to 60 positions, we could feasibly hand-draw them? + static hand(g, w, l, d, c) { + return p => { + g.setColor(c()); + p = ((12 * p / d) + 1) % 12; + let h = l * rectW / 2; + let v = l * rectH / 2; + let poly = + p <= 2 ? [faceCX + w, faceCY, faceCX - w, faceCY, + faceCX + h * (p - 1), faceCY - v, + faceCX + h * (p - 1) + 1, faceCY - v] + : p < 6 ? [faceCX + 1, faceCY + w, faceCX + 1, faceCY - w, + faceCX + h, faceCY + v / 2 * (p - 4), + faceCX + h, faceCY + v / 2 * (p - 4) + 1] + : p <= 8 ? [faceCX - w, faceCY + 1, faceCX + w, faceCY + 1, + faceCX - h * (p - 7), faceCY + v, + faceCX - h * (p - 7) - 1, faceCY + v] + : [faceCX, faceCY - w, faceCX, faceCY + w, + faceCX - h, faceCY - v / 2 * (p - 10), + faceCX - h, faceCY - v / 2 * (p - 10) - 1]; + g.fillPoly(poly); + }; + } + + static pos(p, r) { + let h = r * rectW / 2; + let v = r * rectH / 2; + p = (p + 1) % 12; + return p <= 2 ? [faceCX + h * (p - 1), faceCY - v] + : p < 6 ? [faceCX + h, faceCY + v / 2 * (p - 4)] + : p <= 8 ? [faceCX - h * (p - 7), faceCY + v] + : [faceCX - h, faceCY - v / 2 * (p - 10)]; + } + + alert(e, date, now, past) { + const g = this.g; + g.setColor(e.colour); + const dt = date - now; + if (e.precision < 60000 && dt >= 0 && e.future && dt <= 59000) { // Seconds away + const p = Roman.pos(date.getSeconds() / 5, 0.95); + g.drawLine(faceCX, faceCY, p[0], p[1]); + return 1000; + } else if (e.precision < 3600000 && dt >= 0 && e.future && dt <= 3540000) { // Minutes away + const p = Roman.pos(date.getMinutes() / 5 + date.getSeconds() / 300, 0.8); + g.drawLine(p[0] - 5, p[1], p[0] + 5, p[1]); + g.drawLine(p[0], p[1] - 5, p[0], p[1] + 5); + return dt < 119000 ? 1000 : 60000; // Turn on second hand two minutes up. + } else if (e.precision < 43200000 && dt >= 0 ? e.future : e.past) { // Hours away + const p = Roman.pos(date.getHours() + date.getMinutes() / 60, 0.6); + const poly = [p[0] - 4, p[1], p[0], p[1] - 4, p[0] + 4, p[1], p[0], p[1] + 4]; + if (date >= now) g.fillPoly(poly); + else g.drawPoly(poly, true); + return 3600000; + } + return Infinity; + } + + render(d, rate) { + const g = this.g; + const state = this.state; + const options = this.options; + const events = this.events; + events.clean(d, -39600000); // 11h + + // Sidebar: icons and date + if (d.getDate() !== state.date || !state.iconsOk) { + const sidebar = this.sidebar; + state.date = d.getDate(); + state.iconsOk = true; + g.setColor(options.barBg).fillRect(barX, barY, barX + barW, barY + barH); + + sidebar.reset(rate).doLocked().doPower().doGPS().doHRM().doCompass(); + g.setFontCustom.apply(g, fontF); + let formatters = []; + let month, dom, dow; + if (options.calendric > 1) { + formatters.push(month = formatMonth.on(d.getMonth())); + } + if (options.calendric > 0) { + formatters.push(dom = formatDom.on(d.getDate())); + } + if (options.dow) { + formatters.push(dow = formatDow.on(d.getDay())); + } + // Obnoxiously inefficient iterative method :( + let ava = sidebar.free() - 3, use, i = 0, j = 0; + while ((use = formatters.reduce((l, f) => l + f.width(g) + 4, 0)) > ava && + j < formatters.length) + for (j = 0; + !formatters[i++ % formatters.length].squeeze() && + j < formatters.length; + j++) ; + if (dow) sidebar.print(dow); + sidebar.pad(ava - use); + if (month) sidebar.print(month); + if (dom) sidebar.print(dom); + } + + // Hour labels and (purely aesthetic) box; clear inner face. + let keyHour = d.getHours() < 12 ? 1 : 13; + let alertSpan = events.span(d, 43200000); + let l = Math.floor(alertSpan[0] / 3600000) % 24; + let h = Math.ceil(alertSpan[1] / 3600000) % 24; + if ((l - keyHour + 24) % 24 >= 12) keyHour = l; + else if ((h - keyHour + 24) % 24 >= 12) keyHour = (h + 13) % 24; + if (keyHour !== state.keyHour) { + state.keyHour = keyHour; + g.setColor(options.bg) + .fillRect(faceX, faceY, faceX + faceW, faceY + faceH) + .setFontCustom.apply(g, romanPartsF) + .setFontAlign(0, 1) + .setColor(options.fg); + // In order to deal with timezone changes more logic will be required, + // since the labels may be in unusual locations (even offset when + // a non-integral zone is involved). The value of keyHour can be + // anything in [hr-12, hr] mod 24. + for (let h = keyHour; h < keyHour + 12; h++) { + g.drawString( + numeral(h % 24, options), + faceX + layout[h % 12 * 2], + faceY + layout[h % 12 * 2 + 1] + ); + } + g.setColor(options.rectFg) + .drawRect(rectX, rectY, rectX + rectW - 1, rectY + rectH - 1); + } else { + g.setColor(options.bg) + .fillRect(rectX + 1, rectY + 1, rectX + rectW - 2, rectY + rectH - 2) + .setColor(options.fg); + } + + // Alerts + let b = new Date(d.getTime()); + b.setHours(keyHour, 0, 0, 0); + if (b > d) b.setDate(b.getDate() - 1); + let requestedRate = events.scan( + d, b, b + 43200000, (e, t, p) => this.alert(e, t, d, p) + ); + if (rate > requestedRate) rate = requestedRate; + + // Hands + // Here we are using incremental hands for hours and minutes. + // If we quantised, we could use hand-crafted bitmaps, though. + this.hours(d.getHours() + d.getMinutes() / 60); + if (rate < 3600000) { + this.minutes(d.getMinutes() + d.getSeconds() / 60); + } + if (rate < 60000) this.seconds(d.getSeconds()); + g.setColor(options.hubFg).fillCircle(faceCX, faceCY, 3); + return requestedRate; + } +} + +////////////////////////////////////////////////////////////////////////////// +/* Master clock */ + +class Clock { + constructor(face) { + this.face = face; + this.timescales = face.timescales; + this.options = face.options; + this.rates = {}; + + this.options.on('done', () => this.start()); + + this.listeners = { + lcdPower: on => on ? this.active() : this.inactive(), + charging: () => {face.doIcons('charging'); this.active();}, + lock: () => {face.doIcons('locked'); this.active();}, + faceUp: up => {this.conservative = !up; this.active();}, + drag: e => { + if (this.t0) { + if (e.b) { + e.x > this.xN && (this.xN = e.x) || e.x > this.xX && (this.xX = e.x); + e.y > this.yN && (this.yN = e.y) || e.y > this.yX && (this.xY = e.y); + } else if (this.xX - this.xN < 20) { + if (e.y - this.e0.y < -50) { + this.options.resolution > 0 && this.options.resolution--; + this.rates.clock = this.timescales[this.options.resolution]; + this.active(); + } else if (e.y - this.e0.y > 50) { + this.options.resolution < this.timescales.length - 1 && + this.options.resolution++; + this.rates.clock = this.timescales[this.options.resolution]; + this.active(); + } else if (this.yX - this.yN < 20 && Date.now() - this.t0 > 500) { + this.stop(); + this.options.interact(); + } + this.t0 = null; + } + } else if (e.b) { + this.t0 = Date.now(); this.e0 = e; + this.xN = this.xX = e.x; this.yN = this.yX = e.y; + } + } + }; + } + + redraw(rate) { + const now = this.updated = new Date(); + if (this.refresh) this.face.reset(); + this.refresh = false; + rate = this.face.render(now, rate); + if (rate !== this.rates.face) { + this.rates.face = rate; + this.active(); + } + return this; + } + + inactive() { + this.timeout && clearTimeout(this.timeout); + this.exception && clearTimeout(this.exception); + this.interval && clearInterval(this.interval); + this.timeout = this.exception = this.interval = this.rate = null; + return this; + } + + active() { + const prev = this.rate; + const now = Date.now(); + let rate = Infinity; + for (const k in this.rates) { + let r = this.rates[k]; + r === +r || (r = r[+this.conservative]) + r < rate && (rate = r); + } + const delay = rate - now % rate + 1; + this.refresh = true; + + if (rate !== prev) { + this.inactive(); + this.redraw(rate); + if (rate < 31622400000) { // A year! + this.timeout = setTimeout( + () => { + this.inactive(); + this.interval = setInterval(() => this.redraw(rate), rate); + if (delay > 1000) this.redraw(rate); + this.rate = rate; + }, delay + ); + } + } else if (rate > 1000) { + if (!this.exception) this.exception = setTimeout(() => { + this.redraw(rate); + this.exception = null; + }, this.updated + 1000 - Date.now()); + } + return this; + } + + stop() { + this.inactive(); + for (const l in this.listeners) { + Bangle.removeListener(l, this.listeners[l]); + } + return this; + } + + start() { + this.inactive(); // Reset to known state. + this.conservative = false; + this.rates.clock = this.timescales[this.options.resolution]; + this.active(); + for (const l in this.listeners) { + Bangle.on(l, this.listeners[l]); + } + Bangle.setUI('clock'); + return this; + } +} + +////////////////////////////////////////////////////////////////////////////// +/* Main */ + +const clock = new Clock(new Roman(g, events)).start();