A 64x64 Weather Display for a Divoom

I have a nifty Divoom Pixoo-64 on my desk that I got for Christmas a few years back. It’s a 64x64 LED display that can show pixel art, animations, the time, etc. But if you don’t mind code, it can show whatever you want. I wanted it to show the weather in a specific way that I liked.

Divoom Pixoo-64 displaying weather

The Divoom on my office wall

The result: a little serverless function that generates a 64x64 pixel image with the current temperature, forecast, date, and time. It runs on Cloudflare Workers now, though it started as a Netlify function back in July 2023.

You can see it live at ryanmpoe.com/weather.

What it does

  • Fetches weather from weather.gov (with Visual Crossing as fallback)
  • Renders a 64x64 pixel image with a tiny bitmap font
  • Shows date, time, current temp, high/low, and a short forecast
  • Outputs either a BMP image or base64 RGB data for the Divoom API
  • Caches weather data for 15 minutes

The whole thing is ~400 lines of TypeScript with zero dependencies. No image libraries, just raw pixel manipulation and a hand-rolled BMP encoder. (BMP is dead simple. See more below.)

The pixel font

One tricky part was the font. At 64x64 pixels, you don’t have room for anything fancy. I don’t remember if I hand-rolled all of these characters, or found a typeface on the web to use as reference… I wrote this almost 3 years ago, now. Each character is defined as a 5-row array of strings:

1
"a": ["  0 ", " 00 ", " 0 0", "0000", "0  0"],

Stacked, for a more obvious representation:

1
2
3
4
5
"a": ["  0 ",
      " 00 ",
      " 0 0",   // Yep, that's an A
      "0000",
      "0  0"],

Where 0 means “draw a pixel” and space means “skip.” It’s crude but readable at tiny sizes.

No dependencies

The original version used Jimp for image manipulation. When I migrated to Cloudflare Workers, I rewrote it with a simple PixelBuffer class using Uint8Array for raw pixel data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class PixelBuffer {
  width: number;
  height: number;
  data: Uint8Array; // RGBA

  setPixel(x: number, y: number, color: number) {
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
    const i = (y * this.width + x) * 4;
    this.data[i] = (color >> 16) & 0xff;
    this.data[i + 1] = (color >> 8) & 0xff;
    this.data[i + 2] = color & 0xff;
    this.data[i + 3] = 255;
  }

  toBMP(): Uint8Array {
    // 54-byte header + BGR pixel data, bottom-up
    // ...
  }
}

BMP is a beautifully stupid format. No compression, just a 54-byte header and raw pixels (BGR, bottom-up). Perfect for a 64x64 image where the whole file is 12KB.

The format we hagve to pass to the Divoom is even simpler: a single array of RGB values that get painted, from top left to bottom right, onto the screen; when it reaches the 64th pixel, it wraps around to the next row.

1
2
const bmp = [[0, 0, 0], [0,0,0], [0,0,0]]; // 3x3
const divoom = [0,0,0,0,0,0,0,0,0]; // 1x9

Same data, just flattened.

Deploying to Cloudflare

The migration from Netlify to Cloudflare Workers was straightforward:

1
2
3
4
5
6
7
8
9
# wrangler.toml
name = "weather"
main = "src/index.ts"
compatibility_date = "2024-12-01"

routes = [
  { pattern = "ryanmpoe.com/weather*", zone_name = "ryanmpoe.com" },
  { pattern = "www.ryanmpoe.com/weather*", zone_name = "ryanmpoe.com" }
]

Then npx wrangler deploy. The routes only capture /weather*, so my existing Cloudflare Pages site stays untouched.

Total bundle size: 14.77 KiB (4.34 KiB gzipped).

Pushing to the Divoom

The Divoom has a local HTTP API, but it’s only accessible from devices on the same network. Since the Cloudflare Worker runs in the cloud, I use Node-RED and Home Assistant to bridge the gap.

Node-RED runs on my home server and pings the worker every minute, fetches the base64 image data with ?output=divoom, and pushes it to the Pixoo on my local network. Home Assistant handles the scheduling and lets me disable updates when I’m not in the office.

It’s a simple flow: inject node (every 60s) → HTTP request to ryanmpoe.com/weather?output=divoom → function node to format the Divoom API payload → HTTP request to http://<divoom-ip>/post with the image data.

⚠️ For the Divoom API, use ?output=divoom to get base64-encoded RGB bytes instead of a BMP image. The BMP is for previewing, but the Divoom device needs the flattened base64 data.

(Fair warning: I found some of the documented features like animated GIF playback didn’t work well on my years-old firmware. Newer updates might be more stable.)

A tribute to Magic

Magic the cat

Magic, our tortoiseshell cat.

There’s a little pixel art sprite at the bottom, in case you didn’t notice: a cat between two hearts.

That’s Magic.

I built the original version of this in July 2023, right after she passed. She was my partner’s cat, and didn’t like me very much, but I certainly wanted her to love me! She was a beautiful little turtle shell dumpster kitty, and I wanted to keep her memory alive. Adding her sprite to my frame was my way of keeping her around.

The showMagic parameter defaults to true. You can turn it off with ?showMagic=0.

I plan to add a showAllie parameter to show a sprite of my cat, when she passes. (She was born in 2009 and is still kicking!)

Customization

Everything’s configurable via URL parameters:

Parameter Default Description
latLong 42.01,-87.67 Latitude,longitude for weather
toTz America/Chicago Timezone for date/time display
theme default Color theme: default, orange, purple
tempUnit F Temperature unit: F, C, or K
tempLowerBound 60 Below this → cold (blue). In your chosen unit
tempUpperBound 79 At or above → hot (pink). In your chosen unit
showMagic 1 Show the cat sprite (0 to hide)
output (image) Set to divoom for base64 RGB output
customTheme — JSON object with custom colors

Some examples:

ryanmpoe.com/weather
ryanmpoe.com/weather?latLong=40.71,-74.00
ryanmpoe.com/weather?theme=orange
ryanmpoe.com/weather?theme=purple
ryanmpoe.com/weather?tempUnit=C

Weather display examples showing default, NYC, orange, purple, and Celsius themes

Theme colors

Each theme defines these colors (as hex integers like 0xff6600):

Property Purpose
background Canvas fill color
time Time text color
date Date text color
forecast Forecast text color
tempGood / tempGoodFg Comfortable temp box/text
tempHot / tempHotFg Hot temp box/text
tempCold / tempColdFg Cold temp box/text

Built-in themes are default, orange, and purple. Use ?customTheme={...} to override any colors without touching the code.

Hardcoded settings

Some deeper settings require editing the source code in weather/src/index.ts:

What Where Default
Cache duration getWeatherData() 15 minutes
High/low window getWeatherDotGov() loop 12 hours
Forecast shortening trimForecast() Replaces verbose phrases
Pixel font characters object A-Z, 0-9, and a few symbols
Sprites weatherFrame, heartShape, magicSprite ASCII art → pixels

High/low window: The high and low temps shown are for the next 12 hours, not the calendar day. I find this more useful—if it’s 8pm, I care about tonight and tomorrow morning, not today’s already-passed high. Change the 12 in the getWeatherDotGov() loop if you prefer a different window.

Forecast shortening: The weather.gov API sometimes returns lengthy forecasts like “Slight Chance Showers And Thunderstorms” that don’t fit on a 64-pixel-wide display. The trimForecast() function shortens these—“Slight Chance” → “Maybe”, “Isolated” → “Some”, etc.

Sprites: These use single characters as color keys. For example in magicSprite: o = outline (black), p = pink, b = brown, d = dark brown, . = white (eyes), e = green (eyes).


Sometimes the best side projects are the ones that don’t need to exist. This could have been a static image, or I could have used one of the built-in Divoom apps. But writing it myself meant I got to add a tiny memorial that shows up every time I glance at my wall to check the weather.

ryanmpoe.com/weather
ryanmpoe.com/weather?theme=orange
ryanmpoe.com/weather?output=divoom

Full source

Here’s the complete TypeScript source—about 440 lines, zero dependencies:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
interface Env {
  VISUAL_CROSSING_API_KEY?: string;
}

// Simple in-memory cache
const cachedWeatherData = new Map<string, { data: WeatherData; expires: number }>();
const coldCachedWeatherData = new Map<string, WeatherData>();

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const params = url.searchParams;

    const tempUnitParam = (params.get("tempUnit") || "F").toUpperCase();
    const tempUnit = (tempUnitParam === "C" || tempUnitParam === "K") ? tempUnitParam : "F";

    const options = {
      toTz: params.get("toTz") || "America/Chicago",
      latLong: params.get("latLong") || "42.01,-87.67",
      tempLowerBound: parseInt(params.get("tempLowerBound") || "60", 10),
      tempUpperBound: parseInt(params.get("tempUpperBound") || "79", 10),
      tempUnit,
      showMagic: params.get("showMagic") !== "0",
    };

    const preCalculatedWeatherData = params.get("weatherData");
    let weatherData: WeatherData = {
      humidity: 0, temp: 0, minTemp: 0, maxTemp: 0,
      shortForecast: "", maxHumidity: 0, minHumidity: 0, tempUnit: "F",
    };

    try {
      weatherData = preCalculatedWeatherData
        ? JSON.parse(preCalculatedWeatherData)
        : await getWeatherData(options.latLong, env);
    } catch (e) {
      console.error("Weather fetch failed:", e);
    }

    const theme = getTheme(params);
    const image = new PixelBuffer(64, 64, theme.background);

    // Convert temp to requested unit for color comparison (bounds are in user's unit)
    const tempForColor = convertTemp(weatherData.temp, options.tempUnit);
    const tempColor = tempForColor >= options.tempUpperBound ? theme.tempHot
      : tempForColor < options.tempLowerBound ? theme.tempCold : theme.tempGood;
    const tempColorFg = tempForColor >= options.tempUpperBound ? theme.tempHotFg
      : tempForColor < options.tempLowerBound ? theme.tempColdFg : theme.tempGoodFg;

    const now = convertTZ(new Date(), options.toTz);

    // Date
    const date = `${dayOfTheWeekStrings[now.getDay()]} ${monthStrings[now.getMonth()]} ${now.getDate()} ${now.getFullYear()}`;
    paintCharacters(image, date, 2, 2, theme.date);

    // Time
    const hours = now.getHours() % 12 || 12;
    const time = `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} ${now.getHours() < 12 ? "am" : "pm"}`;
    paintCharacters(image, time, 65, 10, theme.time, "right", 2);

    // Weather frame
    paintShape(image, weatherFrame, 2, 12, { o: tempColor, c: theme.tempCold, h: theme.tempHot, e: tempColor, a: tempColorFg });

    // Forecast
    if (weatherData.shortForecast) {
      paintCharacters(image, weatherData.shortForecast, 32, 42, theme.forecast, "center");
    }

    // Temperatures (convert from F to requested unit)
    const displayMin = weatherData.minTemp !== undefined ? convertTemp(weatherData.minTemp, options.tempUnit) : undefined;
    const displayMax = weatherData.maxTemp !== undefined ? convertTemp(weatherData.maxTemp, options.tempUnit) : undefined;

    if (tempForColor !== undefined) {
      paintCharacters(image, `${tempForColor}°`, 14, 21, tempColorFg, "center");
    }
    if (displayMin !== undefined) {
      paintCharacters(image, `${displayMin}°`, displayMin >= 0 ? 30 : 27, 25, theme.tempCold);
    }
    if (displayMax !== undefined) {
      paintCharacters(image, `${displayMax}°`, displayMax >= 0 ? 59 : 60, 25, theme.tempHot, "right");
    }

    // Magic decorations
    if (options.showMagic) {
      const heartColors = { o: 0x000000, w: 0xffffff, "0": 0xe096a5, "1": 0xeb8fa1, "2": 0xdd3f5c, "3": 0xab354c, "4": 0x782635 };
      paintShape(image, heartShape, 10, 53, heartColors);
      paintShape(image, heartShape, 43, 53, heartColors);
      paintShape(image, magicSprite, 23, 49, { o: 0x000000, p: 0xf0ddd3, ".": 0xffffff, e: 0xa6e897, b: 0x9e5833, d: 0x210e03 });
    }

    const output = params.get("output");

    if (output === "divoom") {
      return new Response(image.toBase64RGB(), {
        headers: { "Content-Type": "text/plain", "Cache-Control": "no-cache" },
      });
    }

    // Default: return BMP image (simple, no compression needed)
    return new Response(image.toBMP(), {
      headers: { "Content-Type": "image/bmp", "Cache-Control": "no-cache" },
    });
  },
};

// --- Simple pixel buffer (no dependencies) ---

class PixelBuffer {
  width: number;
  height: number;
  data: Uint8Array; // RGBA

  constructor(width: number, height: number, fillColor: number = 0x000000) {
    this.width = width;
    this.height = height;
    this.data = new Uint8Array(width * height * 4);
    const r = (fillColor >> 16) & 0xff;
    const g = (fillColor >> 8) & 0xff;
    const b = fillColor & 0xff;
    for (let i = 0; i < width * height; i++) {
      this.data[i * 4] = r;
      this.data[i * 4 + 1] = g;
      this.data[i * 4 + 2] = b;
      this.data[i * 4 + 3] = 255;
    }
  }

  setPixel(x: number, y: number, color: number) {
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
    const i = (y * this.width + x) * 4;
    this.data[i] = (color >> 16) & 0xff;
    this.data[i + 1] = (color >> 8) & 0xff;
    this.data[i + 2] = color & 0xff;
    this.data[i + 3] = 255;
  }

  toBase64RGB(): string {
    const rgb = new Uint8Array(this.width * this.height * 3);
    for (let i = 0; i < this.width * this.height; i++) {
      rgb[i * 3] = this.data[i * 4];
      rgb[i * 3 + 1] = this.data[i * 4 + 1];
      rgb[i * 3 + 2] = this.data[i * 4 + 2];
    }
    let binary = "";
    for (let i = 0; i < rgb.length; i++) binary += String.fromCharCode(rgb[i]);
    return btoa(binary);
  }

  toBMP(): Uint8Array {
    const w = this.width, h = this.height;
    const rowSize = Math.ceil((w * 3) / 4) * 4;
    const pixelDataSize = rowSize * h;
    const fileSize = 54 + pixelDataSize;
    const bmp = new Uint8Array(fileSize);

    // BMP Header
    bmp[0] = 0x42; bmp[1] = 0x4d; // "BM"
    bmp[2] = fileSize & 0xff; bmp[3] = (fileSize >> 8) & 0xff;
    bmp[4] = (fileSize >> 16) & 0xff; bmp[5] = (fileSize >> 24) & 0xff;
    bmp[10] = 54; // Pixel data offset

    // DIB Header
    bmp[14] = 40; // Header size
    bmp[18] = w & 0xff; bmp[19] = (w >> 8) & 0xff;
    bmp[22] = h & 0xff; bmp[23] = (h >> 8) & 0xff;
    bmp[26] = 1; // Color planes
    bmp[28] = 24; // Bits per pixel

    // Pixel data (BMP is bottom-up, BGR)
    for (let y = 0; y < h; y++) {
      for (let x = 0; x < w; x++) {
        const srcIdx = ((h - 1 - y) * w + x) * 4;
        const dstIdx = 54 + y * rowSize + x * 3;
        bmp[dstIdx] = this.data[srcIdx + 2];     // B
        bmp[dstIdx + 1] = this.data[srcIdx + 1]; // G
        bmp[dstIdx + 2] = this.data[srcIdx];     // R
      }
    }
    return bmp;
  }
}

// --- Constants ---

const monthStrings = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec"];
const dayOfTheWeekStrings = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];

const weatherFrame = `
 eeeeeeeeeeeeeeeeeeeeee
eaaaaaaaaaaaaaaaaaaaaaae
eaeeeeeeeeeeeeeeeeeeeeae
eaeeeeeeeeeeeeeeeeeeeeae
eaeeeeeeeeeeeeeeeeeeeeae
eaeeeeeeeeeeeeeeeeeeeeae
eaeeeeeeeeeeeeeeeeeeeeae
eaeeeeeeeeeeeeeeeeeeeeae oooooooooooooooooooooooooooooooooo
eaeeeeeeeeeeeeeeeeeeeeae                                   o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae                                    o
eaeeeeeeeeeeeeeeeeeeeeae     c   ccc          h h h         o
eaaaaaaaaaaaaaaaaaaaaaae     c   c c          hhh h         o
 eeeeeeeeeeeeeeeeeeeeee      ccc ccc          h h h         o
                                                            o
               o                                           o
                ooooooooooooooooooooooooooooooooooooooooooo`;

const heartShape = `
 000 012
00w001234
0w0001234o
000001234o
 0011234o
  11234o
   234o
    4o
    o`;

const magicSprite = `
 oo       oo
opdo   obpo
opddoodbppo
oddddddbbbo
oddddbbddbd    oo
 odedbebdbbdo  obbo
obbdbddb.ddoo oboo
obbpdbbd.bobbobdo
 obbdd..bbbbbdoo
  o....bbdbbdbo
  ob.ddddb..bbo
  obbo.bb   bbo
  o..o..o   o.o
  ooooooo   ooo`;

const characters: Record<string, string[]> = {
  "-": ["   ", "   ", "000", "   ", "   "],
  "°": [" 0 ", "0 0", " 0 ", "  ", "  "],
  " ": [" ", " ", " ", " ", " "],
  ":": [" ", "0", " ", "0", " "],
  "0": [" 00 ", "0  0", "0  0", "0  0", " 00 "],
  "1": [" 0", "00", " 0", " 0", " 0"],
  "2": ["000", "  0", "  0", " 0 ", "000"],
  "3": ["000", "  0", " 00", "  0", "000"],
  "4": ["0 0", "0 0", "000", "  0", "  0"],
  "5": ["000", "0  ", " 00", "  0", "000"],
  "6": [" 00 ", "0   ", "000 ", "0  0", " 00 "],
  "7": ["0000", "   0", "  0 ", " 0  ", " 0  "],
  "8": [" 00 ", "0  0", " 00 ", "0  0", " 00"],
  "9": ["000 ", "0  0", "0000", "   0", " 00"],
  "a": ["  0 ", " 00 ", " 0 0", "0000", "0  0"],
  "b": ["00 ", "0 0", "000", "0 0", "00 "],
  "c": [" 00", "0  ", "0  ", "0  ", " 00"],
  "d": ["00 ", "0 0", "0 0", "0 0", "00 "],
  "e": ["000", "0  ", "000", "0  ", "000"],
  "f": ["000", "0  ", "000", "0  ", "0  "],
  "g": [" 000", "0  ", "0 00", "0  0", " 000"],
  "h": ["0 0", "0 0", "000", "0 0", "0 0"],
  "i": ["0", "0", "0", "0", "0"],
  "j": ["  0", "  0", "  0", "  0", " 0 "],
  "k": ["0  0", "0 0 ", "00  ", "0 0 ", "0 00"],
  "l": ["0  ", "0  ", "0  ", "0  ", "000"],
  "m": [" 0 0 ", "00000", "0 0 0", "0 0 0", "0   0"],
  "n": ["000 ", "0  0", "0  0", "0  0", "0  0"],
  "o": [" 00 ", "0  0", "0  0", "0  0", " 00"],
  "p": ["000 ", "0  0", "000 ", "0   ", "0   "],
  "q": [" 00 ", "0  0", "0  0", " 000", "   0"],
  "r": ["000 ", "0  0", "000 ", "0  0", "0  0"],
  "s": [" 000", "0   ", " 00 ", "   0", "000 "],
  "t": ["000", " 0 ", " 0 ", " 0 ", " 0 "],
  "u": ["0  0", "0  0", "0  0", "0  0", " 000"],
  "v": ["0 0", "0 0", "0 0", "0 0", " 0 "],
  "w": ["0   0", "0 0 0", "0 0 0", "00000", " 0 0 "],
  "x": [" 0 0 ", "  0  ", "  0  ", "  0  ", " 0 0 "],
  "y": ["0 0", "0 0", "000", " 0 ", " 0 "],
  "z": ["0000", "   0", "  0 ", " 0  ", "0000"],
};

// --- Paint functions ---

function paintCharacters(image: PixelBuffer, text: string, x: number, y: number, color: number, align: "left" | "right" | "center" = "left", margin = 0) {
  const buffer: [number, number][] = [];
  let offsetX = 0;

  for (const char of text.toLowerCase()) {
    const glyph = characters[char];
    if (!glyph) continue;
    for (let row = 0; row < glyph.length; row++) {
      for (let col = 0; col < glyph[row].length; col++) {
        if (glyph[row][col] === "0") buffer.push([offsetX + col, row]);
      }
    }
    offsetX += glyph[0].length + 1;
  }

  const width = offsetX - 1;
  const offsetAdjust = align === "right" ? -width - margin : align === "center" ? -Math.floor(width / 2) : margin;

  for (const [px, py] of buffer) {
    image.setPixel(x + px + offsetAdjust, y + py, color);
  }
}

function paintShape(image: PixelBuffer, shape: string, x: number, y: number, colors: Record<string, number>) {
  let ox = 0, oy = 0;
  for (const char of shape) {
    if (char === "\n") { ox = 0; oy++; continue; }
    if (char === " ") { ox++; continue; }
    if (colors[char] !== undefined) image.setPixel(x + ox, y + oy, colors[char]);
    ox++;
  }
}

function convertTZ(date: Date, tz: string): Date {
  return new Date(date.toLocaleString("en-US", { timeZone: tz }));
}

function convertTemp(f: number, unit: string): number {
  if (unit === "C") return Math.round((f - 32) * 5 / 9);
  if (unit === "K") return Math.round((f - 32) * 5 / 9 + 273);
  return f;
}

// --- Weather data ---

type WeatherData = {
  maxTemp: number | undefined;
  minTemp: number | undefined;
  maxHumidity: number | undefined;
  minHumidity: number | undefined;
  temp: number;
  tempUnit: string | undefined;
  shortForecast: string | undefined;
  humidity: number | undefined;
};

async function getWeatherData(latLong: string, env: Env): Promise<WeatherData> {
  const cached = cachedWeatherData.get(latLong);
  if (cached && cached.expires > Date.now()) return cached.data;

  // Try weather.gov first
  try {
    const data = await getWeatherDotGov(latLong);
    cachedWeatherData.set(latLong, { data, expires: Date.now() + 15 * 60 * 1000 });
    coldCachedWeatherData.set(latLong, data);
    return data;
  } catch (e) {
    console.error("weather.gov failed:", e);
  }

  // Fallback to Visual Crossing
  if (env.VISUAL_CROSSING_API_KEY) {
    try {
      const data = await getVisualCrossing(latLong, env.VISUAL_CROSSING_API_KEY);
      cachedWeatherData.set(latLong, { data, expires: Date.now() + 15 * 60 * 1000 });
      coldCachedWeatherData.set(latLong, data);
      return data;
    } catch (e) {
      console.error("Visual Crossing failed:", e);
    }
  }

  // Return cold cache if available
  const cold = coldCachedWeatherData.get(latLong);
  if (cold) return cold;

  throw new Error("All weather sources failed");
}

async function getWeatherDotGov(latLong: string): Promise<WeatherData> {
  const headers = { "User-Agent": "(ryanmpoe.com, [email protected])" };
  const info = await (await fetch(`https://api.weather.gov/points/${latLong}`, { headers })).json() as any;
  const hourlyUrl = info.properties?.forecastHourly;
  if (!hourlyUrl) throw new Error("No forecast URL");

  const hourly = await (await fetch(hourlyUrl, { headers })).json() as any;
  const hours = hourly.properties?.periods || [];
  if (!hours.length) throw new Error("No hourly data");

  const current = hours[0];
  let maxTemp = -999, minTemp = 999, maxHumidity = -999, minHumidity = 999;

  for (const hour of hours) {
    const diff = Math.abs(Date.now() - new Date(hour.startTime).getTime()) / 3600000;
    if (diff > 12) continue;
    if (hour.temperature > maxTemp) maxTemp = hour.temperature;
    if (hour.temperature < minTemp) minTemp = hour.temperature;
    const h = hour.relativeHumidity?.value;
    if (h > maxHumidity) maxHumidity = h;
    if (h < minHumidity) minHumidity = h;
  }

  return {
    temp: current.temperature,
    tempUnit: current.temperatureUnit,
    shortForecast: trimForecast(current.shortForecast || ""),
    humidity: current.relativeHumidity?.value,
    maxTemp, minTemp, maxHumidity, minHumidity,
  };
}

async function getVisualCrossing(latLong: string, apiKey: string): Promise<WeatherData> {
  const url = `https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/${latLong}?key=${apiKey}`;
  const data = await (await fetch(url)).json() as any;
  const today = data.days?.[0];

  return {
    temp: Math.floor(data.currentConditions?.temp || 0),
    tempUnit: "F",
    shortForecast: today?.conditions,
    humidity: data.currentConditions?.humidity,
    maxTemp: today?.tempmax ? Math.floor(today.tempmax) : undefined,
    minTemp: today?.tempmin ? Math.floor(today.tempmin) : undefined,
    maxHumidity: today?.humiditymax,
    minHumidity: today?.humiditymin,
  };
}

function trimForecast(s: string): string {
  return s.replace("Showers", "").replace("Isolated", "Some").replace("Areas Of Drizzle", "Drizzles").replace("Slight Chance", "Maybe");
}

// --- Themes ---

type Theme = { background: number; time: number; date: number; forecast: number; tempGood: number; tempGoodFg: number; tempHot: number; tempHotFg: number; tempCold: number; tempColdFg: number };

const themes: Record<string, Theme> = {
  default: { background: 0x020e14, time: 0xe8eefc, date: 0xa4bdfc, forecast: 0xe8efff, tempGood: 0x1adb4b, tempGoodFg: 0x000000, tempHot: 0xff6395, tempHotFg: 0x000000, tempCold: 0x3d98ff, tempColdFg: 0x000000 },
  orange: { background: 0x1a0e00, time: 0xff6600, date: 0xff8533, forecast: 0xff6600, tempGood: 0x1adb4b, tempGoodFg: 0x000000, tempHot: 0xff6395, tempHotFg: 0x000000, tempCold: 0x3d98ff, tempColdFg: 0x000000 },
  purple: { background: 0x0e0e1a, time: 0x9933cc, date: 0xb366ff, forecast: 0x9933cc, tempGood: 0x1adb4b, tempGoodFg: 0x000000, tempHot: 0xff6395, tempHotFg: 0x000000, tempCold: 0x3d98ff, tempColdFg: 0x000000 },
};

function getTheme(params: URLSearchParams): Theme {
  const custom = params.get("customTheme");
  if (custom) try { return JSON.parse(custom); } catch {}
  return themes[params.get("theme") || "default"] || themes.default;
}