/** * 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. */ import { STAGES } from "./srs.ts"; 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 ( `` + `` ); } /** A 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 `${content}`; } // ── progress: stacked bar per phase ────────────────────────────── interface ProgressRow { milestone: number; set_label: string; done: number; total: number; } export async function progressChart(db: D1Database): Promise { 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(); // 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(``); 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(``); // 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(""); return parts.join(""); } // ── ladder: bar per stage ──────────────────────────────────────── // 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. export async function ladderChart(db: D1Database): Promise { 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( ``, ); } 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(``); parts.push(""); 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 { 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( ``, ); } // 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( ``, ); }); parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED })); parts.push(""); 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 { 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 { 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 ( `` + `` + `` + `${label}` + `${value}` + `` + `` ); }