learn-loops 0.0.0-stage → 0.4.0
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 +63 -2
- package/dist/cli.js +250 -0
- package/dist/diagram.js +49 -0
- package/dist/figures.js +200 -0
- package/dist/flows/learn.js +344 -0
- package/dist/flows/onboard.js +124 -0
- package/dist/flows/quiz.js +175 -0
- package/dist/flows/think.js +120 -0
- package/dist/llm.js +87 -0
- package/dist/markdown.js +136 -0
- package/dist/math.js +307 -0
- package/dist/prompts.js +149 -0
- package/dist/srs.js +50 -0
- package/dist/stats.js +48 -0
- package/dist/store.js +88 -0
- package/dist/termimg.js +27 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +185 -0
- package/package.json +45 -4
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# loops
|
|
2
2
|
|
|
3
|
-
|
|
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
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { listTopics, loadProfile, configDir } from "./store.js";
|
|
4
|
+
import { isDue } from "./srs.js";
|
|
5
|
+
import { currentStreak, doneToday, loadStats, recordActivity, saveCheckin } from "./stats.js";
|
|
6
|
+
import { banner, bar, must, t } from "./ui.js";
|
|
7
|
+
import { onboard } from "./flows/onboard.js";
|
|
8
|
+
import { continueTopic, newTopic, printMap } from "./flows/learn.js";
|
|
9
|
+
import { reviewCards } from "./flows/quiz.js";
|
|
10
|
+
const HELP = `${t.bold("loops")} — learn anything with Claude Code or Codex as your tutor
|
|
11
|
+
|
|
12
|
+
loops interactive menu
|
|
13
|
+
loops new [topic] start learning something new
|
|
14
|
+
loops continue pick up where you left off
|
|
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
|
|
20
|
+
loops topics list your topics and progress
|
|
21
|
+
loops profile edit how you like to learn
|
|
22
|
+
loops --help this help
|
|
23
|
+
|
|
24
|
+
Env: LOOPS_HOME (config dir), LOOPS_PROVIDER=claude|codex, NO_COLOR`;
|
|
25
|
+
const dueCount = (topics) => topics.reduce((n, tp) => n + tp.state.cards.filter((c) => isDue(c)).length, 0);
|
|
26
|
+
function progressOf(tp) {
|
|
27
|
+
const areas = tp.state.map?.areas ?? [];
|
|
28
|
+
const lessons = areas.reduce((n, a) => n + (tp.state.progress[a.id]?.done.length ?? 0), 0);
|
|
29
|
+
return { areas: areas.length, surveyed: areas.filter((a) => tp.state.progress[a.id]?.surveyed).length, lessons };
|
|
30
|
+
}
|
|
31
|
+
async function chooseTopic(topics, message) {
|
|
32
|
+
const idx = must(await p.select({
|
|
33
|
+
message,
|
|
34
|
+
options: topics.map((tp, i) => {
|
|
35
|
+
const pr = progressOf(tp);
|
|
36
|
+
return { value: i, label: `${tp.state.title}`, hint: `${tp.state.category} · ${pr.surveyed}/${pr.areas} surveyed · ${pr.lessons} deep lessons` };
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
return topics[idx];
|
|
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
|
+
}
|
|
115
|
+
async function menu(profile) {
|
|
116
|
+
let first = true;
|
|
117
|
+
let rec = "continue";
|
|
118
|
+
while (true) {
|
|
119
|
+
const topics = await listTopics(profile);
|
|
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));
|
|
141
|
+
const action = must(await p.select({
|
|
142
|
+
message: `Hi ${profile.name}, what now?`,
|
|
143
|
+
options: [
|
|
144
|
+
...opts,
|
|
145
|
+
{ value: "profile", label: "Edit my learner profile" },
|
|
146
|
+
{ value: "quit", label: "Quit" },
|
|
147
|
+
],
|
|
148
|
+
}));
|
|
149
|
+
if (action === "quit")
|
|
150
|
+
return p.outro(t.dim("Keep the loop going ✦"));
|
|
151
|
+
await dispatch(action, profile);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function dispatch(action, profile, arg) {
|
|
155
|
+
const topics = await listTopics(profile);
|
|
156
|
+
switch (action) {
|
|
157
|
+
case "new": {
|
|
158
|
+
const tp = await newTopic(profile, arg);
|
|
159
|
+
if (tp && must(await p.select({ message: "Start the broad survey now?", options: [{ value: true, label: "Yes, let's go" }, { value: false, label: "Later" }] })))
|
|
160
|
+
await continueTopic(profile, tp);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case "continue": {
|
|
164
|
+
if (!topics.length)
|
|
165
|
+
return p.log.warn("No topics yet — try `loops new`.");
|
|
166
|
+
const tp = topics.length === 1 ? topics[0] : await chooseTopic(topics, "Which topic?");
|
|
167
|
+
if (tp.state.map && tp.state.phase === "survey" && !tp.state.progress[tp.state.map.areas[0].id]?.surveyed)
|
|
168
|
+
printMap(tp);
|
|
169
|
+
await continueTopic(profile, tp);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
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");
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "topics": {
|
|
201
|
+
if (!topics.length)
|
|
202
|
+
return p.log.warn("No topics yet.");
|
|
203
|
+
const lines = topics.map((tp) => {
|
|
204
|
+
const pr = progressOf(tp);
|
|
205
|
+
return `${t.bold(tp.state.title)} ${t.dim(`(${tp.state.category})`)}\n survey ${bar(pr.surveyed, pr.areas, 12)} ${t.dim(`${pr.lessons} deep lessons · ${tp.state.cards.length} cards`)}\n ${t.dim(tp.dir)}`;
|
|
206
|
+
});
|
|
207
|
+
p.note(lines.join("\n\n"), "Your topics");
|
|
208
|
+
break;
|
|
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
|
+
}
|
|
218
|
+
case "profile":
|
|
219
|
+
await onboard(profile);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function main() {
|
|
224
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
225
|
+
if (cmd === "--help" || cmd === "-h" || cmd === "help")
|
|
226
|
+
return console.log(HELP);
|
|
227
|
+
if (cmd === "--version" || cmd === "-v")
|
|
228
|
+
return console.log("0.4.0");
|
|
229
|
+
if (!process.stdin.isTTY) {
|
|
230
|
+
console.error("loops needs an interactive terminal.");
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
banner();
|
|
234
|
+
let profile = await loadProfile();
|
|
235
|
+
if (!profile)
|
|
236
|
+
profile = await onboard();
|
|
237
|
+
if (process.env.LOOPS_PROVIDER === "claude" || process.env.LOOPS_PROVIDER === "codex")
|
|
238
|
+
profile.provider = process.env.LOOPS_PROVIDER;
|
|
239
|
+
if (!cmd)
|
|
240
|
+
return menu(profile);
|
|
241
|
+
if (["new", "continue", "review", "quiz", "mistakes", "streak", "topics", "profile", "map"].includes(cmd))
|
|
242
|
+
return dispatch(cmd, profile, rest.join(" ") || undefined);
|
|
243
|
+
console.log(HELP);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
main().catch((e) => {
|
|
247
|
+
p.log.error(t.bad(e.message));
|
|
248
|
+
console.error(t.dim(`(config: ${configDir()})`));
|
|
249
|
+
process.exit(1);
|
|
250
|
+
});
|
package/dist/diagram.js
ADDED
|
@@ -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
|
+
}
|
package/dist/figures.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { compile } from "mathjs";
|
|
2
|
+
import { svgToPng } from "./math.js";
|
|
3
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|