2026-08-25 11:19:19 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Hand-rolled SVG chart generation for the SRS Worker.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
|
|
|
|
|
|
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
|
|
|
|
|
|
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
|
|
|
|
|
|
* every major email client blocks SVG, so the digest cannot embed the vector.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The SVG is string-built: no chart library, no dependencies, and the one
|
|
|
|
|
|
* import is the hand-rolled PNG encoder next door. The only dynamic values
|
|
|
|
|
|
* entering the markup are numbers, percentages, and dates the Worker itself
|
|
|
|
|
|
* computes, plus the fixed phase/stage labels — so nothing here needs XML
|
|
|
|
|
|
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
|
|
|
|
|
|
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
|
|
|
|
|
* green ramp. Every function renders on a zero-row DB.
|
|
|
|
|
|
*/
|
2026-08-31 10:41:45 -04:00
|
|
|
|
import { STAGES } from "./srs.ts";
|
2026-08-25 11:19:19 -04:00
|
|
|
|
import { Canvas, textWidth } from "./png.ts";
|
|
|
|
|
|
|
|
|
|
|
|
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
|
|
|
|
|
|
|
|
|
|
|
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
|
|
|
|
|
|
|
|
|
|
|
// shadcn dark-zinc palette, matching the README's shieldcn badges.
|
|
|
|
|
|
const BG = "#09090b"; // zinc-950 card
|
|
|
|
|
|
const FG = "#fafafa"; // zinc-50 text
|
|
|
|
|
|
const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
|
|
|
|
|
const LINE = "#27272a"; // zinc-800 structure / empty
|
|
|
|
|
|
const GREEN = "#16a34a"; // green-600 (core / pass)
|
|
|
|
|
|
const BLUE = "#2563eb"; // blue-600 (optional)
|
|
|
|
|
|
const RED = "#f87171"; // red-400 (fail, on dark)
|
|
|
|
|
|
|
|
|
|
|
|
// Green ramp shared by the heatmap and the ladder (GitHub dark-mode
|
|
|
|
|
|
// contribution hues; level 0 is the empty zinc cell).
|
|
|
|
|
|
const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
|
|
|
|
|
|
|
|
|
|
|
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
|
|
|
|
|
|
// strings go straight into markup and nothing else here needs escaping.
|
|
|
|
|
|
const PHASES = [
|
|
|
|
|
|
{ milestone: 1, name: "I — Linear" },
|
|
|
|
|
|
{ milestone: 2, name: "II — Nodal & Grid" },
|
|
|
|
|
|
{ milestone: 3, name: "III — Hierarchical" },
|
|
|
|
|
|
{ milestone: 4, name: "IV — Relational" },
|
|
|
|
|
|
{ milestone: 5, name: "V — Decision Space" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ── svg helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Opening tag plus the rounded dark card every chart starts with. */
|
|
|
|
|
|
function svgOpen(width: number, height: number): string {
|
|
|
|
|
|
return (
|
|
|
|
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
|
|
|
|
|
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
|
|
|
|
|
|
`<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A <text> element; charts place dozens of these with the same defaults. */
|
|
|
|
|
|
function text(
|
|
|
|
|
|
x: number,
|
|
|
|
|
|
y: number,
|
|
|
|
|
|
content: string,
|
|
|
|
|
|
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const { size = 12, fill = FG, anchor = "start", weight } = attrs;
|
|
|
|
|
|
const bold = weight ? ` font-weight="${weight}"` : "";
|
|
|
|
|
|
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── progress: stacked bar per phase ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface ProgressRow {
|
|
|
|
|
|
milestone: number;
|
|
|
|
|
|
set_label: string;
|
|
|
|
|
|
done: number;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function progressChart(db: D1Database): Promise<string> {
|
|
|
|
|
|
const { results } = await db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
|
|
|
|
|
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
|
|
|
|
|
|
COUNT(*) AS total
|
|
|
|
|
|
FROM problems p JOIN topics t ON p.topic_issue = t.issue
|
|
|
|
|
|
WHERE t.milestone IS NOT NULL
|
|
|
|
|
|
GROUP BY t.milestone, p.set_label`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.all<ProgressRow>();
|
|
|
|
|
|
|
|
|
|
|
|
// Per phase: core done / optional done / remaining (all-set total − all done).
|
|
|
|
|
|
const phases = PHASES.map((phase) => {
|
|
|
|
|
|
const rows = results.filter((r) => r.milestone === phase.milestone);
|
|
|
|
|
|
const total = rows.reduce((n, r) => n + r.total, 0);
|
|
|
|
|
|
const done = rows.reduce((n, r) => n + r.done, 0);
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: phase.name,
|
|
|
|
|
|
coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0,
|
|
|
|
|
|
optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0,
|
|
|
|
|
|
remaining: total - done,
|
|
|
|
|
|
done,
|
|
|
|
|
|
total,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const width = 640;
|
|
|
|
|
|
const height = 300;
|
|
|
|
|
|
const barX = 168;
|
|
|
|
|
|
const barMaxW = 400;
|
|
|
|
|
|
const barH = 22;
|
|
|
|
|
|
const rowStep = 48;
|
|
|
|
|
|
const scale = Math.max(1, ...phases.map((p) => p.total));
|
|
|
|
|
|
|
|
|
|
|
|
const parts = [svgOpen(width, height)];
|
|
|
|
|
|
|
|
|
|
|
|
// Legend on top.
|
|
|
|
|
|
const legend: [string, string][] = [
|
|
|
|
|
|
[GREEN, "core done"],
|
|
|
|
|
|
[BLUE, "optional done"],
|
|
|
|
|
|
[LINE, "remaining"],
|
|
|
|
|
|
];
|
|
|
|
|
|
let lx = barX;
|
|
|
|
|
|
for (const [color, label] of legend) {
|
|
|
|
|
|
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
|
|
|
|
|
|
parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED }));
|
|
|
|
|
|
lx += 17 + label.length * 7 + 24;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
phases.forEach((p, i) => {
|
|
|
|
|
|
const y = 52 + i * rowStep;
|
|
|
|
|
|
const midY = y + barH / 2 + 4;
|
|
|
|
|
|
parts.push(text(barX - 12, midY, p.name, { anchor: "end" }));
|
|
|
|
|
|
|
|
|
|
|
|
let x = barX;
|
|
|
|
|
|
const segments: [number, string][] = [
|
|
|
|
|
|
[p.coreDone, GREEN],
|
|
|
|
|
|
[p.optionalDone, BLUE],
|
|
|
|
|
|
[p.remaining, LINE],
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const [count, color] of segments) {
|
|
|
|
|
|
const w = (count / scale) * barMaxW;
|
|
|
|
|
|
if (w > 0) {
|
|
|
|
|
|
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
|
|
|
|
|
|
// Count inside the segment when it fits; tiny slivers stay unlabeled.
|
|
|
|
|
|
if (w >= 20) {
|
|
|
|
|
|
const labelFill = color === LINE ? MUTED : FG;
|
|
|
|
|
|
parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
|
|
|
|
|
|
}
|
|
|
|
|
|
x += w;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
parts.push("</svg>");
|
|
|
|
|
|
return parts.join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── ladder: bar per stage ────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-31 10:41:45 -04:00
|
|
|
|
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
|
|
|
|
|
|
// ladder can never leave a stale bar here: the slot width is derived from the
|
|
|
|
|
|
// rung count, only the bar width inside a slot is fixed.
|
2026-08-25 11:19:19 -04:00
|
|
|
|
export async function ladderChart(db: D1Database): Promise<string> {
|
|
|
|
|
|
const { results } = await db
|
|
|
|
|
|
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
|
|
|
|
|
.all<{ stage: string; count: number }>();
|
|
|
|
|
|
|
|
|
|
|
|
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
|
|
|
|
|
|
|
|
|
|
|
|
const width = 640;
|
|
|
|
|
|
const height = 220;
|
|
|
|
|
|
const plotTop = 26;
|
|
|
|
|
|
const plotBottom = height - 32;
|
|
|
|
|
|
const plotH = plotBottom - plotTop;
|
|
|
|
|
|
const slotW = (width - 80) / STAGES.length;
|
|
|
|
|
|
const barW = 64;
|
|
|
|
|
|
const scale = Math.max(1, ...counts);
|
|
|
|
|
|
|
|
|
|
|
|
const parts = [svgOpen(width, height)];
|
|
|
|
|
|
|
|
|
|
|
|
counts.forEach((count, i) => {
|
|
|
|
|
|
const cx = 40 + slotW * i + slotW / 2;
|
|
|
|
|
|
const barH = (count / scale) * plotH;
|
|
|
|
|
|
const y = plotBottom - barH;
|
|
|
|
|
|
if (barH > 0) {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
|
|
|
|
|
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
|
|
|
|
|
|
parts.push("</svg>");
|
|
|
|
|
|
return parts.join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── heatmap: attempts per campaign day ───────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const DAY_MS = 86_400_000;
|
|
|
|
|
|
|
|
|
|
|
|
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
|
|
|
|
|
|
// scale, so the vector and raster pictures cannot drift apart.
|
|
|
|
|
|
const HEAT_W = 560;
|
|
|
|
|
|
const HEAT_H = 160;
|
|
|
|
|
|
const HEAT_GRID_X = 34;
|
|
|
|
|
|
const HEAT_GRID_Y = 24;
|
|
|
|
|
|
const HEAT_CELL = 16;
|
|
|
|
|
|
const HEAT_STEP = 19; // cell + 3px gap
|
|
|
|
|
|
|
|
|
|
|
|
// Grid row → left-hand label; both renderers print only these three.
|
|
|
|
|
|
const WEEKDAYS: [number, string][] = [
|
|
|
|
|
|
[0, "Mon"],
|
|
|
|
|
|
[2, "Wed"],
|
|
|
|
|
|
[4, "Fri"],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** One grid square: column, Mon-based row, and index into GREENS. */
|
|
|
|
|
|
interface HeatmapCell {
|
|
|
|
|
|
col: number;
|
|
|
|
|
|
row: number;
|
|
|
|
|
|
level: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The heatmap's whole data model — one cell per campaign day, plus the week
|
|
|
|
|
|
* count that positions the legend — shared by both renderers.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
|
|
|
|
|
|
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
|
|
|
|
|
|
* column i/7; the row comes from the real weekday, so an off-Monday start still
|
|
|
|
|
|
* lands correctly.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function heatmapCells(
|
|
|
|
|
|
db: D1Database,
|
|
|
|
|
|
start: string,
|
|
|
|
|
|
days: number,
|
|
|
|
|
|
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
|
|
|
|
|
|
const [sy, sm, sd] = start.split("-").map(Number);
|
|
|
|
|
|
const base = Date.UTC(sy!, sm! - 1, sd!);
|
|
|
|
|
|
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
const { results } = await db
|
|
|
|
|
|
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
|
|
|
|
|
|
.bind(start, end)
|
|
|
|
|
|
.all<{ date: string; attempts: number }>();
|
|
|
|
|
|
|
|
|
|
|
|
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
|
|
|
|
|
|
|
|
|
|
|
|
const cells: HeatmapCell[] = [];
|
|
|
|
|
|
for (let i = 0; i < days; i++) {
|
|
|
|
|
|
const day = new Date(base + i * DAY_MS);
|
|
|
|
|
|
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
|
|
|
|
|
|
cells.push({
|
|
|
|
|
|
col: Math.floor(i / 7),
|
|
|
|
|
|
row: (day.getUTCDay() + 6) % 7, // Mon = 0
|
|
|
|
|
|
level: Math.min(attempts, 4),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { cells, weeks: Math.ceil(days / 7) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
|
|
|
|
|
const { cells, weeks } = await heatmapCells(db, start, days);
|
|
|
|
|
|
|
|
|
|
|
|
const parts = [svgOpen(HEAT_W, HEAT_H)];
|
|
|
|
|
|
|
|
|
|
|
|
// Week numbers across the top, Mon/Wed/Fri down the left.
|
|
|
|
|
|
for (let w = 0; w < weeks; w++) {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
|
|
|
|
|
|
size: 10,
|
|
|
|
|
|
fill: MUTED,
|
|
|
|
|
|
anchor: "middle",
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [row, label] of WEEKDAYS) {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
|
|
|
|
|
|
size: 10,
|
|
|
|
|
|
fill: MUTED,
|
|
|
|
|
|
anchor: "end",
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const { col, row, level } of cells) {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
`<rect x="${HEAT_GRID_X + col * HEAT_STEP}" y="${HEAT_GRID_Y + row * HEAT_STEP}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${GREENS[level]}"/>`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Less → More ramp fills the space right of the grid.
|
|
|
|
|
|
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
|
|
|
|
|
|
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
|
|
|
|
|
|
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
|
|
|
|
|
|
GREENS.forEach((color, i) => {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
`<rect x="${legendX + i * HEAT_STEP}" y="${legendY}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${color}"/>`,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
|
|
|
|
|
|
|
|
|
|
|
|
parts.push("</svg>");
|
|
|
|
|
|
return parts.join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The PNG carries no alpha channel, so whatever the rounded card does not cover
|
|
|
|
|
|
// has to be painted with the colour sitting behind the image: the digest
|
|
|
|
|
|
// email's GitHub-dark card.
|
|
|
|
|
|
const EMAIL_CARD = "#0d1117";
|
|
|
|
|
|
|
|
|
|
|
|
// Device-pixel multiplier. The email hands the image the full 552px card
|
|
|
|
|
|
// width, so 3x lands a little over 2x density on a retina screen; the labels
|
|
|
|
|
|
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
|
|
|
|
|
|
// either way, so the extra resolution is close to free.
|
|
|
|
|
|
const HEAT_SCALE = 3;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The same picture as heatmapChart, rastered for email.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Two deliberate departures from the SVG. The canvas is trimmed to the width
|
|
|
|
|
|
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
|
|
|
|
|
|
* narrow table cell and scales up, whereas the email gives the image the full
|
|
|
|
|
|
* 552px card, and the SVG's slack right margin would otherwise shrink the
|
|
|
|
|
|
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
|
|
|
|
|
|
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function heatmapPng(db: D1Database, start: string, days: number): Promise<Uint8Array> {
|
|
|
|
|
|
const { cells, weeks } = await heatmapCells(db, start, days);
|
|
|
|
|
|
|
|
|
|
|
|
const s = HEAT_SCALE;
|
|
|
|
|
|
const gridX = HEAT_GRID_X * s;
|
|
|
|
|
|
const gridY = HEAT_GRID_Y * s;
|
|
|
|
|
|
const cell = HEAT_CELL * s;
|
|
|
|
|
|
const step = HEAT_STEP * s;
|
|
|
|
|
|
const capH = 7 * s;
|
|
|
|
|
|
|
|
|
|
|
|
const legendX = gridX + weeks * step + 40 * s;
|
|
|
|
|
|
const legendY = gridY + 3 * step;
|
|
|
|
|
|
const moreX = legendX + GREENS.length * step + 3 * s;
|
|
|
|
|
|
const width = moreX + textWidth("More", s) + gridX;
|
|
|
|
|
|
const height = HEAT_H * s;
|
|
|
|
|
|
|
|
|
|
|
|
const canvas = new Canvas(width, height, EMAIL_CARD);
|
|
|
|
|
|
canvas.rect(0, 0, width, height, BG, 6 * s);
|
|
|
|
|
|
|
|
|
|
|
|
for (let w = 0; w < weeks; w++) {
|
|
|
|
|
|
const label = `W${w + 1}`;
|
|
|
|
|
|
const middle = gridX + w * step + cell / 2;
|
|
|
|
|
|
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [row, label] of WEEKDAYS) {
|
|
|
|
|
|
canvas.text(
|
|
|
|
|
|
gridX - 6 * s - textWidth(label, s),
|
|
|
|
|
|
gridY + row * step + cell - 4 * s - capH,
|
|
|
|
|
|
label,
|
|
|
|
|
|
MUTED,
|
|
|
|
|
|
s,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const { col, row, level } of cells) {
|
|
|
|
|
|
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const legendBase = legendY + cell - 4 * s - capH;
|
|
|
|
|
|
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
|
|
|
|
|
|
GREENS.forEach((color, i) => {
|
|
|
|
|
|
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
|
|
|
|
|
|
});
|
|
|
|
|
|
canvas.text(moreX, legendBase, "More", MUTED, s);
|
|
|
|
|
|
|
|
|
|
|
|
return await canvas.encode();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── gate badge: shieldcn-style dark pill ─────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export async function gateBadge(db: D1Database): Promise<string> {
|
|
|
|
|
|
const row = await db
|
|
|
|
|
|
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
|
|
|
|
|
|
.first<{ pass_rate: number }>();
|
|
|
|
|
|
|
|
|
|
|
|
const label = "gate";
|
|
|
|
|
|
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
|
|
|
|
|
|
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
|
|
|
|
|
|
|
|
|
|
|
|
// shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
|
|
|
|
|
|
// ≈ 7.5px per char; 12px outer padding, 8px between label and value.
|
|
|
|
|
|
const labelW = Math.round(label.length * 7.5);
|
|
|
|
|
|
const valueW = Math.round(value.length * 7.5);
|
|
|
|
|
|
const total = 12 + labelW + 8 + valueW + 12;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="32" ` +
|
|
|
|
|
|
`viewBox="0 0 ${total} 32" role="img" aria-label="${label}: ${value}" font-family="${FONT}">` +
|
|
|
|
|
|
`<rect width="${total}" height="32" rx="6" fill="#18181b"/>` +
|
|
|
|
|
|
`<g font-size="13">` +
|
|
|
|
|
|
`<text x="12" y="21" fill="${FG}" fill-opacity=".7">${label}</text>` +
|
|
|
|
|
|
`<text x="${12 + labelW + 8}" y="21" fill="${valueFill}" font-weight="bold">${value}</text>` +
|
|
|
|
|
|
`</g>` +
|
|
|
|
|
|
`</svg>`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|