learn-loops 0.1.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # loops
2
+
3
+ A colorful, interactive CLI that turns **Claude Code** or **Codex** into a personal tutor. It maps a topic, surveys it broadly, then takes you deep one area at a time, and it builds a folder of markdown notes with diagrams, equations and figures as you go.
4
+
5
+ ```bash
6
+ npm i -g learn-loops
7
+ loops
8
+ ```
9
+
10
+ Requires Node 20+ and either the `claude` or `codex` CLI installed and logged in. If `loops` isn't found after install, add npm's global bin folder to your `PATH` (`npm prefix -g`, then `<that>/bin`).
11
+
12
+ ## How it teaches
13
+
14
+ 1. **Profile first**: the first run asks how you learn (examples vs. theory, diagrams, tone, quiz difficulty, Socratic or not, session length) and tailors every prompt.
15
+ 2. **Launch screen**: each time you start, a quick energy/time check-in, then reopen a topic you've started or begin something new.
16
+ 3. **Brain dump**: you write what you already know, which activates prior knowledge and sets the starting level.
17
+ 4. **Map**: the whole territory as areas, concepts and a prerequisite graph (`map.md`, mermaid mindmap + flowchart, also drawn in the terminal).
18
+ 5. **Survey**: a short lesson per area, so you get the basic idea of everything before depth.
19
+ 6. **Deep dive**: you pick an area; it plans 4-7 lessons that build on each other, each with worked examples and figures.
20
+ 7. **Every lesson**: guess first, read a small chunk, "explain it differently" on demand, predict-then-reveal, quiz with confidence ratings, then an **explain-it-in-your-own-words** teach-back to a persona (a curious 12-year-old, a skeptical expert...) who asks a follow-up.
21
+ 8. **Think deeper**: trade-offs, failure modes, scale and second-order effects, with push-back, rotating through different lenses; an end-of-area **application challenge** with no single right answer.
22
+ 9. **When you're stuck**: a short diagnosis finds the mental model that's wrong, re-teaches that, and re-tests it.
23
+ 10. **Remembering**: SM-2 spaced repetition, a warm-up before each session, interleaved mixed quizzes (weakest first, confident mistakes first), a **mistake journal**, and a **streak**.
24
+
25
+ ## Equations, physics diagrams and plots
26
+
27
+ Technical lessons use real typesetting, not ASCII approximations:
28
+
29
+ - **Equations** are written in LaTeX and rendered with [MathJax](https://www.mathjax.org) (physics, ams, mhchem and cancel packages: `\ket{}`, `\pdv{}{}`, `\vb{}`, `\ce{}`), then rasterised with [resvg](https://github.com/RazrFalcon/resvg). No browser or LaTeX install needed.
30
+ - **Physical diagrams** (free-body, circuits, optics, fields) are drawn as SVG by the tutor, validated and sanitised, then shown inline.
31
+ - **Function plots** are exact: the tutor supplies expressions, [mathjs](https://mathjs.org) evaluates them, and `loops` draws the chart.
32
+ - **Inline display** works in iTerm2, WezTerm, Kitty, Ghostty and VS Code's terminal. Elsewhere you get a Unicode fallback: stacked fractions, roots, sums and bra-kets for equations, braille line art for plots. SVG diagrams need an image-capable terminal or the saved file.
33
+ - Saved notes keep `$$TeX$$` (rendered by GitHub, VS Code and Obsidian) and write each figure to `figures/*.svg` with a normal image link.
34
+ - Flowcharts, sequence, state and class diagrams (mermaid) are drawn in the terminal with [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid).
35
+
36
+ Override detection with `LOOPS_IMAGES=iterm|kitty|off`.
37
+
38
+ ## Commands
39
+
40
+ ```
41
+ loops interactive menu (check-in, then reopen or start new)
42
+ loops new [topic] start a new topic
43
+ loops continue resume
44
+ loops review review due flashcards
45
+ loops quiz interleaved mixed quiz
46
+ loops mistakes your mistake journal
47
+ loops streak your streak
48
+ loops map a topic's map and prerequisite diagram
49
+ loops topics progress overview
50
+ loops profile edit your learner profile
51
+ ```
52
+
53
+ ## Where things live
54
+
55
+ ```
56
+ ~/Learning/<folder>/<topic>/
57
+ README.md map.md mistakes.md topic.json
58
+ survey/<area>.md
59
+ deep/<area>/01-<lesson>.md challenge.md
60
+ figures/*.svg
61
+ ```
62
+
63
+ Your profile and streak live in `~/.config/loops/`.
64
+ Env: `LOOPS_HOME`, `LOOPS_PROVIDER=claude|codex`, `LOOPS_IMAGES`, `NO_COLOR`.
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as p from "@clack/prompts";
3
3
  import { listTopics, loadProfile, configDir } from "./store.js";
4
4
  import { isDue } from "./srs.js";
5
+ import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
5
6
  import { banner, bar, must, t } from "./ui.js";
6
7
  import { onboard } from "./flows/onboard.js";
7
8
  import { continueTopic, newTopic, printMap } from "./flows/learn.js";
@@ -12,6 +13,10 @@ const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as
12
13
  loops new [topic] start learning something new
13
14
  loops continue pick up where you left off
14
15
  loops review spaced-repetition review of due flashcards
16
+ loops quiz interleaved mixed quiz across everything you've learned
17
+ loops mistakes your mistake journal
18
+ loops streak your streak
19
+ loops map show a topic's map and prerequisite diagram
15
20
  loops topics list your topics and progress
16
21
  loops profile edit how you like to learn
17
22
  loops --help this help
@@ -33,17 +38,110 @@ async function chooseTopic(topics, message) {
33
38
  }));
34
39
  return topics[idx];
35
40
  }
41
+ const streakLine = (st) => {
42
+ const n = currentStreak(st);
43
+ const today = doneToday(st) ? t.ok("today ✓") : n ? t.warn("study today to keep it") : t.dim("start one today");
44
+ return `${n ? t.warn("🔥 " + n + "-day streak") : t.dim("no streak yet")} ${t.dim("· best " + st.longest + " ·")} ${today}`;
45
+ };
46
+ /** Once a day: ask about energy and time, then recommend what to do. Also sets today's session length. */
47
+ async function checkin(profile, st, topics) {
48
+ const cardCount = topics.reduce((n, tp) => n + tp.state.cards.length, 0);
49
+ const due = dueCount(topics);
50
+ let energy = "medium";
51
+ let minutes = profile.minutesPerSession;
52
+ if (st.checkin?.day === new Date().toLocaleDateString("en-CA")) {
53
+ energy = st.checkin.energy;
54
+ minutes = st.checkin.minutes;
55
+ }
56
+ else {
57
+ p.log.message(streakLine(st));
58
+ energy = must(await p.select({
59
+ message: "Quick check-in: how's your energy today?",
60
+ initialValue: "medium",
61
+ options: [
62
+ { value: "low", label: "Low", hint: "keep it light" },
63
+ { value: "medium", label: "Okay" },
64
+ { value: "high", label: "Sharp", hint: "good day for hard stuff" },
65
+ ],
66
+ }));
67
+ minutes = must(await p.select({
68
+ message: "How much time do you have?",
69
+ initialValue: profile.minutesPerSession,
70
+ options: [10, 15, 25, 45, 60].map((m) => ({ value: m, label: `${m} min` })),
71
+ }));
72
+ st = await saveCheckin(st, energy, minutes);
73
+ }
74
+ profile.minutesPerSession = minutes; // today only; not saved
75
+ let rec = "continue";
76
+ if (!topics.length)
77
+ rec = "new";
78
+ else if (due > 0 && (energy === "low" || minutes <= 10))
79
+ rec = "review";
80
+ else if (energy === "low" && cardCount >= 4)
81
+ rec = "quiz";
82
+ else if (due >= 8)
83
+ rec = "review";
84
+ return { stats: st, rec };
85
+ }
86
+ /** On launch: reopen something you've started, or begin something new. Returns once that session ends. */
87
+ async function startScreen(profile, topics, rec) {
88
+ if (!topics.length) {
89
+ p.log.message(t.dim("Nothing started yet — let's pick your first topic."));
90
+ return dispatch("new", profile);
91
+ }
92
+ const recent = topics.slice(0, 5);
93
+ const choice = must(await p.select({
94
+ message: "Pick up where you left off, or start something new?",
95
+ initialValue: rec === "new" ? "__new" : "0",
96
+ options: [
97
+ ...recent.map((tp, i) => {
98
+ const pr = progressOf(tp);
99
+ const where = tp.state.phase === "survey" ? `surveying ${pr.surveyed}/${pr.areas}` : `deep dive · ${pr.lessons} lessons done`;
100
+ return { value: String(i), label: tp.state.title, hint: `${tp.state.category} · ${where}` };
101
+ }),
102
+ { value: "__new", label: t.accent("+ Start something new") },
103
+ { value: "__menu", label: t.dim("Go to the main menu") },
104
+ ],
105
+ }));
106
+ if (choice === "__menu")
107
+ return;
108
+ if (choice === "__new")
109
+ return dispatch("new", profile);
110
+ const tp = recent[Number(choice)];
111
+ if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
112
+ printMap(tp);
113
+ await continueTopic(profile, tp);
114
+ }
36
115
  async function menu(profile) {
116
+ let first = true;
117
+ let rec = "continue";
37
118
  while (true) {
38
119
  const topics = await listTopics(profile);
39
120
  const due = dueCount(topics);
121
+ let stats = await loadStats();
122
+ if (first) {
123
+ ({ stats, rec } = await checkin(profile, stats, topics));
124
+ first = false;
125
+ await startScreen(profile, topics, rec);
126
+ continue; // reload topics and stats after the session
127
+ }
128
+ const cards = topics.reduce((n, tp) => n + tp.state.cards.length, 0);
129
+ const star = (v) => (rec === v ? t.accent(" ★ recommended for today") : "");
130
+ const opts = [
131
+ ...(topics.length ? [{ value: "continue", label: "Continue learning", hint: topics[0].state.title + star("continue") }] : []),
132
+ { value: "new", label: "Start a new topic", hint: star("new").trim() || undefined },
133
+ ...(due ? [{ value: "review", label: "Review flashcards", hint: `${due} due${star("review")}` }] : []),
134
+ ...(cards >= 4 ? [{ value: "quiz", label: "Mixed quiz", hint: `interleaved, weakest first${star("quiz")}` }] : []),
135
+ ...(topics.some((tp) => tp.state.mistakes?.length) ? [{ value: "mistakes", label: "Mistake journal" }] : []),
136
+ ...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
137
+ ];
138
+ // put the recommendation first
139
+ opts.sort((a, b) => Number(b.value === rec) - Number(a.value === rec));
140
+ p.log.message(streakLine(stats));
40
141
  const action = must(await p.select({
41
142
  message: `Hi ${profile.name}, what now?`,
42
143
  options: [
43
- ...(topics.length ? [{ value: "continue", label: "Continue learning", hint: topics[0].state.title }] : []),
44
- { value: "new", label: "Start a new topic" },
45
- ...(due ? [{ value: "review", label: `Review flashcards`, hint: `${due} due` }] : []),
46
- ...(topics.length ? [{ value: "topics", label: "My topics" }] : []),
144
+ ...opts,
47
145
  { value: "profile", label: "Edit my learner profile" },
48
146
  { value: "quit", label: "Quit" },
49
147
  ],
@@ -71,9 +169,32 @@ async function dispatch(action, profile, arg) {
71
169
  await continueTopic(profile, tp);
72
170
  break;
73
171
  }
74
- case "review": {
75
- const n = await reviewCards(topics);
76
- p.log.success(n ? t.ok(`Reviewed ${n} card${n > 1 ? "s" : ""}.`) : "Nothing due right now. Come back tomorrow — that's the point.");
172
+ case "review":
173
+ case "quiz": {
174
+ const mixed = action === "quiz";
175
+ const n = await reviewCards(topics, mixed ? 10 : 20, mixed ? "mixed" : "due");
176
+ p.log.success(n ? t.ok(`Reviewed ${n} card${n > 1 ? "s" : ""}.`) : mixed ? "No cards yet — finish a lesson first." : "Nothing due right now. Come back tomorrow — that's the point.");
177
+ if (n) {
178
+ const r = await recordActivity();
179
+ if (r.message)
180
+ p.log.success(t.warn("🔥 ") + t.bold(r.message));
181
+ }
182
+ break;
183
+ }
184
+ case "mistakes": {
185
+ const withM = topics.filter((tp) => tp.state.mistakes?.length);
186
+ if (!withM.length)
187
+ return p.log.info("No mistakes logged yet — they're how you learn fastest, so they'll show up.");
188
+ for (const tp of withM) {
189
+ const recent = (tp.state.mistakes ?? []).slice(-6);
190
+ p.note(recent.map((m) => `${t.warn(m.kind === "overconfident" ? "sure, but wrong" : m.kind)} ${t.dim("· " + m.lesson)}\n ${m.what}\n ${t.ok("→")} ${m.correction}${m.inMyWords ? `\n ${t.dim("in my words: " + m.inMyWords)}` : ""}`).join("\n\n"), `${tp.state.title} — recent mistakes`);
191
+ p.log.info(t.dim(`Full journal: ${tp.dir}/mistakes.md`));
192
+ }
193
+ break;
194
+ }
195
+ case "streak": {
196
+ const st = await loadStats();
197
+ p.note(`${streakLine(st)}\n${t.dim(`${st.totalDays} day${st.totalDays === 1 ? "" : "s"} studied in total`)}`, "Streak");
77
198
  break;
78
199
  }
79
200
  case "topics": {
@@ -86,6 +207,14 @@ async function dispatch(action, profile, arg) {
86
207
  p.note(lines.join("\n\n"), "Your topics");
87
208
  break;
88
209
  }
210
+ case "map": {
211
+ if (!topics.length)
212
+ return p.log.warn("No topics yet.");
213
+ const tp = topics.length === 1 ? topics[0] : await chooseTopic(topics, "Which topic?");
214
+ if (tp.state.map)
215
+ printMap(tp);
216
+ break;
217
+ }
89
218
  case "profile":
90
219
  await onboard(profile);
91
220
  break;
@@ -96,7 +225,7 @@ async function main() {
96
225
  if (cmd === "--help" || cmd === "-h" || cmd === "help")
97
226
  return console.log(HELP);
98
227
  if (cmd === "--version" || cmd === "-v")
99
- return console.log("0.1.0");
228
+ return console.log("0.4.0");
100
229
  if (!process.stdin.isTTY) {
101
230
  console.error("loops needs an interactive terminal.");
102
231
  process.exit(1);
@@ -109,7 +238,7 @@ async function main() {
109
238
  profile.provider = process.env.LOOPS_PROVIDER;
110
239
  if (!cmd)
111
240
  return menu(profile);
112
- if (["new", "continue", "review", "topics", "profile"].includes(cmd))
241
+ if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
113
242
  return dispatch(cmd, profile, rest.join(" ") || undefined);
114
243
  console.log(HELP);
115
244
  process.exit(1);
@@ -0,0 +1,49 @@
1
+ import { renderMermaidASCII } from "beautiful-mermaid";
2
+ const color = !!process.stdout.isTTY && !process.env.NO_COLOR;
3
+ const widest = (s) => Math.max(...s.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").length));
4
+ /** mindmap isn't supported by beautiful-mermaid, so draw it as an indented tree. */
5
+ function mindmapTree(src) {
6
+ const rows = src.split("\n").slice(1).filter((l) => l.trim());
7
+ const label = (s) => s.trim().replace(/^[\w-]*\(\((.*)\)\)$/, "$1").replace(/^[\w-]*[(\[{]+(.*?)[)\]}]+$/, "$1");
8
+ const base = rows[0]?.search(/\S/) ?? 0;
9
+ const nodes = rows.map((r) => ({ depth: Math.round((r.search(/\S/) - base) / 2), text: label(r) }));
10
+ return nodes
11
+ .map((n, i) => {
12
+ if (n.depth === 0)
13
+ return n.text;
14
+ let prefix = "";
15
+ for (let d = 1; d < n.depth; d++)
16
+ prefix += hasLaterSibling(nodes, i, d) ? "│ " : " ";
17
+ return prefix + (hasLaterSibling(nodes, i, n.depth) ? "├─ " : "└─ ") + n.text;
18
+ })
19
+ .join("\n");
20
+ }
21
+ function hasLaterSibling(nodes, i, depth) {
22
+ for (let j = i + 1; j < nodes.length; j++) {
23
+ if (nodes[j].depth < depth)
24
+ return false;
25
+ if (nodes[j].depth === depth)
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ /** Returns terminal art for a mermaid source, or null if it can't be drawn. */
31
+ export function renderDiagram(src, cols = process.stdout.columns ?? 100) {
32
+ const text = src.trim();
33
+ if (/^mindmap\b/.test(text))
34
+ return mindmapTree(text);
35
+ const attempt = (s) => {
36
+ try {
37
+ return renderMermaidASCII(s, { colorMode: color ? "ansi256" : "none", paddingY: 2, paddingX: 4 });
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ };
43
+ let out = attempt(text);
44
+ // Too wide for this terminal? Flow top-down instead.
45
+ if (out && widest(out) > cols - 4 && /^(flowchart|graph)\s+(LR|RL)/.test(text)) {
46
+ out = attempt(text.replace(/^(flowchart|graph)\s+(LR|RL)/, "$1 TD")) ?? out;
47
+ }
48
+ return out && widest(out) <= cols - 4 ? out : null;
49
+ }
@@ -0,0 +1,200 @@
1
+ import { compile } from "mathjs";
2
+ import { svgToPng } from "./math.js";
3
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
4
+ /**
5
+ * Model-drawn diagrams (free-body, ray, circuit, field, wave...) arrive as raw SVG. Keep only static
6
+ * vector content: no scripts, event handlers, foreign objects or external references. Returns null
7
+ * if it isn't usable SVG or resvg can't render it.
8
+ */
9
+ export function sanitizeSvg(src) {
10
+ let svg = src.trim();
11
+ const start = svg.indexOf("<svg");
12
+ const end = svg.lastIndexOf("</svg>");
13
+ if (start === -1 || end === -1)
14
+ return null;
15
+ svg = svg.slice(start, end + 6);
16
+ svg = svg
17
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
18
+ .replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "")
19
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*')/gi, "")
20
+ .replace(/\s(?:xlink:)?href\s*=\s*("(?!#)[^"]*"|'(?!#)[^']*')/gi, "");
21
+ if (!/xmlns=/.test(svg))
22
+ svg = svg.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"');
23
+ if (!/viewBox=/.test(svg) && !/width=/.test(svg))
24
+ svg = svg.replace("<svg", '<svg viewBox="0 0 640 360"');
25
+ // A white page behind it, so line art is legible in light and dark viewers alike.
26
+ svg = svg.replace(/(<svg[^>]*>)/, '$1<rect width="100%" height="100%" fill="#ffffff"/>');
27
+ try {
28
+ svgToPng(svg, 1);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ return svg;
34
+ }
35
+ export const svgCaption = (svg) => svg.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? "";
36
+ export function parsePlot(src) {
37
+ try {
38
+ const x = JSON.parse(src);
39
+ if (!Number.isFinite(x.xmin) || !Number.isFinite(x.xmax) || x.xmax <= x.xmin || !Array.isArray(x.series) || !x.series.length)
40
+ return null;
41
+ return x;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ const COLORS = ["#4c78a8", "#e45756", "#54a24b", "#b279a2", "#f58518"];
48
+ const niceStep = (range, target = 6) => {
49
+ const raw = range / target;
50
+ const pow = 10 ** Math.floor(Math.log10(raw));
51
+ const f = raw / pow;
52
+ return (f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10) * pow;
53
+ };
54
+ const fmt = (v) => (Math.abs(v) < 1e-9 ? "0" : String(Number(v.toPrecision(3))));
55
+ /** Exact function plot: the model supplies expressions, mathjs evaluates them, we draw the SVG. */
56
+ export function plotToSvg(spec) {
57
+ const W = 640, H = 360, L = 62, R = 24, T = spec.title ? 44 : 24, B = 56;
58
+ const N = 480;
59
+ const xs = Array.from({ length: N + 1 }, (_, i) => spec.xmin + ((spec.xmax - spec.xmin) * i) / N);
60
+ const curves = [];
61
+ for (const s of spec.series.slice(0, 5)) {
62
+ let f;
63
+ try {
64
+ f = compile(s.expr.replace(/^\s*(?:y|f\(x\))\s*=\s*/, ""));
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ curves.push({
70
+ label: s.label ?? s.expr,
71
+ pts: xs.map((x) => {
72
+ try {
73
+ const v = f.evaluate({ x, pi: Math.PI, e: Math.E });
74
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }),
80
+ });
81
+ }
82
+ const vals = curves.flatMap((c) => c.pts.filter((v) => v !== null));
83
+ if (!vals.length)
84
+ return null;
85
+ let ymin = spec.ymin ?? Math.min(...vals);
86
+ let ymax = spec.ymax ?? Math.max(...vals);
87
+ if (ymax - ymin < 1e-9)
88
+ (ymin -= 1), (ymax += 1);
89
+ if (spec.ymin === undefined)
90
+ ymin -= (ymax - ymin) * 0.06;
91
+ if (spec.ymax === undefined)
92
+ ymax += (ymax - ymin) * 0.06;
93
+ const px = (x) => L + ((x - spec.xmin) / (spec.xmax - spec.xmin)) * (W - L - R);
94
+ const py = (y) => H - B - ((y - ymin) / (ymax - ymin)) * (H - T - B);
95
+ const out = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" font-family="Helvetica, Arial, sans-serif" font-size="12">`, '<rect width="100%" height="100%" fill="#ffffff"/>'];
96
+ const xstep = niceStep(spec.xmax - spec.xmin);
97
+ for (let v = Math.ceil(spec.xmin / xstep) * xstep; v <= spec.xmax + 1e-9; v += xstep)
98
+ out.push(`<line x1="${px(v)}" y1="${T}" x2="${px(v)}" y2="${H - B}" stroke="#e6e6ee"/><text x="${px(v)}" y="${H - B + 16}" text-anchor="middle" fill="#555">${fmt(v)}</text>`);
99
+ const ystep = niceStep(ymax - ymin);
100
+ for (let v = Math.ceil(ymin / ystep) * ystep; v <= ymax + 1e-9; v += ystep)
101
+ out.push(`<line x1="${L}" y1="${py(v)}" x2="${W - R}" y2="${py(v)}" stroke="#e6e6ee"/><text x="${L - 8}" y="${py(v) + 4}" text-anchor="end" fill="#555">${fmt(v)}</text>`);
102
+ if (ymin < 0 && ymax > 0)
103
+ out.push(`<line x1="${L}" y1="${py(0)}" x2="${W - R}" y2="${py(0)}" stroke="#888"/>`);
104
+ if (spec.xmin < 0 && spec.xmax > 0)
105
+ out.push(`<line x1="${px(0)}" y1="${T}" x2="${px(0)}" y2="${H - B}" stroke="#888"/>`);
106
+ out.push(`<rect x="${L}" y="${T}" width="${W - L - R}" height="${H - T - B}" fill="none" stroke="#999"/>`);
107
+ curves.forEach((c, k) => {
108
+ let d = "";
109
+ let pen = false;
110
+ c.pts.forEach((v, i) => {
111
+ if (v === null || v < ymin - (ymax - ymin) || v > ymax + (ymax - ymin))
112
+ return void (pen = false);
113
+ d += `${pen ? "L" : "M"}${px(xs[i]).toFixed(1)},${py(v).toFixed(1)}`;
114
+ pen = true;
115
+ });
116
+ out.push(`<path d="${d}" fill="none" stroke="${COLORS[k % COLORS.length]}" stroke-width="2.4" stroke-linejoin="round"/>`);
117
+ });
118
+ if (spec.title)
119
+ out.push(`<text x="${W / 2}" y="24" text-anchor="middle" font-size="15" font-weight="bold" fill="#222">${esc(spec.title)}</text>`);
120
+ if (spec.xlabel)
121
+ out.push(`<text x="${(L + W - R) / 2}" y="${H - 12}" text-anchor="middle" fill="#333">${esc(spec.xlabel)}</text>`);
122
+ if (spec.ylabel)
123
+ out.push(`<text transform="translate(16 ${(T + H - B) / 2}) rotate(-90)" text-anchor="middle" fill="#333">${esc(spec.ylabel)}</text>`);
124
+ if (curves.length > 1 || spec.series[0].label)
125
+ curves.forEach((c, k) => out.push(`<rect x="${W - R - 150}" y="${T + 8 + k * 18}" width="12" height="4" fill="${COLORS[k % COLORS.length]}"/><text x="${W - R - 132}" y="${T + 14 + k * 18}" fill="#333">${esc(c.label)}</text>`));
126
+ out.push("</svg>");
127
+ return out.join("");
128
+ }
129
+ export function renderPlot(src) {
130
+ const spec = parsePlot(src);
131
+ const svg = spec && plotToSvg(spec);
132
+ if (!spec || !svg)
133
+ return null;
134
+ return { svg, raster: svgToPng(svg, 2), caption: spec.title ?? "plot" };
135
+ }
136
+ const BRAILLE = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
137
+ const CURVE_ANSI = [75, 203, 114, 176, 208];
138
+ /** Plot as braille dots (2x4 per cell): decent line art in any Unicode terminal. */
139
+ export function plotToBraille(spec, cols = 64, rows = 14, color = true) {
140
+ const Wd = cols * 2, Hd = rows * 4;
141
+ const curves = spec.series.slice(0, 5).map((s) => {
142
+ try {
143
+ const f = compile(s.expr.replace(/^\s*(?:y|f\(x\))\s*=\s*/, ""));
144
+ return Array.from({ length: Wd }, (_, i) => {
145
+ const v = f.evaluate({ x: spec.xmin + ((spec.xmax - spec.xmin) * i) / (Wd - 1), pi: Math.PI, e: Math.E });
146
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
147
+ });
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ });
153
+ if (curves.some((c) => c === null))
154
+ return null;
155
+ const vals = curves.flat().filter((v) => v !== null);
156
+ if (!vals.length)
157
+ return null;
158
+ let ymin = spec.ymin ?? Math.min(...vals), ymax = spec.ymax ?? Math.max(...vals);
159
+ if (ymax - ymin < 1e-9)
160
+ (ymin -= 1), (ymax += 1);
161
+ const dots = new Map();
162
+ const plot = (x, y, k) => {
163
+ if (x < 0 || x >= Wd || y < 0 || y >= Hd)
164
+ return;
165
+ const key = Math.floor(y / 4) * cols + Math.floor(x / 2);
166
+ const cur = dots.get(key) ?? { mask: 0, series: k };
167
+ cur.mask |= BRAILLE[y % 4][x % 2];
168
+ dots.set(key, cur);
169
+ };
170
+ curves.forEach((c, k) => {
171
+ let prev = null;
172
+ c.forEach((v, x) => {
173
+ if (v === null)
174
+ return void (prev = null);
175
+ const y = Math.round((1 - (v - ymin) / (ymax - ymin)) * (Hd - 1));
176
+ const from = prev ?? y;
177
+ for (let yy = Math.min(from, y); yy <= Math.max(from, y); yy++)
178
+ plot(x, yy, k);
179
+ prev = y;
180
+ });
181
+ });
182
+ const paint = (s, k) => (color ? `\x1b[38;5;${CURVE_ANSI[k % CURVE_ANSI.length]}m${s}\x1b[39m` : s);
183
+ const lab = (v) => fmt(v).padStart(7);
184
+ const lines = [];
185
+ if (spec.title)
186
+ lines.push(" ".repeat(8) + spec.title);
187
+ for (let r = 0; r < rows; r++) {
188
+ let row = "";
189
+ for (let c = 0; c < cols; c++) {
190
+ const d = dots.get(r * cols + c);
191
+ row += d ? paint(String.fromCharCode(0x2800 + d.mask), d.series) : " ";
192
+ }
193
+ lines.push((r === 0 ? lab(ymax) : r === rows - 1 ? lab(ymin) : r === Math.floor(rows / 2) ? lab((ymin + ymax) / 2) : " ".repeat(7)) + " │" + row);
194
+ }
195
+ lines.push(" ".repeat(8) + "└" + "─".repeat(cols));
196
+ lines.push(" ".repeat(9) + fmt(spec.xmin) + " ".repeat(Math.max(1, cols - fmt(spec.xmin).length - fmt(spec.xmax).length)) + fmt(spec.xmax) + (spec.xlabel ? ` ${spec.xlabel}` : ""));
197
+ if (spec.series.length > 1 || spec.series[0].label)
198
+ lines.push(" ".repeat(9) + spec.series.slice(0, 5).map((s, k) => paint("━ " + (s.label ?? s.expr), k)).join(" "));
199
+ return lines;
200
+ }