mirror of
https://github.com/i12bp8/TagTinker.git
synced 2026-10-23 05:32:13 +00:00
Add optional WiFi Plugins (ESP32-S2 bridge + Cloudflare Worker)
- Flipper FAP: WiFi Setup, Plugins picker, plugin-run scene wired into Targeted Payloads -> <tag> -> WiFi Plugins - Framed UART link to the dev board with bulk-read RX path and 16 KB stream buffer to keep up with full plugin bursts - ESP firmware: 900 KB UART<->HTTPS bridge, USB-Serial-JTAG console, cert-bundle TLS, 16 KB worker stack to host mbedTLS handshake - Cloudflare Worker: Crypto, Weather, Identicon plugins out of the box; new plugins are a single TypeScript file, no Flipper reflash - Shared protocol header + 1bpp BMP writer that matches web-image-prep palette/stride conventions
This commit is contained in:
1 parent
8fab06a807
commit
2739069f22
40 files changed
+5310
-1
No files matched your search
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Server-side 1bpp / 2bpp canvas. Each plane is a packed bitmap with the
|
||||
* exact byte layout the ESP forwards to the Flipper (and the Flipper
|
||||
* TXes verbatim to the tag): rows top-down, bytes MSB-first within a
|
||||
* row, row_stride padded to 4 bytes (BMP convention).
|
||||
*
|
||||
* bit == 0 -> ink off (white)
|
||||
* bit == 1 -> ink on (black on plane 0, accent on plane 1)
|
||||
*
|
||||
* Plane 1 only allocated when accent is requested AND supported.
|
||||
*/
|
||||
|
||||
import { FONT_5x7, FONT_5x7_W, FONT_5x7_H } from "./font";
|
||||
|
||||
export type Ink = 0 | 1; // 0 = primary (black), 1 = accent (red/yellow)
|
||||
|
||||
export class Canvas {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly planes: number;
|
||||
readonly rowStride: number;
|
||||
readonly plane0: Uint8Array;
|
||||
readonly plane1: Uint8Array | null;
|
||||
|
||||
constructor(width: number, height: number, planes: 1 | 2) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.planes = planes;
|
||||
this.rowStride = ((width + 31) >> 5) << 2; // round up to 4 bytes
|
||||
this.plane0 = new Uint8Array(this.rowStride * height);
|
||||
this.plane1 = planes === 2 ? new Uint8Array(this.rowStride * height) : null;
|
||||
}
|
||||
|
||||
private plane(ink: Ink): Uint8Array {
|
||||
if (ink === 1 && this.plane1) return this.plane1;
|
||||
return this.plane0;
|
||||
}
|
||||
|
||||
setPixel(x: number, y: number, ink: Ink = 0): void {
|
||||
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
||||
const p = this.plane(ink);
|
||||
const i = y * this.rowStride + (x >> 3);
|
||||
p[i] |= 0x80 >> (x & 7);
|
||||
}
|
||||
|
||||
clearPixel(x: number, y: number, ink: Ink = 0): void {
|
||||
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
||||
const p = this.plane(ink);
|
||||
const i = y * this.rowStride + (x >> 3);
|
||||
p[i] &= ~(0x80 >> (x & 7));
|
||||
}
|
||||
|
||||
hline(x: number, y: number, w: number, ink: Ink = 0): void {
|
||||
for (let i = 0; i < w; i++) this.setPixel(x + i, y, ink);
|
||||
}
|
||||
|
||||
vline(x: number, y: number, h: number, ink: Ink = 0): void {
|
||||
for (let i = 0; i < h; i++) this.setPixel(x, y + i, ink);
|
||||
}
|
||||
|
||||
line(x0: number, y0: number, x1: number, y1: number, ink: Ink = 0): void {
|
||||
// Bresenham
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const dy = -Math.abs(y1 - y0);
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx + dy;
|
||||
while (true) {
|
||||
this.setPixel(x0, y0, ink);
|
||||
if (x0 === x1 && y0 === y1) break;
|
||||
const e2 = 2 * err;
|
||||
if (e2 >= dy) { err += dy; x0 += sx; }
|
||||
if (e2 <= dx) { err += dx; y0 += sy; }
|
||||
}
|
||||
}
|
||||
|
||||
rect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
|
||||
this.hline(x, y, w, ink);
|
||||
this.hline(x, y + h - 1, w, ink);
|
||||
this.vline(x, y, h, ink);
|
||||
this.vline(x + w - 1, y, h, ink);
|
||||
}
|
||||
|
||||
fillRect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
|
||||
for (let yy = 0; yy < h; yy++) this.hline(x, y + yy, w, ink);
|
||||
}
|
||||
|
||||
/* ---- Text -------------------------------------------------------- */
|
||||
|
||||
textSize(s: string, scale = 1): { w: number; h: number } {
|
||||
return { w: s.length * (FONT_5x7_W + 1) * scale, h: FONT_5x7_H * scale };
|
||||
}
|
||||
|
||||
drawText(x: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
|
||||
for (let ci = 0; ci < s.length; ci++) {
|
||||
const ch = s.charCodeAt(ci);
|
||||
const idx = ch >= 32 && ch <= 127 ? ch - 32 : 0;
|
||||
const glyph = FONT_5x7[idx] ?? FONT_5x7[0];
|
||||
for (let row = 0; row < FONT_5x7_H; row++) {
|
||||
const bits = glyph[row];
|
||||
for (let col = 0; col < FONT_5x7_W; col++) {
|
||||
if (bits & (1 << (FONT_5x7_W - 1 - col))) {
|
||||
for (let dy = 0; dy < scale; dy++) {
|
||||
for (let dx = 0; dx < scale; dx++) {
|
||||
this.setPixel(
|
||||
x + (ci * (FONT_5x7_W + 1) + col) * scale + dx,
|
||||
y + row * scale + dy,
|
||||
ink,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawTextCentered(cx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
|
||||
const { w } = this.textSize(s, scale);
|
||||
this.drawText(cx - (w >> 1), y, s, ink, scale);
|
||||
}
|
||||
|
||||
drawTextRight(rx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
|
||||
const { w } = this.textSize(s, scale);
|
||||
this.drawText(rx - w, y, s, ink, scale);
|
||||
}
|
||||
|
||||
/* ---- Sparkline --------------------------------------------------- */
|
||||
|
||||
sparkline(
|
||||
x: number, y: number, w: number, h: number,
|
||||
samples: number[], ink: Ink = 0, dot = true,
|
||||
): void {
|
||||
if (samples.length < 2) return;
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const v of samples) {
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
}
|
||||
if (hi === lo) hi = lo + 1;
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const px = x + Math.round((i * (w - 1)) / (samples.length - 1));
|
||||
const py = y + h - 1 - Math.round(((samples[i] - lo) * (h - 1)) / (hi - lo));
|
||||
xs.push(px); ys.push(py);
|
||||
}
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
this.line(xs[i - 1], ys[i - 1], xs[i], ys[i], ink);
|
||||
}
|
||||
if (dot) {
|
||||
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
|
||||
this.fillRect(lx - 1, ly - 1, 3, 3, ink);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Floyd-Steinberg dither (image to 1bpp/2bpp) ----------------- */
|
||||
|
||||
/**
|
||||
* Blit a pre-scaled grayscale (or RGB) image into the canvas with
|
||||
* Floyd-Steinberg dithering. `gray` is row-major, 1 byte per pixel.
|
||||
* `rgb` (optional) is row-major RGB triplets; when present and an accent
|
||||
* mode is set, saturated red/yellow pixels are routed to plane 1.
|
||||
*/
|
||||
blitDithered(
|
||||
dx: number, dy: number, dw: number, dh: number,
|
||||
gray: Uint8Array, rgb: Uint8Array | null, sw: number, sh: number,
|
||||
accentMode: "none" | "red" | "yellow",
|
||||
): void {
|
||||
// Nearest-neighbour scale source -> dest, then dither in place.
|
||||
const buf = new Float32Array(dw * dh);
|
||||
const accFlag = new Uint8Array(dw * dh);
|
||||
for (let y = 0; y < dh; y++) {
|
||||
const sy = Math.min(sh - 1, Math.floor((y * sh) / dh));
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const sx = Math.min(sw - 1, Math.floor((x * sw) / dw));
|
||||
const gi = sy * sw + sx;
|
||||
buf[y * dw + x] = gray[gi];
|
||||
if (rgb && accentMode !== "none") {
|
||||
const ri = gi * 3;
|
||||
const r = rgb[ri], g = rgb[ri + 1], b = rgb[ri + 2];
|
||||
if (accentMode === "red" && r > 150 && g < 90 && b < 90) {
|
||||
accFlag[y * dw + x] = 1;
|
||||
} else if (accentMode === "yellow" && r > 180 && g > 150 && b < 100) {
|
||||
accFlag[y * dw + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < dh; y++) {
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const i = y * dw + x;
|
||||
const old = buf[i];
|
||||
const isAccent = accFlag[i] === 1;
|
||||
const newPx = old < 128 ? 0 : 255;
|
||||
if (newPx === 0) {
|
||||
this.setPixel(dx + x, dy + y, isAccent ? 1 : 0);
|
||||
}
|
||||
const err = old - newPx;
|
||||
if (x + 1 < dw) buf[i + 1] += err * 7 / 16;
|
||||
if (y + 1 < dh) {
|
||||
if (x > 0) buf[i + dw - 1] += err * 3 / 16;
|
||||
buf[i + dw] += err * 5 / 16;
|
||||
if (x + 1 < dw) buf[i + dw + 1] += err * 1 / 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Serialization ---------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Encode as the binary blob the ESP forwards to the Flipper:
|
||||
* uint16 width LE, uint16 height LE, uint8 planes, uint8 reserved,
|
||||
* uint16 row_stride LE,
|
||||
* plane0 bytes,
|
||||
* plane1 bytes (if planes == 2).
|
||||
*/
|
||||
toBytes(): Uint8Array {
|
||||
const planeBytes = this.rowStride * this.height;
|
||||
const total = 8 + planeBytes * this.planes;
|
||||
const out = new Uint8Array(total);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint16(0, this.width, true);
|
||||
view.setUint16(2, this.height, true);
|
||||
out[4] = this.planes;
|
||||
out[5] = 0;
|
||||
view.setUint16(6, this.rowStride, true);
|
||||
out.set(this.plane0, 8);
|
||||
if (this.plane1) out.set(this.plane1, 8 + planeBytes);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Compact 5x7 ASCII bitmap font (95 glyphs, 0x20..0x7E).
|
||||
*
|
||||
* Each glyph is 7 bytes. Each byte is a row, low 5 bits = pixels left to
|
||||
* right (we shift starting at bit 4 down to bit 0). Stored MSB-aligned in
|
||||
* a uint8 by left-shifting 3 bits at runtime (we just mask `(bits >> n)`
|
||||
* with FONT_5x7_W).
|
||||
*
|
||||
* Mirrors esp32-wifi-fw/main/font_5x7.c so designs look identical whether
|
||||
* a future plugin is rendered server-side here or fell back to the device.
|
||||
*/
|
||||
|
||||
export const FONT_5x7_W = 5;
|
||||
export const FONT_5x7_H = 7;
|
||||
|
||||
// Each entry: 7 row bytes, low 5 bits = pixel row left -> right.
|
||||
// Columns shifted to bits [4..0]. We test `bits & (1 << (4 - col))` in canvas.
|
||||
export const FONT_5x7: number[][] = [
|
||||
[0,0,0,0,0,0,0], // ' '
|
||||
[0x04,0x04,0x04,0x04,0x00,0x00,0x04], // '!'
|
||||
[0x0a,0x0a,0x00,0x00,0x00,0x00,0x00], // '"'
|
||||
[0x0a,0x1f,0x0a,0x1f,0x0a,0x00,0x00], // '#'
|
||||
[0x04,0x0f,0x14,0x0e,0x05,0x1e,0x04], // '$'
|
||||
[0x18,0x19,0x02,0x04,0x08,0x13,0x03], // '%'
|
||||
[0x08,0x14,0x14,0x08,0x15,0x12,0x0d], // '&'
|
||||
[0x04,0x04,0x00,0x00,0x00,0x00,0x00], // '''
|
||||
[0x02,0x04,0x08,0x08,0x08,0x04,0x02], // '('
|
||||
[0x08,0x04,0x02,0x02,0x02,0x04,0x08], // ')'
|
||||
[0x00,0x04,0x15,0x0e,0x15,0x04,0x00], // '*'
|
||||
[0x00,0x04,0x04,0x1f,0x04,0x04,0x00], // '+'
|
||||
[0x00,0x00,0x00,0x00,0x06,0x06,0x04], // ','
|
||||
[0x00,0x00,0x00,0x1f,0x00,0x00,0x00], // '-'
|
||||
[0x00,0x00,0x00,0x00,0x00,0x06,0x06], // '.'
|
||||
[0x00,0x01,0x02,0x04,0x08,0x10,0x00], // '/'
|
||||
[0x0e,0x11,0x13,0x15,0x19,0x11,0x0e], // '0'
|
||||
[0x04,0x0c,0x04,0x04,0x04,0x04,0x0e], // '1'
|
||||
[0x0e,0x11,0x01,0x02,0x04,0x08,0x1f], // '2'
|
||||
[0x1f,0x02,0x04,0x02,0x01,0x11,0x0e], // '3'
|
||||
[0x02,0x06,0x0a,0x12,0x1f,0x02,0x02], // '4'
|
||||
[0x1f,0x10,0x1e,0x01,0x01,0x11,0x0e], // '5'
|
||||
[0x06,0x08,0x10,0x1e,0x11,0x11,0x0e], // '6'
|
||||
[0x1f,0x01,0x02,0x04,0x08,0x08,0x08], // '7'
|
||||
[0x0e,0x11,0x11,0x0e,0x11,0x11,0x0e], // '8'
|
||||
[0x0e,0x11,0x11,0x0f,0x01,0x02,0x0c], // '9'
|
||||
[0x00,0x06,0x06,0x00,0x06,0x06,0x00], // ':'
|
||||
[0x00,0x06,0x06,0x00,0x06,0x06,0x04], // ';'
|
||||
[0x02,0x04,0x08,0x10,0x08,0x04,0x02], // '<'
|
||||
[0x00,0x00,0x1f,0x00,0x1f,0x00,0x00], // '='
|
||||
[0x08,0x04,0x02,0x01,0x02,0x04,0x08], // '>'
|
||||
[0x0e,0x11,0x01,0x02,0x04,0x00,0x04], // '?'
|
||||
[0x0e,0x11,0x17,0x15,0x17,0x10,0x0e], // '@'
|
||||
[0x0e,0x11,0x11,0x1f,0x11,0x11,0x11], // 'A'
|
||||
[0x1e,0x11,0x11,0x1e,0x11,0x11,0x1e], // 'B'
|
||||
[0x0e,0x11,0x10,0x10,0x10,0x11,0x0e], // 'C'
|
||||
[0x1c,0x12,0x11,0x11,0x11,0x12,0x1c], // 'D'
|
||||
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x1f], // 'E'
|
||||
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x10], // 'F'
|
||||
[0x0e,0x11,0x10,0x17,0x11,0x11,0x0e], // 'G'
|
||||
[0x11,0x11,0x11,0x1f,0x11,0x11,0x11], // 'H'
|
||||
[0x0e,0x04,0x04,0x04,0x04,0x04,0x0e], // 'I'
|
||||
[0x07,0x02,0x02,0x02,0x02,0x12,0x0c], // 'J'
|
||||
[0x11,0x12,0x14,0x18,0x14,0x12,0x11], // 'K'
|
||||
[0x10,0x10,0x10,0x10,0x10,0x10,0x1f], // 'L'
|
||||
[0x11,0x1b,0x15,0x15,0x11,0x11,0x11], // 'M'
|
||||
[0x11,0x11,0x19,0x15,0x13,0x11,0x11], // 'N'
|
||||
[0x0e,0x11,0x11,0x11,0x11,0x11,0x0e], // 'O'
|
||||
[0x1e,0x11,0x11,0x1e,0x10,0x10,0x10], // 'P'
|
||||
[0x0e,0x11,0x11,0x11,0x15,0x12,0x0d], // 'Q'
|
||||
[0x1e,0x11,0x11,0x1e,0x14,0x12,0x11], // 'R'
|
||||
[0x0e,0x11,0x10,0x0e,0x01,0x11,0x0e], // 'S'
|
||||
[0x1f,0x04,0x04,0x04,0x04,0x04,0x04], // 'T'
|
||||
[0x11,0x11,0x11,0x11,0x11,0x11,0x0e], // 'U'
|
||||
[0x11,0x11,0x11,0x11,0x11,0x0a,0x04], // 'V'
|
||||
[0x11,0x11,0x11,0x15,0x15,0x15,0x0a], // 'W'
|
||||
[0x11,0x11,0x0a,0x04,0x0a,0x11,0x11], // 'X'
|
||||
[0x11,0x11,0x11,0x0a,0x04,0x04,0x04], // 'Y'
|
||||
[0x1f,0x01,0x02,0x04,0x08,0x10,0x1f], // 'Z'
|
||||
[0x0e,0x08,0x08,0x08,0x08,0x08,0x0e], // '['
|
||||
[0x00,0x10,0x08,0x04,0x02,0x01,0x00], // '\'
|
||||
[0x0e,0x02,0x02,0x02,0x02,0x02,0x0e], // ']'
|
||||
[0x04,0x0a,0x11,0x00,0x00,0x00,0x00], // '^'
|
||||
[0x00,0x00,0x00,0x00,0x00,0x00,0x1f], // '_'
|
||||
[0x08,0x04,0x02,0x00,0x00,0x00,0x00], // '`'
|
||||
[0x00,0x00,0x0e,0x01,0x0f,0x11,0x0f], // 'a'
|
||||
[0x10,0x10,0x16,0x19,0x11,0x11,0x1e], // 'b'
|
||||
[0x00,0x00,0x0e,0x10,0x10,0x11,0x0e], // 'c'
|
||||
[0x01,0x01,0x0d,0x13,0x11,0x11,0x0f], // 'd'
|
||||
[0x00,0x00,0x0e,0x11,0x1f,0x10,0x0e], // 'e'
|
||||
[0x06,0x09,0x08,0x1c,0x08,0x08,0x08], // 'f'
|
||||
[0x00,0x0f,0x11,0x11,0x0f,0x01,0x0e], // 'g'
|
||||
[0x10,0x10,0x16,0x19,0x11,0x11,0x11], // 'h'
|
||||
[0x04,0x00,0x0c,0x04,0x04,0x04,0x0e], // 'i'
|
||||
[0x02,0x00,0x06,0x02,0x02,0x12,0x0c], // 'j'
|
||||
[0x10,0x10,0x12,0x14,0x18,0x14,0x12], // 'k'
|
||||
[0x0c,0x04,0x04,0x04,0x04,0x04,0x0e], // 'l'
|
||||
[0x00,0x00,0x1a,0x15,0x15,0x11,0x11], // 'm'
|
||||
[0x00,0x00,0x16,0x19,0x11,0x11,0x11], // 'n'
|
||||
[0x00,0x00,0x0e,0x11,0x11,0x11,0x0e], // 'o'
|
||||
[0x00,0x1e,0x11,0x11,0x1e,0x10,0x10], // 'p'
|
||||
[0x00,0x0d,0x13,0x13,0x0d,0x01,0x01], // 'q'
|
||||
[0x00,0x00,0x16,0x19,0x10,0x10,0x10], // 'r'
|
||||
[0x00,0x00,0x0f,0x10,0x0e,0x01,0x1e], // 's'
|
||||
[0x08,0x08,0x1c,0x08,0x08,0x09,0x06], // 't'
|
||||
[0x00,0x00,0x11,0x11,0x11,0x13,0x0d], // 'u'
|
||||
[0x00,0x00,0x11,0x11,0x11,0x0a,0x04], // 'v'
|
||||
[0x00,0x00,0x11,0x11,0x15,0x15,0x0a], // 'w'
|
||||
[0x00,0x00,0x11,0x0a,0x04,0x0a,0x11], // 'x'
|
||||
[0x00,0x00,0x11,0x11,0x0f,0x01,0x0e], // 'y'
|
||||
[0x00,0x00,0x1f,0x02,0x04,0x08,0x1f], // 'z'
|
||||
[0x02,0x04,0x04,0x08,0x04,0x04,0x02], // '{'
|
||||
[0x04,0x04,0x04,0x00,0x04,0x04,0x04], // '|'
|
||||
[0x08,0x04,0x04,0x02,0x04,0x04,0x08], // '}'
|
||||
[0x09,0x15,0x12,0x00,0x00,0x00,0x00], // '~'
|
||||
[0,0,0,0,0,0,0], // 0x7f (fallback)
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* TagTinker WiFi Plugins - Cloudflare Worker entry.
|
||||
*
|
||||
* Endpoints:
|
||||
*
|
||||
* GET /plugins
|
||||
* -> JSON: { plugins: [ {id,name,description,accent_modes,params}, ... ] }
|
||||
*
|
||||
* GET /render/:id?w=<int>&h=<int>&accent=<none|red|yellow>&<param>=<val>...
|
||||
* -> application/octet-stream
|
||||
* Format (little-endian):
|
||||
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
|
||||
* uint16 row_stride,
|
||||
* plane0 bytes (rowStride * height),
|
||||
* plane1 bytes (if planes == 2).
|
||||
*
|
||||
* The ESP32 simply forwards the byte stream to the Flipper as a
|
||||
* RESULT_BEGIN + N x RESULT_CHUNK + RESULT_END frame sequence.
|
||||
*/
|
||||
|
||||
import { Plugin, AccentMode } from "./plugin";
|
||||
import { cryptoPlugin } from "./plugins/crypto";
|
||||
import { weatherPlugin } from "./plugins/weather";
|
||||
import { identiconPlugin } from "./plugins/identicon";
|
||||
|
||||
const PLUGINS: Plugin[] = [cryptoPlugin, weatherPlugin, identiconPlugin];
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, status = 400): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
|
||||
function parseAccent(s: string | null): AccentMode {
|
||||
return s === "red" || s === "yellow" ? s : "none";
|
||||
}
|
||||
|
||||
async function handleRender(id: string, url: URL): Promise<Response> {
|
||||
const plugin = PLUGINS.find((p) => p.manifest.id === id);
|
||||
if (!plugin) return errorResponse(`unknown plugin '${id}'`, 404);
|
||||
|
||||
const w = parseInt(url.searchParams.get("w") ?? "296", 10);
|
||||
const h = parseInt(url.searchParams.get("h") ?? "128", 10);
|
||||
if (!(w > 0 && w <= 1024 && h > 0 && h <= 1024)) {
|
||||
return errorResponse("bad w/h", 400);
|
||||
}
|
||||
const accent = parseAccent(url.searchParams.get("accent"));
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [k, v] of url.searchParams.entries()) {
|
||||
if (k !== "w" && k !== "h" && k !== "accent") params[k] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
const c = await plugin.render(params, w, h, accent);
|
||||
const bytes = c.toBytes();
|
||||
return new Response(bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return errorResponse(`render failed: ${e?.message ?? e}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/health") {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
service: "tagtinker-cloud-plugins",
|
||||
plugins: PLUGINS.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/plugins") {
|
||||
return jsonResponse({ plugins: PLUGINS.map((p) => p.manifest) });
|
||||
}
|
||||
|
||||
const m = url.pathname.match(/^\/render\/([a-zA-Z0-9_-]+)$/);
|
||||
if (m) return handleRender(m[1], url);
|
||||
|
||||
return errorResponse("not found", 404);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Plugin shape. A plugin exports:
|
||||
* - manifest: served verbatim from /plugins
|
||||
* - render(params, w, h, accent): builds a Canvas and returns it
|
||||
*
|
||||
* Adding a new plugin = drop a new file in src/plugins/, import + push it
|
||||
* to PLUGINS in src/index.ts. Cloudflare deploys, Flipper picks it up on
|
||||
* the next "Refresh Plugins".
|
||||
*/
|
||||
|
||||
import { Canvas } from "./canvas";
|
||||
|
||||
export type ParamType = "string" | "int" | "enum" | "bool";
|
||||
|
||||
export interface ParamSpec {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ParamType;
|
||||
default: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export const ACCENT_NONE = 0;
|
||||
export const ACCENT_RED = 1;
|
||||
export const ACCENT_YELLOW = 2;
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Bitmask: 1 = mono OK, 2 = red, 4 = yellow */
|
||||
accent_modes: number;
|
||||
params: ParamSpec[];
|
||||
}
|
||||
|
||||
export type AccentMode = "none" | "red" | "yellow";
|
||||
|
||||
export interface Plugin {
|
||||
manifest: PluginManifest;
|
||||
render(params: Record<string, string>, w: number, h: number, accent: AccentMode): Promise<Canvas>;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Crypto Price plugin.
|
||||
*
|
||||
* Renders the requested coin's spot price headline, 24h delta + a
|
||||
* sparkline filling the bottom band. Accent-capable tags get the arrow
|
||||
* and sparkline drawn on the accent plane.
|
||||
*
|
||||
* ┌─────────────────────────────────────────┐
|
||||
* │ BTC / USD LIVE │
|
||||
* │ │
|
||||
* │ $ 67,415.20 │
|
||||
* │ ▲ +2.31% 24H │
|
||||
* │ ╱╲ │
|
||||
* │╱ ╲╱╲ ╱╲╱╲ │
|
||||
* │ ╲╱ ╲╱╲╱ │
|
||||
* └─────────────────────────────────────────┘
|
||||
*
|
||||
* Data source: CoinGecko free tier (no key required).
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
|
||||
const COIN_MAP: Record<string, string> = {
|
||||
BTC: "bitcoin", ETH: "ethereum", SOL: "solana", XRP: "ripple",
|
||||
DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink",
|
||||
};
|
||||
const RANGE_DAYS: Record<string, number> = { "24H": 1, "7D": 7, "30D": 30 };
|
||||
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "EUR ", GBP: "GBP " };
|
||||
|
||||
function formatPrice(v: number, prefix: string): string {
|
||||
let body: string;
|
||||
if (v >= 1000) body = v.toFixed(2);
|
||||
else if (v >= 1) body = v.toFixed(2);
|
||||
else if (v >= 0.01) body = v.toFixed(4);
|
||||
else body = v.toFixed(6);
|
||||
// Thousand separators on the integer part.
|
||||
const [intPart, frac] = body.split(".");
|
||||
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return prefix + withCommas + (frac ? "." + frac : "");
|
||||
}
|
||||
|
||||
async function fetchPrice(id: string, vs: string): Promise<{ price: number; change: number }> {
|
||||
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=${vs}&include_24hr_change=true`;
|
||||
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) throw new Error(`price ${r.status}`);
|
||||
const j: any = await r.json();
|
||||
const c = j[id];
|
||||
if (!c) throw new Error("coin not in response");
|
||||
const k = vs.toLowerCase();
|
||||
return { price: c[k] ?? 0, change: c[`${k}_24h_change`] ?? 0 };
|
||||
}
|
||||
|
||||
async function fetchHistory(id: string, vs: string, days: number, samples: number): Promise<number[]> {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=${vs.toLowerCase()}&days=${days}`;
|
||||
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) throw new Error(`history ${r.status}`);
|
||||
const j: any = await r.json();
|
||||
const arr: [number, number][] = j.prices ?? [];
|
||||
if (arr.length === 0) return [];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const idx = Math.floor((i * (arr.length - 1)) / Math.max(1, samples - 1));
|
||||
out.push(arr[idx][1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const cryptoPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "crypto",
|
||||
name: "Crypto Price",
|
||||
description: "Live coin price + sparkline",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "symbol", label: "Coin", type: "enum", default: "BTC",
|
||||
options: Object.keys(COIN_MAP) },
|
||||
{ key: "currency", label: "Currency", type: "enum", default: "USD",
|
||||
options: Object.keys(CCY_PREFIX) },
|
||||
{ key: "range", label: "Range", type: "enum", default: "24H",
|
||||
options: Object.keys(RANGE_DAYS) },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const sym = (params.symbol ?? "BTC").toUpperCase();
|
||||
const vs = (params.currency ?? "USD").toUpperCase();
|
||||
const range = (params.range ?? "24H").toUpperCase();
|
||||
const id = COIN_MAP[sym] ?? "bitcoin";
|
||||
const days = RANGE_DAYS[range] ?? 1;
|
||||
const prefix = CCY_PREFIX[vs] ?? "$";
|
||||
|
||||
const { price, change } = await fetchPrice(id, vs);
|
||||
const samples = Math.min(W, 256);
|
||||
const hist = await fetchHistory(id, vs, days, samples);
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
|
||||
const margin = W < 200 ? 4 : 8;
|
||||
|
||||
// Header
|
||||
c.drawText(margin, margin, `${sym} / ${vs}`, 0, 1);
|
||||
|
||||
// LIVE badge top-right (accent rectangle).
|
||||
{
|
||||
const badge = "LIVE";
|
||||
const { w: bw, h: bh } = c.textSize(badge, 1);
|
||||
const bx = W - margin - bw - 4;
|
||||
const by = margin - 1;
|
||||
c.rect(bx - 2, by - 1, bw + 4, bh + 2, accentInk);
|
||||
c.drawText(bx, by, badge, accentInk, 1);
|
||||
}
|
||||
|
||||
// Price headline. Pick scale so it just fits.
|
||||
const priceTxt = formatPrice(price, prefix);
|
||||
let scale = W >= 280 ? 3 : W >= 180 ? 2 : 1;
|
||||
while (scale > 1 && c.textSize(priceTxt, scale).w > W - 2 * margin) scale--;
|
||||
const priceY = margin + 12 + Math.floor(H / 12);
|
||||
c.drawTextCentered(W >> 1, priceY, priceTxt, 0, scale);
|
||||
const priceH = c.textSize(priceTxt, scale).h;
|
||||
|
||||
// Delta line: "▲ +X.XX% 24H"
|
||||
const delta = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}% ${range}`;
|
||||
const dy = priceY + priceH + Math.floor(H / 16);
|
||||
c.drawTextCentered(W >> 1, dy, delta, 0, 1);
|
||||
{
|
||||
const triW = 5;
|
||||
const dwt = c.textSize(delta, 1).w;
|
||||
const dx = (W >> 1) - (dwt >> 1) - triW - 2;
|
||||
const dyy = dy + 1;
|
||||
if (change >= 0) {
|
||||
c.line(dx, dyy + 5, dx + triW, dyy + 5, accentInk);
|
||||
c.line(dx, dyy + 5, dx + (triW >> 1), dyy, accentInk);
|
||||
c.line(dx + triW, dyy + 5, dx + (triW >> 1), dyy, accentInk);
|
||||
} else {
|
||||
c.line(dx, dyy, dx + triW, dyy, accentInk);
|
||||
c.line(dx, dyy, dx + (triW >> 1), dyy + 5, accentInk);
|
||||
c.line(dx + triW, dyy, dx + (triW >> 1), dyy + 5, accentInk);
|
||||
}
|
||||
}
|
||||
|
||||
// Sparkline filling the bottom third.
|
||||
if (hist.length >= 2) {
|
||||
const sh = Math.floor(H / 3);
|
||||
const sy = H - margin - sh;
|
||||
const sx = margin;
|
||||
const sw = W - 2 * margin;
|
||||
c.hline(sx, sy + sh, sw, 0); // baseline tick on primary plane
|
||||
c.sparkline(sx, sy, sw, sh, hist, accentInk, true);
|
||||
}
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Identicon plugin.
|
||||
*
|
||||
* Procedural avatar from a name string. Hash -> symmetric pixel art with
|
||||
* an accent stripe so it actually looks like a *card*, not just a square.
|
||||
*
|
||||
* ┌──────────────────────┐
|
||||
* │ PIETER │
|
||||
* │ ████ ██ ████ │
|
||||
* │ ██ ██████ ██ │
|
||||
* │ ██████ ██████ │
|
||||
* │ ██ ██████ ██ │
|
||||
* │ ████ ██ ████ │
|
||||
* │ member since │
|
||||
* │ 2026 · #A4F1 │
|
||||
* └──────────────────────────────────────┘
|
||||
*
|
||||
* No external API calls - pure compute, instant render.
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
|
||||
function hash32(s: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export const identiconPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "identicon",
|
||||
name: "Identicon",
|
||||
description: "Symmetric pixel-art avatar from a name",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "name", label: "Name", type: "string", default: "Dolphin" },
|
||||
{ key: "grid", label: "Grid", type: "enum", default: "5", options: ["5", "7", "9"] },
|
||||
{ key: "style", label: "Style", type: "enum", default: "blocky",
|
||||
options: ["blocky", "rings", "diagonal"] },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const name = (params.name ?? "Dolphin").trim() || "Dolphin";
|
||||
const grid = parseInt(params.grid ?? "5", 10);
|
||||
const style = params.style ?? "blocky";
|
||||
const seed = hash32(name);
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
|
||||
const margin = W < 200 ? 6 : 10;
|
||||
|
||||
// Header
|
||||
c.drawText(margin, margin, name.toUpperCase().slice(0, 24), 0, 1);
|
||||
|
||||
// Avatar block: square sized to fit on the left half.
|
||||
const blockSize = Math.min(H - margin * 2 - 24, Math.floor(W * 0.55));
|
||||
const cell = Math.floor(blockSize / grid);
|
||||
const used = cell * grid;
|
||||
const ax = margin;
|
||||
const ay = margin + 16;
|
||||
|
||||
const half = Math.ceil(grid / 2);
|
||||
let r = seed;
|
||||
const next = () => {
|
||||
// xorshift32
|
||||
r ^= r << 13; r >>>= 0;
|
||||
r ^= r >>> 17;
|
||||
r ^= r << 5; r >>>= 0;
|
||||
return r;
|
||||
};
|
||||
|
||||
const cells: boolean[][] = [];
|
||||
for (let y = 0; y < grid; y++) {
|
||||
cells.push([]);
|
||||
for (let x = 0; x < half; x++) {
|
||||
let on = (next() & 0xff) > 110;
|
||||
if (style === "rings") {
|
||||
const dx = x - (half - 1) / 2;
|
||||
const dy = y - (grid - 1) / 2;
|
||||
on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0);
|
||||
} else if (style === "diagonal") {
|
||||
on = on !== (((x + y) & 1) === 0);
|
||||
}
|
||||
cells[y].push(on);
|
||||
}
|
||||
}
|
||||
// Mirror to right half.
|
||||
for (let y = 0; y < grid; y++) {
|
||||
for (let x = 0; x < grid; x++) {
|
||||
const sx = x < half ? x : grid - 1 - x;
|
||||
if (cells[y][sx]) {
|
||||
c.fillRect(ax + x * cell, ay + y * cell, cell, cell, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accent stripe down the side of the block (signature flair).
|
||||
c.fillRect(ax + used + 4, ay, 3, used, accentInk);
|
||||
|
||||
// Right-hand info column.
|
||||
const infoX = ax + used + 14;
|
||||
let iy = ay;
|
||||
c.drawText(infoX, iy, "MEMBER SINCE", 0, 1); iy += 10;
|
||||
c.drawText(infoX, iy, "2026", accentInk, 2); iy += 18;
|
||||
const codeStr = "#" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0").slice(0, 4);
|
||||
c.drawText(infoX, iy, codeStr, 0, 1);
|
||||
|
||||
// Border + inner bevel for finish.
|
||||
c.rect(0, 0, W, H, 0);
|
||||
c.rect(2, 2, W - 4, H - 4, 0);
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Weather plugin.
|
||||
*
|
||||
* Renders a clean, minimalist weather card:
|
||||
*
|
||||
* ┌────────────────────────────────────────┐
|
||||
* │ AMSTERDAM NL │
|
||||
* │ │
|
||||
* │ ☀ 12°C │
|
||||
* │ partly cloudy │
|
||||
* │ │
|
||||
* │ Mon 14° / 8° Tue 11° / 6° │
|
||||
* └────────────────────────────────────────┘
|
||||
*
|
||||
* Procedural icons (sun, cloud, rain, snow, storm) drawn with the canvas
|
||||
* primitives - they scale cleanly to any tag size and look distinctive
|
||||
* even at the smallest 152x152 panels.
|
||||
*
|
||||
* Data source: wttr.in (free, no key, returns JSON when ?format=j1).
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
|
||||
type Sky = "sun" | "cloud" | "rain" | "snow" | "storm" | "fog";
|
||||
|
||||
function classify(code: number): Sky {
|
||||
// wttr WWO weatherCode mapping to a small icon vocabulary.
|
||||
if ([113].includes(code)) return "sun";
|
||||
if ([116, 119, 122].includes(code)) return "cloud";
|
||||
if ([143, 248, 260].includes(code)) return "fog";
|
||||
if ([200, 386, 389, 392, 395].includes(code)) return "storm";
|
||||
if ([179, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377].includes(code))
|
||||
return "snow";
|
||||
return "rain";
|
||||
}
|
||||
|
||||
function drawIcon(c: Canvas, sky: Sky, cx: number, cy: number, r: number, accent: Ink): void {
|
||||
switch (sky) {
|
||||
case "sun": {
|
||||
// Filled disc + 8 rays in accent ink.
|
||||
for (let y = -r; y <= r; y++)
|
||||
for (let x = -r; x <= r; x++)
|
||||
if (x * x + y * y <= (r - 1) * (r - 1)) c.setPixel(cx + x, cy + y, accent);
|
||||
const rr = r + 2, R = r + r;
|
||||
c.line(cx - R, cy, cx - rr, cy, accent);
|
||||
c.line(cx + rr, cy, cx + R, cy, accent);
|
||||
c.line(cx, cy - R, cx, cy - rr, accent);
|
||||
c.line(cx, cy + rr, cx, cy + R, accent);
|
||||
c.line(cx - R, cy - R, cx - rr, cy - rr, accent);
|
||||
c.line(cx + rr, cy - rr, cx + R, cy - R, accent);
|
||||
c.line(cx - R, cy + R, cx - rr, cy + rr, accent);
|
||||
c.line(cx + rr, cy + rr, cx + R, cy + R, accent);
|
||||
break;
|
||||
}
|
||||
case "cloud": {
|
||||
// Three overlapping discs forming a cloud silhouette.
|
||||
const blob = (ox: number, oy: number, br: number) => {
|
||||
for (let y = -br; y <= br; y++)
|
||||
for (let x = -br; x <= br; x++)
|
||||
if (x * x + y * y <= br * br) c.setPixel(cx + ox + x, cy + oy + y, 0);
|
||||
};
|
||||
blob(-r, 2, Math.floor(r * 0.6));
|
||||
blob(0, -2, Math.floor(r * 0.7));
|
||||
blob(r - 2, 2, Math.floor(r * 0.6));
|
||||
c.hline(cx - r, cy + Math.floor(r * 0.6), 2 * r, 0);
|
||||
break;
|
||||
}
|
||||
case "rain":
|
||||
drawIcon(c, "cloud", cx, cy, r, 0);
|
||||
for (let i = -r; i <= r; i += 4) {
|
||||
c.line(cx + i, cy + r + 2, cx + i - 2, cy + r + 6, accent);
|
||||
}
|
||||
break;
|
||||
case "snow":
|
||||
drawIcon(c, "cloud", cx, cy, r, 0);
|
||||
for (let i = -r; i <= r; i += 5) {
|
||||
const yy = cy + r + 4;
|
||||
c.setPixel(cx + i, yy, accent);
|
||||
c.setPixel(cx + i - 1, yy + 1, accent);
|
||||
c.setPixel(cx + i + 1, yy + 1, accent);
|
||||
c.setPixel(cx + i, yy + 2, accent);
|
||||
}
|
||||
break;
|
||||
case "storm":
|
||||
drawIcon(c, "cloud", cx, cy, r, 0);
|
||||
// Lightning bolt
|
||||
const bx = cx, by = cy + r;
|
||||
c.line(bx, by, bx + 3, by + 4, accent);
|
||||
c.line(bx + 3, by + 4, bx - 1, by + 5, accent);
|
||||
c.line(bx - 1, by + 5, bx + 3, by + 9, accent);
|
||||
break;
|
||||
case "fog":
|
||||
for (let i = 0; i < 4; i++) {
|
||||
c.hline(cx - r + (i % 2) * 2, cy - r + i * 4, 2 * r - (i % 2) * 4, 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWeather(loc: string): Promise<any> {
|
||||
const url = `https://wttr.in/${encodeURIComponent(loc)}?format=j1`;
|
||||
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) throw new Error(`wttr ${r.status}`);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
export const weatherPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "weather",
|
||||
name: "Weather",
|
||||
description: "Live weather card with forecast",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "location", label: "Location", type: "string", default: "Paris" },
|
||||
{ key: "units", label: "Units", type: "enum", default: "C", options: ["C", "F"] },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const loc = (params.location ?? "Paris").trim() || "Paris";
|
||||
/* Defensive: only accept C or F. The Flipper used to (briefly) leak
|
||||
* stale param values across plugins, which once produced "20°USD". */
|
||||
const rawUnits = (params.units ?? "C").toUpperCase();
|
||||
const units = rawUnits === "F" ? "F" : "C";
|
||||
const data = await fetchWeather(loc);
|
||||
|
||||
const cur = data.current_condition?.[0] ?? {};
|
||||
const code = parseInt(cur.weatherCode ?? "113", 10);
|
||||
const sky = classify(code);
|
||||
const tempC = parseFloat(cur.temp_C ?? "0");
|
||||
const tempF = parseFloat(cur.temp_F ?? "32");
|
||||
const desc = (cur.weatherDesc?.[0]?.value ?? "").toLowerCase();
|
||||
const area = data.nearest_area?.[0];
|
||||
const city = (area?.areaName?.[0]?.value ?? loc).toUpperCase();
|
||||
const country = area?.country?.[0]?.value ?? "";
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
|
||||
const margin = W < 200 ? 4 : 8;
|
||||
|
||||
// Header: city + country on right.
|
||||
c.drawText(margin, margin, city, 0, 1);
|
||||
c.drawTextRight(W - margin, margin, country.toUpperCase(), 0, 1);
|
||||
c.hline(margin, margin + 9, W - 2 * margin, 0);
|
||||
|
||||
// Big icon + temperature
|
||||
const iconR = Math.min(20, Math.floor(H / 6));
|
||||
const ix = margin + iconR + 4;
|
||||
const iy = margin + 18 + iconR;
|
||||
drawIcon(c, sky, ix, iy, iconR, accentInk);
|
||||
|
||||
const t = `${units === "F" ? Math.round(tempF) : Math.round(tempC)}\xB0${units}`;
|
||||
let scale = W >= 260 ? 3 : 2;
|
||||
while (scale > 1 && c.textSize(t, scale).w > W - ix - iconR - margin * 2) scale--;
|
||||
c.drawText(ix + iconR + 8, iy - Math.floor(c.textSize(t, scale).h / 2), t, 0, scale);
|
||||
|
||||
// Description in italics-ish (just plain, but a row under).
|
||||
c.drawText(margin, iy + iconR + 6, desc, 0, 1);
|
||||
|
||||
// 2-day forecast.
|
||||
const fcs = data.weather?.slice(0, 2) ?? [];
|
||||
if (fcs.length > 0) {
|
||||
const baseY = H - margin - 9;
|
||||
const cellW = Math.floor((W - 2 * margin) / fcs.length);
|
||||
for (let i = 0; i < fcs.length; i++) {
|
||||
const f = fcs[i];
|
||||
const lo = units === "F" ? f.mintempF : f.mintempC;
|
||||
const hi = units === "F" ? f.maxtempF : f.maxtempC;
|
||||
const dn = f.date ? new Date(f.date).toLocaleDateString("en-US", { weekday: "short" }) : "";
|
||||
const txt = `${dn} ${hi}\xB0/${lo}\xB0`;
|
||||
c.drawText(margin + i * cellW, baseY, txt, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user