mirror of
https://github.com/i12bp8/TagTinker.git
synced 2026-08-14 06:29:45 +00:00
github plugin: redesign for 208x112 tag
- Add month-initial axis under the heatmap - Add current-streak counter in caption (accent-highlighted) - Replace stats/bio fight with a single icon stat row (star, followers, repos) - Fix bullet/middle-dot rendering bug (U+00B7 -> U+2022, in font table) - Allow display name at scale 2 on wide tags only; safer truncation - Robust avatar URL ?s= append (handles avatars without query string) - Tighter geometry tuned for 208x112; still scales to 296x128
This commit is contained in:
+199
-147
@@ -1,30 +1,31 @@
|
||||
/*
|
||||
* GitHub Profile plugin.
|
||||
*
|
||||
* Layout (designed for 208x112, scales up to 296x128):
|
||||
* Layout, tuned for the 208x112 tag (the common case) and gracefully
|
||||
* scaling 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
|
||||
* └────────────────────────────────────────┘
|
||||
* ┌──────────────────────────────────────────┐
|
||||
* │ ┌──────┐ Linus Torvalds │ display name
|
||||
* │ │ │ @torvalds │ handle
|
||||
* │ │AVATAR│ Creator of Linux, │ bio (wraps under
|
||||
* │ │ │ unrepentantly cranky... │ avatar if it must)
|
||||
* │ └──────┘ │
|
||||
* │ ★ 178K ◉ 234 ◆ 712 repos │ stat row (1 line)
|
||||
* │ ──────────────────────────────────── │ hairline rule
|
||||
* │ J F M A M J J A S O N D │ month axis
|
||||
* │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ contribution heatmap
|
||||
* │ 1.2K contribs · 42d streak │ caption + streak
|
||||
* └──────────────────────────────────────────┘
|
||||
*
|
||||
* Palette (kept light + airy on purpose):
|
||||
* Palette:
|
||||
* - Black: typography, avatar frame, hottest-day cells (level 4).
|
||||
* - Accent: the star glyph and most heatmap cells (levels 1-3).
|
||||
* - White: paper / bg.
|
||||
* - Accent: star glyph, streak flame, heatmap levels 1-3.
|
||||
* - White: paper.
|
||||
*
|
||||
* No solid coloured banner, no boxed badges - the card reads as a
|
||||
* tiny print-magazine page rather than a UI screenshot.
|
||||
* Data sources:
|
||||
* - api.github.com/users/:login (name, bio, avatar, counts)
|
||||
* - api.github.com/users/:login/repos (sum stargazers across page 1)
|
||||
* - github-contributions-api.jogruber.de (daily heatmap + totals)
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
@@ -112,34 +113,61 @@ function wrapText(s: string, maxChars: number, maxLines: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let cur = "";
|
||||
let i = 0;
|
||||
for (; i < words.length; i++) {
|
||||
for (; i < words.length && lines.length < maxLines; i++) {
|
||||
const w = words[i];
|
||||
const cand = cur ? cur + " " + w : w;
|
||||
if (cand.length <= maxChars) {
|
||||
cur = cand;
|
||||
} else {
|
||||
if (cur) lines.push(cur);
|
||||
if (lines.length >= maxLines) { cur = ""; break; }
|
||||
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;
|
||||
if ((i < words.length || cur) && lines.length > 0) {
|
||||
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) + "...";
|
||||
lines[lines.length - 1] = last.length <= maxChars - 3
|
||||
? last + "..."
|
||||
: last.slice(0, maxChars - 3) + "...";
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Trailing streak in days. If today has 0 contributions we still
|
||||
* count whatever streak ended yesterday (mirrors github.com's rule:
|
||||
* a streak isn't broken until the day is fully over). */
|
||||
function currentStreak(contribs: ContribCell[]): number {
|
||||
let i = contribs.length - 1;
|
||||
if (i >= 0 && contribs[i].count === 0) i--;
|
||||
let s = 0;
|
||||
while (i >= 0 && contribs[i].count > 0) { s++; i--; }
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Compute the column index at which each calendar month FIRST appears
|
||||
* in the heatmap (column = floor(dayIndex / 7)). Returns 12 entries
|
||||
* (Jan..Dec) with the leftmost column where that month is visible, or
|
||||
* -1 if the month isn't in the visible window. */
|
||||
function monthColumns(contribs: ContribCell[], cols: number, rows: number): number[] {
|
||||
const out = new Array<number>(12).fill(-1);
|
||||
const padN = Math.max(0, contribs.length - cols * rows);
|
||||
for (let i = padN; i < contribs.length; i++) {
|
||||
const d = new Date(contribs[i].date + "T00:00:00Z");
|
||||
const m = d.getUTCMonth();
|
||||
const col = Math.floor((i - padN) / rows);
|
||||
if (out[m] === -1) out[m] = col;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const MONTH_INITIAL = ["J","F","M","A","M","J","J","A","S","O","N","D"];
|
||||
|
||||
export const githubPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "github",
|
||||
name: "GitHub Profile",
|
||||
description: "Avatar, bio and contribution heatmap card",
|
||||
description: "Avatar, bio, stats and contribution heatmap card",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "username", label: "Username", type: "string", default: "torvalds" },
|
||||
@@ -170,22 +198,29 @@ export const githubPlugin: Plugin = {
|
||||
return c;
|
||||
}
|
||||
|
||||
/* ── Geometry ─────────────────────────────────────────────
|
||||
* Tuned for 208×112; scales up to 296×128 with a bigger
|
||||
* avatar and roomier heatmap cells. */
|
||||
/* ── Geometry ───────────────────────────────────────────────
|
||||
* Tuned around 208x112; everything is computed from W/H so the
|
||||
* 296x128 tag (and any future panel) gets a nicer, roomier
|
||||
* version of the same layout for free. */
|
||||
const isWide = W >= 256;
|
||||
const margin = 4;
|
||||
const margin = isWide ? 5 : 3;
|
||||
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 avatarS = isWide ? 60 : 50;
|
||||
const avX = margin;
|
||||
const avY = margin;
|
||||
|
||||
/* ── Avatar ─────────────────────────────────────────────────
|
||||
* 1-px frame around a Floyd-Steinberg-dithered face. Fetch at
|
||||
* 2× to give the dither real detail to chew on. The ?s= query
|
||||
* param is what GitHub uses for size hints; avatar_url already
|
||||
* has a `?v=4` so we append with `&`. */
|
||||
c.rect(avX, avY, avatarS, avatarS, blk);
|
||||
const avatar = await fetchImageGray(`${user.avatar_url}&s=${avatarS * 2}`);
|
||||
const avatarUrl = user.avatar_url.includes("?")
|
||||
? `${user.avatar_url}&s=${avatarS * 2}`
|
||||
: `${user.avatar_url}?s=${avatarS * 2}`;
|
||||
const avatar = await fetchImageGray(avatarUrl);
|
||||
if (avatar) {
|
||||
blitGrayDither(c, avX + 1, avY + 1, avatarS - 2, avatarS - 2, avatar, blk);
|
||||
} else {
|
||||
@@ -196,118 +231,121 @@ export const githubPlugin: Plugin = {
|
||||
ini, blk, 5);
|
||||
}
|
||||
|
||||
/* ── Right column: name / handle+stars / bio ─────────────── */
|
||||
const tX = avX + avatarS + 6;
|
||||
/* ── Right column: name / handle / bio ──────────────────────
|
||||
* The bio is allowed to wrap below the avatar baseline if it
|
||||
* needs the room - the stat row sits BELOW the avatar so they
|
||||
* don't compete for vertical space (the old layout did, and
|
||||
* the bio almost always lost). */
|
||||
const tX = avX + avatarS + (isWide ? 8 : 5);
|
||||
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. */
|
||||
/* Vertical budget for the bottom block (stat row + heatmap +
|
||||
* caption). We reserve this up-front so the bio knows where to
|
||||
* stop wrapping. */
|
||||
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;
|
||||
const heatCell = isWide ? 3 : 2;
|
||||
const heatGap = (isWide && cell_fits(cols, heatCell, 1, W - margin * 2)) ? 1 : 0;
|
||||
const heatH = rows * heatCell + (rows - 1) * heatGap;
|
||||
const monthH = lineH; // single row of single-letter labels
|
||||
const statH = lineH; // ★ N ◉ N ◆ N
|
||||
const capH = lineH; // bottom caption (contribs · streak)
|
||||
const bottomBlockH = statH + 2 + monthH + heatH + 2 + capH + 1;
|
||||
const bottomTop = H - bottomBlockH - 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;
|
||||
}
|
||||
/* Display name. Scale 2 if the column is wide enough AND the
|
||||
* name is short enough; otherwise scale 1. */
|
||||
const nameRaw = user.name && user.name.trim()
|
||||
? ascii(user.name).trim()
|
||||
: user.login;
|
||||
const wantBig = !isWide ? false : c.textSize(nameRaw, 2).w <= tW;
|
||||
const nameScale = wantBig ? 2 : 1;
|
||||
const nameMax = nameScale === 2
|
||||
? Math.max(1, Math.floor(tW / (charW * 2)))
|
||||
: maxChars;
|
||||
c.drawText(tX, ty, truncate(nameRaw, nameMax), blk, nameScale);
|
||||
ty += 7 * nameScale + 2;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
/* Handle line. */
|
||||
c.drawText(tX, ty, truncate("@" + ascii(user.login), maxChars), blk, 1);
|
||||
ty += lineH;
|
||||
|
||||
/* 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));
|
||||
/* Bio - allowed to flow under the avatar (down to bottomTop). */
|
||||
if (user.bio) {
|
||||
const bioBudget = bottomTop - ty;
|
||||
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) {
|
||||
for (const line of wrapText(ascii(user.bio).trim(), maxChars, maxLines)) {
|
||||
c.drawText(tX, ty, line, blk, 1);
|
||||
ty += lineH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Now drop the stats in left-aligned with everything else. */
|
||||
/* ── Stat row ───────────────────────────────────────────────
|
||||
* One horizontal line of icon+number triplets, full-width below
|
||||
* the avatar. Star glyph in accent for a deliberate splash. */
|
||||
const statY = bottomTop;
|
||||
{
|
||||
let sy = statsTop;
|
||||
for (const line of statLines) {
|
||||
c.drawText(tX, sy, line, blk, 1);
|
||||
sy += lineH;
|
||||
const items: Array<{ glyph: string; value: string; accentGlyph: boolean }> = [
|
||||
{ glyph: "\u2605", value: compact(stars ?? 0), accentGlyph: true }, // ★
|
||||
{ glyph: "\u25C9", value: compact(user.followers), accentGlyph: false }, // ◉
|
||||
{ glyph: "\u25C6", value: compact(user.public_repos), accentGlyph: false }, // ◆
|
||||
];
|
||||
// Measure total width with " " between glyph & value, " " between items.
|
||||
const sepW = c.textSize(" ", 1).w;
|
||||
let totalW = 0;
|
||||
const partW: number[] = [];
|
||||
for (const it of items) {
|
||||
const w = c.textSize(it.glyph + " " + it.value, 1).w;
|
||||
partW.push(w);
|
||||
totalW += w;
|
||||
}
|
||||
totalW += sepW * (items.length - 1);
|
||||
let sx = margin + Math.max(0, ((W - margin * 2) - totalW) >> 1);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
c.drawText(sx, statY, it.glyph, it.accentGlyph ? acc : blk, 1);
|
||||
const gW = c.textSize(it.glyph + " ", 1).w;
|
||||
c.drawText(sx + gW, statY, it.value, blk, 1);
|
||||
sx += partW[i] + sepW;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
/* ── Heatmap geometry ───────────────────────────────────────
|
||||
* Centred horizontally inside the page margins. */
|
||||
const gw = cols * heatCell + (cols - 1) * heatGap;
|
||||
const gh = rows * heatCell + (rows - 1) * heatGap;
|
||||
const gx = (W - gw) >> 1;
|
||||
const gy = heatTop + ((heatH - gh) >> 1);
|
||||
const monthY = statY + statH + 2;
|
||||
const gy = monthY + monthH;
|
||||
|
||||
/* ── Month axis ─────────────────────────────────────────────
|
||||
* Single-letter initials placed at the left edge of each
|
||||
* month's first visible column. Letters are kept at scale 1
|
||||
* (5px wide) so even adjacent months don't collide unless the
|
||||
* month is shorter than 5 columns (rare; only happens at the
|
||||
* very start of the rolling window). */
|
||||
if (contrib) {
|
||||
const mc = monthColumns(contrib.contributions, cols, rows);
|
||||
let lastX = -999;
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const col = mc[m];
|
||||
if (col < 0) continue;
|
||||
const lx = gx + col * (heatCell + heatGap);
|
||||
if (lx - lastX < 6) continue; // no overlap with prev label
|
||||
c.drawText(lx, monthY, MONTH_INITIAL[m], blk, 1);
|
||||
lastX = lx;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Heatmap cells ──────────────────────────────────────────
|
||||
* Level 0 = paper (skip). Level 4 = solid black. Levels 1-3 in
|
||||
* accent ink, getting denser with level so even on a mono tag
|
||||
* you can still read the gradient (level 1 = centre dot,
|
||||
* level 2 = inset square, level 3 = full cell). */
|
||||
if (contrib) {
|
||||
const data = contrib.contributions;
|
||||
const padN = Math.max(0, data.length - cols * rows);
|
||||
@@ -315,20 +353,19 @@ export const githubPlugin: Plugin = {
|
||||
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 cx = gx + col * (heatCell + heatGap);
|
||||
const cy = gy + row * (heatCell + heatGap);
|
||||
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);
|
||||
c.fillRect(cx, cy, heatCell, heatCell, blk);
|
||||
} else if (lvl >= 3 || heatCell <= 2) {
|
||||
c.fillRect(cx, cy, heatCell, heatCell, acc);
|
||||
} else if (lvl === 2) {
|
||||
c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc);
|
||||
c.fillRect(cx + 1, cy + 1, heatCell - 2, heatCell - 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);
|
||||
if (heatCell >= 4) c.fillRect(cx + 1, cy + 1, heatCell - 2, heatCell - 2, acc);
|
||||
else c.setPixel(cx + (heatCell >> 1), cy + (heatCell >> 1), acc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -337,27 +374,42 @@ export const githubPlugin: Plugin = {
|
||||
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. */
|
||||
/* ── Caption + streak ───────────────────────────────────────
|
||||
* "1.2K contribs • 42d streak" - bullet glyph (U+2022) is in
|
||||
* the extra-glyph table so it actually renders (the previous
|
||||
* version used U+00B7 which is not, leaving a phantom gap). */
|
||||
{
|
||||
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 streak = contrib ? currentStreak(contrib.contributions) : 0;
|
||||
const left = contrib ? `${compact(total)} contribs` : `last 12 mo offline`;
|
||||
const right = contrib && streak > 0 ? `${streak}d streak` : "";
|
||||
const sep = " \u2022 ";
|
||||
const cap = right ? left + sep + right : left;
|
||||
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);
|
||||
const cyText = gy + gh + 2;
|
||||
const cxText = (W - cs.w) >> 1;
|
||||
c.drawText(cxText, cyText, cap, blk, 1);
|
||||
/* Highlight the streak number in accent - tiny, deliberate
|
||||
* splash that mirrors the star glyph above. */
|
||||
if (right) {
|
||||
const leftW = c.textSize(left + sep, 1).w;
|
||||
const numW = c.textSize(`${streak}`, 1).w;
|
||||
c.drawText(cxText + leftW, cyText, `${streak}`, acc, 1);
|
||||
c.drawText(cxText + leftW + numW, cyText, "d streak", blk, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Outer 1-px frame. */
|
||||
c.rect(0, 0, W, H, blk);
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
|
||||
/** Helper used while picking heatmap geometry: does N cells of
|
||||
* size `cell` plus (N-1) gaps fit in `avail` pixels? */
|
||||
function cell_fits(n: number, cell: number, gap: number, avail: number): boolean {
|
||||
return n * cell + (n - 1) * gap <= avail;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user