mirror of
https://github.com/i12bp8/ESLPwn.git
synced 2026-10-23 13:32:57 +00:00
WiFi plugins: GitHub Profile + DiceBear identicon + tri-state canvas
Worker (cloud-plugins/): - New plugin 'GitHub Profile': avatar dithered upper-left, display name + @handle (with accent star) + word-wrapped bio in the right column, followers/repos line, and a 53x7 contribution heatmap with an accent palette (red dots for everyday activity, black for the rare hottest days). Tuned for 208x112 ESLs and adapts to 296x128 with a bigger avatar and roomier heatmap. - Identicon plugin gains DiceBear style selector (pixel-art / bottts / lorelei / adventurer / micah / fun-emoji / shapes) on top of the original symmetric grid 'local' mode. Both are rendered through the new shared image_util pipeline. - Crypto plugin redesigned: editorial layout with corner brackets, bold price headline, white-knockout delta badge with a real triangle glyph, currency symbol prefix using the canvas font's new euro / pound / yen glyphs, and a stippled accent fill under the sparkline. Canvas: - setPixel is now last-write-wins across planes, so drawing black on top of an accent-filled area produces a clean black pixel instead of the muddy plane-conflict the e-paper driver was showing previously. Every pixel is therefore exactly one of white / black / accent which is what plugin authors intuit. - New whitePixel + drawTextWhite primitives for knockout text (e.g. white labels punched through a coloured badge). - FONT_EXTRA shipped with the canvas for currency / arrow / shape / star / bracket glyphs. Plugins can now write '\u20AC1234' and have the euro symbol Just Work. Image pipeline (new image_util.ts): - One fetchImageGray() that sniffs PNG vs JPEG, decodes via upng-js / jpeg-js, composites alpha over white, and returns a tightly-packed grayscale buffer. - One blitGrayDither() that nearest-neighbour scales the gray buffer to a destination rect and serpentine Floyd-Steinberg dithers it on the way in. Used by identicon and github. FAP: - WiFi BMP writer is now plane-aware: writes the same biPlanes=1, biBitCount=2, 3-entry palette layout the rest of the IR TX pipeline already understood for web-image-prep BMPs. Tri-colour tags now actually receive accent ink from cloud-rendered plugins. - WiFi run scene un-forces accent: plugins are invoked with the tag's actual accent capability and the BMP is opened with the matching plane count. - Run scene now calls bmp_abort on exit so an aborted transfer doesn't leak the ~10 KB pixel buffer. - Plugin manifest cache capped at TT_WIFI_MAX_FAP_PLUGINS=8 (down from 16) and the UART RX stream buffer shrunk to 8 KB. Together these cut WiFi-flow heap pressure by ~23 KB and fix the OOM crashes that occasionally happened when opening WiFi Plugins. - Broadcast scene auto-transmit fix: enter_callback now filters by the >> Transmit << row index, and the cursor defaults to row 0 instead of Transmit. Previously a stale OK key-release from the prior submenu would land on the Transmit row and blast the radio without any user action.
This commit is contained in:
1 parent
99bcc162b8
commit
287481453e
18 files changed
+1298
-195
No files matched your search
@@ -1,21 +1,10 @@
|
||||
/*
|
||||
* Crypto Price plugin.
|
||||
* Crypto Price plugin - editorial / share-worthy layout.
|
||||
*
|
||||
* Renders the requested coin's spot price headline, 24h delta + a
|
||||
* sparkline filling the bottom band. Accent-capable tags get the arrow
|
||||
* and sparkline drawn on the accent plane.
|
||||
*
|
||||
* ┌─────────────────────────────────────────┐
|
||||
* │ BTC / USD LIVE │
|
||||
* │ │
|
||||
* │ $ 67,415.20 │
|
||||
* │ ▲ +2.31% 24H │
|
||||
* │ ╱╲ │
|
||||
* │╱ ╲╱╲ ╱╲╱╲ │
|
||||
* │ ╲╱ ╲╱╲╱ │
|
||||
* └─────────────────────────────────────────┘
|
||||
*
|
||||
* Data source: CoinGecko free tier (no key required).
|
||||
* Mono tags get a clean black & white card; tri-colour tags get a
|
||||
* dramatic accent treatment on the sparkline fill, the delta badge,
|
||||
* the corner brackets and a thin underline accent on the price - the
|
||||
* kind of thing that looks intentional in a photo of an e-paper tag.
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
@@ -26,15 +15,15 @@ const COIN_MAP: Record<string, string> = {
|
||||
DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink",
|
||||
};
|
||||
const RANGE_DAYS: Record<string, number> = { "24H": 1, "7D": 7, "30D": 30 };
|
||||
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "EUR ", GBP: "GBP " };
|
||||
/* The Canvas now ships €/£/¥ glyphs in FONT_EXTRA so we can render them
|
||||
* literally as the price prefix - same width budget as "$". */
|
||||
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "\u20AC", GBP: "\u00A3" };
|
||||
|
||||
function formatPrice(v: number, prefix: string): string {
|
||||
let body: string;
|
||||
if (v >= 1000) body = v.toFixed(2);
|
||||
else if (v >= 1) body = v.toFixed(2);
|
||||
if (v >= 1) body = v.toFixed(2);
|
||||
else if (v >= 0.01) body = v.toFixed(4);
|
||||
else body = v.toFixed(6);
|
||||
// Thousand separators on the integer part.
|
||||
const [intPart, frac] = body.split(".");
|
||||
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return prefix + withCommas + (frac ? "." + frac : "");
|
||||
@@ -66,11 +55,59 @@ async function fetchHistory(id: string, vs: string, days: number, samples: numbe
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ─── Drawing helpers ─────────────────────────────────────────────── */
|
||||
|
||||
const BAYER4 = [
|
||||
[ 0, 8, 2, 10],
|
||||
[12, 4, 14, 6],
|
||||
[ 3, 11, 1, 9],
|
||||
[15, 7, 13, 5],
|
||||
];
|
||||
|
||||
function fillStippled(c: Canvas, x: number, y: number, w: number, h: number,
|
||||
density: number, ink: Ink) {
|
||||
const t = Math.max(0, Math.min(15, Math.round(density * 16) - 1));
|
||||
for (let yy = 0; yy < h; yy++) {
|
||||
for (let xx = 0; xx < w; xx++) {
|
||||
if (BAYER4[yy & 3][xx & 3] <= t) c.setPixel(x + xx, y + yy, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillUnderCurve(c: Canvas, xs: number[], ys: number[],
|
||||
baselineY: number, ink: Ink) {
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const x0 = xs[i], y0 = ys[i];
|
||||
const x1 = xs[i + 1], y1 = ys[i + 1];
|
||||
const dx = x1 - x0;
|
||||
if (dx <= 0) continue;
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const t = (x - x0) / dx;
|
||||
const y = Math.round(y0 + (y1 - y0) * t);
|
||||
const top = Math.min(y, baselineY);
|
||||
const bot = Math.max(y, baselineY);
|
||||
for (let yy = top; yy <= bot; yy++) {
|
||||
if (BAYER4[yy & 3][x & 3] < 8) c.setPixel(x, yy, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cornerBrackets(c: Canvas, x: number, y: number, w: number, h: number,
|
||||
len: number, ink: Ink) {
|
||||
c.hline(x, y, len, ink); c.vline(x, y, len, ink);
|
||||
c.hline(x + w - len, y, len, ink); c.vline(x + w - 1, y, len, ink);
|
||||
c.hline(x, y + h - 1, len, ink); c.vline(x, y + h - len, len, ink);
|
||||
c.hline(x + w - len, y + h - 1, len, ink); c.vline(x + w - 1, y + h - len, len, ink);
|
||||
}
|
||||
|
||||
/* ─── Plugin ─────────────────────────────────────────────────────── */
|
||||
|
||||
export const cryptoPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "crypto",
|
||||
name: "Crypto Price",
|
||||
description: "Live coin price + sparkline",
|
||||
description: "Editorial price card with sparkline",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "symbol", label: "Coin", type: "enum", default: "BTC",
|
||||
@@ -96,59 +133,122 @@ export const cryptoPlugin: Plugin = {
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
/* On mono tags accent ink == primary - visually a bit boring but the
|
||||
* structural design (corner brackets, dithered fills, big type) still
|
||||
* carries the look. On tri-colour tags the accent plane gives the
|
||||
* splash of red/yellow that makes the card pop. */
|
||||
const acc: Ink = planes === 2 ? 1 : 0;
|
||||
const blk: Ink = 0;
|
||||
|
||||
const margin = W < 200 ? 4 : 8;
|
||||
|
||||
// Header
|
||||
c.drawText(margin, margin, `${sym} / ${vs}`, 0, 1);
|
||||
/* ── Corner viewfinder brackets in accent ─────────────────────── */
|
||||
const bracketLen = Math.max(8, Math.min(16, Math.floor(W / 24)));
|
||||
cornerBrackets(c, 1, 1, W - 2, H - 2, bracketLen, acc);
|
||||
|
||||
// LIVE badge top-right (accent rectangle).
|
||||
/* ── Header row: symbol/ccy on the left, delta badge right ────── */
|
||||
const headerY = margin + 2;
|
||||
const symLabel = `${sym}/${vs}`;
|
||||
/* Header: "BTC/USD" in scale 2 - the focal element of the top bar. */
|
||||
c.drawText(margin + 4, headerY, symLabel, blk, 2);
|
||||
const symH = c.textSize(symLabel, 2).h;
|
||||
|
||||
/* Range chip on header row right of the symbol. */
|
||||
{
|
||||
const badge = "LIVE";
|
||||
const { w: bw, h: bh } = c.textSize(badge, 1);
|
||||
const bx = W - margin - bw - 4;
|
||||
const by = margin - 1;
|
||||
c.rect(bx - 2, by - 1, bw + 4, bh + 2, accentInk);
|
||||
c.drawText(bx, by, badge, accentInk, 1);
|
||||
const chip = range;
|
||||
const cs = c.textSize(chip, 1);
|
||||
const cx = margin + 4 + c.textSize(symLabel, 2).w + 6;
|
||||
const cy = headerY + Math.max(0, (symH - cs.h) >> 1);
|
||||
/* Box outline in primary, the chip text reads black on white. */
|
||||
c.rect(cx - 2, cy - 1, cs.w + 4, cs.h + 2, blk);
|
||||
c.drawText(cx, cy, chip, blk, 1);
|
||||
}
|
||||
|
||||
// Price headline. Pick scale so it just fits.
|
||||
/* Delta badge top-right: solid accent block with a chunky black
|
||||
* triangle and the % delta in black, all rendered on plane 0 over
|
||||
* the accent plane fill. Reads cleanly as "black ink on red/yellow
|
||||
* paint" on tri-colour tags - and as plain black on mono tags. */
|
||||
{
|
||||
const arrow = change >= 0 ? "\u25B2" : "\u25BC"; // ▲ / ▼ glyph
|
||||
const pct = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}%`;
|
||||
const arrowSize = c.textSize(arrow, 1);
|
||||
const pctSize = c.textSize(pct, 1);
|
||||
const padX = 5;
|
||||
const gap = 3;
|
||||
const bw = padX + arrowSize.w + gap + pctSize.w + padX;
|
||||
const bh = Math.max(arrowSize.h, pctSize.h) + 6;
|
||||
const bx = W - margin - bw - 2;
|
||||
const by = headerY + Math.max(0, (symH - bh) >> 1);
|
||||
|
||||
/* Solid accent fill behind everything, then knock out the arrow
|
||||
* and the % text in WHITE (paper showing through both planes).
|
||||
* White-on-red has way higher contrast than black-on-red on the
|
||||
* actual e-paper - the black ink and the red ink absorb similar
|
||||
* amounts of light, so black text on red reads as a muddy
|
||||
* monochrome blob. White (paper) on red is the print-magazine
|
||||
* standard for a reason. */
|
||||
c.fillRect(bx, by, bw, bh, acc);
|
||||
const ax = bx + padX;
|
||||
const ay = by + ((bh - arrowSize.h) >> 1);
|
||||
c.drawTextWhite(ax, ay, arrow, 1);
|
||||
const px = ax + arrowSize.w + gap;
|
||||
const py = by + ((bh - pctSize.h) >> 1);
|
||||
c.drawTextWhite(px, py, pct, 1);
|
||||
}
|
||||
|
||||
/* ── Big price ─────────────────────────────────────────────── */
|
||||
const priceTxt = formatPrice(price, prefix);
|
||||
let scale = W >= 280 ? 3 : W >= 180 ? 2 : 1;
|
||||
while (scale > 1 && c.textSize(priceTxt, scale).w > W - 2 * margin) scale--;
|
||||
const priceY = margin + 12 + Math.floor(H / 12);
|
||||
c.drawTextCentered(W >> 1, priceY, priceTxt, 0, scale);
|
||||
const priceH = c.textSize(priceTxt, scale).h;
|
||||
/* Pick the largest scale that fits. */
|
||||
let pscale = W >= 280 ? 4 : W >= 200 ? 3 : 2;
|
||||
while (pscale > 1 && c.textSize(priceTxt, pscale).w > W - 2 * margin - 6) pscale--;
|
||||
const pSize = c.textSize(priceTxt, pscale);
|
||||
const priceY = headerY + symH + Math.max(6, Math.floor(H / 14));
|
||||
const priceX = (W - pSize.w) >> 1;
|
||||
c.drawText(priceX, priceY, priceTxt, blk, pscale);
|
||||
|
||||
// Delta line: "▲ +X.XX% 24H"
|
||||
const delta = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}% ${range}`;
|
||||
const dy = priceY + priceH + Math.floor(H / 16);
|
||||
c.drawTextCentered(W >> 1, dy, delta, 0, 1);
|
||||
{
|
||||
const triW = 5;
|
||||
const dwt = c.textSize(delta, 1).w;
|
||||
const dx = (W >> 1) - (dwt >> 1) - triW - 2;
|
||||
const dyy = dy + 1;
|
||||
if (change >= 0) {
|
||||
c.line(dx, dyy + 5, dx + triW, dyy + 5, accentInk);
|
||||
c.line(dx, dyy + 5, dx + (triW >> 1), dyy, accentInk);
|
||||
c.line(dx + triW, dyy + 5, dx + (triW >> 1), dyy, accentInk);
|
||||
} else {
|
||||
c.line(dx, dyy, dx + triW, dyy, accentInk);
|
||||
c.line(dx, dyy, dx + (triW >> 1), dyy + 5, accentInk);
|
||||
c.line(dx + triW, dyy, dx + (triW >> 1), dyy + 5, accentInk);
|
||||
}
|
||||
}
|
||||
|
||||
// Sparkline filling the bottom third.
|
||||
/* ── Sparkline + accent fill ─────────────────────────────── */
|
||||
if (hist.length >= 2) {
|
||||
const sh = Math.floor(H / 3);
|
||||
const sy = H - margin - sh;
|
||||
const sx = margin;
|
||||
const sw = W - 2 * margin;
|
||||
c.hline(sx, sy + sh, sw, 0); // baseline tick on primary plane
|
||||
c.sparkline(sx, sy, sw, sh, hist, accentInk, true);
|
||||
/* No underline now - the price headline breathes and the sparkline
|
||||
* gets the full lower half of the card. */
|
||||
const sparkTop = priceY + pSize.h + Math.max(6, Math.floor(H / 14));
|
||||
const sparkBot = H - margin - 4;
|
||||
const sparkH = sparkBot - sparkTop;
|
||||
const sparkX = margin + 4;
|
||||
const sparkW = W - 2 * (margin + 4);
|
||||
|
||||
if (sparkH > 8) {
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const v of hist) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
if (hi === lo) hi = lo + 1;
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
for (let i = 0; i < hist.length; i++) {
|
||||
const px = sparkX + Math.round((i * (sparkW - 1)) / (hist.length - 1));
|
||||
const py = sparkTop + sparkH - 1 -
|
||||
Math.round(((hist[i] - lo) * (sparkH - 1)) / (hi - lo));
|
||||
xs.push(px); ys.push(py);
|
||||
}
|
||||
|
||||
/* Accent stippled fill below the curve - the visual hero. */
|
||||
fillUnderCurve(c, xs, ys, sparkBot - 1, acc);
|
||||
|
||||
/* Curve itself in BLACK, drawn thick so it pops over the
|
||||
* stippled fill - this is the "real" line the eye reads. */
|
||||
for (let i = 1; i < xs.length; i++) {
|
||||
c.line(xs[i - 1], ys[i - 1], xs[i], ys[i], blk);
|
||||
/* Add a 1-pixel "halo" above for extra weight. */
|
||||
if (ys[i] > sparkTop)
|
||||
c.line(xs[i - 1], ys[i - 1] - 1, xs[i], ys[i] - 1, blk);
|
||||
}
|
||||
|
||||
/* Baseline rule. */
|
||||
c.hline(sparkX, sparkBot, sparkW, blk);
|
||||
|
||||
/* Latest value pin: small accent dot + black ring. */
|
||||
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
|
||||
c.fillRect(lx - 2, ly - 2, 5, 5, acc);
|
||||
c.rect(lx - 2, ly - 2, 5, 5, blk);
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* GitHub Profile plugin.
|
||||
*
|
||||
* Layout (designed for 208x112, scales up to 296x128):
|
||||
*
|
||||
* ┌────────────────────────────────────────┐
|
||||
* │ ┌───────┐ Linus Torvalds │ <- display name (scale 1)
|
||||
* │ │ │ @torvalds ★ 178K │ <- handle + stars (scale 1, accent star)
|
||||
* │ │AVATAR │ │
|
||||
* │ │ │ Creator of Linux, │ <- bio (scale 1, word-wrapped,
|
||||
* │ │ │ unrepentantly cranky, │ multiple lines, can extend
|
||||
* │ │ │ mostly benevolent dictator │ below the avatar)
|
||||
* │ └───────┘ │
|
||||
* │ │
|
||||
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ <- contribution heatmap
|
||||
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ (red dots, hottest = black)
|
||||
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │
|
||||
* │ 1,234 contribs / yr │ <- caption
|
||||
* └────────────────────────────────────────┘
|
||||
*
|
||||
* Palette (kept light + airy on purpose):
|
||||
* - Black: typography, avatar frame, hottest-day cells (level 4).
|
||||
* - Accent: the star glyph and most heatmap cells (levels 1-3).
|
||||
* - White: paper / bg.
|
||||
*
|
||||
* No solid coloured banner, no boxed badges - the card reads as a
|
||||
* tiny print-magazine page rather than a UI screenshot.
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
import { fetchImageGray, blitGrayDither } from "../image_util";
|
||||
|
||||
interface GhUser {
|
||||
login: string;
|
||||
name: string | null;
|
||||
bio: string | null;
|
||||
avatar_url: string;
|
||||
followers: number;
|
||||
public_repos: number;
|
||||
}
|
||||
|
||||
interface GhRepoLite { stargazers_count: number; }
|
||||
|
||||
interface ContribCell { date: string; count: number; level: 0|1|2|3|4; }
|
||||
interface ContribResp {
|
||||
total: Record<string, number>;
|
||||
contributions: ContribCell[];
|
||||
}
|
||||
|
||||
async function fetchUser(login: string): Promise<GhUser | null> {
|
||||
try {
|
||||
const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {
|
||||
headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" },
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return await r.json() as GhUser;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function fetchTotalStars(login: string): Promise<number | null> {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`https://api.github.com/users/${encodeURIComponent(login)}/repos` +
|
||||
`?per_page=100&sort=pushed`,
|
||||
{ headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" } });
|
||||
if (!r.ok) return null;
|
||||
const repos: GhRepoLite[] = await r.json();
|
||||
let total = 0;
|
||||
for (const r of repos) total += r.stargazers_count | 0;
|
||||
return total;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function fetchContributions(login: string): Promise<ContribResp | null> {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`https://github-contributions-api.jogruber.de/v4/${encodeURIComponent(login)}?y=last`,
|
||||
{ headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) return null;
|
||||
return await r.json() as ContribResp;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function compact(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function ascii(s: string): string {
|
||||
/* Smart-typography → ASCII so apostrophes / em-dashes survive. */
|
||||
return s
|
||||
.replace(/[\u2018\u2019\u02BC]/g, "'")
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
.replace(/\u2026/g, "...")
|
||||
.replace(/[^\x20-\x7E]/g, "");
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
if (max <= 3) return s.slice(0, max);
|
||||
return s.slice(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
/** Greedy word-wrap into at most `maxLines` lines of `maxChars`. The
|
||||
* last line gets an ellipsis if there's still content left over. */
|
||||
function wrapText(s: string, maxChars: number, maxLines: number): string[] {
|
||||
if (maxChars <= 0 || maxLines <= 0) return [];
|
||||
const words = s.split(/\s+/).filter(Boolean);
|
||||
const lines: string[] = [];
|
||||
let cur = "";
|
||||
let i = 0;
|
||||
for (; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
const cand = cur ? cur + " " + w : w;
|
||||
if (cand.length <= maxChars) {
|
||||
cur = cand;
|
||||
} else {
|
||||
if (cur) lines.push(cur);
|
||||
cur = w.length <= maxChars ? w : w.slice(0, maxChars);
|
||||
if (lines.length >= maxLines) { i++; break; }
|
||||
}
|
||||
}
|
||||
if (cur && lines.length < maxLines) { lines.push(cur); cur = ""; }
|
||||
/* Anything that didn't fit gets reflected as an ellipsis on the
|
||||
* last line, so the user can tell text was truncated. */
|
||||
if (i < words.length || cur) {
|
||||
if (lines.length === 0) return lines;
|
||||
const last = lines[lines.length - 1];
|
||||
if (last.length <= maxChars - 3) lines[lines.length - 1] = last + "...";
|
||||
else lines[lines.length - 1] = last.slice(0, maxChars - 3) + "...";
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export const githubPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "github",
|
||||
name: "GitHub Profile",
|
||||
description: "Avatar, bio and contribution heatmap card",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "username", label: "Username", type: "string", default: "torvalds" },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const login = ((params.username ?? "torvalds").trim() || "torvalds");
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const acc: Ink = planes === 2 ? 1 : 0;
|
||||
const blk: Ink = 0;
|
||||
|
||||
const [user, stars, contrib] = await Promise.all([
|
||||
fetchUser(login),
|
||||
fetchTotalStars(login),
|
||||
fetchContributions(login),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
const t1 = "USER NOT FOUND";
|
||||
const t2 = "@" + login;
|
||||
const s1 = c.textSize(t1, 2), s2 = c.textSize(t2, 1);
|
||||
c.drawText((W - s1.w) >> 1, (H >> 1) - s1.h, t1, blk, 2);
|
||||
c.drawText((W - s2.w) >> 1, (H >> 1) + 2, t2, blk, 1);
|
||||
c.rect(0, 0, W, H, blk);
|
||||
return c;
|
||||
}
|
||||
|
||||
/* ── Geometry ─────────────────────────────────────────────
|
||||
* Tuned for 208×112; scales up to 296×128 with a bigger
|
||||
* avatar and roomier heatmap cells. */
|
||||
const isWide = W >= 256;
|
||||
const margin = 4;
|
||||
const charW = 6; // FONT_5x7_W + gap
|
||||
const lineH = 9; // FONT_5x7_H + gap
|
||||
|
||||
/* Avatar: dominate the upper-left. Bigger here = more typography
|
||||
* room AND more pixels for the dithered face. */
|
||||
const avatarS = isWide ? 64 : 60;
|
||||
const avX = margin;
|
||||
const avY = margin;
|
||||
|
||||
c.rect(avX, avY, avatarS, avatarS, blk);
|
||||
const avatar = await fetchImageGray(`${user.avatar_url}&s=${avatarS * 2}`);
|
||||
if (avatar) {
|
||||
blitGrayDither(c, avX + 1, avY + 1, avatarS - 2, avatarS - 2, avatar, blk);
|
||||
} else {
|
||||
const ini = (user.login.charAt(0) || "?").toUpperCase();
|
||||
const sz = c.textSize(ini, 5);
|
||||
c.drawText(avX + ((avatarS - sz.w) >> 1),
|
||||
avY + ((avatarS - sz.h) >> 1),
|
||||
ini, blk, 5);
|
||||
}
|
||||
|
||||
/* ── Right column: name / handle+stars / bio ─────────────── */
|
||||
const tX = avX + avatarS + 6;
|
||||
const tW = W - tX - margin;
|
||||
const maxChars = Math.max(1, Math.floor(tW / charW));
|
||||
|
||||
/* Heatmap budget computed up-front so we know where the bio is
|
||||
* allowed to flow into. */
|
||||
const cols = 53, rows = 7;
|
||||
const captionH = lineH + 2;
|
||||
const heatCellTarget = isWide ? 4 : 3;
|
||||
const heatBudget = rows * heatCellTarget;
|
||||
const heatBlockH = heatBudget + captionH + 4;
|
||||
const bioMaxBot = H - heatBlockH - 2;
|
||||
|
||||
let ty = avY;
|
||||
|
||||
/* Display name first, in primary ink. Falls back to login
|
||||
* (capitalised) if the user has no display name set. */
|
||||
const displayName = user.name && user.name.trim()
|
||||
? truncate(ascii(user.name).trim(), maxChars)
|
||||
: truncate(user.login, maxChars);
|
||||
if (displayName) {
|
||||
c.drawText(tX, ty, displayName, blk, 1);
|
||||
ty += lineH;
|
||||
}
|
||||
|
||||
/* Handle + stars on a single line. The ★ glyph is drawn in
|
||||
* accent for a deliberate splash of colour right next to the
|
||||
* stars number; everything else stays primary. */
|
||||
{
|
||||
const handle = "@" + ascii(user.login);
|
||||
const sNum = `\u2605 ${compact(stars ?? 0)}`;
|
||||
const hSize = c.textSize(handle, 1);
|
||||
const sSize = c.textSize(sNum, 1);
|
||||
const sep = " ";
|
||||
const sepW = c.textSize(sep, 1).w;
|
||||
/* If both pieces fit, draw them inline. Else drop the handle
|
||||
* (the avatar already says who this is). */
|
||||
if (hSize.w + sepW + sSize.w <= tW) {
|
||||
c.drawText(tX, ty, handle, blk, 1);
|
||||
/* Star glyph in accent, number text in primary - splits the
|
||||
* `★ 178K` string into two drawText calls so the colour break
|
||||
* is exactly at the glyph. */
|
||||
const sx = tX + hSize.w + sepW;
|
||||
c.drawText(sx, ty, "\u2605", acc, 1);
|
||||
c.drawText(sx + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1);
|
||||
} else {
|
||||
c.drawText(tX, ty, "\u2605", acc, 1);
|
||||
c.drawText(tX + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1);
|
||||
}
|
||||
ty += lineH + 2;
|
||||
}
|
||||
|
||||
/* Followers / repos line(s) - drawn at the BOTTOM of the right
|
||||
* column, left-aligned to the same `tX` as name / handle / bio.
|
||||
* Reserve their vertical room first so the bio knows when to
|
||||
* stop wrapping. The single-line form is preferred; if it
|
||||
* doesn't fit the column width, fall back to two stacked lines. */
|
||||
const statSingle = `${compact(user.followers)} followers \u00B7 ${compact(user.public_repos)} repos`;
|
||||
const statLines: string[] = c.textSize(statSingle, 1).w <= tW
|
||||
? [statSingle]
|
||||
: [`${compact(user.followers)} followers`,
|
||||
`${compact(user.public_repos)} repos`];
|
||||
const statsBlockH = statLines.length * lineH;
|
||||
/* Stats sit just above the heatmap caption / heatmap, butted up
|
||||
* against the bottom of the right column. */
|
||||
const statsTop = bioMaxBot - statsBlockH;
|
||||
const bioBudget = statsTop - 2 - ty; // px the bio is allowed
|
||||
|
||||
/* Bio - word-wrapped into the budget left after the stats are
|
||||
* accounted for. */
|
||||
if (user.bio && bioBudget >= lineH) {
|
||||
const maxLines = Math.max(0, Math.floor(bioBudget / lineH));
|
||||
if (maxLines > 0) {
|
||||
const lines = wrapText(ascii(user.bio).trim(), maxChars, maxLines);
|
||||
for (const line of lines) {
|
||||
c.drawText(tX, ty, line, blk, 1);
|
||||
ty += lineH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Now drop the stats in left-aligned with everything else. */
|
||||
{
|
||||
let sy = statsTop;
|
||||
for (const line of statLines) {
|
||||
c.drawText(tX, sy, line, blk, 1);
|
||||
sy += lineH;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Heatmap ─────────────────────────────────────────────── */
|
||||
const heatTop = H - heatBlockH;
|
||||
const heatBot = H - captionH;
|
||||
const heatH = heatBot - heatTop;
|
||||
|
||||
let cell = Math.max(1, Math.min(
|
||||
Math.floor((W - margin * 2 + 1) / cols),
|
||||
Math.floor((heatH + 1) / rows),
|
||||
));
|
||||
let gap = cell >= 3 ? 1 : 0;
|
||||
while (cell > 1 &&
|
||||
(cell * cols + gap * (cols - 1) > W - margin * 2 ||
|
||||
cell * rows + gap * (rows - 1) > heatH)) {
|
||||
if (gap > 0) gap--;
|
||||
else cell--;
|
||||
}
|
||||
const gw = cell * cols + gap * (cols - 1);
|
||||
const gh = cell * rows + gap * (rows - 1);
|
||||
const gx = (W - gw) >> 1;
|
||||
const gy = heatTop + ((heatH - gh) >> 1);
|
||||
|
||||
if (contrib) {
|
||||
const data = contrib.contributions;
|
||||
const padN = Math.max(0, data.length - cols * rows);
|
||||
for (let i = padN; i < data.length; i++) {
|
||||
const off = i - padN;
|
||||
const col = Math.floor(off / rows);
|
||||
const row = off % rows;
|
||||
const cx = gx + col * (cell + gap);
|
||||
const cy = gy + row * (cell + gap);
|
||||
const lvl = data[i].level;
|
||||
if (lvl === 0) continue;
|
||||
/* See doc-comment at top of file for the palette story. */
|
||||
if (lvl >= 4) {
|
||||
c.fillRect(cx, cy, cell, cell, blk);
|
||||
} else if (lvl >= 3 || cell <= 2) {
|
||||
c.fillRect(cx, cy, cell, cell, acc);
|
||||
} else if (lvl === 2) {
|
||||
c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
|
||||
} else {
|
||||
if (cell >= 4) c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
|
||||
else c.setPixel(cx + (cell >> 1), cy + (cell >> 1), acc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const t = "GRAPH OFFLINE";
|
||||
const sz = c.textSize(t, 1);
|
||||
c.drawText((W - sz.w) >> 1, gy + ((gh - sz.h) >> 1), t, blk, 1);
|
||||
}
|
||||
|
||||
/* ── Caption: tight, abbreviated so it always fits ─────────
|
||||
* "1.2K contribs / yr" is 17 chars ≈ 102 px at scale 1, fits in
|
||||
* 200 px easily. We use compact() on the total so a 50K-streak
|
||||
* pro doesn't break the layout. */
|
||||
{
|
||||
const total = contrib
|
||||
? (contrib.total["lastYear"]
|
||||
?? Object.values(contrib.total).reduce((a, b) => a + b, 0))
|
||||
: 0;
|
||||
const cap = contrib
|
||||
? `${compact(total)} contribs / yr`
|
||||
: `last 12 mo \u00B7 offline`;
|
||||
const cs = c.textSize(cap, 1);
|
||||
const cx = (W - cs.w) >> 1;
|
||||
const cy = H - cs.h - 2;
|
||||
c.drawText(cx, cy, cap, blk, 1);
|
||||
}
|
||||
|
||||
/* Outer 1-px frame. */
|
||||
c.rect(0, 0, W, H, blk);
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -1,26 +1,31 @@
|
||||
/*
|
||||
* Identicon plugin.
|
||||
* Identicon plugin - DEF CON / hardware-hacker badge aesthetic.
|
||||
*
|
||||
* Procedural avatar from a name string. Hash -> symmetric pixel art with
|
||||
* an accent stripe so it actually looks like a *card*, not just a square.
|
||||
* The card is laid out like an actual electronics badge: a "chip" on the
|
||||
* left holding the symmetric pixel-art identicon (with silkscreen border
|
||||
* and orientation notch), a typographic stack on the right with the
|
||||
* callsign in big type / a hex UID / a role tag, PCB-style pin headers
|
||||
* along the top edge, and a binary "barcode" footer that's deterministic
|
||||
* from the seed so two people with the same name get the same pattern -
|
||||
* exactly like a real con badge. Accent ink (red/yellow on tri-colour
|
||||
* tags) carries the role tag, chip notch, pin row and a few signature
|
||||
* highlights so the card pops in a photo.
|
||||
*
|
||||
* ┌──────────────────────┐
|
||||
* │ PIETER │
|
||||
* │ ████ ██ ████ │
|
||||
* │ ██ ██████ ██ │
|
||||
* │ ██████ ██████ │
|
||||
* │ ██ ██████ ██ │
|
||||
* │ ████ ██ ████ │
|
||||
* │ member since │
|
||||
* │ 2026 · #A4F1 │
|
||||
* └──────────────────────────────────────┘
|
||||
*
|
||||
* No external API calls - pure compute, instant render.
|
||||
* Avatar sources:
|
||||
* - "local" : the built-in symmetric 5x5/7x7/9x9 pixel grid.
|
||||
* - DiceBear : pixel-art / bottts / lorelei / micah / adventurer /
|
||||
* fun-emoji / shapes / pixel - fetched from the public
|
||||
* DiceBear API as a small PNG, decoded with upng-js,
|
||||
* and Floyd-Steinberg dithered into the chip area.
|
||||
* Same callsign always picks the same face because
|
||||
* the seed is the literal name.
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
import { fetchImageGray, blitGrayDither } from "../image_util";
|
||||
|
||||
/* FNV-1a 32-bit. Used everywhere we need a deterministic seed. */
|
||||
function hash32(s: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
@@ -30,91 +35,357 @@ function hash32(s: string): number {
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/* xorshift32 PRNG factory. */
|
||||
function rng(seed: number) {
|
||||
let r = seed || 0xCAFEBABE;
|
||||
return () => {
|
||||
r ^= r << 13; r >>>= 0;
|
||||
r ^= r >>> 17;
|
||||
r ^= r << 5; r >>>= 0;
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
/* Hacker-flavoured class tags. Pick deterministically from the seed
|
||||
* so the same name always gets the same role - just like a real con
|
||||
* where your callsign is the immutable part of your identity. */
|
||||
const ROLES = [
|
||||
"OPERATOR", "ANALYST", "PHREAK", "CRACKER",
|
||||
"HACKER", "AGENT", "GHOST", "ROGUE",
|
||||
"ARTIST", "WIZARD", "SCOUT", "CIPHER",
|
||||
];
|
||||
|
||||
/* DiceBear style ids supported by this plugin. The empty string maps to
|
||||
* the local generator. Order matters - it's the order shown in the FAP
|
||||
* dropdown. */
|
||||
const STYLES = [
|
||||
"local", "pixel-art", "bottts", "lorelei",
|
||||
"adventurer", "micah", "fun-emoji", "shapes",
|
||||
];
|
||||
|
||||
/* DiceBear avatar URL builder. The actual fetch / decode / dither is
|
||||
* done by the shared `image_util` helpers so every other image plugin
|
||||
* (GitHub avatars, NASA APOD, etc.) shares the same pipeline. */
|
||||
function dicebearUrl(style: string, seed: string, size: number): string {
|
||||
return `https://api.dicebear.com/9.x/${encodeURIComponent(style)}` +
|
||||
`/png?seed=${encodeURIComponent(seed)}` +
|
||||
`&size=${size}` +
|
||||
`&backgroundColor=ffffff`;
|
||||
}
|
||||
|
||||
/* ─── Drawing helpers ─────────────────────────────────────────────── */
|
||||
|
||||
/** A dashed horizontal line - reads as a PCB trace. */
|
||||
function dashedHLine(c: Canvas, x: number, y: number, w: number, dash: number, gap: number, ink: Ink) {
|
||||
let xx = 0;
|
||||
while (xx < w) {
|
||||
const len = Math.min(dash, w - xx);
|
||||
for (let i = 0; i < len; i++) c.setPixel(x + xx + i, y, ink);
|
||||
xx += dash + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pin-header row: alternating filled/empty squares like an IC pin
|
||||
* strip. Used at the top of the badge for hardware authenticity. */
|
||||
function pinHeader(c: Canvas, x: number, y: number, count: number,
|
||||
pin: number, gap: number, ink: Ink) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const px = x + i * (pin + gap);
|
||||
if ((i & 1) === 0) {
|
||||
// filled square
|
||||
for (let dy = 0; dy < pin; dy++)
|
||||
for (let dx = 0; dx < pin; dx++)
|
||||
c.setPixel(px + dx, y + dy, ink);
|
||||
} else {
|
||||
// empty square (outline only)
|
||||
c.rect(px, y, pin, pin, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Chip silkscreen: rounded-corner rectangle with a notch on top
|
||||
* (the universal "this is an IC" affordance). */
|
||||
function chipFrame(c: Canvas, x: number, y: number, w: number, h: number,
|
||||
notchInk: Ink, frameInk: Ink) {
|
||||
// Outer frame
|
||||
c.rect(x, y, w, h, frameInk);
|
||||
// Inner shadow line for "engraved" feel
|
||||
c.rect(x + 2, y + 2, w - 4, h - 4, frameInk);
|
||||
// Top-centre orientation notch (semicircle approximation in 5 px)
|
||||
const nx = x + (w >> 1) - 2;
|
||||
const ny = y;
|
||||
// Fill paper above the notch then re-draw with notch ink.
|
||||
for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny);
|
||||
for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny + 1);
|
||||
// Notch arc
|
||||
c.setPixel(nx + 1, ny, notchInk);
|
||||
c.setPixel(nx + 2, ny, notchInk);
|
||||
c.setPixel(nx + 3, ny, notchInk);
|
||||
c.setPixel(nx, ny + 1, notchInk);
|
||||
c.setPixel(nx + 4, ny + 1, notchInk);
|
||||
c.setPixel(nx + 1, ny + 2, notchInk);
|
||||
c.setPixel(nx + 2, ny + 2, notchInk);
|
||||
c.setPixel(nx + 3, ny + 2, notchInk);
|
||||
}
|
||||
|
||||
/** Crosshair / reticle - decorative element. */
|
||||
function crosshair(c: Canvas, cx: number, cy: number, r: number, ink: Ink) {
|
||||
c.line(cx - r, cy, cx + r, cy, ink);
|
||||
c.line(cx, cy - r, cx, cy + r, ink);
|
||||
// Outer tick marks
|
||||
c.setPixel(cx - r - 2, cy, ink);
|
||||
c.setPixel(cx + r + 2, cy, ink);
|
||||
c.setPixel(cx, cy - r - 2, ink);
|
||||
c.setPixel(cx, cy + r + 2, ink);
|
||||
// Centre dot
|
||||
c.setPixel(cx, cy, ink);
|
||||
}
|
||||
|
||||
/** Binary "barcode" strip derived from a seed. Each bit becomes a
|
||||
* filled or empty cell of the given width. Looks like a hardware
|
||||
* serial barcode, reads as cyberpunk decoration. */
|
||||
function binaryBar(c: Canvas, x: number, y: number, totalW: number, h: number,
|
||||
seed: number, ink: Ink) {
|
||||
const cellW = 2;
|
||||
const cells = Math.floor(totalW / cellW);
|
||||
let r = seed;
|
||||
for (let i = 0; i < cells; i++) {
|
||||
r ^= r << 13; r >>>= 0;
|
||||
r ^= r >>> 17;
|
||||
r ^= r << 5; r >>>= 0;
|
||||
if ((r & 0xFF) > 110) {
|
||||
for (let dy = 0; dy < h; dy++)
|
||||
for (let dx = 0; dx < cellW; dx++)
|
||||
c.setPixel(x + i * cellW + dx, y + dy, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Plugin ─────────────────────────────────────────────────────── */
|
||||
|
||||
export const identiconPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "identicon",
|
||||
name: "Identicon",
|
||||
description: "Symmetric pixel-art avatar from a name",
|
||||
description: "DEF CON-style hacker badge from a callsign",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "name", label: "Name", type: "string", default: "Dolphin" },
|
||||
{ key: "grid", label: "Grid", type: "enum", default: "5", options: ["5", "7", "9"] },
|
||||
{ key: "style", label: "Style", type: "enum", default: "blocky",
|
||||
{ key: "name", label: "Callsign", type: "string", default: "Dolphin" },
|
||||
{ key: "style", label: "Style", type: "enum", default: "pixel-art",
|
||||
options: STYLES },
|
||||
/* These two only matter when style == "local" - the DiceBear
|
||||
* styles render a pre-composed image and ignore them. */
|
||||
{ key: "grid", label: "Grid", type: "enum", default: "7",
|
||||
options: ["5", "7", "9"] },
|
||||
{ key: "subStyle", label: "Local Pat.", type: "enum", default: "blocky",
|
||||
options: ["blocky", "rings", "diagonal"] },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const name = (params.name ?? "Dolphin").trim() || "Dolphin";
|
||||
const grid = parseInt(params.grid ?? "5", 10);
|
||||
const style = params.style ?? "blocky";
|
||||
const seed = hash32(name);
|
||||
const name = ((params.name ?? "Dolphin").trim() || "Dolphin").toUpperCase();
|
||||
const style = params.style ?? "pixel-art";
|
||||
const grid = parseInt(params.grid ?? "7", 10);
|
||||
const subStyle = params.subStyle ?? "blocky";
|
||||
const seed = hash32(name);
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
const acc: Ink = planes === 2 ? 1 : 0;
|
||||
const blk: Ink = 0;
|
||||
|
||||
const margin = W < 200 ? 6 : 10;
|
||||
/* ── Geometry ──────────────────────────────────────────────── */
|
||||
const margin = 4;
|
||||
const headerH = 8; // top pin row + trace
|
||||
const footerH = 12; // bottom barcode + tagline
|
||||
|
||||
// Header
|
||||
c.drawText(margin, margin, name.toUpperCase().slice(0, 24), 0, 1);
|
||||
|
||||
// Avatar block: square sized to fit on the left half.
|
||||
const blockSize = Math.min(H - margin * 2 - 24, Math.floor(W * 0.55));
|
||||
const cell = Math.floor(blockSize / grid);
|
||||
const used = cell * grid;
|
||||
const ax = margin;
|
||||
const ay = margin + 16;
|
||||
|
||||
const half = Math.ceil(grid / 2);
|
||||
let r = seed;
|
||||
const next = () => {
|
||||
// xorshift32
|
||||
r ^= r << 13; r >>>= 0;
|
||||
r ^= r >>> 17;
|
||||
r ^= r << 5; r >>>= 0;
|
||||
return r;
|
||||
};
|
||||
|
||||
const cells: boolean[][] = [];
|
||||
for (let y = 0; y < grid; y++) {
|
||||
cells.push([]);
|
||||
for (let x = 0; x < half; x++) {
|
||||
let on = (next() & 0xff) > 110;
|
||||
if (style === "rings") {
|
||||
const dx = x - (half - 1) / 2;
|
||||
const dy = y - (grid - 1) / 2;
|
||||
on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0);
|
||||
} else if (style === "diagonal") {
|
||||
on = on !== (((x + y) & 1) === 0);
|
||||
}
|
||||
cells[y].push(on);
|
||||
}
|
||||
/* ── Top pin header strip ──────────────────────────────────── */
|
||||
{
|
||||
const pinSize = 3;
|
||||
const pinGap = 2;
|
||||
const stride = pinSize + pinGap;
|
||||
const count = Math.floor((W - margin * 2) / stride);
|
||||
const px = margin + 1;
|
||||
const py = margin;
|
||||
pinHeader(c, px, py, count, pinSize, pinGap, acc);
|
||||
}
|
||||
// Mirror to right half.
|
||||
for (let y = 0; y < grid; y++) {
|
||||
for (let x = 0; x < grid; x++) {
|
||||
const sx = x < half ? x : grid - 1 - x;
|
||||
if (cells[y][sx]) {
|
||||
c.fillRect(ax + x * cell, ay + y * cell, cell, cell, 0);
|
||||
/* PCB trace just under the pin row, with a gap in the middle for
|
||||
* a small label tag. */
|
||||
{
|
||||
const traceY = margin + 8;
|
||||
const tagText = `\u25C6 DEFCON 2026 \u25C6`;
|
||||
const tagSize = c.textSize(tagText, 1);
|
||||
const tagX = (W - tagSize.w) >> 1;
|
||||
// Left dashed segment.
|
||||
dashedHLine(c, margin + 4, traceY, tagX - margin - 8, 4, 2, blk);
|
||||
// Right dashed segment.
|
||||
const rStart = tagX + tagSize.w + 4;
|
||||
dashedHLine(c, rStart, traceY, W - margin - 4 - rStart, 4, 2, blk);
|
||||
// Tag label centred.
|
||||
c.drawText(tagX, traceY - 3, tagText, blk, 1);
|
||||
}
|
||||
|
||||
/* ── Identicon "chip" on the left ─────────────────────────── */
|
||||
const bodyTop = margin + headerH + 8;
|
||||
const bodyBot = H - footerH - margin;
|
||||
const bodyH = bodyBot - bodyTop;
|
||||
|
||||
const chipSize = Math.min(bodyH, Math.floor(W * 0.42));
|
||||
const chipX = margin + 2;
|
||||
const chipY = bodyTop + ((bodyH - chipSize) >> 1);
|
||||
|
||||
chipFrame(c, chipX, chipY, chipSize, chipSize, acc, blk);
|
||||
|
||||
/* Inner area where the avatar lives - the chip frame eats 6 px on
|
||||
* each side so the avatar isn't crowding the silkscreen. */
|
||||
const inset = 6;
|
||||
const innerW = chipSize - inset * 2;
|
||||
const innerH = chipSize - inset * 2;
|
||||
const innerX = chipX + inset;
|
||||
const innerY = chipY + inset;
|
||||
|
||||
if (style === "local") {
|
||||
/* Built-in symmetric pixel grid (zero-network). */
|
||||
const cell = Math.floor(innerW / grid);
|
||||
const used = cell * grid;
|
||||
const ax = chipX + ((chipSize - used) >> 1);
|
||||
const ay = chipY + ((chipSize - used) >> 1);
|
||||
const half = Math.ceil(grid / 2);
|
||||
const next = rng(seed);
|
||||
|
||||
const cells: boolean[][] = [];
|
||||
for (let y = 0; y < grid; y++) {
|
||||
cells.push([]);
|
||||
for (let x = 0; x < half; x++) {
|
||||
let on = (next() & 0xFF) > 110;
|
||||
if (subStyle === "rings") {
|
||||
const dx = x - (half - 1) / 2;
|
||||
const dy = y - (grid - 1) / 2;
|
||||
on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0);
|
||||
} else if (subStyle === "diagonal") {
|
||||
on = on !== (((x + y) & 1) === 0);
|
||||
}
|
||||
cells[y].push(on);
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < grid; y++) {
|
||||
for (let x = 0; x < grid; x++) {
|
||||
const sx = x < half ? x : grid - 1 - x;
|
||||
if (cells[y][sx]) {
|
||||
c.fillRect(ax + x * cell, ay + y * cell, cell, cell, blk);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* DiceBear-rendered avatar: fetch a small PNG (max(innerW, 96))
|
||||
* for crisp dithering, then Floyd-Steinberg into the chip. We
|
||||
* fall through to the local generator on any network failure
|
||||
* so a flaky connection still produces a valid badge. */
|
||||
const fetchSize = Math.max(96, innerW);
|
||||
const dib = await fetchImageGray(dicebearUrl(style, name, fetchSize));
|
||||
if (dib) {
|
||||
blitGrayDither(c, innerX, innerY, innerW, innerH, dib, blk);
|
||||
} else {
|
||||
/* Soft fallback: a single accented "?" in the chip area so the
|
||||
* user knows the avatar fetch failed but the rest of the
|
||||
* badge is still useful. */
|
||||
const q = "?";
|
||||
const qs = c.textSize(q, 4);
|
||||
c.drawText(innerX + ((innerW - qs.w) >> 1),
|
||||
innerY + ((innerH - qs.h) >> 1),
|
||||
q, acc, 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Accent stripe down the side of the block (signature flair).
|
||||
c.fillRect(ax + used + 4, ay, 3, used, accentInk);
|
||||
/* Tiny crosshair in the chip's bottom-right corner - reads as a
|
||||
* registration/orientation mark, very PCB. */
|
||||
crosshair(c, chipX + chipSize - 6, chipY + chipSize - 6, 2, acc);
|
||||
|
||||
// Right-hand info column.
|
||||
const infoX = ax + used + 14;
|
||||
let iy = ay;
|
||||
c.drawText(infoX, iy, "MEMBER SINCE", 0, 1); iy += 10;
|
||||
c.drawText(infoX, iy, "2026", accentInk, 2); iy += 18;
|
||||
const codeStr = "#" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0").slice(0, 4);
|
||||
c.drawText(infoX, iy, codeStr, 0, 1);
|
||||
/* ── Right column: callsign / UID / role tag ──────────────── */
|
||||
const rightX = chipX + chipSize + 8;
|
||||
const rightW = W - rightX - margin - 2;
|
||||
|
||||
// Border + inner bevel for finish.
|
||||
c.rect(0, 0, W, H, 0);
|
||||
c.rect(2, 2, W - 4, H - 4, 0);
|
||||
/* CALLSIGN: pick the largest scale the name fits in. */
|
||||
let callScale = 3;
|
||||
while (callScale > 1 && c.textSize(name, callScale).w > rightW) callScale--;
|
||||
const callY = bodyTop + 2;
|
||||
c.drawText(rightX, callY, name.slice(0, 12), blk, callScale);
|
||||
const callH = c.textSize(name, callScale).h;
|
||||
|
||||
/* Black underline below callsign - thick statement bar. */
|
||||
{
|
||||
const ulY = callY + callH + 2;
|
||||
const ulW = Math.min(rightW - 4, c.textSize(name.slice(0, 12), callScale).w);
|
||||
for (let i = 0; i < 2; i++) c.hline(rightX, ulY + i, ulW, blk);
|
||||
}
|
||||
|
||||
/* UID line: "UID 0xA4F1B2C3" in mono. */
|
||||
const uidY = callY + callH + 8;
|
||||
const uidStr = "UID 0x" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0");
|
||||
c.drawText(rightX, uidY, uidStr, blk, 1);
|
||||
|
||||
/* Role pill: "OPERATOR" etc. in solid accent block with white
|
||||
* knockout text - matches the crypto badge styling. */
|
||||
{
|
||||
const role = ROLES[seed % ROLES.length];
|
||||
const roleSize = c.textSize(role, 1);
|
||||
const padX = 4;
|
||||
const padY = 3;
|
||||
const bw = roleSize.w + padX * 2;
|
||||
const bh = roleSize.h + padY * 2;
|
||||
const bx = rightX;
|
||||
const by = uidY + roleSize.h + 6;
|
||||
c.fillRect(bx, by, bw, bh, acc);
|
||||
c.drawTextWhite(bx + padX, by + padY, role, 1);
|
||||
}
|
||||
|
||||
/* "MEMBER SINCE 2026" small caps line, lower right. */
|
||||
{
|
||||
const labelY = bodyBot - 8;
|
||||
const small = "MEMBER \u00B7 2026";
|
||||
c.drawText(rightX, labelY, small, blk, 1);
|
||||
}
|
||||
|
||||
/* ── Footer: binary barcode + tagline ─────────────────────── */
|
||||
{
|
||||
const fy = H - footerH - 1;
|
||||
// Left & right ranger marks.
|
||||
c.drawText(margin, fy + 4, "\u25A0\u25A0\u25A1\u25A0", blk, 1);
|
||||
const tail = "\u25A0\u25A1\u25A0\u25A0";
|
||||
const tailW = c.textSize(tail, 1).w;
|
||||
c.drawText(W - margin - tailW, fy + 4, tail, blk, 1);
|
||||
|
||||
// Binary barcode strip in the middle.
|
||||
const barX = margin + 28;
|
||||
const barW = W - margin * 2 - 56;
|
||||
binaryBar(c, barX, fy + 2, barW, 6, seed ^ 0xA5A5A5A5, blk);
|
||||
}
|
||||
|
||||
/* ── Outer card border + corner brackets in accent ────────── */
|
||||
c.rect(0, 0, W, H, blk);
|
||||
/* Heavy corner brackets in accent give the card the "viewfinder"
|
||||
* frame look that translates so well to a phone photo. */
|
||||
const cornerLen = 8;
|
||||
// top-left
|
||||
for (let i = 0; i < 2; i++) {
|
||||
c.hline(0, i, cornerLen, acc);
|
||||
c.vline(i, 0, cornerLen, acc);
|
||||
}
|
||||
// top-right
|
||||
for (let i = 0; i < 2; i++) {
|
||||
c.hline(W - cornerLen, i, cornerLen, acc);
|
||||
c.vline(W - 1 - i, 0, cornerLen, acc);
|
||||
}
|
||||
// bottom-left
|
||||
for (let i = 0; i < 2; i++) {
|
||||
c.hline(0, H - 1 - i, cornerLen, acc);
|
||||
c.vline(i, H - cornerLen, cornerLen, acc);
|
||||
}
|
||||
// bottom-right
|
||||
for (let i = 0; i < 2; i++) {
|
||||
c.hline(W - cornerLen, H - 1 - i, cornerLen, acc);
|
||||
c.vline(W - 1 - i, H - cornerLen, cornerLen, acc);
|
||||
}
|
||||
|
||||
return c;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user