// SEATEMP-WIDGET: the line widget/loader.js looks for before it keeps a download of this file. // // Maroochydore sea temperature: a Scriptable (https://scriptable.app) widget for the iPhone home screen and Lock Screen. // // Normally this isn't pasted into Scriptable: widget/loader.js is, and it downloads this file from the site each time // it runs, so a push to main updates every phone. Pasted on its own, this works too, but never updates. // Small, medium and large work on the home screen; circular, rectangular and inline on the Lock Screen. Tapping it // opens the full page. // // It reads /api/now (a few kilobytes) and keeps the last good answer, so with no signal it shows that, marked with its // age, instead of an error. iOS decides when widgets refresh; this asks for every 15 minutes, and the data itself only // changes hourly. // // Lives in dom/sea_temp as widget/seatemp-widget.js; widget/check.mjs runs it outside Scriptable, and CI runs that. // The site serves this file with its own address in API (seatemp.py's localise), so each place's widget reads its own // answer, and caches it under its own name: two places' widgets on one phone keep apart. const API = "https://maroochydore.seatemp.eliza.house/api/now"; const CACHE = `seatemp-now-${API.split("/")[2]}.json`; const SEA = new Color("#5cc2df"); const TEXT = Color.white(); const SOFT = new Color("#ffffff", 0.72); const GOOD = new Color("#4fd08e"); const AIR = new Color("#f0a06a"); const WIND = new Color("#9fb0f0"); const RAIN = "#6fb4e6"; const SUN = "#f6c35c"; const BG = [new Color("#0f5470"), new Color("#08283a")]; // ---- data ----------------------------------------------------------------- async function load() { const fm = FileManager.local(); const path = fm.joinPath(fm.documentsDirectory(), CACHE); try { const req = new Request(API); req.timeoutInterval = 15; const d = await req.loadJSON(); if (!d || !d.sea) throw new Error("no reading in the answer"); fm.writeString(path, JSON.stringify(d)); return { d, stale: false }; } catch (e) { if (fm.fileExists(path)) return { d: JSON.parse(fm.readString(path)), stale: true }; return { d: null, error: String(e.message || e) }; } } // ---- words ---------------------------------------------------------------- const fmt = (tz, opts) => new Intl.DateTimeFormat("en-AU", { timeZone: tz, ...opts }); const hour = (tz, t) => fmt(tz, { hour: "numeric" }).format(new Date(t * 1000)).replace(" ", ""); const day = (tz, t) => fmt(tz, { weekday: "short" }).format(new Date(t * 1000)); const sameDay = (tz, a, b) => fmt(tz, { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(a * 1000)) === fmt(tz, { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(b * 1000)); const REASON = { sea: (s) => `sea under ${s.min_sea}°`, wind: () => "too windy", gust: () => "too gusty", waves: () => "surf too big", chop: () => "choppy", storm: () => "storms", rain: () => "rain likely" }; // The swim line, long or short; null when there's no swim information at all. function swimText(d, short) { const s = d.swim; if (!s) return null; if (s.now) return short ? "Swim now" : "Good for a swim now"; if (s.next) { const when = sameDay(d.tz, s.next, d.now) ? hour(d.tz, s.next) : `${day(d.tz, s.next)} ${hour(d.tz, s.next)}`; return short ? `Swim ${when}` : `Next good swim ${when}`; } return short ? "No swim" : `No swim this week: ${(REASON[s.reason] || (() => "conditions"))(s)}`; } const CODES = [ [0, "Sunny", "sun.max.fill", "moon.stars.fill"], [1, "Mostly sunny", "cloud.sun.fill", "cloud.moon.fill"], [2, "Partly cloudy", "cloud.sun.fill", "cloud.moon.fill"], [3, "Overcast", "cloud.fill"], [48, "Fog", "cloud.fog.fill"], [57, "Drizzle", "cloud.drizzle.fill"], [67, "Rain", "cloud.rain.fill"], [77, "Snow", "cloud.snow.fill"], [82, "Showers", "cloud.sun.rain.fill"], [86, "Snow showers", "cloud.snow.fill"], [99, "Storms", "cloud.bolt.rain.fill"], ]; function condition(w) { const c = CODES.find(([max]) => w.weather_code <= max) || CODES[CODES.length - 1]; const night = w.is_day === 0; return { word: night && w.weather_code <= 1 ? (w.weather_code ? "Mostly clear" : "Clear") : c[1], symbol: (night && c[3]) || c[2] }; } const COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; const compass = (deg) => COMPASS[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16]; const change = (v) => (v == null ? "" : Math.round(v * 10) === 0 ? "steady" : `${v > 0 ? "▲" : "▼"}${Math.abs(v).toFixed(1)}°`); // ---- drawing -------------------------------------------------------------- // Charts cover the sea readings' window, three days either side of now. Answers cached before the widget charted the // weather have no spark.weather, sun or good, and draw without them. function canvas(w, h) { const dc = new DrawContext(); dc.size = new Size(w, h); dc.opaque = false; dc.respectScreenScale = true; return dc; } function stroke(dc, pts, color, width) { if (pts.length < 2) return; const p = new Path(); p.addLines(pts); dc.addPath(p); dc.setStrokeColor(color); dc.setLineWidth(width); dc.strokePath(); } // Daylight: a strip every half hour from sunrise to sunset, strongest at midday. A sine over the day stands in for the // sun's height, which the page works out properly; at this size the two can't be told apart. function shadeSun(dc, d, x, top, bottom, strength) { for (const [rise, set] of d.spark.sun || []) { for (let t = rise; t < set; t += 1800) { const k = Math.sin((Math.PI * (Math.min(t + 900, set) - rise)) / (set - rise)); // On whole points, so neighbouring strips neither overlap nor leave a seam: either shows as stripes. const a = Math.round(x(t)), b = Math.round(x(Math.min(t + 1800, set))); if (b <= a) continue; dc.setFillColor(new Color(SUN, strength * k)); dc.fillRect(new Rect(a, top, b - a, bottom - top)); } } } // Open-Meteo rounds the sea to 0.1°, so an hourly line is a staircase: draw a five-hour rolling average, as the page does. function smooth(rows) { return rows.map(([t], i) => { const near = rows.slice(Math.max(0, i - 2), i + 3); return [t, near.reduce((s, r) => s + r[1], 0) / near.length]; }); } // A green mark under each hour that's good for a swim, as on the page's chart. function swimMarks(dc, d, x, y, h) { dc.setFillColor(GOOD); for (const t of d.spark.good || []) dc.fillRect(new Rect(x(t), y, Math.max(x(t + 3600) - x(t), 1), h)); } // The sea for a few days either side of now: measured solid, forecast faint, a dot at the latest reading, with the // daylight behind it and the good swimming hours under it. function sparkline(d, w, h) { const past = smooth(d.spark.past || []), ahead = smooth(d.spark.forecast || []); const all = [...past, ...ahead]; const dc = canvas(w, h); if (all.length < 2) return dc.getImage(); const t0 = all[0][0], t1 = all[all.length - 1][0]; const vs = all.map((r) => r[1]); const lo = Math.min(...vs), hi = Math.max(...vs), pad = 3, marks = 2; const x = (t) => ((t - t0) / Math.max(t1 - t0, 1)) * (w - 2 * pad) + pad; const y = (v) => h - pad - marks - ((v - lo) / Math.max(hi - lo, 0.2)) * (h - 2 * pad - marks); shadeSun(dc, d, x, 0, h, 0.3); swimMarks(dc, d, x, h - marks, marks); const pt = ([t, v]) => new Point(x(t), y(v)); stroke(dc, (ahead.length && past.length ? [past[past.length - 1], ...ahead] : ahead).map(pt), new Color("#5cc2df", 0.45), 1.5); stroke(dc, past.map(pt), SEA, 2); if (past.length) { const [t, v] = past[past.length - 1]; dc.setFillColor(TEXT); dc.fillEllipse(new Rect(x(t) - 2.5, y(v) - 2.5, 5, 5)); } return dc.getImage(); } // The large widget's chart, the page's combined chart in small: the sea on top and tallest, then the air, the wind with // its gusts, and the chance of rain, each lane scaled on its own and labelled with its range, the weekdays underneath. function laneChart(d, w, h) { const past = smooth(d.spark.past || []), ahead = smooth(d.spark.forecast || []); const all = [...past, ...ahead]; const dc = canvas(w, h); if (all.length < 2) return dc.getImage(); const t0 = all[0][0], t1 = all[all.length - 1][0]; const x = (t) => ((t - t0) / Math.max(t1 - t0, 1)) * w; const now = Math.min(Math.max(d.now, t0), t1); const wx = (d.spark.weather || []).filter((r) => r[0] >= t0 && r[0] <= t1); const wxPast = wx.filter((r) => r[0] <= d.now), wxAhead = wx.filter((r) => r[0] > d.now); const join = (p, a) => (p.length && a.length ? [p[p.length - 1], ...a] : a); const label = 11, days = 12, gap = 3; const lanes = [["sea", 0.46], ["air", 0.18], ["wind", 0.18], ["rain", 0.18]]; const inner = h - days - lanes.length * (label + gap); let top = 0; const lane = {}; for (const [k, share] of lanes) { lane[k] = { top: top + label, h: inner * share }; top += label + inner * share + gap; } const yIn = (l, lo, hi) => (v) => l.top + l.h - ((v - lo) / Math.max(hi - lo, 0.01)) * l.h; const bottom = lane.rain.top + lane.rain.h; for (const l of Object.values(lane)) shadeSun(dc, d, x, l.top, l.top + l.h, 0.22); dc.setFillColor(new Color("#ffffff", 0.35)); dc.fillRect(new Rect(x(now) - 0.5, lane.sea.top, 1, bottom - lane.sea.top)); const caption = (l, s) => { dc.setFont(Font.semiboldSystemFont(8)); dc.setTextColor(SOFT); dc.setTextAlignedLeft(); dc.drawTextInRect(s, new Rect(0, l.top - label, w, label)); }; // Sea, with the swim threshold faintly if it's in range, and the good hours along the bottom. const vs = all.map((r) => r[1]); const lo = Math.min(...vs), hi = Math.max(...vs); const span = Math.max(hi - lo, 0.4), y = yIn(lane.sea, lo - span * 0.1, hi + span * 0.1); const ref = d.swim && d.swim.min_sea; if (ref != null && ref > lo - span * 0.1 && ref < hi + span * 0.1) stroke(dc, [new Point(0, y(ref)), new Point(w, y(ref))], new Color("#4fd08e", 0.45), 1); swimMarks(dc, d, x, lane.sea.top + lane.sea.h - 3, 3); const pt = (yf) => ([t, v]) => new Point(x(t), yf(v)); stroke(dc, join(past, ahead).map(pt(y)), new Color("#5cc2df", 0.45), 1.5); stroke(dc, past.map(pt(y)), SEA, 2); if (past.length) { const [t, v] = past[past.length - 1]; dc.setFillColor(TEXT); dc.fillEllipse(new Rect(x(t) - 2.5, y(v) - 2.5, 5, 5)); } const raw = [...(d.spark.past || []), ...(d.spark.forecast || [])].map((r) => r[1]); // the range as measured, not smoothed caption(lane.sea, `SEA ${Math.min(...raw).toFixed(1)}–${Math.max(...raw).toFixed(1)}°` + (ref != null ? ` · SWIM FROM ${ref}°` : "")); if (wx.length >= 2) { // Air: past solid, forecast faint. const airs = wx.map((r) => r[1]); const ya = yIn(lane.air, Math.min(...airs), Math.max(...airs)); const air = (rows) => rows.map((r) => new Point(x(r[0]), ya(r[1]))); stroke(dc, air(join(wxPast, wxAhead)), new Color("#f0a06a", 0.5), 1.2); stroke(dc, air(wxPast), AIR, 1.5); caption(lane.air, `AIR ${Math.round(Math.min(...airs))}–${Math.round(Math.max(...airs))}°`); // Wind from calm: the swim limit, then the gusts faint above the speed. The scale reaches the limit, so it always shows. const windLimit = d.swim && d.swim.max_wind; const gmax = Math.max(...wx.map((r) => r[3]), 10, windLimit || 0); const yw = yIn(lane.wind, 0, gmax); if (windLimit != null) stroke(dc, [new Point(0, yw(windLimit)), new Point(w, yw(windLimit))], new Color("#9fb0f0", 0.5), 0.6); stroke(dc, wx.map((r) => new Point(x(r[0]), yw(r[3]))), new Color("#9fb0f0", 0.35), 1); stroke(dc, join(wxPast, wxAhead).map((r) => new Point(x(r[0]), yw(r[2]))), new Color("#9fb0f0", 0.5), 1.2); stroke(dc, wxPast.map((r) => new Point(x(r[0]), yw(r[2]))), WIND, 1.5); caption(lane.wind, `WIND · GUSTS TO ${Math.round(Math.max(...wx.map((r) => r[3])))} KM/H` + (windLimit != null ? ` · ${windLimit} LINE` : "")); // Rain: a bar per two hours, stronger over the swimming limit. const limit = d.swim && d.swim.max_rain != null ? d.swim.max_rain : 50; const bw = Math.max(x(t0 + 7200) - x(t0) - 0.5, 1); for (const r of wx) { if (!r[4]) continue; const bh = (r[4] / 100) * lane.rain.h; dc.setFillColor(new Color(RAIN, r[4] > limit ? 0.9 : 0.45)); dc.fillRect(new Rect(x(r[0]) - bw / 2, bottom - bh, bw, bh)); } caption(lane.rain, `CHANCE OF RAIN · ${limit}% LINE`); stroke(dc, [new Point(0, bottom - (limit / 100) * lane.rain.h), new Point(w, bottom - (limit / 100) * lane.rain.h)], new Color(RAIN, 0.5), 0.6); } // A weekday under the middle of each day's daylight. dc.setFont(Font.systemFont(8)); dc.setTextColor(SOFT); dc.setTextAlignedCenter(); for (const [rise, set] of d.spark.sun || []) { const mid = (rise + set) / 2; if (mid < t0 || mid > t1) continue; dc.drawTextInRect(day(d.tz, mid), new Rect(x(mid) - 15, h - days + 1, 30, days)); } return dc.getImage(); } function text(stack, s, size, color = TEXT, weight = "regular") { const t = stack.addText(s); t.font = weight === "bold" ? Font.boldSystemFont(size) : weight === "light" ? Font.lightRoundedSystemFont(size) : weight === "semibold" ? Font.semiboldSystemFont(size) : Font.systemFont(size); t.textColor = color; t.lineLimit = 1; t.minimumScaleFactor = 0.7; return t; } function background(w) { const g = new LinearGradient(); g.colors = BG; g.locations = [0, 1]; w.backgroundGradient = g; } function staleNote(stack, d, stale) { if (!stale) return; const mins = Math.round((Date.now() / 1000 - d.now) / 60); text(stack, `Offline · from ${mins < 120 ? `${mins} min` : `${Math.round(mins / 60)} h`} ago`, 10, SOFT); } // ---- layouts -------------------------------------------------------------- function small(w, d, stale) { background(w); w.setPadding(14, 14, 12, 14); text(w, "SEA · MAROOCHYDORE", 10, SOFT, "semibold"); text(w, `${d.sea.value.toFixed(1)}°`, 40, TEXT, "light"); text(w, `${change(d.sea.change_12h)} in 12 h`, 11, SOFT); w.addSpacer(); const swim = swimText(d, true); if (swim) text(w, swim, 13, d.swim.now || d.swim.next ? GOOD : SOFT, "semibold"); w.addSpacer(4); w.addImage(sparkline(d, 130, 22)); staleNote(w, d, stale); } function conditions(stack, d, size) { if (!d.weather) return; const c = condition(d.weather); const row = stack.addStack(); row.centerAlignContent(); const img = row.addImage(SFSymbol.named(c.symbol).image); img.imageSize = new Size(size + 4, size + 4); img.tintColor = TEXT; row.addSpacer(5); text(row, `${c.word} · ${Math.round(d.weather.temperature_2m)}°`, size, TEXT, "semibold"); const rain = d.weather.precipitation_probability; text(stack, `Wind ${compass(d.weather.wind_direction_10m)} ${Math.round(d.weather.wind_speed_10m)} km/h, gusts ${Math.round(d.weather.wind_gusts_10m)}` + (rain != null ? ` · ${rain}% rain` : ""), size - 2, SOFT); } function medium(w, d, stale, large) { background(w); w.setPadding(14, 16, 12, 16); const top = w.addStack(); const left = top.addStack(); left.layoutVertically(); text(left, "SEA · MAROOCHYDORE", 10, SOFT, "semibold"); text(left, `${d.sea.value.toFixed(1)}°`, 44, TEXT, "light"); text(left, `${change(d.sea.change_12h)} in 12 h`, 11, SOFT); top.addSpacer(); const right = top.addStack(); right.layoutVertically(); right.addSpacer(4); conditions(right, d, 13); right.addSpacer(6); const swim = swimText(d, false); if (swim) text(right, swim, 13, d.swim.now || d.swim.next ? GOOD : SOFT, "semibold"); if (d.swim && d.swim.today) text(right, `Today ${hour(d.tz, d.swim.today[0])}–${hour(d.tz, d.swim.today[1])}`, 11, SOFT); w.addSpacer(); if (large) { if (d.vs_last_year != null) { const v = d.vs_last_year; text(w, Math.round(v * 10) === 0 ? "Same as this week last year" : `${Math.abs(v).toFixed(1)}° ${v > 0 ? "warmer" : "cooler"} than this week last year`, 13, SOFT); } w.addSpacer(6); w.addImage(laneChart(d, 300, 200)); w.addSpacer(); } else { w.addImage(sparkline(d, 300, 30)); } staleNote(w, d, stale); } function lockScreen(w, d, family) { const v = `${d.sea.value.toFixed(1)}°`; if (family === "accessoryInline") { const swim = swimText(d, true); text(w, `🌊 ${v}${swim ? ` · ${swim}` : ""}`, 12); } else if (family === "accessoryCircular") { w.addAccessoryWidgetBackground = true; text(w, "SEA", 9, TEXT, "semibold").centerAlignText(); text(w, d.sea.value.toFixed(0) + "°", 20, TEXT, "semibold").centerAlignText(); } else { text(w, `🌊 Sea ${v} ${change(d.sea.change_12h)}`, 14, TEXT, "semibold"); const swim = swimText(d, false); if (swim) text(w, swim, 12); if (d.weather) text(w, `${condition(d.weather).word} · air ${Math.round(d.weather.temperature_2m)}°`, 12); } } // ---- main ----------------------------------------------------------------- const family = config.widgetFamily || "medium"; const { d, stale, error } = await load(); const w = new ListWidget(); if (!d) { background(w); text(w, "Sea temperature", 13, TEXT, "semibold"); text(w, `Couldn't load: ${error}`, 11, SOFT).lineLimit = 3; } else { w.url = d.url; if (family.startsWith("accessory")) lockScreen(w, d, family); else if (family === "small") small(w, d, stale); else medium(w, d, stale, family === "large" || family === "extraLarge"); } w.refreshAfterDate = new Date(Date.now() + 15 * 60 * 1000); if (config.runsInWidget) Script.setWidget(w); else await w.presentMedium(); Script.complete();