conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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();