mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
srs-api: dark-mode charts matching shieldcn zinc style
This commit is contained in:
+48
-48
@@ -8,15 +8,26 @@
|
||||
* Everything is string-built: no chart library, no runtime imports. 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. GitHub renders these on white; no dark-mode variants.
|
||||
* Every function renders a valid (if empty-looking) SVG on a zero-row DB.
|
||||
* 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 valid SVG on a zero-row DB.
|
||||
*/
|
||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||
|
||||
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||
|
||||
// Green ramp shared by the heatmap and the ladder (GitHub contribution hues).
|
||||
const GREENS = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"];
|
||||
// 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.
|
||||
@@ -32,12 +43,12 @@ const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
||||
|
||||
// ── svg helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Opening tag plus the white background rect every chart starts with. */
|
||||
/** 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}" fill="#ffffff"/>`
|
||||
`<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +59,7 @@ function text(
|
||||
content: string,
|
||||
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
|
||||
): string {
|
||||
const { size = 12, fill = "#1f2328", anchor = "start", weight } = attrs;
|
||||
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>`;
|
||||
}
|
||||
@@ -101,14 +112,14 @@ export async function progressChart(db: D1Database): Promise<string> {
|
||||
|
||||
// Legend on top.
|
||||
const legend: [string, string][] = [
|
||||
["#2da44e", "core done"],
|
||||
["#0969da", "optional done"],
|
||||
["#d0d7de", "remaining"],
|
||||
[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: "#57606a" }));
|
||||
parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED }));
|
||||
lx += 17 + label.length * 7 + 24;
|
||||
}
|
||||
|
||||
@@ -119,9 +130,9 @@ export async function progressChart(db: D1Database): Promise<string> {
|
||||
|
||||
let x = barX;
|
||||
const segments: [number, string][] = [
|
||||
[p.coreDone, "#2da44e"],
|
||||
[p.optionalDone, "#0969da"],
|
||||
[p.remaining, "#d0d7de"],
|
||||
[p.coreDone, GREEN],
|
||||
[p.optionalDone, BLUE],
|
||||
[p.remaining, LINE],
|
||||
];
|
||||
for (const [count, color] of segments) {
|
||||
const w = (count / scale) * barMaxW;
|
||||
@@ -129,13 +140,13 @@ export async function progressChart(db: D1Database): Promise<string> {
|
||||
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 === "#d0d7de" ? "#57606a" : "#ffffff";
|
||||
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: "#57606a" }));
|
||||
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED }));
|
||||
});
|
||||
|
||||
parts.push("</svg>");
|
||||
@@ -153,7 +164,7 @@ export async function ladderChart(db: D1Database): Promise<string> {
|
||||
|
||||
const width = 640;
|
||||
const height = 220;
|
||||
const plotTop = 34;
|
||||
const plotTop = 26;
|
||||
const plotBottom = height - 32;
|
||||
const plotH = plotBottom - plotTop;
|
||||
const slotW = (width - 80) / STAGES.length;
|
||||
@@ -161,7 +172,6 @@ export async function ladderChart(db: D1Database): Promise<string> {
|
||||
const scale = Math.max(1, ...counts);
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
parts.push(text(40, 20, "review ladder", { size: 13, weight: "bold" }));
|
||||
|
||||
counts.forEach((count, i) => {
|
||||
const cx = 40 + slotW * i + slotW / 2;
|
||||
@@ -172,11 +182,11 @@ export async function ladderChart(db: D1Database): Promise<string> {
|
||||
`<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, y - 6, String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
||||
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: "#57606a", anchor: "middle" }));
|
||||
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="#d0d7de"/>`);
|
||||
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
@@ -211,7 +221,7 @@ export async function heatmapChart(db: D1Database, start: string, days: number):
|
||||
|
||||
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: "#57606a", anchor: "middle" }));
|
||||
parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: MUTED, anchor: "middle" }));
|
||||
}
|
||||
const weekdays: [number, string][] = [
|
||||
[0, "Mon"],
|
||||
@@ -219,7 +229,7 @@ export async function heatmapChart(db: D1Database, start: string, days: number):
|
||||
[4, "Fri"],
|
||||
];
|
||||
for (const [row, label] of weekdays) {
|
||||
parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: MUTED, anchor: "end" }));
|
||||
}
|
||||
|
||||
// One cell per campaign day; the start date is a Monday, so day i sits at
|
||||
@@ -239,17 +249,17 @@ export async function heatmapChart(db: D1Database, start: string, days: number):
|
||||
// Less → More ramp fills the space right of the grid.
|
||||
const legendX = gridX + weeks * step + 40;
|
||||
const legendY = gridY + 3 * step;
|
||||
parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
|
||||
GREENS.forEach((color, i) => {
|
||||
parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`);
|
||||
});
|
||||
parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: "#57606a" }));
|
||||
parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: MUTED }));
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── gate badge: shields.io flat pill ─────────────────────────────
|
||||
// ── gate badge: shieldcn-style dark pill ─────────────────────────
|
||||
|
||||
export async function gateBadge(db: D1Database): Promise<string> {
|
||||
const row = await db
|
||||
@@ -258,31 +268,21 @@ export async function gateBadge(db: D1Database): Promise<string> {
|
||||
|
||||
const label = "gate";
|
||||
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
|
||||
const color = row ? (row.pass_rate >= 0.7 ? "#4c1" : "#e05d44") : "#9f9f9f";
|
||||
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
|
||||
|
||||
// Shields flat geometry: height 20, rx 3, verdana 11px, ~6.5px per char
|
||||
// plus 5px padding each side. Each caption is drawn twice — a dark
|
||||
// 30%-opacity copy 1px low as the shadow, then white on top.
|
||||
const labelW = Math.round(label.length * 6.5) + 10;
|
||||
const valueW = Math.round(value.length * 6.5) + 10;
|
||||
const total = labelW + valueW;
|
||||
const labelX = labelW / 2;
|
||||
const valueX = labelW + valueW / 2;
|
||||
// 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="20" role="img" aria-label="${label}: ${value}">` +
|
||||
`<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>` +
|
||||
`<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>` +
|
||||
`<g clip-path="url(#r)">` +
|
||||
`<rect width="${labelW}" height="20" fill="#555"/>` +
|
||||
`<rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/>` +
|
||||
`<rect width="${total}" height="20" fill="url(#s)"/>` +
|
||||
`</g>` +
|
||||
`<g fill="#fff" text-anchor="middle" font-family="${FONT}" font-size="11">` +
|
||||
`<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${label}</text>` +
|
||||
`<text x="${labelX}" y="14">${label}</text>` +
|
||||
`<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${value}</text>` +
|
||||
`<text x="${valueX}" y="14">${value}</text>` +
|
||||
`<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>`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user