explico 0.2.0 → 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/CHANGELOG.md +173 -0
- package/README.md +67 -7
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +43 -9
- package/dist/compiler/collections.js +4 -1
- package/dist/compiler/course-config.d.ts +80 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +98 -2
- package/dist/compiler/manifest.js +153 -24
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +51 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +10 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +80 -13
- package/dist/reference/equations.mjs +109 -19
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/keyed-float-validate.d.ts +73 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +64 -0
- package/dist/reference/keyed-jsx-floats.mjs +191 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/remark-notation.d.mts +61 -0
- package/dist/reference/remark-notation.mjs +344 -10
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +100 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +169 -0
- package/docs/authoring/notation.md +297 -0
- package/docs/authoring/structure.md +139 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +52 -7
- package/src/compiler/collections.ts +12 -3
- package/src/compiler/course-config.ts +93 -3
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +320 -26
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/notation/NotationLayer.astro +11 -0
- package/src/components/notation/render-notation-math.ts +13 -2
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +15 -2
- package/src/components/table/Table.astro +105 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +83 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +8 -1
- package/src/reference/equations.mjs +109 -19
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/keyed-float-validate.ts +210 -0
- package/src/reference/keyed-jsx-floats.mjs +191 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/remark-notation.mjs +344 -10
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +244 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base operator library: the named mathematical operators every reader is
|
|
3
|
+
* assumed to know, so a lesson never has to introduce them with a notation
|
|
4
|
+
* entry.
|
|
5
|
+
*
|
|
6
|
+
* Rationale (parallel to `base-library.mjs`): the completeness gate has no
|
|
7
|
+
* opinion about operators — KaTeX parses `\exp`, `\int`, `\sin`, … as operator
|
|
8
|
+
* nodes, and `\operatorname{Var}` as an `operatorname` node, none of which the
|
|
9
|
+
* glyph resolver treats as a bindable symbol. The `operator-explained`
|
|
10
|
+
* consistency check (rule N8) fills that gap: an operator a lesson uses is
|
|
11
|
+
* either on this list (or the course's additions to it) or it carries a
|
|
12
|
+
* notation entry the reader can hover. This set is the "everyone knows these"
|
|
13
|
+
* baseline — trig, the exponential and logarithms, limits, extrema, and the
|
|
14
|
+
* big operators — kept greppable and small like the base library.
|
|
15
|
+
*
|
|
16
|
+
* A domain operator (`Var`, `Cov`, `Corr`, `sgn`, `erf`, `Tr`, `argmax`, …) is
|
|
17
|
+
* written `\operatorname{…}` and is NOT here: it earns a card, a
|
|
18
|
+
* `notation.local` entry, or an explicit line in `course.config.ts`
|
|
19
|
+
* (`operators.allow`).
|
|
20
|
+
*
|
|
21
|
+
* Entries are the KaTeX command without its backslash (`exp`, not `\exp`) for
|
|
22
|
+
* `op` nodes, and the verbatim body for `\operatorname{…}` nodes (`Var`).
|
|
23
|
+
* Matching is case-sensitive.
|
|
24
|
+
*/
|
|
25
|
+
export const BASE_OPERATORS = Object.freeze(
|
|
26
|
+
new Set([
|
|
27
|
+
// exponential / logarithms
|
|
28
|
+
'exp',
|
|
29
|
+
'log',
|
|
30
|
+
'ln',
|
|
31
|
+
'lg',
|
|
32
|
+
// trigonometric
|
|
33
|
+
'sin',
|
|
34
|
+
'cos',
|
|
35
|
+
'tan',
|
|
36
|
+
'cot',
|
|
37
|
+
'sec',
|
|
38
|
+
'csc',
|
|
39
|
+
// inverse trigonometric
|
|
40
|
+
'arcsin',
|
|
41
|
+
'arccos',
|
|
42
|
+
'arctan',
|
|
43
|
+
// hyperbolic
|
|
44
|
+
'sinh',
|
|
45
|
+
'cosh',
|
|
46
|
+
'tanh',
|
|
47
|
+
'coth',
|
|
48
|
+
// limits
|
|
49
|
+
'lim',
|
|
50
|
+
'limsup',
|
|
51
|
+
'liminf',
|
|
52
|
+
'injlim',
|
|
53
|
+
'projlim',
|
|
54
|
+
'varlimsup',
|
|
55
|
+
'varliminf',
|
|
56
|
+
'varinjlim',
|
|
57
|
+
'varprojlim',
|
|
58
|
+
// extrema
|
|
59
|
+
'max',
|
|
60
|
+
'min',
|
|
61
|
+
'sup',
|
|
62
|
+
'inf',
|
|
63
|
+
// linear algebra / number theory / misc
|
|
64
|
+
'arg',
|
|
65
|
+
'deg',
|
|
66
|
+
'det',
|
|
67
|
+
'dim',
|
|
68
|
+
'gcd',
|
|
69
|
+
'hom',
|
|
70
|
+
'ker',
|
|
71
|
+
'Pr',
|
|
72
|
+
'mod',
|
|
73
|
+
// big operators
|
|
74
|
+
'sum',
|
|
75
|
+
'prod',
|
|
76
|
+
'coprod',
|
|
77
|
+
'int',
|
|
78
|
+
'iint',
|
|
79
|
+
'iiint',
|
|
80
|
+
'oint',
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The operators a course treats as common, given its `course.config.ts`
|
|
86
|
+
* `operators` block: the shipped baseline, plus `allow`, minus `deny`.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} operators
|
|
89
|
+
* @returns {Set<string>}
|
|
90
|
+
*/
|
|
91
|
+
export function effectiveOperatorAllowlist(operators) {
|
|
92
|
+
const allow = operators?.allow ?? [];
|
|
93
|
+
const deny = new Set(operators?.deny ?? []);
|
|
94
|
+
const effective = new Set(BASE_OPERATORS);
|
|
95
|
+
for (const name of allow) effective.add(name);
|
|
96
|
+
for (const name of deny) effective.delete(name);
|
|
97
|
+
return effective;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when `name` needs no notation entry for this course. */
|
|
101
|
+
export function isBaseOperator(name, operators) {
|
|
102
|
+
return effectiveOperatorAllowlist(operators).has(name);
|
|
103
|
+
}
|
|
@@ -19,15 +19,23 @@
|
|
|
19
19
|
* (f) weak-local a `notation.local` symbol used only in its
|
|
20
20
|
* own defining equation and prose
|
|
21
21
|
* (g) notation-source-locator every notation `sources` entry has a locator
|
|
22
|
+
* (h) operator-explained a named operator a lesson uses is on the
|
|
23
|
+
* shipped/course operator allowlist or
|
|
24
|
+
* carries a notation entry (rule N8)
|
|
22
25
|
*
|
|
23
26
|
* Check (d) — the committed per-lesson resolution report — lives in
|
|
24
27
|
* `src/content/resolution-report.ts`.
|
|
25
28
|
*/
|
|
26
29
|
|
|
27
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
CourseConventions,
|
|
32
|
+
CourseOperators,
|
|
33
|
+
} from '../compiler/course-config';
|
|
28
34
|
import type { CurriculumCatalog } from '../curriculum/validation';
|
|
29
35
|
import type { NotationSpec } from '../compiler/collections';
|
|
30
36
|
import type { LintIgnore } from './lint-ignore';
|
|
37
|
+
import { effectiveOperatorAllowlist } from './base-operators.mjs';
|
|
38
|
+
import { collectOperators } from './math-glyphs.mjs';
|
|
31
39
|
import { offVocabularyTokens } from './units-vocab';
|
|
32
40
|
import type {
|
|
33
41
|
NotationDefinitionRecord,
|
|
@@ -45,7 +53,8 @@ export type ConsistencyCode =
|
|
|
45
53
|
| 'notation-source-locator'
|
|
46
54
|
| 'gloss-wants-promoting'
|
|
47
55
|
| 'card-wants-demoting'
|
|
48
|
-
| 'gloss-name-shape'
|
|
56
|
+
| 'gloss-name-shape'
|
|
57
|
+
| 'operator-explained';
|
|
49
58
|
|
|
50
59
|
export interface ConsistencyDiagnostic {
|
|
51
60
|
readonly code: ConsistencyCode;
|
|
@@ -68,6 +77,8 @@ export interface ConsistencyInput {
|
|
|
68
77
|
readonly lintIgnore: LintIgnore;
|
|
69
78
|
/** Convention keys this course anchors on (from `content/course.config.ts`). */
|
|
70
79
|
readonly conventions: CourseConventions;
|
|
80
|
+
/** This course's edits to the named-operator baseline (rule N8). */
|
|
81
|
+
readonly operators?: CourseOperators;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
const CONVENTION_RE =
|
|
@@ -472,6 +483,74 @@ function checkGlossNameShape(
|
|
|
472
483
|
}
|
|
473
484
|
}
|
|
474
485
|
|
|
486
|
+
/**
|
|
487
|
+
* A named operator a lesson uses is either universal — on the shipped
|
|
488
|
+
* `BASE_OPERATORS` baseline plus this course's `operators.allow`, minus its
|
|
489
|
+
* `operators.deny` — or it is explained: a notation card or `notation.local`
|
|
490
|
+
* entry the lesson binds with `[[key]]` whose `latex` is that operator, so the
|
|
491
|
+
* reader can hover it. Anything else (`\operatorname{Var}` with no entry, or a
|
|
492
|
+
* baseline operator the course chose to `deny`) is a finding. Rule N8.
|
|
493
|
+
*
|
|
494
|
+
* The glyph resolver skips operator bodies whole, so this is the only check
|
|
495
|
+
* that looks at them; it reads `collectOperators`, which parses with the same
|
|
496
|
+
* pinned KaTeX as the completeness gate.
|
|
497
|
+
*/
|
|
498
|
+
function checkOperatorExplained(
|
|
499
|
+
input: ConsistencyInput,
|
|
500
|
+
out: ConsistencyDiagnostic[],
|
|
501
|
+
): void {
|
|
502
|
+
const allowlist = effectiveOperatorAllowlist(input.operators);
|
|
503
|
+
const byId = new Map<string, NotationDefinitionRecord>(
|
|
504
|
+
input.registry.definitions.map((definition) => [definition.id, definition]),
|
|
505
|
+
);
|
|
506
|
+
const bindingsByLesson = new Map(
|
|
507
|
+
input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings]),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
for (const lesson of input.notationInput.lessons) {
|
|
511
|
+
// Operators the reader is shown on this page: the lesson's own
|
|
512
|
+
// `notation.local` entries, plus every shared card it binds with `[[key]]`.
|
|
513
|
+
const explained = new Set<string>();
|
|
514
|
+
const noteEntryOperators = (latex: string): void => {
|
|
515
|
+
for (const operator of collectOperators(latex)) {
|
|
516
|
+
explained.add(operator.name);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
for (const definition of lesson.localDefinitions) {
|
|
520
|
+
noteEntryOperators(definition.notation);
|
|
521
|
+
}
|
|
522
|
+
for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
|
|
523
|
+
const definition = byId.get(binding.definitionId);
|
|
524
|
+
if (definition) noteEntryOperators(definition.notation);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const reported = new Set<string>();
|
|
528
|
+
for (const span of mathSpans(lesson.body)) {
|
|
529
|
+
for (const operator of collectOperators(span.latex)) {
|
|
530
|
+
if (allowlist.has(operator.name)) continue;
|
|
531
|
+
if (explained.has(operator.name)) continue;
|
|
532
|
+
if (reported.has(operator.name)) continue;
|
|
533
|
+
reported.add(operator.name);
|
|
534
|
+
if (input.lintIgnore.matches('operator-explained', operator.name)) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const display =
|
|
538
|
+
operator.source === 'operatorname'
|
|
539
|
+
? `\\operatorname{${operator.name}}`
|
|
540
|
+
: `\\${operator.name}`;
|
|
541
|
+
out.push({
|
|
542
|
+
code: 'operator-explained',
|
|
543
|
+
severity: 'warning',
|
|
544
|
+
lessonId: lesson.lessonId,
|
|
545
|
+
file: lesson.source.file,
|
|
546
|
+
glyph: operator.name,
|
|
547
|
+
message: `${lesson.lessonId}: operator ${JSON.stringify(display)} is neither on the course operator allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(display)}) introduced with [[key]], or add ${JSON.stringify(operator.name)} to operators.allow in course.config.ts`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
475
554
|
const CHECKS: ((
|
|
476
555
|
input: ConsistencyInput,
|
|
477
556
|
out: ConsistencyDiagnostic[],
|
|
@@ -485,6 +564,7 @@ const CHECKS: ((
|
|
|
485
564
|
checkGlossWantsPromoting,
|
|
486
565
|
checkCardWantsDemoting,
|
|
487
566
|
checkGlossNameShape,
|
|
567
|
+
checkOperatorExplained,
|
|
488
568
|
];
|
|
489
569
|
|
|
490
570
|
/**
|
|
@@ -522,6 +602,7 @@ export function consistencyCounts(
|
|
|
522
602
|
'gloss-wants-promoting': 0,
|
|
523
603
|
'card-wants-demoting': 0,
|
|
524
604
|
'gloss-name-shape': 0,
|
|
605
|
+
'operator-explained': 0,
|
|
525
606
|
} satisfies Record<ConsistencyCode, number>;
|
|
526
607
|
for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
|
|
527
608
|
return counts;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time graph layout for the `Diagram` component.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@dagrejs/dagre` — a pure layout algorithm, no DOM — so node
|
|
5
|
+
* positions and edge routes are computed once at build time, the same way
|
|
6
|
+
* `renderLabMath` and the notation formulas are: static output, no runtime
|
|
7
|
+
* layout library shipped to the reader. `Diagram.astro` turns the result into
|
|
8
|
+
* SVG; this module only computes geometry and is deliberately framework-free
|
|
9
|
+
* so it is unit-testable without KaTeX, Astro, or a browser.
|
|
10
|
+
*
|
|
11
|
+
* Box sizes are not measured from a real render (there is no DOM at build
|
|
12
|
+
* time) — `estimateBoxSize` is a deliberately generous character-count
|
|
13
|
+
* heuristic. It is meant to avoid overlap and clipping in the common case,
|
|
14
|
+
* not to be pixel-accurate; a diagram author can always pass an explicit
|
|
15
|
+
* `width`/`height` on a node for a wide equation.
|
|
16
|
+
*/
|
|
17
|
+
import dagre from '@dagrejs/dagre';
|
|
18
|
+
import type {
|
|
19
|
+
EdgeLabel as DagreEdgeLabel,
|
|
20
|
+
GraphLabel as DagreGraphLabel,
|
|
21
|
+
NodeLabel as DagreNodeLabel,
|
|
22
|
+
} from '@dagrejs/dagre';
|
|
23
|
+
|
|
24
|
+
export interface DiagramNodeInput {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly width: number;
|
|
27
|
+
readonly height: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DiagramEdgeInput {
|
|
31
|
+
readonly from: string;
|
|
32
|
+
readonly to: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DiagramLayoutOptions {
|
|
36
|
+
/** Top-to-bottom (default) or left-to-right. */
|
|
37
|
+
readonly direction?: 'TB' | 'LR';
|
|
38
|
+
readonly nodeSeparation?: number;
|
|
39
|
+
readonly rankSeparation?: number;
|
|
40
|
+
/** Uniform padding added around the computed bounding box. */
|
|
41
|
+
readonly padding?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Point {
|
|
45
|
+
readonly x: number;
|
|
46
|
+
readonly y: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LaidOutNode {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
/** Top-left corner, not the center dagre itself works in. */
|
|
52
|
+
readonly x: number;
|
|
53
|
+
readonly y: number;
|
|
54
|
+
readonly width: number;
|
|
55
|
+
readonly height: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LaidOutEdge {
|
|
59
|
+
readonly from: string;
|
|
60
|
+
readonly to: string;
|
|
61
|
+
readonly points: readonly Point[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DiagramLayout {
|
|
65
|
+
readonly nodes: readonly LaidOutNode[];
|
|
66
|
+
readonly edges: readonly LaidOutEdge[];
|
|
67
|
+
/** The SVG canvas size a caller should use as the `viewBox`. */
|
|
68
|
+
readonly width: number;
|
|
69
|
+
readonly height: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lay out a small directed graph. Node ids must be unique; an edge naming an
|
|
74
|
+
* undeclared node fails loudly rather than silently dropping it (dagre itself
|
|
75
|
+
* would otherwise create a phantom zero-size node).
|
|
76
|
+
*/
|
|
77
|
+
export function layoutDiagram(
|
|
78
|
+
nodes: readonly DiagramNodeInput[],
|
|
79
|
+
edges: readonly DiagramEdgeInput[],
|
|
80
|
+
options: DiagramLayoutOptions = {},
|
|
81
|
+
): DiagramLayout {
|
|
82
|
+
const ids = new Set<string>();
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
if (ids.has(node.id)) {
|
|
85
|
+
throw new Error(`Diagram declares node "${node.id}" more than once.`);
|
|
86
|
+
}
|
|
87
|
+
if (!(node.width > 0) || !(node.height > 0)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Diagram node "${node.id}" must have a positive width and height.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
ids.add(node.id);
|
|
93
|
+
}
|
|
94
|
+
for (const edge of edges) {
|
|
95
|
+
if (!ids.has(edge.from)) {
|
|
96
|
+
throw new Error(`Diagram edge references unknown node "${edge.from}".`);
|
|
97
|
+
}
|
|
98
|
+
if (!ids.has(edge.to)) {
|
|
99
|
+
throw new Error(`Diagram edge references unknown node "${edge.to}".`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const graph = new dagre.graphlib.Graph<
|
|
104
|
+
DagreGraphLabel,
|
|
105
|
+
DagreNodeLabel,
|
|
106
|
+
DagreEdgeLabel
|
|
107
|
+
>();
|
|
108
|
+
graph.setGraph({
|
|
109
|
+
rankdir: options.direction ?? 'TB',
|
|
110
|
+
nodesep: options.nodeSeparation ?? 24,
|
|
111
|
+
ranksep: options.rankSeparation ?? 48,
|
|
112
|
+
marginx: 0,
|
|
113
|
+
marginy: 0,
|
|
114
|
+
});
|
|
115
|
+
graph.setDefaultEdgeLabel(() => ({}));
|
|
116
|
+
|
|
117
|
+
for (const node of nodes) {
|
|
118
|
+
graph.setNode(node.id, { width: node.width, height: node.height });
|
|
119
|
+
}
|
|
120
|
+
for (const edge of edges) {
|
|
121
|
+
graph.setEdge(edge.from, edge.to);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dagre.layout(graph);
|
|
125
|
+
|
|
126
|
+
const centeredNodes = nodes.map((node) => {
|
|
127
|
+
const label = graph.node(node.id);
|
|
128
|
+
const cx = label.x ?? 0;
|
|
129
|
+
const cy = label.y ?? 0;
|
|
130
|
+
return {
|
|
131
|
+
id: node.id,
|
|
132
|
+
x: cx - node.width / 2,
|
|
133
|
+
y: cy - node.height / 2,
|
|
134
|
+
width: node.width,
|
|
135
|
+
height: node.height,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const rawEdges = edges.map((edge) => {
|
|
140
|
+
const label = graph.edge(edge.from, edge.to) ?? {};
|
|
141
|
+
return {
|
|
142
|
+
from: edge.from,
|
|
143
|
+
to: edge.to,
|
|
144
|
+
points: (label.points ?? []).map((point) => ({
|
|
145
|
+
x: point.x,
|
|
146
|
+
y: point.y,
|
|
147
|
+
})),
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// dagre does not guarantee the layout starts at (0, 0); compute the real
|
|
152
|
+
// bounding box from node boxes and edge routing points, then shift
|
|
153
|
+
// everything so the canvas starts at `padding`.
|
|
154
|
+
const padding = options.padding ?? 12;
|
|
155
|
+
const xs = [
|
|
156
|
+
...centeredNodes.flatMap((node) => [node.x, node.x + node.width]),
|
|
157
|
+
...rawEdges.flatMap((edge) => edge.points.map((point) => point.x)),
|
|
158
|
+
];
|
|
159
|
+
const ys = [
|
|
160
|
+
...centeredNodes.flatMap((node) => [node.y, node.y + node.height]),
|
|
161
|
+
...rawEdges.flatMap((edge) => edge.points.map((point) => point.y)),
|
|
162
|
+
];
|
|
163
|
+
const minX = xs.length > 0 ? Math.min(...xs) : 0;
|
|
164
|
+
const minY = ys.length > 0 ? Math.min(...ys) : 0;
|
|
165
|
+
const maxX = xs.length > 0 ? Math.max(...xs) : 0;
|
|
166
|
+
const maxY = ys.length > 0 ? Math.max(...ys) : 0;
|
|
167
|
+
const shiftX = padding - minX;
|
|
168
|
+
const shiftY = padding - minY;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
nodes: centeredNodes.map((node) => ({
|
|
172
|
+
...node,
|
|
173
|
+
x: node.x + shiftX,
|
|
174
|
+
y: node.y + shiftY,
|
|
175
|
+
})),
|
|
176
|
+
edges: rawEdges.map((edge) => ({
|
|
177
|
+
...edge,
|
|
178
|
+
points: edge.points.map((point) => ({
|
|
179
|
+
x: point.x + shiftX,
|
|
180
|
+
y: point.y + shiftY,
|
|
181
|
+
})),
|
|
182
|
+
})),
|
|
183
|
+
width: maxX - minX + padding * 2,
|
|
184
|
+
height: maxY - minY + padding * 2,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- box-size estimation (no DOM at build time) --------------------------
|
|
189
|
+
|
|
190
|
+
export interface BoxSizeEstimate {
|
|
191
|
+
readonly width: number;
|
|
192
|
+
readonly height: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const DEFAULT_MAX_WIDTH = 220;
|
|
196
|
+
const MIN_WIDTH = 90;
|
|
197
|
+
const PADDING_X = 20;
|
|
198
|
+
const PADDING_Y = 16;
|
|
199
|
+
const LINE_HEIGHT = 22;
|
|
200
|
+
/** Generous per-visible-character width estimates, in px, at diagram scale. */
|
|
201
|
+
const CHAR_WIDTH: Record<'text' | 'formula', number> = {
|
|
202
|
+
text: 7.2,
|
|
203
|
+
formula: 11,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A LaTeX command name (`\theta`, `\cdot`, …) renders as roughly one glyph,
|
|
208
|
+
* not one character per letter of its name; braces are structural, not
|
|
209
|
+
* visible. Collapsing both keeps the estimate from reading a formula as far
|
|
210
|
+
* wider than it will actually render.
|
|
211
|
+
*/
|
|
212
|
+
function formulaVisibleLength(latex: string): number {
|
|
213
|
+
const collapsed = latex
|
|
214
|
+
.replace(/\\[a-zA-Z]+/g, 'x')
|
|
215
|
+
.replace(/[{}$]/g, '')
|
|
216
|
+
.trim();
|
|
217
|
+
return Math.max(1, collapsed.length);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Estimate a diagram node's box from its label text (or raw, pre-resolution
|
|
222
|
+
* LaTeX for a formula node). Deliberately generous: it is meant to avoid
|
|
223
|
+
* clipping, not to match a real render pixel-for-pixel. Pass an explicit
|
|
224
|
+
* `width`/`height` on the node to override for a label this estimate gets
|
|
225
|
+
* badly wrong.
|
|
226
|
+
*/
|
|
227
|
+
export function estimateBoxSize(
|
|
228
|
+
content: string,
|
|
229
|
+
kind: 'text' | 'formula',
|
|
230
|
+
options: { readonly maxWidth?: number } = {},
|
|
231
|
+
): BoxSizeEstimate {
|
|
232
|
+
const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
233
|
+
const visibleLength =
|
|
234
|
+
kind === 'formula' ? formulaVisibleLength(content) : content.trim().length || 1;
|
|
235
|
+
const charWidth = CHAR_WIDTH[kind];
|
|
236
|
+
|
|
237
|
+
const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
|
|
238
|
+
const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
|
|
239
|
+
|
|
240
|
+
const usableWidth = width - PADDING_X * 2;
|
|
241
|
+
const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
|
|
242
|
+
const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
|
|
243
|
+
const height = lines * LINE_HEIGHT + PADDING_Y * 2;
|
|
244
|
+
|
|
245
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
246
|
+
}
|
|
@@ -53,6 +53,13 @@ export interface EquationLessonInput {
|
|
|
53
53
|
readonly slug: string;
|
|
54
54
|
readonly file: string;
|
|
55
55
|
readonly body: string;
|
|
56
|
+
/**
|
|
57
|
+
* `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
|
|
58
|
+
* and how many display equations earlier lessons of that part already used.
|
|
59
|
+
* When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
|
|
60
|
+
* legacy `##`-section scheme. See `docs/authoring/structure.md`.
|
|
61
|
+
*/
|
|
62
|
+
readonly numbering?: { readonly prefix: number; readonly offset: number };
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
|
|
@@ -90,7 +97,7 @@ export function validateEquations(
|
|
|
90
97
|
)) {
|
|
91
98
|
const seen = new Set<string>();
|
|
92
99
|
const table: Record<string, string> = {};
|
|
93
|
-
for (const raw of scanEquationLabels(lesson.body) as {
|
|
100
|
+
for (const raw of scanEquationLabels(lesson.body, lesson.numbering) as {
|
|
94
101
|
key: string;
|
|
95
102
|
number: string;
|
|
96
103
|
section: number;
|