watchfloor-tizenbrew 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -2
  2. package/app/index.html +596 -0
  3. package/package.json +10 -4
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # Watchfloor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A CRT-style live status wall for Samsung TVs, packaged as a TizenBrew app module.
4
+
5
+ Live data: local weather with a 12-hour strip, NWS advisories for your exact location (the panel inverts when one is active), US AQI, PM2.5 and UV, status of GitHub/Cloudflare/Vercel/Netlify/Anthropic, the NOAA Kp index, M4.5+ earthquakes in the last 24h with distance from you, and the Hacker News front page. No API keys.
6
+
7
+ ## Configure
8
+ Edit the `CFG` block at the top of the script in `app/index.html`: station name, latitude/longitude, imperial or metric, and the status pages to watch (any Atlassian Statuspage URL ending in `/api/v2/status.json` works).
9
+
10
+ ## Preview on your PC
11
+ Open `app/index.html` in a browser. It scales to fit any window.
12
+
13
+ ## Install on the TV
14
+ 1. Change `name` in `package.json` to something you own on npm (e.g. `@yourname/watchfloor`).
15
+ 2. `npm publish --access public`
16
+ 3. In TizenBrew, press GREEN, add the module by its npm name, then launch it from the module list.
17
+ 4. After edits, bump `version` and publish again.
18
+
19
+ Any panel that can't reach its source shows NO SIGNAL and retries every 60 seconds.
package/app/index.html ADDED
@@ -0,0 +1,596 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Watchfloor</title>
7
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=VT323&display=swap">
8
+ <style>
9
+ /* Amber P3 phosphor. Tuned for a 2018 Tizen browser (Chromium ~56):
10
+ flexbox only, no CSS grid, no flex gap. */
11
+ :root {
12
+ --glass: #0d0905;
13
+ --p100: #ffb648;
14
+ --p70: #d08a2a;
15
+ --p45: #a8702a;
16
+ --p25: #4a3112;
17
+ --glow: 0 0 8px rgba(255, 170, 60, 0.55);
18
+ }
19
+ html, body {
20
+ margin: 0; height: 100%; overflow: hidden;
21
+ background: var(--glass); color: var(--p100);
22
+ font-family: "VT323", "Courier New", monospace;
23
+ }
24
+ #stage {
25
+ position: absolute; left: 50%; top: 50%;
26
+ width: 1920px; height: 1080px;
27
+ transform-origin: 50% 50%;
28
+ transform: translate(-50%, -50%);
29
+ box-sizing: border-box; padding: 54px 72px 40px;
30
+ display: flex; flex-direction: column;
31
+ }
32
+
33
+ /* Misconvergence and chroma bleed: the red and blue guns land a little
34
+ off the amber, worse toward the screen edges, and colour smears right.
35
+ text-shadow inherits as a computed value, so each region restates it. */
36
+ #stage, .col, .top > div {
37
+ text-shadow: var(--cvl) 0 0 rgba(255, 40, 30, 0.55), var(--cvr) 0 0 rgba(40, 170, 255, 0.42),
38
+ 3px 0 5px rgba(255, 140, 40, 0.35), var(--glow);
39
+ }
40
+ #stage { --cvl: -1.5px; --cvr: 1.5px; }
41
+ .col-a, .station { --cvl: -3px; --cvr: 1px; }
42
+ .col-c, .when { --cvl: -1px; --cvr: 3px; }
43
+
44
+ /* header */
45
+ .top { display: flex; align-items: flex-end; border-bottom: 2px solid var(--p25); padding-bottom: 14px; }
46
+ .station { flex: 1; font-size: 34px; color: var(--p70); line-height: 1.15; }
47
+ .station b { display: block; font-weight: normal; font-size: 44px; color: var(--p100); letter-spacing: 2px; }
48
+ .clock { font-size: 176px; line-height: 0.8; letter-spacing: 4px; }
49
+ .clock .s { color: var(--p45); }
50
+ .when { flex: 1; text-align: right; font-size: 34px; color: var(--p70); line-height: 1.15; }
51
+ .when b { display: block; font-weight: normal; font-size: 44px; color: var(--p100); }
52
+
53
+ /* columns */
54
+ .cols { flex: 1; display: flex; margin-top: 26px; min-height: 0; overflow: hidden; }
55
+ .col { display: flex; flex-direction: column; min-width: 0; }
56
+ .col-a { flex: 1.25; margin-right: 56px; }
57
+ .col-b { flex: 0.9; margin-right: 56px; }
58
+ .col-c { flex: 1.25; }
59
+ .panel { margin-bottom: 26px; }
60
+ .panel:last-child { margin-bottom: 0; }
61
+ .grow { flex: 1; min-height: 0; overflow: hidden; }
62
+
63
+ h2 {
64
+ margin: 0 0 8px; font-weight: normal; font-size: 28px; letter-spacing: 3px;
65
+ color: var(--p45); display: flex; text-shadow: none;
66
+ border-bottom: 1px dashed var(--p25); padding-bottom: 4px;
67
+ }
68
+ h2 .t { flex: 1; }
69
+ h2 .age { letter-spacing: 1px; }
70
+ h2 .age.dead { color: var(--p100); }
71
+
72
+ .row { display: flex; font-size: 34px; line-height: 1.2; }
73
+ .row .k { color: var(--p70); width: 230px; flex-shrink: 0; }
74
+ .row .v { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
75
+ .dim { color: var(--p45); }
76
+ .line { font-size: 32px; line-height: 1.15; margin-bottom: 4px; }
77
+ .nosig { font-size: 34px; color: var(--p45); }
78
+
79
+ /* atmosphere */
80
+ .wx-now { display: flex; align-items: flex-start; }
81
+ .temp { font-size: 140px; line-height: 0.85; margin-right: 30px; }
82
+ .wx-meta { flex: 1; font-size: 30px; line-height: 1.15; padding-top: 4px; }
83
+ .wx-meta .cond { font-size: 40px; color: var(--p100); }
84
+ .wx-meta div { color: var(--p70); }
85
+ .strip { display: flex; align-items: flex-end; height: 150px; margin-top: 12px; }
86
+ .hr { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; font-size: 26px; }
87
+ .hr .bar { width: 58%; background: var(--p70); box-shadow: 0 0 6px rgba(255,170,60,.35); }
88
+ .hr .pp { color: var(--p45); height: 26px; }
89
+ .hr .h { color: var(--p45); margin-top: 6px; }
90
+
91
+ /* alerts: the one loud element on the wall */
92
+ #p-alerts.hot { background: var(--p100); color: var(--glass); text-shadow: none; padding: 10px 16px 14px; margin-left: -16px; margin-right: -16px; }
93
+ #p-alerts.hot h2 { color: var(--glass); border-color: var(--glass); animation: blink 1.6s steps(1) infinite; }
94
+ .alert { font-size: 36px; line-height: 1.15; margin-bottom: 8px; }
95
+ .alert small { display: block; font-size: 28px; opacity: 0.8; }
96
+ @keyframes blink { 50% { opacity: 0.35; } }
97
+
98
+ .big { font-size: 96px; line-height: 0.9; }
99
+ .svc .v { text-align: right; }
100
+ .svc.bad .k, .svc.bad .v { color: var(--glass); background: var(--p100); text-shadow: none; }
101
+
102
+ .quake, .story { font-size: 32px; line-height: 1.15; margin-bottom: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
103
+ .quake .m { display: inline-block; width: 70px; }
104
+ .quake .d { display: inline-block; width: 300px; color: var(--p45); }
105
+ .story .meta { color: var(--p45); display: inline-block; width: 80px; }
106
+
107
+ .log { border-top: 2px solid var(--p25); padding-top: 10px; margin-top: 18px; font-size: 26px; color: var(--p45); height: 64px; overflow: hidden; text-shadow: none; line-height: 1.2; }
108
+
109
+ /* ---------- the tube ----------
110
+ Every effect is a separate layer that only animates transform or
111
+ opacity, so the 2018 TV's compositor does the work instead of
112
+ repainting the page. */
113
+ #tube {
114
+ position: fixed; left: 0; top: 0; right: 0; bottom: 0; overflow: hidden;
115
+ background: var(--glass);
116
+ /* Tired high-voltage supply: the picture breathes and sits slightly
117
+ rotated from the Earth's field, drifting over time. */
118
+ animation: breathe 9s ease-in-out infinite alternate;
119
+ }
120
+ @keyframes breathe {
121
+ 0% { transform: scale(1.000, 1.000) rotate(0.10deg) translateX(0); }
122
+ 40% { transform: scale(1.004, 1.007) rotate(0.16deg) translateX(1px); }
123
+ 100% { transform: scale(0.998, 1.002) rotate(0.06deg) translateX(-1px); }
124
+ }
125
+
126
+ .fx { position: fixed; left: 0; top: 0; right: 0; bottom: 0; pointer-events: none; }
127
+
128
+ /* Static glass: heavy scanlines plus the aperture-grille phosphor stripes. */
129
+ .scan {
130
+ background:
131
+ repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, rgba(0,0,0,0.55) 2px, rgba(0,0,0,0.55) 4px),
132
+ repeating-linear-gradient(to right, rgba(255,0,0,0.07) 0, rgba(255,0,0,0.07) 1px, rgba(0,255,0,0.05) 1px, rgba(0,255,0,0.05) 2px, rgba(0,80,255,0.07) 2px, rgba(0,80,255,0.07) 3px);
133
+ }
134
+
135
+ /* Interlace: odd and even fields alternate 1px apart, so fine lines twitter. */
136
+ .interlace {
137
+ top: -2px;
138
+ background: repeating-linear-gradient(to bottom, rgba(0,0,0,0.16) 0, rgba(0,0,0,0.16) 1px, rgba(0,0,0,0) 1px, rgba(0,0,0,0) 2px);
139
+ animation: field 0.0334s steps(1) infinite;
140
+ }
141
+ @keyframes field { 50% { transform: translateY(1px); } }
142
+
143
+ /* Colour purity: a magnetised shadow mask stains the corners, and the
144
+ stain wanders as the field shifts. Oversized so drifting shows no edge. */
145
+ .purity {
146
+ left: -6%; top: -6%; right: -6%; bottom: -6%;
147
+ background:
148
+ radial-gradient(circle at 86% 14%, rgba(210,60,230,0.20), rgba(210,60,230,0) 30%),
149
+ radial-gradient(circle at 12% 88%, rgba(60,230,130,0.13), rgba(60,230,130,0) 28%),
150
+ radial-gradient(circle at 60% 95%, rgba(80,120,255,0.08), rgba(80,120,255,0) 25%);
151
+ animation: magnet 53s ease-in-out infinite alternate;
152
+ }
153
+ @keyframes magnet {
154
+ 0% { transform: translate(0, 0) rotate(0deg); }
155
+ 35% { transform: translate(-2.5%, 1.2%) rotate(-1deg); }
156
+ 70% { transform: translate(1.5%, -1%) rotate(0.6deg); }
157
+ 100% { transform: translate(-1%, 2%) rotate(0deg); }
158
+ }
159
+
160
+ /* Snow: a random tile generated at boot, jumped around at ~12 fps. */
161
+ .noise {
162
+ left: -256px; top: -256px; right: -256px; bottom: -256px;
163
+ opacity: 0.045; animation: snow 0.5s infinite;
164
+ }
165
+ @keyframes snow {
166
+ 0% { transform: translate(0, 0); animation-timing-function: steps(1); }
167
+ 17% { transform: translate(-91px, 37px); animation-timing-function: steps(1); }
168
+ 33% { transform: translate(53px, -120px); animation-timing-function: steps(1); }
169
+ 50% { transform: translate(-170px, -44px); animation-timing-function: steps(1); }
170
+ 67% { transform: translate(122px, 88px); animation-timing-function: steps(1); }
171
+ 83% { transform: translate(-33px, 171px); animation-timing-function: steps(1); }
172
+ }
173
+
174
+ /* Hum bar: a 60 Hz ground loop rolling slowly up the picture. */
175
+ .hum { overflow: hidden; }
176
+ .hum::after {
177
+ content: ""; position: absolute; left: 0; right: 0; top: 0; height: 34%;
178
+ background: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,0.22) 45%, rgba(255,190,110,0.05) 58%, rgba(0,0,0,0));
179
+ animation: hum 13s linear infinite;
180
+ }
181
+ @keyframes hum { from { transform: translateY(330%); } to { transform: translateY(-120%); } }
182
+
183
+ /* Flicker from a tired supply, plus slow brightness drift as it warms. */
184
+ .flick { background: rgba(255,170,60,0.035); animation: flick 3.7s steps(1) infinite; }
185
+ @keyframes flick {
186
+ 0% { opacity: 1; } 9% { opacity: 0.3; } 10% { opacity: 1; } 41% { opacity: 0.5; } 42% { opacity: 1; }
187
+ 63% { opacity: 0.2; } 64% { opacity: 0.8; } 65% { opacity: 1; } 88% { opacity: 0.6; } 89% { opacity: 1; }
188
+ }
189
+ .drift { background: #000; opacity: 0; animation: drift 29s ease-in-out infinite alternate; }
190
+ @keyframes drift { 0% { opacity: 0; } 45% { opacity: 0.1; } 70% { opacity: 0.03; } 100% { opacity: 0.07; } }
191
+
192
+ /* Tube shape: rounded corners, darker curved edges, glare on the glass. */
193
+ .glass {
194
+ border-radius: 5% / 8%;
195
+ box-shadow: 0 0 0 80px #000, inset 0 0 140px 40px rgba(0,0,0,0.85), inset 0 0 22px 6px rgba(0,0,0,0.9);
196
+ background:
197
+ radial-gradient(ellipse at 28% 16%, rgba(255,255,255,0.06), rgba(255,255,255,0) 38%),
198
+ radial-gradient(ellipse at center, rgba(0,0,0,0) 52%, rgba(0,0,0,0.6) 100%);
199
+ }
200
+
201
+ /* Degauss: the rainbow wobble a CRT makes when it powers on. */
202
+ .degauss {
203
+ left: -10%; top: -10%; right: -10%; bottom: -10%; opacity: 0;
204
+ background: linear-gradient(115deg, rgba(255,0,140,0.32), rgba(0,255,170,0.26) 25%, rgba(90,120,255,0.32) 50%, rgba(255,210,0,0.26) 75%, rgba(255,0,140,0.32));
205
+ }
206
+
207
+ /* ---------- glitches, triggered from script ---------- */
208
+ .power #tube { animation: poweron 1.1s ease-out; }
209
+ @keyframes poweron {
210
+ 0% { transform: scale(0.4, 0.003); opacity: 1; }
211
+ 30% { transform: scale(1, 0.003); }
212
+ 65% { transform: scale(1, 1.05); }
213
+ 100% { transform: scale(1, 1); }
214
+ }
215
+ .degaussing #tube { animation: wobble 1.7s ease-out; }
216
+ .degaussing .degauss { animation: degauss 1.7s ease-out; }
217
+ @keyframes wobble {
218
+ 0% { transform: rotate(0deg) skewX(0deg); } 12% { transform: rotate(1.4deg) skewX(2deg); }
219
+ 25% { transform: rotate(-1.1deg) skewX(-1.6deg); } 40% { transform: rotate(0.8deg) skewX(1deg); }
220
+ 60% { transform: rotate(-0.4deg) skewX(-0.5deg); } 100% { transform: rotate(0deg) skewX(0deg); }
221
+ }
222
+ @keyframes degauss {
223
+ 0% { opacity: 0.9; transform: rotate(0deg) scale(1); } 30% { opacity: 0.7; transform: rotate(8deg) scale(1.1); }
224
+ 60% { opacity: 0.35; transform: rotate(-5deg) scale(1.05); } 100% { opacity: 0; transform: rotate(0deg) scale(1); }
225
+ }
226
+ /* Horizontal sync loses lock for a moment and the picture tears sideways. */
227
+ .hsync #tube { animation: hsync 0.42s; }
228
+ @keyframes hsync {
229
+ 0% { transform: translateX(0) skewX(0deg); animation-timing-function: steps(1); }
230
+ 15% { transform: translateX(-14px) skewX(-3deg); animation-timing-function: steps(1); }
231
+ 35% { transform: translateX(9px) skewX(2.2deg); animation-timing-function: steps(1); }
232
+ 55% { transform: translateX(-5px) skewX(-1deg); animation-timing-function: steps(1); }
233
+ 75% { transform: translateX(2px) skewX(0.4deg); animation-timing-function: steps(1); }
234
+ 100% { transform: translateX(0) skewX(0deg); }
235
+ }
236
+ /* Vertical hold slips and the picture rolls once before catching. */
237
+ .vroll #tube { animation: vroll 0.9s ease-in-out; }
238
+ @keyframes vroll {
239
+ 0% { transform: translateY(0); } 45% { transform: translateY(55%); }
240
+ 46% { transform: translateY(-45%); } 100% { transform: translateY(0); }
241
+ }
242
+ /* Brownout: the supply sags, the picture shrinks and dims, then recovers. */
243
+ .brown #tube { animation: brown 1.4s ease-in-out; }
244
+ @keyframes brown {
245
+ 0% { transform: scale(1, 1); opacity: 1; } 35% { transform: scale(0.965, 0.975); opacity: 0.72; }
246
+ 55% { transform: scale(0.975, 0.985); opacity: 0.8; } 100% { transform: scale(1, 1); opacity: 1; }
247
+ }
248
+
249
+ @media (prefers-reduced-motion: reduce) {
250
+ #tube, .interlace, .purity, .noise, .hum::after, .flick, .drift, #p-alerts.hot h2 { animation: none !important; }
251
+ }
252
+ </style>
253
+ </head>
254
+ <body class="power">
255
+ <div id="tube">
256
+ <div id="stage">
257
+ <header class="top">
258
+ <div class="station"><b id="st-name">WATCHFLOOR</b><span id="st-coords"></span></div>
259
+ <div class="clock" id="clock">--:--<span class="s">:--</span></div>
260
+ <div class="when"><b id="date">----</b><span id="sun">sun --:-- / --:--</span></div>
261
+ </header>
262
+
263
+ <div class="cols">
264
+ <section class="col col-a">
265
+ <div class="panel" id="p-wx">
266
+ <h2><span class="t">ATMOSPHERE</span><span class="age" data-k="wx"></span></h2>
267
+ <div id="wx"><div class="nosig">Acquiring…</div></div>
268
+ </div>
269
+ <div class="panel" id="p-alerts">
270
+ <h2><span class="t">NWS ADVISORIES</span><span class="age" data-k="alerts"></span></h2>
271
+ <div id="alerts"><div class="nosig">Acquiring…</div></div>
272
+ </div>
273
+ </section>
274
+
275
+ <section class="col col-b">
276
+ <div class="panel">
277
+ <h2><span class="t">AIR</span><span class="age" data-k="air"></span></h2>
278
+ <div id="air"><div class="nosig">Acquiring…</div></div>
279
+ </div>
280
+ <div class="panel">
281
+ <h2><span class="t">INFRASTRUCTURE</span><span class="age" data-k="svc"></span></h2>
282
+ <div id="svc"><div class="nosig">Acquiring…</div></div>
283
+ </div>
284
+ <div class="panel">
285
+ <h2><span class="t">GEOMAGNETIC</span><span class="age" data-k="kp"></span></h2>
286
+ <div id="kp"><div class="nosig">Acquiring…</div></div>
287
+ </div>
288
+ </section>
289
+
290
+ <section class="col col-c">
291
+ <div class="panel">
292
+ <h2><span class="t">SEISMIC M4.5+ / 24H</span><span class="age" data-k="quake"></span></h2>
293
+ <div id="quake"><div class="nosig">Acquiring…</div></div>
294
+ </div>
295
+ <div class="panel grow">
296
+ <h2><span class="t">HACKER NEWS</span><span class="age" data-k="hn"></span></h2>
297
+ <div id="hn"><div class="nosig">Acquiring…</div></div>
298
+ </div>
299
+ </section>
300
+ </div>
301
+
302
+ <footer class="log" id="log"></footer>
303
+ </div>
304
+ <div class="fx purity"></div>
305
+ </div>
306
+ <div class="fx noise" id="noise"></div>
307
+ <div class="fx scan"></div>
308
+ <div class="fx interlace"></div>
309
+ <div class="fx hum"></div>
310
+ <div class="fx flick"></div>
311
+ <div class="fx drift"></div>
312
+ <div class="fx degauss"></div>
313
+ <div class="fx glass"></div>
314
+
315
+ <script>
316
+ /* ---------- config: edit these ---------- */
317
+ var CFG = {
318
+ name: "WATCHFLOOR / KANSAS CITY",
319
+ lat: 39.0997,
320
+ lon: -94.5786,
321
+ imperial: true,
322
+ services: [
323
+ { name: "GitHub", url: "https://www.githubstatus.com/api/v2/status.json" },
324
+ { name: "Cloudflare", url: "https://www.cloudflarestatus.com/api/v2/status.json" },
325
+ { name: "Vercel", url: "https://www.vercel-status.com/api/v2/status.json" },
326
+ { name: "Netlify", url: "https://www.netlifystatus.com/api/v2/status.json" },
327
+ { name: "Anthropic", url: "https://status.claude.com/api/v2/status.json" }
328
+ ]
329
+ };
330
+ /* ----------------------------------------- */
331
+
332
+ var $ = function (id) { return document.getElementById(id); };
333
+ var pad = function (n) { return (n < 10 ? "0" : "") + n; };
334
+ var esc = function (s) {
335
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
336
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
337
+ });
338
+ };
339
+ var hhmm = function (d) { return pad(d.getHours()) + ":" + pad(d.getMinutes()); };
340
+
341
+ function getJSON(url, ms) {
342
+ var t = new Promise(function (_, rej) { setTimeout(function () { rej(new Error("timeout")); }, ms || 15000); });
343
+ var f = fetch(url, { cache: "no-store" }).then(function (r) {
344
+ if (!r.ok) throw new Error("HTTP " + r.status);
345
+ return r.json();
346
+ });
347
+ return Promise.race([f, t]);
348
+ }
349
+
350
+ /* ---------- log + freshness ---------- */
351
+ var lines = [];
352
+ function log(msg) {
353
+ lines.unshift(hhmm(new Date()) + " " + msg);
354
+ lines = lines.slice(0, 2);
355
+ $("log").innerHTML = lines.map(esc).join("<br>");
356
+ }
357
+ var lastOk = {}, dead = {};
358
+ function ages() {
359
+ var els = document.querySelectorAll(".age");
360
+ for (var i = 0; i < els.length; i++) {
361
+ var k = els[i].getAttribute("data-k");
362
+ if (dead[k]) { els[i].textContent = "NO SIGNAL"; els[i].className = "age dead"; continue; }
363
+ els[i].className = "age";
364
+ if (!lastOk[k]) { els[i].textContent = ""; continue; }
365
+ var m = Math.floor((Date.now() - lastOk[k]) / 60000);
366
+ els[i].textContent = m < 1 ? "live" : m + "m";
367
+ }
368
+ }
369
+
370
+ function every(key, label, fn, ms) {
371
+ function run() {
372
+ fn().then(function () {
373
+ lastOk[key] = Date.now(); dead[key] = false; ages();
374
+ setTimeout(run, ms);
375
+ }).catch(function (e) {
376
+ dead[key] = true; ages();
377
+ log(label + ": no signal (" + e.message + "), retry in 60s");
378
+ setTimeout(run, 60000);
379
+ });
380
+ }
381
+ run();
382
+ }
383
+
384
+ /* ---------- clock ---------- */
385
+ var DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
386
+ var MONS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
387
+ function tick() {
388
+ var d = new Date();
389
+ $("clock").innerHTML = hhmm(d) + '<span class="s">:' + pad(d.getSeconds()) + "</span>";
390
+ $("date").textContent = DAYS[d.getDay()] + " " + d.getDate() + " " + MONS[d.getMonth()] + " " + d.getFullYear();
391
+ }
392
+
393
+ /* ---------- weather ---------- */
394
+ var WMO = { 0: "Clear", 1: "Mostly clear", 2: "Partly cloudy", 3: "Overcast", 45: "Fog", 48: "Freezing fog",
395
+ 51: "Light drizzle", 53: "Drizzle", 55: "Heavy drizzle", 56: "Freezing drizzle", 57: "Freezing drizzle",
396
+ 61: "Light rain", 63: "Rain", 65: "Heavy rain", 66: "Freezing rain", 67: "Freezing rain",
397
+ 71: "Light snow", 73: "Snow", 75: "Heavy snow", 77: "Snow grains", 80: "Showers", 81: "Showers", 82: "Violent showers",
398
+ 85: "Snow showers", 86: "Heavy snow showers", 95: "Thunderstorm", 96: "Thunderstorm, hail", 99: "Thunderstorm, hail" };
399
+ var COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
400
+ var uT = CFG.imperial ? "fahrenheit" : "celsius", uW = CFG.imperial ? "mph" : "kmh";
401
+
402
+ function weather() {
403
+ var url = "https://api.open-meteo.com/v1/forecast?latitude=" + CFG.lat + "&longitude=" + CFG.lon +
404
+ "&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m" +
405
+ "&hourly=temperature_2m,precipitation_probability&daily=sunrise,sunset,temperature_2m_max,temperature_2m_min" +
406
+ "&temperature_unit=" + uT + "&wind_speed_unit=" + uW + "&timezone=auto&forecast_days=2";
407
+ return getJSON(url).then(function (d) {
408
+ var c = d.current, h = d.hourly, dy = d.daily;
409
+ var nowKey = c.time.slice(0, 13), i0 = 0;
410
+ for (var i = 0; i < h.time.length; i++) { if (h.time[i].slice(0, 13) === nowKey) { i0 = i; break; } }
411
+ var temps = h.temperature_2m.slice(i0, i0 + 12), pp = h.precipitation_probability.slice(i0, i0 + 12), hrs = h.time.slice(i0, i0 + 12);
412
+ var lo = Math.min.apply(null, temps), hi = Math.max.apply(null, temps), span = Math.max(hi - lo, 1);
413
+ var strip = "";
414
+ for (var j = 0; j < temps.length; j++) {
415
+ var hgt = 18 + Math.round(((temps[j] - lo) / span) * 70);
416
+ strip += '<div class="hr"><div class="pp">' + (pp[j] >= 10 ? pp[j] + "%" : "") + "</div>" +
417
+ "<div>" + Math.round(temps[j]) + '</div><div class="bar" style="height:' + hgt + 'px"></div>' +
418
+ '<div class="h">' + hrs[j].slice(11, 13) + "</div></div>";
419
+ }
420
+ var gust = c.wind_gusts_10m > c.wind_speed_10m + 5 ? ", gusts " + Math.round(c.wind_gusts_10m) : "";
421
+ $("wx").innerHTML =
422
+ '<div class="wx-now"><div class="temp">' + Math.round(c.temperature_2m) + "°</div>" +
423
+ '<div class="wx-meta"><span class="cond">' + esc(WMO[c.weather_code] || "Code " + c.weather_code) + "</span>" +
424
+ "<div>Feels " + Math.round(c.apparent_temperature) + "°, humidity " + c.relative_humidity_2m + "%</div>" +
425
+ "<div>Wind " + COMPASS[Math.round(c.wind_direction_10m / 22.5) % 16] + " " + Math.round(c.wind_speed_10m) + " " + uW + gust + "</div>" +
426
+ "<div>Today " + Math.round(dy.temperature_2m_min[0]) + "° to " + Math.round(dy.temperature_2m_max[0]) + "°</div></div></div>" +
427
+ '<div class="strip">' + strip + "</div>";
428
+ $("sun").textContent = "sun " + dy.sunrise[0].slice(11, 16) + " / " + dy.sunset[0].slice(11, 16);
429
+ });
430
+ }
431
+
432
+ /* ---------- NWS alerts (US only) ---------- */
433
+ function alerts() {
434
+ return getJSON("https://api.weather.gov/alerts/active?point=" + CFG.lat.toFixed(4) + "," + CFG.lon.toFixed(4)).then(function (d) {
435
+ var f = (d.features || []).map(function (x) { return x.properties; });
436
+ var p = $("p-alerts");
437
+ if (!f.length) {
438
+ p.className = "panel";
439
+ $("alerts").innerHTML = '<div class="nosig">No active advisories for this location.</div>';
440
+ return;
441
+ }
442
+ if (p.className.indexOf("hot") < 0) log("NWS: " + f[0].event);
443
+ p.className = "panel hot";
444
+ $("alerts").innerHTML = f.slice(0, 3).map(function (a) {
445
+ var until = a.ends || a.expires;
446
+ return '<div class="alert">' + esc(a.event) +
447
+ "<small>until " + (until ? hhmm(new Date(until)) : "further notice") + ", " + esc(a.severity || "") + "</small></div>";
448
+ }).join("");
449
+ });
450
+ }
451
+
452
+ /* ---------- air ---------- */
453
+ function aqiBand(a) {
454
+ return a <= 50 ? "Good" : a <= 100 ? "Moderate" : a <= 150 ? "Unhealthy for sensitive groups" :
455
+ a <= 200 ? "Unhealthy" : a <= 300 ? "Very unhealthy" : "Hazardous";
456
+ }
457
+ function air() {
458
+ return getJSON("https://air-quality-api.open-meteo.com/v1/air-quality?latitude=" + CFG.lat + "&longitude=" + CFG.lon +
459
+ "&current=us_aqi,pm2_5,ozone,uv_index&timezone=auto").then(function (d) {
460
+ var c = d.current;
461
+ $("air").innerHTML =
462
+ '<div class="row"><span class="big">' + Math.round(c.us_aqi) + '</span><span class="dim" style="align-self:flex-end;margin-left:14px">US AQI</span></div>' +
463
+ '<div class="line">' + aqiBand(c.us_aqi) + "</div>" +
464
+ '<div class="row"><span class="k">PM2.5</span><span class="v">' + (c.pm2_5 == null ? "--" : c.pm2_5.toFixed(1)) + " µg/m³</span></div>" +
465
+ '<div class="row"><span class="k">UV index</span><span class="v">' + (c.uv_index == null ? "--" : c.uv_index.toFixed(1)) + "</span></div>";
466
+ });
467
+ }
468
+
469
+ /* ---------- service status (Atlassian Statuspage) ---------- */
470
+ var svcState = {};
471
+ function services() {
472
+ return Promise.all(CFG.services.map(function (s) {
473
+ return getJSON(s.url, 10000).then(function (d) {
474
+ return { name: s.name, ind: d.status.indicator, desc: d.status.description };
475
+ }).catch(function () { return { name: s.name, ind: "nosig", desc: "No signal" }; });
476
+ })).then(function (rows) {
477
+ if (rows.every(function (r) { return r.ind === "nosig"; })) throw new Error("all sources down");
478
+ $("svc").innerHTML = rows.map(function (r) {
479
+ if (svcState[r.name] && svcState[r.name] !== r.ind) log(r.name + ": " + r.desc);
480
+ svcState[r.name] = r.ind;
481
+ var bad = r.ind === "major" || r.ind === "critical";
482
+ var txt = r.ind === "none" ? "nominal" : r.ind === "nosig" ? "no signal" : r.ind;
483
+ return '<div class="row svc' + (bad ? " bad" : "") + '"><span class="k">' + esc(r.name) +
484
+ '</span><span class="v' + (r.ind === "none" || r.ind === "nosig" ? " dim" : "") + '">' + esc(txt) + "</span></div>";
485
+ }).join("");
486
+ });
487
+ }
488
+
489
+ /* ---------- geomagnetic Kp (NOAA SWPC) ---------- */
490
+ function kp() {
491
+ return getJSON("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json").then(function (d) {
492
+ var last = d[d.length - 1];
493
+ var v = parseFloat(Array.isArray(last) ? last[1] : (last.Kp != null ? last.Kp : last.kp_index));
494
+ if (isNaN(v)) throw new Error("bad data");
495
+ var g = v >= 9 ? "G5 extreme storm" : v >= 8 ? "G4 severe storm" : v >= 7 ? "G3 strong storm" :
496
+ v >= 6 ? "G2 moderate storm" : v >= 5 ? "G1 minor storm" : v >= 4 ? "Active" : "Quiet";
497
+ var aur = v >= 7 ? "Aurora possible at this latitude." : "Aurora unlikely here below Kp 7.";
498
+ $("kp").innerHTML = '<div class="line">Kp ' + v.toFixed(1) + ", " + g + "</div>" +
499
+ '<div class="line dim">' + aur + "</div>";
500
+ });
501
+ }
502
+
503
+ /* ---------- earthquakes (USGS) ---------- */
504
+ function miles(lat1, lon1, lat2, lon2) {
505
+ var r = Math.PI / 180, dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
506
+ var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
507
+ return 3958.8 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
508
+ }
509
+ function ago(t) {
510
+ var m = Math.floor((Date.now() - t) / 60000);
511
+ return m < 60 ? m + "m" : Math.floor(m / 60) + "h";
512
+ }
513
+ function quakes() {
514
+ return getJSON("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson").then(function (d) {
515
+ var f = d.features.slice().sort(function (a, b) { return b.properties.mag - a.properties.mag; });
516
+ var head = '<div class="line dim">' + f.length + " events, largest first, distance from here</div>";
517
+ $("quake").innerHTML = head + f.slice(0, 5).map(function (q) {
518
+ var p = q.properties, c = q.geometry.coordinates;
519
+ var dist = Math.round(miles(CFG.lat, CFG.lon, c[1], c[0]));
520
+ return '<div class="quake"><span class="m">' + p.mag.toFixed(1) + '</span><span class="d">' + dist.toLocaleString() + " mi " +
521
+ ago(p.time) + "</span>" + esc(p.place || "Unknown") + "</div>";
522
+ }).join("");
523
+ });
524
+ }
525
+
526
+ /* ---------- Hacker News ---------- */
527
+ function hn() {
528
+ return getJSON("https://hacker-news.firebaseio.com/v0/topstories.json").then(function (ids) {
529
+ return Promise.all(ids.slice(0, 6).map(function (id) {
530
+ return getJSON("https://hacker-news.firebaseio.com/v0/item/" + id + ".json");
531
+ }));
532
+ }).then(function (items) {
533
+ $("hn").innerHTML = items.filter(Boolean).map(function (s) {
534
+ return '<div class="story"><span class="meta">' + (s.score || 0) + "</span>" + esc(s.title) + "</div>";
535
+ }).join("");
536
+ });
537
+ }
538
+
539
+ /* ---------- tube faults ---------- */
540
+ function makeNoise() {
541
+ var c = document.createElement("canvas");
542
+ c.width = c.height = 256;
543
+ var g = c.getContext("2d"), img = g.createImageData(256, 256), px = img.data;
544
+ for (var i = 0; i < px.length; i += 4) {
545
+ var v = Math.random() * 255;
546
+ px[i] = v; px[i + 1] = v * 0.8; px[i + 2] = v * 0.6; px[i + 3] = Math.random() < 0.5 ? 255 : 0;
547
+ }
548
+ g.putImageData(img, 0, 0);
549
+ $("noise").style.backgroundImage = "url(" + c.toDataURL() + ")";
550
+ }
551
+
552
+ // Play one glitch by putting its class on <body> for the length of its animation.
553
+ function fault(cls, ms, done) {
554
+ document.body.className = cls;
555
+ setTimeout(function () { document.body.className = ""; if (done) done(); }, ms);
556
+ }
557
+
558
+ // Every 20-70 s something in the old set misbehaves.
559
+ function scheduleFault() {
560
+ setTimeout(function () {
561
+ var r = Math.random();
562
+ if (r < 0.5) fault("hsync", 450, scheduleFault);
563
+ else if (r < 0.75) fault("brown", 1450, scheduleFault);
564
+ else if (r < 0.92) fault("vroll", 950, scheduleFault);
565
+ else fault("degaussing", 1750, scheduleFault);
566
+ }, 20000 + Math.random() * 50000);
567
+ }
568
+
569
+ /* ---------- boot ---------- */
570
+ function fit() {
571
+ var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
572
+ $("stage").style.transform = "translate(-50%, -50%) scale(" + s + ")";
573
+ }
574
+ window.addEventListener("resize", fit);
575
+ fit();
576
+
577
+ $("st-name").textContent = CFG.name;
578
+ $("st-coords").textContent = Math.abs(CFG.lat).toFixed(2) + "°" + (CFG.lat >= 0 ? "N " : "S ") +
579
+ Math.abs(CFG.lon).toFixed(2) + "°" + (CFG.lon >= 0 ? "E" : "W");
580
+ tick(); setInterval(tick, 1000);
581
+ makeNoise();
582
+ // Power on, then degauss, then settle into random faults.
583
+ fault("power", 1150, function () { fault("degaussing", 1750, scheduleFault); });
584
+ setInterval(ages, 15000);
585
+ log("Terminal online");
586
+
587
+ every("wx", "Weather", weather, 10 * 60000);
588
+ every("alerts", "NWS", alerts, 2 * 60000);
589
+ every("air", "Air quality", air, 30 * 60000);
590
+ every("svc", "Status", services, 3 * 60000);
591
+ every("kp", "SWPC", kp, 30 * 60000);
592
+ every("quake", "USGS", quakes, 10 * 60000);
593
+ every("hn", "Hacker News", hn, 15 * 60000);
594
+ </script>
595
+ </body>
596
+ </html>
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "watchfloor-tizenbrew",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "CRT-style live status wall for Samsung TVs via TizenBrew: weather, NWS alerts, air quality, service status, earthquakes, geomagnetic activity, Hacker News.",
5
+ "packageType": "app",
6
+ "appName": "Watchfloor",
7
+ "appPath": "app/index.html",
8
+ "keys": [],
9
+ "keywords": ["tizenbrew"],
10
+ "files": ["app"],
11
+ "license": "MIT"
12
+ }