dashboard/icons.mjs (5511 bytes)
1 // The icons of the dashboard for a phone: a lamp on the dark of the board. `node dashboard/icons.mjs` 2 // writes them again. They are in the repository, so the server needs no drawing program. 3 // 4 // The drawing is plain arithmetic on a pixel buffer, at four times the size, and then averaged 5 // down. That gives smooth edges without a graphics library. 6 7 import { deflateSync } from 'node:zlib'; 8 import fs from 'node:fs'; 9 import path from 'node:path'; 10 import { fileURLToPath } from 'node:url'; 11 12 const DIR = path.dirname(fileURLToPath(import.meta.url)); 13 const BG = [11, 11, 11]; // --bg of the dark theme 14 const LAMP = [57, 135, 229]; // --accent 15 const BASE = [195, 194, 183]; // --text-dim 16 const SUPER = 4; 17 18 const mix = (under, over, alpha) => under.map((c, i) => Math.round(c * (1 - alpha) + over[i] * alpha)); 19 20 /** A rounded square that holds the whole icon, or the full square for a maskable icon. */ 21 function inTile(x, y, size, radius) { 22 const near = (v, edge) => Math.min(v, edge - 1 - v); 23 const dx = near(x, size); 24 const dy = near(y, size); 25 if (dx >= radius || dy >= radius) return true; 26 return (radius - dx) ** 2 + (radius - dy) ** 2 <= radius ** 2; 27 } 28 29 const inCircle = (x, y, cx, cy, r) => (x - cx) ** 2 + (y - cy) ** 2 <= r * r; 30 31 /** Distance from the point to the line piece a→b, for the filament of the lamp. */ 32 function distToSegment(x, y, ax, ay, bx, by) { 33 const vx = bx - ax; 34 const vy = by - ay; 35 const len = vx * vx + vy * vy; 36 const t = len ? Math.max(0, Math.min(1, ((x - ax) * vx + (y - ay) * vy) / len)) : 0; 37 return Math.hypot(x - (ax + t * vx), y - (ay + t * vy)); 38 } 39 40 /** One pixel of the icon: the tile, the glow, the lamp, its filament, and the screw base. */ 41 function pixel(x, y, size, maskable) { 42 if (!inTile(x, y, size, maskable ? 0 : size * 0.1875)) return null; // outside the rounded square 43 const cx = size / 2; 44 const cy = size * 0.44; 45 const r = size * (maskable ? 0.17 : 0.21); 46 let color = BG; 47 const glow = Math.hypot(x - cx, y - cy) / (r * 2.1); 48 if (glow < 1) color = mix(color, LAMP, 0.45 * (1 - glow) ** 2); 49 const base = [ 50 [cy + r * 1.02, r * 0.24], 51 [cy + r * 1.42, r * 0.24], 52 [cy + r * 1.82, r * 0.24], 53 ]; 54 for (const [top, height] of base) { 55 if (y >= top && y <= top + height && Math.abs(x - cx) <= r * 0.45) color = BASE; 56 } 57 if (inCircle(x, y, cx, cy, r)) { 58 color = LAMP; 59 const wire = r * 0.09; 60 const points = [ 61 [cx - r * 0.42, cy + r * 0.15], 62 [cx - r * 0.14, cy - r * 0.35], 63 [cx + r * 0.14, cy + r * 0.15], 64 [cx + r * 0.42, cy - r * 0.35], 65 ]; 66 for (let i = 0; i < points.length - 1; i++) { 67 if (distToSegment(x, y, ...points[i], ...points[i + 1]) <= wire) color = BG; 68 } 69 } 70 return color; 71 } 72 73 /** The pixels of one icon, averaged down from four times the size. */ 74 function draw(size, maskable) { 75 const big = size * SUPER; 76 const out = Buffer.alloc(size * size * 4); 77 for (let y = 0; y < size; y++) { 78 for (let x = 0; x < size; x++) { 79 let r = 0; 80 let g = 0; 81 let b = 0; 82 let a = 0; 83 for (let sy = 0; sy < SUPER; sy++) { 84 for (let sx = 0; sx < SUPER; sx++) { 85 const color = pixel(x * SUPER + sx + 0.5, y * SUPER + sy + 0.5, big, maskable); 86 if (!color) continue; 87 r += color[0]; 88 g += color[1]; 89 b += color[2]; 90 a += 255; 91 } 92 } 93 const n = SUPER * SUPER; 94 const i = (y * size + x) * 4; 95 const solid = a / 255 || 1; 96 out[i] = Math.round(r / solid); 97 out[i + 1] = Math.round(g / solid); 98 out[i + 2] = Math.round(b / solid); 99 out[i + 3] = Math.round(a / n); 100 } 101 } 102 return out; 103 } 104 105 const CRC = Array.from({ length: 256 }, (_, n) => { 106 let c = n; 107 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; 108 return c >>> 0; 109 }); 110 111 function crc32(buf) { 112 let c = 0xffffffff; 113 for (const byte of buf) c = CRC[(c ^ byte) & 0xff] ^ (c >>> 8); 114 return (c ^ 0xffffffff) >>> 0; 115 } 116 117 function chunk(type, data) { 118 const head = Buffer.alloc(8); 119 head.writeUInt32BE(data.length, 0); 120 head.write(type, 4, 'ascii'); 121 const crc = Buffer.alloc(4); 122 crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); 123 return Buffer.concat([head, data, crc]); 124 } 125 126 /** An RGBA pixel buffer as a PNG file. */ 127 export function png(pixels, size) { 128 const head = Buffer.alloc(13); 129 head.writeUInt32BE(size, 0); 130 head.writeUInt32BE(size, 4); 131 head[8] = 8; // bits for each channel 132 head[9] = 6; // RGBA 133 const rows = Buffer.alloc(size * (size * 4 + 1)); 134 for (let y = 0; y < size; y++) { 135 rows[y * (size * 4 + 1)] = 0; // filter: none 136 pixels.copy(rows, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); 137 } 138 return Buffer.concat([ 139 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 140 chunk('IHDR', head), 141 chunk('IDAT', deflateSync(rows, { level: 9 })), 142 chunk('IEND', Buffer.alloc(0)), 143 ]); 144 } 145 146 export const icon = (size, { maskable = false } = {}) => png(draw(size, maskable), size); 147 148 export const ICONS = [ 149 ['icon-192.png', 192, {}], 150 ['icon-512.png', 512, {}], 151 ['icon-maskable-512.png', 512, { maskable: true }], 152 ['apple-touch-icon.png', 180, {}], 153 ]; 154 155 if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) { 156 for (const [name, size, opts] of ICONS) { 157 const file = path.join(DIR, name); 158 fs.writeFileSync(file, icon(size, opts)); 159 console.log(`${name} ${size}x${size} ${fs.statSync(file).size} bytes`); 160 } 161 }