board / client / scripts / make-icons.mjs
  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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// Generates PWA icons (a tiny diagram on a dark board) as PNGs with zero
// dependencies: 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 = [16, 16, 20]; // the black board
const INK = [236, 235, 240]; // connector
const BLUE = [95, 150, 232]; // rect outline
const AMBER = [240, 164, 78]; // circle

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

const segment = (px, py, ax, ay, bx, by) => {
  const ex = bx - ax, ey = by - ay;
  const wx = px - ax, wy = py - ay;
  const t = clamp((wx * ex + wy * ey) / (ex * ex + ey * ey), 0, 1);
  return Math.hypot(wx - ex * t, wy - ey * t);
};

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;
  const U = (v) => off + v * u;

  // the tiny diagram: amber circle (top right) → ink arrow → blue rect (bottom left)
  const C = [U(0.70), U(0.28)], CR = 0.115 * u;
  const R = [U(0.30), U(0.70)], RHW = 0.15 * u, RHH = 0.115 * u;
  // connector runs edge-to-edge along the C→R diagonal
  const dir = [R[0] - C[0], R[1] - C[1]];
  const dl = Math.hypot(...dir);
  const n = [dir[0] / dl, dir[1] / dl];
  const A = [C[0] + n[0] * (CR + 0.03 * u), C[1] + n[1] * (CR + 0.03 * u)];
  const T = [R[0] - n[0] * (RHH + 0.14 * u), R[1] - n[1] * (RHH + 0.14 * u)]; // arrow tip
  const wing = (ang) => {
    const ca = Math.cos(ang), sa = Math.sin(ang);
    const wx = -n[0] * ca + n[1] * sa, wy = -n[1] * ca - n[0] * sa;
    return [T[0] + wx * 0.085 * u, T[1] + wy * 0.085 * u];
  };
  const W1 = wing(0.45), W2 = wing(-0.45);

  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; }
      const put = (color, cov) => {
        if (cov > 0) {
          r = r + (color[0] - r) * cov;
          g = g + (color[1] - g) * cov;
          b = b + (color[2] - b) * cov;
          a = Math.max(a, cov);
        }
      };
      // blue rect outline (ring)
      const dRect = Math.abs(roundedRect(px, py, R[0], R[1], RHW, RHH, 0.035 * u)) - 0.026 * u;
      put(BLUE, smooth(dRect));
      // amber filled circle
      put(AMBER, smooth(circle(px, py, C[0], C[1], CR)));
      // ink connector + arrowhead
      const dConn = Math.min(
        segment(px, py, A[0], A[1], T[0], T[1]),
        segment(px, py, T[0], T[1], W1[0], W1[1]),
        segment(px, py, T[0], T[1], W2[0], W2[1]),
      ) - 0.024 * u;
      put(INK, smooth(dConn));
      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);
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/board.git