// Zeichnet die App-Symbole fuer das Pult (PWA) und schreibt sie nach // apps/overlay-web/public/icons/. Ohne Grafikprogramm und ohne Abhaengigkeit: // ein paar abgerundete Rechtecke, als PNG von Hand kodiert. // // Aufruf: node scripts/make-icons.ts // // Die PNGs liegen im Repo. Neu zeichnen nur, wenn sich das Motiv aendert. import fs from 'node:fs'; import path from 'node:path'; import zlib from 'node:zlib'; import { fileURLToPath } from 'node:url'; const ZIEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'apps', 'overlay-web', 'public', 'icons'); type Farbe = [number, number, number]; const hex = (h: string): Farbe => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const TASTEN: Farbe[] = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d'].map(hex); const OBEN = hex('#221a3a'); const UNTEN = hex('#0d0b14'); /** Abstand zum Rand eines abgerundeten Rechtecks, negativ heisst innen. */ function rundRect(x: number, y: number, x0: number, y0: number, x1: number, y1: number, r: number): number { const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; const hx = (x1 - x0) / 2 - r, hy = (y1 - y0) / 2 - r; const qx = Math.abs(x - cx) - hx, qy = Math.abs(y - cy) - hy; return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r; } type Motiv = { /** Hintergrund bis zum Rand (maskable, Apple) oder selbst abgerundet. */ vollflaechig: boolean; /** Wo die vier Tasten sitzen, als Anteil der Kantenlaenge. */ von: number; bis: number; }; function zeichne(groesse: number, m: Motiv): Buffer { const px = Buffer.alloc(groesse * groesse * 4); const ss = 4; // 4x4 Unterabtastung je Pixel const spalt = (m.bis - m.von) * 0.09; const taste = (m.bis - m.von - spalt) / 2; for (let py = 0; py < groesse; py++) { for (let pxi = 0; pxi < groesse; pxi++) { let r = 0, g = 0, b = 0, a = 0; for (let sy = 0; sy < ss; sy++) { for (let sx = 0; sx < ss; sx++) { const x = (pxi + (sx + 0.5) / ss) / groesse; const y = (py + (sy + 0.5) / ss) / groesse; const imGrund = m.vollflaechig || rundRect(x, y, 0, 0, 1, 1, 0.22) <= 0; if (!imGrund) continue; let c: Farbe = [0, 1, 2].map((i) => OBEN[i]! + (UNTEN[i]! - OBEN[i]!) * y) as Farbe; for (let i = 0; i < 4; i++) { const tx = m.von + (i % 2) * (taste + spalt); const ty = m.von + Math.floor(i / 2) * (taste + spalt); if (rundRect(x, y, tx, ty, tx + taste, ty + taste, taste * 0.26) <= 0) { // Oben etwas heller: sieht nach Taste aus statt nach Flaeche. const hell = 1.12 - 0.22 * ((y - ty) / taste); c = TASTEN[i]!.map((v) => Math.min(255, v * hell)) as Farbe; } } r += c[0]; g += c[1]; b += c[2]; a += 255; } } const n = ss * ss, o = (py * groesse + pxi) * 4; // Vormultipliziert gemittelt, damit die Kanten nicht dunkel ausfransen. const deck = a / n; px[o] = deck ? Math.round(r / (a / 255)) : 0; px[o + 1] = deck ? Math.round(g / (a / 255)) : 0; px[o + 2] = deck ? Math.round(b / (a / 255)) : 0; px[o + 3] = Math.round(deck); } } return png(groesse, groesse, px); } // ---------------------------------------------------------------- PNG const CRC = new Uint32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(b: Buffer): number { let c = 0xffffffff; for (const v of b) c = CRC[(c ^ v) & 0xff]! ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function block(typ: string, daten: Buffer): Buffer { const len = Buffer.alloc(4); len.writeUInt32BE(daten.length); const td = Buffer.concat([Buffer.from(typ, 'latin1'), daten]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, crc]); } function png(w: number, h: number, rgba: Buffer): Buffer { const kopf = Buffer.alloc(13); kopf.writeUInt32BE(w, 0); kopf.writeUInt32BE(h, 4); kopf[8] = 8; kopf[9] = 6; kopf[10] = 0; kopf[11] = 0; kopf[12] = 0; // 8 bit RGBA const zeilen = Buffer.alloc((w * 4 + 1) * h); for (let y = 0; y < h; y++) rgba.copy(zeilen, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4); return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), block('IHDR', kopf), block('IDAT', zlib.deflateSync(zeilen, { level: 9 })), block('IEND', Buffer.alloc(0)), ]); } // ---------------------------------------------------------------- los const NORMAL: Motiv = { vollflaechig: false, von: 0.2, bis: 0.8 }; // Maskable: alles Wichtige im Kreis mit 40 % Radius, der Rest wird abgeschnitten. const MASKE: Motiv = { vollflaechig: true, von: 0.29, bis: 0.71 }; // Apple rundet selbst und mag keine Durchsicht. const APPLE: Motiv = { vollflaechig: true, von: 0.22, bis: 0.78 }; fs.mkdirSync(ZIEL, { recursive: true }); const dateien: Array<[string, number, Motiv]> = [ ['pult-192.png', 192, NORMAL], ['pult-512.png', 512, NORMAL], ['pult-maskable-512.png', 512, MASKE], ['apple-touch-icon.png', 180, APPLE], ]; for (const [name, groesse, motiv] of dateien) { const daten = zeichne(groesse, motiv); fs.writeFileSync(path.join(ZIEL, name), daten); console.log(`${name} ${groesse}x${groesse} ${(daten.length / 1024).toFixed(1)} KB`); }