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:
i12bp8 committed 2026-04-27 12:49:12 +02:00
1 parent 99bcc162b8
commit 287481453e
18 files changed
+1298 -195

No files matched your search

+164 -64
View File
@@ -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;
+363
View File
@@ -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;
},
};
+348 -77
View File
@@ -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;
},