icon.js (7409B)
1 // util/icon.js - draws the conductor mark, writes web/src/assets 2 3 import fs from 'node:fs'; 4 import path from 'node:path'; 5 import zlib from 'node:zlib'; 6 import { fileURLToPath } from 'node:url'; 7 8 export const GRID = 1024; 9 10 // Square clipped by a diamond, as in the cq mark. 11 const K = 1.45; 12 13 // Design units, measured from the centre. 14 export const DEFAULTS = { 15 outer: 360, 16 stroke: 112, 17 start: 32, 18 reach: 440, 19 stretch: 32, 20 }; 21 22 // Break cut to the inner octagon's corner, so no sliver of limb survives it. 23 function breakHeight(inner, stretch) { 24 return 2 * (inner * (K - 1) + stretch); 25 } 26 27 // Slice leaving the limbs' horizontal face (stroke-d) and vertical face 28 // (stroke*(K-1)+d) equal, both at stroke*K/2. 29 function limbTrim(stroke) { 30 return (stroke * (2 - K)) / 2; 31 } 32 33 function octagon(r) { 34 const limit = r * K; 35 return (x, y) => { 36 const ax = Math.abs(x), ay = Math.abs(y); 37 return ax <= r && ay <= r && ax + ay <= limit; 38 }; 39 } 40 41 // Clockwise from the upper right flat. Every corner sits off the axis, so 42 // the stretch displaces all eight. 43 function corners(r, stretch) { 44 const c = r * K - r; 45 const t = c + stretch, s = r + stretch; 46 return [[r, -t], [c, -s], [-c, -s], [-r, -t], [-r, t], [-c, s], [c, s], [r, t]]; 47 } 48 49 // Ring, stretch, break, slice, bar. Stretching after the cuts would drag 50 // them out of true; the bar goes last so the slice does not shorten it. 51 export function mark(opts = {}) { 52 const { outer, stroke, start, reach, stretch } = { ...DEFAULTS, ...opts }; 53 const inner = outer - stroke; 54 const gap = opts.gap ?? breakHeight(inner, stretch); 55 const trim = opts.trim ?? limbTrim(stroke); 56 const ring = octagon(outer); 57 const hole = octagon(inner); 58 return (x, y) => { 59 // Pulled apart at the seam; the limbs are uniform there, so the 60 // duplicated band reads as a taller octagon rather than a tear. 61 const yy = y > 0 ? y - stretch : y + stretch; 62 let on = ring(x, yy) && !hole(x, yy); 63 if (x >= 0 && Math.abs(y) <= gap / 2) on = false; 64 if (x > outer - trim) on = false; 65 if (x >= start && x <= reach && Math.abs(y) <= stroke / 2) on = true; 66 return on; 67 }; 68 } 69 70 // Small icons need the antialiasing, large ones are nearly all interior. 71 function sampling(size) { 72 return Math.min(16, Math.max(2, Math.round(512 / size))); 73 } 74 75 // Coverage per pixel, 0..255. 76 export function raster(size, shape = mark(), samples = sampling(size)) { 77 const out = new Uint8Array(size * size); 78 const scale = GRID / size; 79 const step = 1 / samples; 80 const total = samples * samples; 81 for (let py = 0; py < size; py++) { 82 for (let px = 0; px < size; px++) { 83 let hit = 0; 84 for (let sy = 0; sy < samples; sy++) { 85 const y = (py + (sy + 0.5) * step) * scale - GRID / 2; 86 for (let sx = 0; sx < samples; sx++) { 87 const x = (px + (sx + 0.5) * step) * scale - GRID / 2; 88 if (shape(x, y)) hit++; 89 } 90 } 91 out[py * size + px] = Math.round((hit * 255) / total); 92 } 93 } 94 return out; 95 } 96 97 const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); 98 99 const CRC = (() => { 100 const table = new Int32Array(256); 101 for (let n = 0; n < 256; n++) { 102 let c = n; 103 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; 104 table[n] = c; 105 } 106 return table; 107 })(); 108 109 function crc32(buf) { 110 let c = -1; 111 for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8); 112 return (c ^ -1) >>> 0; 113 } 114 115 function chunk(type, data) { 116 const head = Buffer.alloc(8); 117 head.writeUInt32BE(data.length, 0); 118 head.write(type, 4, 'ascii'); 119 const crc = Buffer.alloc(4); 120 crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); 121 return Buffer.concat([head, data, crc]); 122 } 123 124 // Colour type 4, greyscale with alpha. A null bg stays transparent, a value 125 // composites against it. 126 export function png(size, coverage, ink = 0x00, bg = null) { 127 const raw = Buffer.alloc(size * (1 + size * 2)); 128 let p = 0; 129 for (let y = 0; y < size; y++) { 130 raw[p++] = 0; 131 for (let x = 0; x < size; x++) { 132 const a = coverage[y * size + x]; 133 raw[p++] = bg === null ? ink : Math.round((ink * a + bg * (255 - a)) / 255); 134 raw[p++] = bg === null ? a : 0xff; 135 } 136 } 137 const ihdr = Buffer.alloc(13); 138 ihdr.writeUInt32BE(size, 0); 139 ihdr.writeUInt32BE(size, 4); 140 ihdr[8] = 8; 141 ihdr[9] = 4; 142 return Buffer.concat([ 143 SIGNATURE, 144 chunk('IHDR', ihdr), 145 chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), 146 chunk('IEND', Buffer.alloc(0)), 147 ]); 148 } 149 150 export function svg(opts = {}) { 151 const { outer, stroke, start, reach, stretch } = { ...DEFAULTS, ...opts }; 152 const inner = outer - stroke; 153 const gap = opts.gap ?? breakHeight(inner, stretch); 154 const trim = opts.trim ?? limbTrim(stroke); 155 const c = GRID / 2; 156 const n = (v) => +v.toFixed(3); 157 const points = (r) => corners(r, stretch).map(([x, y]) => `${n(c + x)},${n(c + y)}`).join(' '); 158 // Same steps, same order as mark(). 159 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID} ${GRID}" width="${GRID}" height="${GRID}"> 160 <style>:root{color:#1c1f23}@media(prefers-color-scheme:dark){:root{color:#e7e9ea}}</style> 161 <mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="${GRID}" height="${GRID}"> 162 <polygon points="${points(outer)}" fill="#fff"/> 163 <polygon points="${points(inner)}" fill="#000"/> 164 <rect x="${c}" y="${n(c - gap / 2)}" width="${c}" height="${n(gap)}" fill="#000"/> 165 <rect x="${n(c + outer - trim)}" y="0" width="${c}" height="${GRID}" fill="#000"/> 166 <rect x="${n(c + start)}" y="${n(c - stroke / 2)}" width="${n(reach - start)}" height="${n(stroke)}" fill="#fff"/> 167 </mask> 168 <rect width="${GRID}" height="${GRID}" fill="currentColor" mask="url(#m)"/> 169 </svg> 170 `; 171 } 172 173 // The palette's fg and bg, as greyscale. 174 const INK = { dark: 0x1c, light: 0xe9 }; 175 const TILE = 0xf6; 176 177 const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/src/assets'); 178 179 const MANIFEST = { 180 name: 'conductor', 181 short_name: 'conductor', 182 // Relative to the manifest's url, so a sub-path deployment resolves to it. 183 start_url: '../', 184 scope: '../', 185 display: 'standalone', 186 background_color: '#f6f7f9', 187 theme_color: '#1c1f23', 188 icons: [ 189 { src: 'icon-192.png', sizes: '192x192', type: 'image/png' }, 190 { src: 'icon-512.png', sizes: '512x512', type: 'image/png' }, 191 ], 192 }; 193 194 function main() { 195 fs.mkdirSync(OUT, { recursive: true }); 196 const shape = mark(); 197 const written = []; 198 const put = (name, data) => { 199 fs.writeFileSync(path.join(OUT, name), data); 200 written.push([name, data.length]); 201 }; 202 203 put('icon.svg', svg()); 204 put('manifest.json', `${JSON.stringify(MANIFEST, null, 2)}\n`); 205 206 const small = raster(32, shape); 207 put('favicon-32.png', png(32, small, INK.dark)); 208 put('favicon-32-dark.png', png(32, small, INK.light)); 209 210 // Opaque: iOS composites a transparent touch icon onto black. 211 for (const [name, size] of [ 212 ['apple-touch-icon.png', 180], 213 ['icon-192.png', 192], 214 ['icon-512.png', 512], 215 ['icon-1024.png', 1024], 216 ]) put(name, png(size, raster(size, shape), INK.dark, TILE)); 217 218 const pad = Math.max(...written.map(([n]) => n.length)); 219 for (const [name, bytes] of written) { 220 process.stdout.write(`${name.padEnd(pad)} ${String(bytes).padStart(7)} bytes\n`); 221 } 222 } 223 224 if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();