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 | // Generates PWA icons (the exchange arrows on dark) as PNGs with zero deps:
// per-pixel SDF rendering + hand-rolled PNG encoding via node:zlib.
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "icons");
mkdirSync(outDir, { recursive: true });
const BG = [13, 17, 23];
const GREEN = [63, 182, 139]; // the leg going out
const AMBER = [217, 164, 82]; // the leg coming back
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const smooth = (d) => clamp(0.5 - d, 0, 1); // 1px antialias band around SDF zero
function roundedRect(px, py, cx, cy, hw, hh, r) {
const qx = Math.abs(px - cx) - hw + r;
const qy = Math.abs(py - cy) - hh + r;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
const circle = (px, py, cx, cy, r) => Math.hypot(px - cx, py - cy) - r;
function triangle(px, py, a, b, c) {
// signed distance to triangle (positive outside)
const seg = (p, q) => {
const ex = q[0] - p[0], ey = q[1] - p[1];
const wx = px - p[0], wy = py - p[1];
const t = clamp((wx * ex + wy * ey) / (ex * ex + ey * ey), 0, 1);
return [wx - ex * t, wy - ey * t];
};
const ds = [seg(a, b), seg(b, c), seg(c, a)].map(([x, y]) => x * x + y * y);
const s1 = (b[0] - a[0]) * (py - a[1]) - (b[1] - a[1]) * (px - a[0]);
const s2 = (c[0] - b[0]) * (py - b[1]) - (c[1] - b[1]) * (px - b[0]);
const s3 = (a[0] - c[0]) * (py - c[1]) - (a[1] - c[1]) * (px - c[0]);
const inside = (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0);
return (inside ? -1 : 1) * Math.sqrt(Math.min(...ds));
}
function draw(S, { maskable = false, opaque = false } = {}) {
const img = Buffer.alloc(S * S * 4);
const pad = maskable ? 0.1 * S : 0; // maskable safe zone
const bgRadius = maskable || opaque ? 0 : 0.22 * S;
const u = (S - 2 * pad) / 1; // content scale
const off = pad;
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const px = x + 0.5, py = y + 0.5;
let r = 0, g = 0, b = 0, a = 0;
// background card
const dBg = roundedRect(px, py, S / 2, S / 2, S / 2, S / 2, bgRadius);
const cBg = maskable || opaque ? 1 : smooth(dBg);
if (cBg > 0) { [r, g, b] = BG; a = cBg; }
// the two legs of a trade: one arrow out (green, top, pointing right),
// one back (amber, bottom, pointing left) — either both move or neither
const barH = 0.055 * u, headW = 0.11 * u, headH = 0.115 * u;
const yTop = off + 0.38 * u, yBot = off + 0.62 * u;
const x0 = off + 0.20 * u, x1 = off + 0.80 * u;
// top: bar then right-pointing head
const dBarT = roundedRect(px, py, (x0 + x1 - headW) / 2, yTop, (x1 - x0 - headW) / 2, barH, barH);
const dHeadT = triangle(px, py, [x1, yTop], [x1 - headW, yTop - headH], [x1 - headW, yTop + headH]);
const cGreen = smooth(Math.min(dBarT, dHeadT));
if (cGreen > 0) {
r = r + (GREEN[0] - r) * cGreen;
g = g + (GREEN[1] - g) * cGreen;
b = b + (GREEN[2] - b) * cGreen;
a = Math.max(a, cGreen);
}
// bottom: bar then left-pointing head
const dBarB = roundedRect(px, py, (x0 + x1 + headW) / 2, yBot, (x1 - x0 - headW) / 2, barH, barH);
const dHeadB = triangle(px, py, [x0, yBot], [x0 + headW, yBot - headH], [x0 + headW, yBot + headH]);
const cAmber = smooth(Math.min(dBarB, dHeadB));
if (cAmber > 0) {
r = r + (AMBER[0] - r) * cAmber;
g = g + (AMBER[1] - g) * cAmber;
b = b + (AMBER[2] - b) * cAmber;
a = Math.max(a, cAmber);
}
const i = (y * S + x) * 4;
img[i] = r; img[i + 1] = g; img[i + 2] = b; img[i + 3] = Math.round(a * 255);
}
}
return img;
}
function png(S, rgba) {
const crcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
crcTable[n] = c >>> 0;
}
const crc = (buf) => {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const cc = Buffer.alloc(4);
cc.writeUInt32BE(crc(td));
return Buffer.concat([len, td, cc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(S, 0);
ihdr.writeUInt32BE(S, 4);
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
const raw = Buffer.alloc(S * (S * 4 + 1));
for (let y = 0; y < S; y++) {
raw[y * (S * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
const targets = [
["icon-192.png", 192, {}],
["icon-512.png", 512, {}],
["icon-512-maskable.png", 512, { maskable: true }],
["apple-touch-icon.png", 180, { opaque: true }],
];
for (const [name, size, opts] of targets) {
writeFileSync(join(outDir, name), png(size, draw(size, opts)));
console.log("wrote", name);
}
|