interactive-markdown 0.2.0 → 0.3.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 +36 -3
- package/examples/normal-density.md +19 -0
- package/index.html +1 -0
- package/package.json +7 -5
- package/src/document-editor.js +111 -5
- package/src/document.js +146 -8
- package/src/index.js +1 -0
- package/src/parse-ivm.js +184 -2
- package/src/plot.js +401 -0
- package/src/render.js +18 -0
- package/styles/app.css +12 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Interactive Markdown (IVM)
|
|
2
2
|
|
|
3
|
-
**Interactive Markdown** is a small browser **editor + renderer** for plain `.md` files that include live **vector graphics** driven by sliders, timers, and a safe expression language, plus a **graph editor** stored in the same fence.
|
|
3
|
+
**Interactive Markdown** is a small browser **editor + renderer** for plain `.md` files that include live **vector graphics** driven by sliders, timers, and a safe expression language, plus **Cartesian XY plots** and a **graph editor** stored in the same fence.
|
|
4
4
|
|
|
5
5
|
Write Markdown as usual. Add an `ivm` fenced block when you want interactive SVG or a directed graph you can edit in the preview. The format stays AI-friendly: readable text an agent can generate like normal Markdown.
|
|
6
6
|
|
|
@@ -15,6 +15,7 @@ License: **MIT** (see [LICENSE](LICENSE)).
|
|
|
15
15
|
- Derived values with `@let` (agents should emit `@let`, not `@calc`)
|
|
16
16
|
- SVG templates with `{{expr}}` bindings
|
|
17
17
|
- Slim `@svg WxH` wrapper for compact markup
|
|
18
|
+
- **XY plots**: `@plot`, `@axis`, and `@series` with live slider-driven curves
|
|
18
19
|
- Visual **graph editor**: `@graph`, `@node`, and `@edge` (drag, connect, rename; edits write back into the fence)
|
|
19
20
|
- Animated SVG: CSS `@keyframes`, SMIL, glow filters, and internal `<use>`
|
|
20
21
|
- Whitelist expression evaluator (no `eval`)
|
|
@@ -43,7 +44,7 @@ npx serve .
|
|
|
43
44
|
|
|
44
45
|
## Graph editor tab
|
|
45
46
|
|
|
46
|
-
The left pane has two tabs. **Source** is the markdown. **Graph editor** shows the same document with prose rendered as formatted text. Sliders, timers, text fields, toggles, formulas, shapes, and graphs stay as blocks. Select a block to edit its label, range, expression, or shape attributes. A shape attribute can be a number or `{{name}}`. The graph block is the canvas: drag, connect, and rename there. An animated graph marches dashes along each edge and sends a dot along the same path. Each change rewrites the source. The right pane is the rendered preview.
|
|
47
|
+
The left pane has two tabs. **Source** is the markdown. **Graph editor** shows the same document with prose rendered as formatted text. Sliders, timers, text fields, toggles, formulas, shapes, XY plots, and graphs stay as blocks. Select a block to edit its label, range, expression, or shape attributes. A shape attribute can be a number or `{{name}}`. The graph block is the canvas: drag, connect, and rename there. An animated graph marches dashes along each edge and sends a dot along the same path. Each change rewrites the source. The right pane is the rendered preview.
|
|
47
48
|
|
|
48
49
|
## IVM dialect (quick reference)
|
|
49
50
|
|
|
@@ -75,6 +76,10 @@ Fence name: **`ivm`**. Long control names are canonical; short aliases are accep
|
|
|
75
76
|
| `@ns name` | Share variable state across IVM blocks with the same name |
|
|
76
77
|
| `@svg` | Everything after this line is full SVG markup |
|
|
77
78
|
| `@svg WxH` | Same, but wrap non-root markup in `<svg viewBox="0 0 W H" xmlns=…>` |
|
|
79
|
+
| `@plot [WxH] [grid\|nogrid]` | Cartesian XY plot (mutually exclusive with `@svg` / `@graph`) |
|
|
80
|
+
| `@axis x\|y: min..max [step=S] ["Label"]` | Plot axis range and label |
|
|
81
|
+
| `@series [id] …` / `y = expr` | Plot series (free var `x`) |
|
|
82
|
+
| `@grid on\|off` | Plot grid toggle |
|
|
78
83
|
| `@graph` | Start an editable directed graph. This is the first directive in that fence |
|
|
79
84
|
| `@node id: "Label" x=N y=N kind=K` | Node. `kind` is `action` (default), `input`, `output`, or `decision` |
|
|
80
85
|
| `@edge from -> to` | Directed edge. Add `from=top\|right\|bottom\|left` and `to=top\|right\|bottom\|left` to choose the sides. Defaults are `from=right` and `to=left` |
|
|
@@ -112,6 +117,32 @@ A fence that starts with `@graph` is a node-and-edge diagram. The preview is the
|
|
|
112
117
|
```
|
|
113
118
|
````
|
|
114
119
|
|
|
120
|
+
|
|
121
|
+
### XY plot
|
|
122
|
+
|
|
123
|
+
A fence that starts with `@plot` draws a Cartesian chart. Axes, grid, and series are declared in the dialect; the renderer builds the SVG. Sliders and `@let` work the same as in figure blocks. Free variable `x` is sampled across the X axis when evaluating each series. Do not mix `@svg` or `@graph` into the same fence.
|
|
124
|
+
|
|
125
|
+
````markdown
|
|
126
|
+
```ivm
|
|
127
|
+
@plot
|
|
128
|
+
@axis x: -10..10 step=2 "x"
|
|
129
|
+
@axis y: 0..0.8 step=0.2 "density"
|
|
130
|
+
@slider mu: -5..5 = 0 step=0.1 "μ (mean)"
|
|
131
|
+
@slider sigma: 0.5..4 = 1.5 step=0.1 "σ (std dev)"
|
|
132
|
+
@series density stroke=#6d28d9 fill=#7c3aed fillOpacity=0.25
|
|
133
|
+
y = (1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)
|
|
134
|
+
```
|
|
135
|
+
````
|
|
136
|
+
|
|
137
|
+
| Line | Meaning |
|
|
138
|
+
|------|---------|
|
|
139
|
+
| `@plot [WxH] [grid\|nogrid]` | Start an XY plot (default size 480×320, grid on) |
|
|
140
|
+
| `@axis x\|y: min..max [step=S] ["Label"]` | Axis range, tick step, and title |
|
|
141
|
+
| `@series [id] [stroke=#…] [fill=#…] [fillOpacity=N] [width=N]` | Curve style; next line (or same line) `y = expr` |
|
|
142
|
+
| `@grid on\|off` | Toggle the background grid |
|
|
143
|
+
|
|
144
|
+
Defaults: grid **on**, fill opacity **0.25**, stroke `#6d28d9`, fill `#7c3aed`.
|
|
145
|
+
|
|
115
146
|
### Expressions (`{{…}}`)
|
|
116
147
|
|
|
117
148
|
Placeholders use **double braces** only: `{{expr}}`.
|
|
@@ -151,6 +182,7 @@ Load from the toolbar **Examples…** menu, or open the files directly:
|
|
|
151
182
|
- [`examples/circular-motion.md`](examples/circular-motion.md) — point on a circle with spin animation
|
|
152
183
|
- [`examples/agent-loop.md`](examples/agent-loop.md) — agent loop drawn as an animated graph
|
|
153
184
|
- [`examples/graph.md`](examples/graph.md) — directed graph you can edit in the preview
|
|
185
|
+
- [`examples/normal-density.md`](examples/normal-density.md) — Gaussian density XY plot with μ/σ sliders
|
|
154
186
|
|
|
155
187
|
## Project layout
|
|
156
188
|
|
|
@@ -167,6 +199,7 @@ src/
|
|
|
167
199
|
sanitize-svg.js SVG sanitizer
|
|
168
200
|
render.js Controls + SVG binding
|
|
169
201
|
graph-editor.js Graph model and canvas editor
|
|
202
|
+
plot.js Cartesian XY plot SVG builder
|
|
170
203
|
styles/app.css
|
|
171
204
|
examples/
|
|
172
205
|
test/ Node smoke tests
|
|
@@ -178,7 +211,7 @@ test/ Node smoke tests
|
|
|
178
211
|
npm test
|
|
179
212
|
```
|
|
180
213
|
|
|
181
|
-
Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, Markdown pipeline, graph editor, and preview document builder (`linkedom` is used where a DOM is required).
|
|
214
|
+
Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, Markdown pipeline, graph editor, XY plots, and preview document builder (`linkedom` is used where a DOM is required).
|
|
182
215
|
|
|
183
216
|
## License
|
|
184
217
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Normal density
|
|
2
|
+
|
|
3
|
+
A Cartesian **XY plot** of the Gaussian PDF. Drag **μ (mean)** and **σ (std dev)** — the curve recomputes live.
|
|
4
|
+
|
|
5
|
+
```ivm
|
|
6
|
+
@plot
|
|
7
|
+
@axis x: -10..10 step=2 "x"
|
|
8
|
+
@axis y: 0..0.8 step=0.2 "density"
|
|
9
|
+
@slider mu: -5..5 = 0 step=0.1 "μ (mean)"
|
|
10
|
+
@slider sigma: 0.5..4 = 1.5 step=0.1 "σ (std dev)"
|
|
11
|
+
@series density stroke=#6d28d9 fill=#7c3aed fillOpacity=0.25
|
|
12
|
+
y = (1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Tips
|
|
16
|
+
|
|
17
|
+
- `@plot` builds axes, grid, and series — no hand-written SVG required.
|
|
18
|
+
- Series expressions use free variable `x` plus any `@slider` / `@let` names.
|
|
19
|
+
- Omit `nogrid` to keep the light gray grid (default on).
|
package/index.html
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
<option value="circular-motion.md">circular-motion.md</option>
|
|
24
24
|
<option value="agent-loop.md">agent-loop.md</option>
|
|
25
25
|
<option value="graph.md">graph.md</option>
|
|
26
|
+
<option value="normal-density.md">normal-density.md</option>
|
|
26
27
|
</select>
|
|
27
28
|
<input id="file-input" type="file" accept=".md,text/markdown,text/plain" />
|
|
28
29
|
</div>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interactive-markdown",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Interactive Markdown (IVM): plain .md with live SVG, a preview block builder, and an editable graph",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Interactive Markdown (IVM): plain .md with live SVG, XY plots, a preview block builder, and an editable graph",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"directories": {
|
|
7
7
|
"example": "examples",
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "serve .",
|
|
12
|
-
"test": "node test/smoke-expr.mjs && node test/smoke-parse-ivm.mjs && node test/smoke-sanitize-svg.mjs && node test/smoke-render.mjs && node test/smoke-markdown.mjs && node test/smoke-graph.mjs && node test/smoke-document.mjs",
|
|
12
|
+
"test": "node test/smoke-expr.mjs && node test/smoke-parse-ivm.mjs && node test/smoke-sanitize-svg.mjs && node test/smoke-render.mjs && node test/smoke-markdown.mjs && node test/smoke-graph.mjs && node test/smoke-plot.mjs && node test/smoke-document.mjs",
|
|
13
13
|
"test:expr": "node test/smoke-expr.mjs",
|
|
14
14
|
"test:parse": "node test/smoke-parse-ivm.mjs",
|
|
15
15
|
"test:sanitize": "node test/smoke-sanitize-svg.mjs",
|
|
16
16
|
"test:render": "node test/smoke-render.mjs",
|
|
17
17
|
"test:markdown": "node test/smoke-markdown.mjs",
|
|
18
18
|
"test:graph": "node test/smoke-graph.mjs",
|
|
19
|
-
"test:document": "node test/smoke-document.mjs"
|
|
19
|
+
"test:document": "node test/smoke-document.mjs",
|
|
20
|
+
"test:plot": "node test/smoke-plot.mjs"
|
|
20
21
|
},
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
@@ -49,7 +50,8 @@
|
|
|
49
50
|
"./parse-ivm": "./src/parse-ivm.js",
|
|
50
51
|
"./sanitize-svg": "./src/sanitize-svg.js",
|
|
51
52
|
"./render": "./src/render.js",
|
|
52
|
-
"./markdown": "./src/markdown.js"
|
|
53
|
+
"./markdown": "./src/markdown.js",
|
|
54
|
+
"./plot": "./src/plot.js"
|
|
53
55
|
},
|
|
54
56
|
"files": [
|
|
55
57
|
"src",
|
package/src/document-editor.js
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
updateBlock,
|
|
15
15
|
updateControl,
|
|
16
16
|
updateShape,
|
|
17
|
+
updatePlotAxis,
|
|
18
|
+
updatePlotSeries,
|
|
19
|
+
updatePlotMeta,
|
|
17
20
|
NAME_RE,
|
|
18
21
|
} from "./document.js";
|
|
19
22
|
import { mountGraphEditor } from "./graph-editor.js";
|
|
@@ -50,6 +53,7 @@ const ADD_GROUPS = [
|
|
|
50
53
|
["shape", "Shape: line", "line"],
|
|
51
54
|
["shape", "Shape: path", "path"],
|
|
52
55
|
["shape", "Shape: text", "text"],
|
|
56
|
+
["plot", "XY plot"],
|
|
53
57
|
["graph", "Graph"],
|
|
54
58
|
],
|
|
55
59
|
},
|
|
@@ -124,7 +128,7 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
124
128
|
|
|
125
129
|
const empty = docEl.createElement("p");
|
|
126
130
|
empty.className = "im-doc-empty";
|
|
127
|
-
empty.textContent = "Drag from Add to place a heading, a control, a shape, or a graph.";
|
|
131
|
+
empty.textContent = "Drag from Add to place a heading, a control, a shape, a plot, or a graph.";
|
|
128
132
|
|
|
129
133
|
const blocksEl = docEl.createElement("div");
|
|
130
134
|
blocksEl.className = "im-doc-blocks";
|
|
@@ -185,12 +189,12 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
185
189
|
prose = [];
|
|
186
190
|
};
|
|
187
191
|
for (const block of doc.blocks) {
|
|
188
|
-
if (block.type === "figure" || block.type === "graph") {
|
|
192
|
+
if (block.type === "figure" || block.type === "graph" || block.type === "plot") {
|
|
189
193
|
flush();
|
|
190
194
|
const el = renderBlock(block);
|
|
191
195
|
el.dataset.at = String(index);
|
|
192
196
|
el.dataset.atEnd = String(index + 1);
|
|
193
|
-
if (block.type === "figure") el.dataset.figureId = block.id;
|
|
197
|
+
if (block.type === "figure" || block.type === "plot") el.dataset.figureId = block.id;
|
|
194
198
|
blocksEl.appendChild(el);
|
|
195
199
|
index++;
|
|
196
200
|
} else {
|
|
@@ -319,6 +323,62 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
319
323
|
}
|
|
320
324
|
return box;
|
|
321
325
|
}
|
|
326
|
+
if (block.type === "plot") {
|
|
327
|
+
const ns = input("text", block.namespace || "");
|
|
328
|
+
ns.dataset.field = "namespace";
|
|
329
|
+
box.appendChild(field("Shared name", ns));
|
|
330
|
+
const grid = input("checkbox", "");
|
|
331
|
+
grid.checked = block.plot?.grid !== false;
|
|
332
|
+
grid.dataset.field = "plotGrid";
|
|
333
|
+
box.appendChild(field("Grid", grid));
|
|
334
|
+
for (const which of ["x", "y"]) {
|
|
335
|
+
const axis = block.plot?.[which] || {};
|
|
336
|
+
const group = docEl.createElement("fieldset");
|
|
337
|
+
group.className = "im-doc-sub";
|
|
338
|
+
const legend = docEl.createElement("legend");
|
|
339
|
+
legend.textContent = which.toUpperCase() + " axis";
|
|
340
|
+
group.appendChild(legend);
|
|
341
|
+
for (const [key, value, title] of [
|
|
342
|
+
["min", axis.min, "Min"],
|
|
343
|
+
["max", axis.max, "Max"],
|
|
344
|
+
["step", axis.step, "Step"],
|
|
345
|
+
["label", axis.label || "", "Label"],
|
|
346
|
+
]) {
|
|
347
|
+
const el = input(key === "label" ? "text" : "number", value ?? "");
|
|
348
|
+
el.dataset.plotAxis = which;
|
|
349
|
+
el.dataset.field = key;
|
|
350
|
+
group.appendChild(field(title, el));
|
|
351
|
+
}
|
|
352
|
+
box.appendChild(group);
|
|
353
|
+
}
|
|
354
|
+
(block.plot?.series || []).forEach((series, index) => {
|
|
355
|
+
const group = docEl.createElement("fieldset");
|
|
356
|
+
group.className = "im-doc-sub";
|
|
357
|
+
const legend = docEl.createElement("legend");
|
|
358
|
+
legend.textContent = "Series" + (series.id ? ` ${series.id}` : "");
|
|
359
|
+
group.appendChild(legend);
|
|
360
|
+
const expr = textarea(series.expression || "", 3);
|
|
361
|
+
expr.dataset.plotSeries = String(index);
|
|
362
|
+
expr.dataset.field = "expression";
|
|
363
|
+
group.appendChild(field("y =", expr));
|
|
364
|
+
for (const [key, value, title] of [
|
|
365
|
+
["stroke", series.stroke || "", "Stroke"],
|
|
366
|
+
["fill", series.fill || "", "Fill"],
|
|
367
|
+
["fillOpacity", series.fillOpacity ?? 0.25, "Fill opacity"],
|
|
368
|
+
["width", series.width ?? 2.25, "Width"],
|
|
369
|
+
]) {
|
|
370
|
+
const el = input(key === "stroke" || key === "fill" ? "text" : "number", value);
|
|
371
|
+
el.dataset.plotSeries = String(index);
|
|
372
|
+
el.dataset.field = key;
|
|
373
|
+
group.appendChild(field(title, el));
|
|
374
|
+
}
|
|
375
|
+
box.appendChild(group);
|
|
376
|
+
});
|
|
377
|
+
(block.controls || []).forEach((control, index) => {
|
|
378
|
+
box.appendChild(renderControl(control, index));
|
|
379
|
+
});
|
|
380
|
+
return box;
|
|
381
|
+
}
|
|
322
382
|
if (block.type === "figure") {
|
|
323
383
|
const ns = input("text", block.namespace || "");
|
|
324
384
|
ns.dataset.field = "namespace";
|
|
@@ -511,7 +571,7 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
511
571
|
function refreshFigure(id) {
|
|
512
572
|
const block = doc.blocks.find((b) => b.id === id);
|
|
513
573
|
const host = blocksEl.querySelector(`.im-doc-block[data-id="${id}"] .im-doc-live-host`);
|
|
514
|
-
if (!block || block.type !== "figure" || !host) return;
|
|
574
|
+
if (!block || (block.type !== "figure" && block.type !== "plot") || !host) return;
|
|
515
575
|
const prev = mounts.get(id);
|
|
516
576
|
if (prev) {
|
|
517
577
|
prev.destroy();
|
|
@@ -729,6 +789,15 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
729
789
|
commit();
|
|
730
790
|
return;
|
|
731
791
|
}
|
|
792
|
+
if (feature === "plot") {
|
|
793
|
+
const at = placement && !placement.figureId ? placement.at : undefined;
|
|
794
|
+
const result = insertFeature(doc, "plot", { at });
|
|
795
|
+
doc = result.doc;
|
|
796
|
+
selectedId = result.focusId || selectedId;
|
|
797
|
+
paint();
|
|
798
|
+
commit();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
732
801
|
if (feature === "shape" && !shapeType) shapeType = "rect";
|
|
733
802
|
/** @type {string | undefined} */
|
|
734
803
|
let figureId;
|
|
@@ -739,7 +808,7 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
739
808
|
at = figureId ? undefined : placement.at;
|
|
740
809
|
} else {
|
|
741
810
|
const selected = doc.blocks.find((b) => b.id === selectedId);
|
|
742
|
-
figureId = selected && selected.type === "figure" ? selected.id : undefined;
|
|
811
|
+
figureId = selected && (selected.type === "figure" || selected.type === "plot") ? selected.id : undefined;
|
|
743
812
|
}
|
|
744
813
|
const result = insertFeature(doc, feature, { figureId, shapeType, at });
|
|
745
814
|
doc = result.doc;
|
|
@@ -864,6 +933,42 @@ export function mountDocumentEditor(container, source, options = {}) {
|
|
|
864
933
|
commit();
|
|
865
934
|
return;
|
|
866
935
|
}
|
|
936
|
+
if (fieldName === "plotGrid") {
|
|
937
|
+
doc = { blocks: updatePlotMeta(doc, id, { grid: !!el.checked }).blocks };
|
|
938
|
+
refreshFigure(id);
|
|
939
|
+
commit();
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (el.dataset.plotAxis) {
|
|
943
|
+
const which = el.dataset.plotAxis;
|
|
944
|
+
const patch = {};
|
|
945
|
+
if (fieldName === "label") patch.label = el.value;
|
|
946
|
+
else {
|
|
947
|
+
const n = Number(el.value);
|
|
948
|
+
if (!Number.isFinite(n)) return;
|
|
949
|
+
if (fieldName === "step" && n <= 0) return;
|
|
950
|
+
patch[fieldName] = n;
|
|
951
|
+
}
|
|
952
|
+
doc = { blocks: updatePlotAxis(doc, id, which, patch).blocks };
|
|
953
|
+
refreshFigure(id);
|
|
954
|
+
commit();
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (el.dataset.plotSeries != null) {
|
|
958
|
+
const index = Number(el.dataset.plotSeries);
|
|
959
|
+
const patch = {};
|
|
960
|
+
if (fieldName === "expression" || fieldName === "stroke" || fieldName === "fill") {
|
|
961
|
+
patch[fieldName] = el.value;
|
|
962
|
+
} else {
|
|
963
|
+
const n = Number(el.value);
|
|
964
|
+
if (!Number.isFinite(n)) return;
|
|
965
|
+
patch[fieldName] = n;
|
|
966
|
+
}
|
|
967
|
+
doc = { blocks: updatePlotSeries(doc, id, index, patch).blocks };
|
|
968
|
+
refreshFigure(id);
|
|
969
|
+
commit();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
867
972
|
if (fieldName === "viewBox") {
|
|
868
973
|
doc = { blocks: updateBlock(doc, id, { viewBox: el.value }).blocks };
|
|
869
974
|
refreshFigure(id);
|
|
@@ -938,6 +1043,7 @@ function kindLabel(block) {
|
|
|
938
1043
|
if (block.type === "bullets") return "Bullets";
|
|
939
1044
|
if (block.type === "markdown") return "Markdown";
|
|
940
1045
|
if (block.type === "graph") return "Graph";
|
|
1046
|
+
if (block.type === "plot") return "XY plot";
|
|
941
1047
|
if (block.type === "figure" && block.customSvg) return "Animated figure";
|
|
942
1048
|
if (block.type === "figure") return "Figure";
|
|
943
1049
|
return "Block";
|
package/src/document.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { parseIvm, splitIvmFences } from "./parse-ivm.js";
|
|
7
7
|
import { quoteGraphLabel, serializeGraph } from "./graph-editor.js";
|
|
8
|
+
import { normalizePlot, serializePlotLines } from "./plot.js";
|
|
8
9
|
|
|
9
10
|
/** @typedef {'rect'|'circle'|'ellipse'|'line'|'path'|'text'} ShapeType */
|
|
10
11
|
/** @typedef {{ type: ShapeType, attrs: Record<string, string> }} Shape */
|
|
@@ -76,6 +77,26 @@ export function serializeDocument(blocks) {
|
|
|
76
77
|
export function serializeFigure(block) {
|
|
77
78
|
if (!block) return "\n";
|
|
78
79
|
if (block.type === "graph") return serializeGraph(block.model || { nodes: [], edges: [] });
|
|
80
|
+
if (block.type === "plot") {
|
|
81
|
+
const rebuilt = [];
|
|
82
|
+
if (block.namespace) rebuilt.push(`@ns ${block.namespace}`);
|
|
83
|
+
const plotLines = serializePlotLines(normalizePlot(block.plot || {}));
|
|
84
|
+
const preamble = [];
|
|
85
|
+
const series = [];
|
|
86
|
+
let sawSeries = false;
|
|
87
|
+
for (const line of plotLines) {
|
|
88
|
+
if (/^@series\b/i.test(line)) sawSeries = true;
|
|
89
|
+
if (sawSeries) series.push(line);
|
|
90
|
+
else preamble.push(line);
|
|
91
|
+
}
|
|
92
|
+
rebuilt.push(...preamble);
|
|
93
|
+
for (const control of block.controls || []) {
|
|
94
|
+
const line = serializeControl(control);
|
|
95
|
+
if (line) rebuilt.push(line);
|
|
96
|
+
}
|
|
97
|
+
rebuilt.push(...series);
|
|
98
|
+
return rebuilt.join("\n").replace(/\s*$/, "\n");
|
|
99
|
+
}
|
|
79
100
|
if (block.type !== "figure") return "\n";
|
|
80
101
|
const lines = [];
|
|
81
102
|
if (block.namespace) lines.push(`@ns ${block.namespace}`);
|
|
@@ -133,6 +154,8 @@ export function insertFeature(doc, feature, options = {}) {
|
|
|
133
154
|
fig.shapes = [shape];
|
|
134
155
|
place(fig);
|
|
135
156
|
}
|
|
157
|
+
} else if (feature === "plot") {
|
|
158
|
+
place(emptyPlot());
|
|
136
159
|
} else if (feature === "slider" || feature === "timer" || feature === "input" || feature === "toggle" || feature === "formula") {
|
|
137
160
|
const kind = feature === "formula" ? "let" : feature;
|
|
138
161
|
const control = defaultControl(kind, nextControlName(blocks, CONTROL_PREFIX[kind]));
|
|
@@ -198,7 +221,7 @@ export function updateControl(doc, blockId, index, patch) {
|
|
|
198
221
|
return {
|
|
199
222
|
blocks: (doc?.blocks || []).map((b) => {
|
|
200
223
|
const copy = cloneBlock(b);
|
|
201
|
-
if (copy.id === blockId && copy.type === "figure" && copy.controls[index]) {
|
|
224
|
+
if (copy.id === blockId && (copy.type === "figure" || copy.type === "plot") && copy.controls[index]) {
|
|
202
225
|
copy.controls[index] = cloneControl({ ...copy.controls[index], ...patch });
|
|
203
226
|
}
|
|
204
227
|
return copy;
|
|
@@ -216,7 +239,7 @@ export function removeControl(doc, blockId, index) {
|
|
|
216
239
|
return {
|
|
217
240
|
blocks: (doc?.blocks || []).map((b) => {
|
|
218
241
|
const copy = cloneBlock(b);
|
|
219
|
-
if (copy.id === blockId && copy.type === "figure") {
|
|
242
|
+
if (copy.id === blockId && (copy.type === "figure" || copy.type === "plot")) {
|
|
220
243
|
copy.controls = copy.controls.filter((_, i) => i !== index);
|
|
221
244
|
}
|
|
222
245
|
return copy;
|
|
@@ -264,6 +287,64 @@ export function removeShape(doc, blockId, index) {
|
|
|
264
287
|
};
|
|
265
288
|
}
|
|
266
289
|
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @param {{ blocks?: object[] }} doc
|
|
293
|
+
* @param {string} blockId
|
|
294
|
+
* @param {"x"|"y"} which
|
|
295
|
+
* @param {object} patch
|
|
296
|
+
* @returns {{ blocks: object[] }}
|
|
297
|
+
*/
|
|
298
|
+
export function updatePlotAxis(doc, blockId, which, patch) {
|
|
299
|
+
return {
|
|
300
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
301
|
+
const copy = cloneBlock(b);
|
|
302
|
+
if (copy.id === blockId && copy.type === "plot" && copy.plot) {
|
|
303
|
+
const axis = { ...(copy.plot[which] || {}), ...patch };
|
|
304
|
+
copy.plot = { ...copy.plot, [which]: axis };
|
|
305
|
+
}
|
|
306
|
+
return copy;
|
|
307
|
+
}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {{ blocks?: object[] }} doc
|
|
313
|
+
* @param {string} blockId
|
|
314
|
+
* @param {number} index
|
|
315
|
+
* @param {object} patch
|
|
316
|
+
* @returns {{ blocks: object[] }}
|
|
317
|
+
*/
|
|
318
|
+
export function updatePlotSeries(doc, blockId, index, patch) {
|
|
319
|
+
return {
|
|
320
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
321
|
+
const copy = cloneBlock(b);
|
|
322
|
+
if (copy.id === blockId && copy.type === "plot" && copy.plot?.series?.[index]) {
|
|
323
|
+
copy.plot.series[index] = { ...copy.plot.series[index], ...patch };
|
|
324
|
+
}
|
|
325
|
+
return copy;
|
|
326
|
+
}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {{ blocks?: object[] }} doc
|
|
332
|
+
* @param {string} blockId
|
|
333
|
+
* @param {object} patch
|
|
334
|
+
* @returns {{ blocks: object[] }}
|
|
335
|
+
*/
|
|
336
|
+
export function updatePlotMeta(doc, blockId, patch) {
|
|
337
|
+
return {
|
|
338
|
+
blocks: (doc?.blocks || []).map((b) => {
|
|
339
|
+
const copy = cloneBlock(b);
|
|
340
|
+
if (copy.id === blockId && copy.type === "plot" && copy.plot) {
|
|
341
|
+
copy.plot = { ...copy.plot, ...patch };
|
|
342
|
+
}
|
|
343
|
+
return copy;
|
|
344
|
+
}),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
267
348
|
export { NAME_RE };
|
|
268
349
|
|
|
269
350
|
function emptyFigure() {
|
|
@@ -278,13 +359,57 @@ function emptyFigure() {
|
|
|
278
359
|
};
|
|
279
360
|
}
|
|
280
361
|
|
|
362
|
+
function emptyPlot() {
|
|
363
|
+
return {
|
|
364
|
+
id: nid(),
|
|
365
|
+
type: "plot",
|
|
366
|
+
namespace: null,
|
|
367
|
+
controls: [
|
|
368
|
+
{ kind: "slider", name: "mu", range: [-5, 5], step: 0.1, default: 0, label: "μ (mean)" },
|
|
369
|
+
{ kind: "slider", name: "sigma", range: [0.5, 4], step: 0.1, default: 1.5, label: "σ (std dev)" },
|
|
370
|
+
],
|
|
371
|
+
plot: {
|
|
372
|
+
width: 480,
|
|
373
|
+
height: 320,
|
|
374
|
+
grid: true,
|
|
375
|
+
x: { min: -10, max: 10, step: 2, label: "x" },
|
|
376
|
+
y: { min: 0, max: 0.8, step: 0.2, label: "density" },
|
|
377
|
+
series: [
|
|
378
|
+
{
|
|
379
|
+
id: "density",
|
|
380
|
+
expression: "(1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)",
|
|
381
|
+
stroke: "#6d28d9",
|
|
382
|
+
fill: "#7c3aed",
|
|
383
|
+
fillOpacity: 0.25,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {object | null | undefined} plot
|
|
392
|
+
*/
|
|
393
|
+
function copyPlotModel(plot) {
|
|
394
|
+
const model = normalizePlot(plot || {});
|
|
395
|
+
return {
|
|
396
|
+
width: model.width,
|
|
397
|
+
height: model.height,
|
|
398
|
+
grid: model.grid,
|
|
399
|
+
samples: model.samples,
|
|
400
|
+
x: { ...model.x },
|
|
401
|
+
y: { ...model.y },
|
|
402
|
+
series: model.series.map((s) => ({ ...s })),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
281
406
|
/**
|
|
282
407
|
* @param {object[]} blocks
|
|
283
408
|
* @param {string} [id]
|
|
284
409
|
*/
|
|
285
410
|
function findFigure(blocks, id) {
|
|
286
411
|
if (!id) return null;
|
|
287
|
-
const block = blocks.find((b) => b.id === id && b.type === "figure");
|
|
412
|
+
const block = blocks.find((b) => b.id === id && (b.type === "figure" || b.type === "plot"));
|
|
288
413
|
return block || null;
|
|
289
414
|
}
|
|
290
415
|
|
|
@@ -295,7 +420,7 @@ function findFigure(blocks, id) {
|
|
|
295
420
|
function nextControlName(blocks, prefix) {
|
|
296
421
|
const used = new Set();
|
|
297
422
|
for (const block of blocks) {
|
|
298
|
-
if (block.type !== "figure") continue;
|
|
423
|
+
if (block.type !== "figure" && block.type !== "plot") continue;
|
|
299
424
|
for (const control of block.controls || []) {
|
|
300
425
|
if (control.name) used.add(control.name);
|
|
301
426
|
}
|
|
@@ -407,6 +532,15 @@ function blockFromIvm(body) {
|
|
|
407
532
|
model: copyGraphModel(parsed.graph),
|
|
408
533
|
};
|
|
409
534
|
}
|
|
535
|
+
if (parsed.plot) {
|
|
536
|
+
return {
|
|
537
|
+
id: nid(),
|
|
538
|
+
type: "plot",
|
|
539
|
+
namespace: parsed.namespace,
|
|
540
|
+
controls: parsed.controls.map(cloneControl),
|
|
541
|
+
plot: copyPlotModel(parsed.plot),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
410
544
|
const raw = parsed.templateSvg;
|
|
411
545
|
const template = wrapSlimSvg(raw, parsed.svgWidth, parsed.svgHeight);
|
|
412
546
|
const shapes = template ? tryParseShapes(template) : [];
|
|
@@ -515,10 +649,7 @@ function serializeBlock(block) {
|
|
|
515
649
|
return (block.items || []).map((item) => `- ${item}`).join("\n");
|
|
516
650
|
}
|
|
517
651
|
if (block.type === "markdown") return String(block.text ?? "");
|
|
518
|
-
if (block.type === "graph") {
|
|
519
|
-
return "```ivm\n" + serializeFigure(block).replace(/\s*$/, "\n") + "```";
|
|
520
|
-
}
|
|
521
|
-
if (block.type === "figure") {
|
|
652
|
+
if (block.type === "graph" || block.type === "figure" || block.type === "plot") {
|
|
522
653
|
return "```ivm\n" + serializeFigure(block).replace(/\s*$/, "\n") + "```";
|
|
523
654
|
}
|
|
524
655
|
return "";
|
|
@@ -622,6 +753,13 @@ function cloneBlock(block) {
|
|
|
622
753
|
if (block.type === "graph") {
|
|
623
754
|
return { ...block, model: copyGraphModel(block.model) };
|
|
624
755
|
}
|
|
756
|
+
if (block.type === "plot") {
|
|
757
|
+
return {
|
|
758
|
+
...block,
|
|
759
|
+
controls: (block.controls || []).map(cloneControl),
|
|
760
|
+
plot: copyPlotModel(block.plot),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
625
763
|
if (block.type === "figure") {
|
|
626
764
|
return {
|
|
627
765
|
...block,
|
package/src/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export { parseIvm, splitIvmFences } from "./parse-ivm.js";
|
|
|
6
6
|
export { sanitizeSvg } from "./sanitize-svg.js";
|
|
7
7
|
export { mountIvm, resetNamespaces } from "./render.js";
|
|
8
8
|
export { renderDocument } from "./markdown.js";
|
|
9
|
+
export { buildPlotSvg, normalizePlot } from "./plot.js";
|
package/src/parse-ivm.js
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* @calc name = expression (alias of @let; prefer @let in docs/generators)
|
|
13
13
|
* @svg [WxH]
|
|
14
14
|
* …markup… (remainder of fence; use {{expr}} in attrs/text)
|
|
15
|
+
* @plot [WxH] [grid|nogrid]
|
|
16
|
+
* @axis x|y: min..max [step=S] ["Label"]
|
|
17
|
+
* @series [id] [stroke=#hex] [fill=#hex] [fillOpacity=N] [width=N]
|
|
18
|
+
* y = expression (free var `x` + controls/@let)
|
|
19
|
+
* @grid on|off
|
|
15
20
|
* @graph
|
|
16
21
|
* @animate [path=ortho|straight|curve] [quantity=N] [direction=forward|reverse] [speed=seconds]
|
|
17
22
|
* @node id: "Label" x=N y=N [kind=action|input|output|decision]
|
|
@@ -19,6 +24,7 @@
|
|
|
19
24
|
*
|
|
20
25
|
* Defaults: step=1, timer loop=true, speed=30. Omitted "Label" → UI shows name.
|
|
21
26
|
* `@svg 200x80` stores width/height so the renderer can wrap non-root markup.
|
|
27
|
+
* `@plot` builds a Cartesian chart (mutually exclusive with `@svg` / `@graph`).
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
/**
|
|
@@ -40,6 +46,23 @@
|
|
|
40
46
|
* @typedef {{ from: string, to: string, fromSide?: GraphSide, toSide?: GraphSide }} GraphEdge
|
|
41
47
|
* @typedef {{ path: string, quantity: number, direction: string, speed: number }} GraphMotion
|
|
42
48
|
* @typedef {{ nodes: GraphNode[], edges: GraphEdge[], animated?: boolean, motion?: GraphMotion }} GraphModel
|
|
49
|
+
* @typedef {{ min: number, max: number, step?: number, label?: string }} PlotAxis
|
|
50
|
+
* @typedef {{
|
|
51
|
+
* id?: string,
|
|
52
|
+
* expression: string,
|
|
53
|
+
* stroke?: string,
|
|
54
|
+
* fill?: string,
|
|
55
|
+
* fillOpacity?: number,
|
|
56
|
+
* width?: number,
|
|
57
|
+
* }} PlotSeries
|
|
58
|
+
* @typedef {{
|
|
59
|
+
* width: number|null,
|
|
60
|
+
* height: number|null,
|
|
61
|
+
* grid: boolean,
|
|
62
|
+
* x: PlotAxis|null,
|
|
63
|
+
* y: PlotAxis|null,
|
|
64
|
+
* series: PlotSeries[],
|
|
65
|
+
* }} PlotModel
|
|
43
66
|
* @typedef {{
|
|
44
67
|
* namespace: string|null,
|
|
45
68
|
* controls: Control[],
|
|
@@ -47,6 +70,7 @@
|
|
|
47
70
|
* svgWidth: number|null,
|
|
48
71
|
* svgHeight: number|null,
|
|
49
72
|
* graph: GraphModel|null,
|
|
73
|
+
* plot: PlotModel|null,
|
|
50
74
|
* errors: string[],
|
|
51
75
|
* }} IvmBlock
|
|
52
76
|
*/
|
|
@@ -184,6 +208,8 @@ export function parseIvm(body) {
|
|
|
184
208
|
let svgHeight = null;
|
|
185
209
|
/** @type {GraphModel | null} */
|
|
186
210
|
let graph = null;
|
|
211
|
+
/** @type {PlotModel | null} */
|
|
212
|
+
let plot = null;
|
|
187
213
|
|
|
188
214
|
const lines = String(body).replace(/\r\n/g, "\n").split("\n");
|
|
189
215
|
let i = 0;
|
|
@@ -196,9 +222,160 @@ export function parseIvm(body) {
|
|
|
196
222
|
continue;
|
|
197
223
|
}
|
|
198
224
|
|
|
225
|
+
// @plot [WxH] [grid|nogrid]
|
|
226
|
+
if (/^@plot\b/i.test(line)) {
|
|
227
|
+
if (plot) {
|
|
228
|
+
errors.push("Duplicate @plot");
|
|
229
|
+
i++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (graph) {
|
|
233
|
+
errors.push("@plot cannot mix with @graph");
|
|
234
|
+
i++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (templateSvg) {
|
|
238
|
+
errors.push("@plot cannot mix with @svg");
|
|
239
|
+
i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
plot = { width: null, height: null, grid: true, x: null, y: null, series: [] };
|
|
243
|
+
const rest = line.replace(/^@plot\b/i, "").trim();
|
|
244
|
+
if (rest) {
|
|
245
|
+
const size = rest.match(/^(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)\b/);
|
|
246
|
+
let flags = rest;
|
|
247
|
+
if (size) {
|
|
248
|
+
plot.width = Number(size[1]);
|
|
249
|
+
plot.height = Number(size[2]);
|
|
250
|
+
flags = rest.slice(size[0].length).trim();
|
|
251
|
+
}
|
|
252
|
+
if (/\bnogrid\b/i.test(flags)) plot.grid = false;
|
|
253
|
+
else if (/\bgrid\b/i.test(flags)) plot.grid = true;
|
|
254
|
+
const leftover = flags.replace(/\b(nogrid|grid)\b/gi, "").trim();
|
|
255
|
+
if (leftover) errors.push(`@plot: unknown ${leftover}`);
|
|
256
|
+
}
|
|
257
|
+
i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// @grid on|off
|
|
262
|
+
if (/^@grid\b/i.test(line)) {
|
|
263
|
+
if (!plot) {
|
|
264
|
+
errors.push("@grid is only valid after @plot");
|
|
265
|
+
i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const rest = line.replace(/^@grid\b/i, "").trim().toLowerCase();
|
|
269
|
+
if (rest === "off" || rest === "false" || rest === "0" || rest === "nogrid") plot.grid = false;
|
|
270
|
+
else if (rest === "on" || rest === "true" || rest === "1" || rest === "grid" || rest === "") plot.grid = true;
|
|
271
|
+
else errors.push(`@grid: expected on|off, got ${rest}`);
|
|
272
|
+
i++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// @axis x|y: min..max [step=S] ["Label"]
|
|
277
|
+
{
|
|
278
|
+
const m = line.match(/^@axis\s+([xy])\s*:\s*(.+)$/i);
|
|
279
|
+
if (m) {
|
|
280
|
+
if (!plot) {
|
|
281
|
+
errors.push(`@axis ${m[1]}: only valid after @plot`);
|
|
282
|
+
i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const which = m[1].toLowerCase();
|
|
286
|
+
const [rest, label] = takeLabel(m[2]);
|
|
287
|
+
const rm = rest.match(
|
|
288
|
+
/^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(.*)$/
|
|
289
|
+
);
|
|
290
|
+
if (!rm) {
|
|
291
|
+
errors.push(`@axis ${which}: expected min..max [step=S]`);
|
|
292
|
+
i++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const min = Number(rm[1]);
|
|
296
|
+
const max = Number(rm[2]);
|
|
297
|
+
const flags = parseFlags(rm[3] || "");
|
|
298
|
+
const step = flags.step !== undefined ? Number(flags.step) : undefined;
|
|
299
|
+
/** @type {PlotAxis} */
|
|
300
|
+
const axis = {
|
|
301
|
+
min: Number.isFinite(min) ? min : 0,
|
|
302
|
+
max: Number.isFinite(max) ? max : 1,
|
|
303
|
+
};
|
|
304
|
+
if (step !== undefined && Number.isFinite(step) && step > 0) axis.step = step;
|
|
305
|
+
if (label || flags.label) axis.label = label || flags.label;
|
|
306
|
+
if (which === "x") {
|
|
307
|
+
if (plot.x) errors.push("Duplicate @axis x");
|
|
308
|
+
plot.x = axis;
|
|
309
|
+
} else {
|
|
310
|
+
if (plot.y) errors.push("Duplicate @axis y");
|
|
311
|
+
plot.y = axis;
|
|
312
|
+
}
|
|
313
|
+
i++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// @series [id] [flags] [y = expr] — expression may continue on the next indented line
|
|
319
|
+
if (/^@series\b/i.test(line)) {
|
|
320
|
+
if (!plot) {
|
|
321
|
+
errors.push("@series is only valid after @plot");
|
|
322
|
+
i++;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const rest = line.replace(/^@series\b/i, "").trim();
|
|
326
|
+
/** @type {PlotSeries} */
|
|
327
|
+
const series = { expression: "" };
|
|
328
|
+
// Optional leading id (not a key=value and not y=)
|
|
329
|
+
let tail = rest;
|
|
330
|
+
const idMatch = tail.match(/^([A-Za-z_][A-Za-z0-9_]*)\b/);
|
|
331
|
+
if (idMatch && !/^(stroke|fill|fillOpacity|width|y)$/i.test(idMatch[1])) {
|
|
332
|
+
series.id = idMatch[1];
|
|
333
|
+
tail = tail.slice(idMatch[0].length).trim();
|
|
334
|
+
}
|
|
335
|
+
// Inline y = expr?
|
|
336
|
+
const yInline = tail.match(/\by\s*=\s*(.+)$/i);
|
|
337
|
+
if (yInline) {
|
|
338
|
+
series.expression = yInline[1].trim();
|
|
339
|
+
tail = tail.slice(0, yInline.index).trim();
|
|
340
|
+
}
|
|
341
|
+
const flags = parseFlags(tail);
|
|
342
|
+
if (flags.stroke) series.stroke = flags.stroke;
|
|
343
|
+
if (flags.fill) series.fill = flags.fill;
|
|
344
|
+
if (flags.fillOpacity !== undefined) {
|
|
345
|
+
const n = Number(flags.fillOpacity);
|
|
346
|
+
if (Number.isFinite(n)) series.fillOpacity = n;
|
|
347
|
+
}
|
|
348
|
+
if (flags.width !== undefined) {
|
|
349
|
+
const n = Number(flags.width);
|
|
350
|
+
if (Number.isFinite(n)) series.width = n;
|
|
351
|
+
}
|
|
352
|
+
// Continuation: next non-empty line starting with y = or indented
|
|
353
|
+
if (!series.expression) {
|
|
354
|
+
let j = i + 1;
|
|
355
|
+
while (j < lines.length) {
|
|
356
|
+
const next = lines[j];
|
|
357
|
+
if (!next.trim() || next.trim().startsWith("#")) {
|
|
358
|
+
j++;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const cont = next.match(/^\s+y\s*=\s*(.+)$/i) || next.match(/^y\s*=\s*(.+)$/i);
|
|
362
|
+
if (cont) {
|
|
363
|
+
series.expression = cont[1].trim();
|
|
364
|
+
i = j;
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!series.expression) errors.push("@series: expected y = expression");
|
|
370
|
+
else plot.series.push(series);
|
|
371
|
+
i++;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
199
375
|
// @graph — following @node / @edge lines are an editable graph
|
|
200
376
|
if (/^@graph$/i.test(line)) {
|
|
201
|
-
if (
|
|
377
|
+
if (plot) errors.push("@graph cannot mix with @plot");
|
|
378
|
+
else if (graph) errors.push("Duplicate @graph");
|
|
202
379
|
else graph = { nodes: [], edges: [] };
|
|
203
380
|
i++;
|
|
204
381
|
continue;
|
|
@@ -321,6 +498,11 @@ export function parseIvm(body) {
|
|
|
321
498
|
|
|
322
499
|
// @svg [WxH] — remainder of fence is SVG markup
|
|
323
500
|
if (/^@svg\b/i.test(line)) {
|
|
501
|
+
if (plot) {
|
|
502
|
+
errors.push("@svg cannot mix with @plot");
|
|
503
|
+
i++;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
324
506
|
const after = line.replace(/^@svg\b/i, "").trim();
|
|
325
507
|
const size = after.match(/^(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)\s*$/);
|
|
326
508
|
if (size) {
|
|
@@ -447,7 +629,7 @@ export function parseIvm(body) {
|
|
|
447
629
|
i++;
|
|
448
630
|
}
|
|
449
631
|
|
|
450
|
-
return { namespace, controls, templateSvg, svgWidth, svgHeight, graph, errors };
|
|
632
|
+
return { namespace, controls, templateSvg, svgWidth, svgHeight, graph, plot, errors };
|
|
451
633
|
}
|
|
452
634
|
|
|
453
635
|
/**
|
package/src/plot.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cartesian XY plot renderer for Interactive Markdown.
|
|
3
|
+
* Builds an SVG matching a clean academic density-curve look:
|
|
4
|
+
* white background, light gray grid, axes with ticks + labels,
|
|
5
|
+
* optional stroke path and soft fill under the curve.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { evaluate } from "./expr.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_WIDTH = 480;
|
|
11
|
+
const DEFAULT_HEIGHT = 320;
|
|
12
|
+
const MARGIN = { top: 24, right: 28, bottom: 48, left: 64 };
|
|
13
|
+
const DEFAULT_SAMPLES = 240;
|
|
14
|
+
const DEFAULT_STROKE = "#6d28d9";
|
|
15
|
+
const DEFAULT_FILL = "#7c3aed";
|
|
16
|
+
const DEFAULT_FILL_OPACITY = 0.25;
|
|
17
|
+
const DEFAULT_STROKE_WIDTH = 2.25;
|
|
18
|
+
const AXIS_COLOR = "#4b5563";
|
|
19
|
+
const GRID_COLOR = "#e5e7eb";
|
|
20
|
+
const TICK_COLOR = "#6b7280";
|
|
21
|
+
const LABEL_COLOR = "#374151";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ min: number, max: number, step?: number, label?: string }} PlotAxis
|
|
25
|
+
* @typedef {{
|
|
26
|
+
* id?: string,
|
|
27
|
+
* expression: string,
|
|
28
|
+
* stroke?: string,
|
|
29
|
+
* fill?: string,
|
|
30
|
+
* fillOpacity?: number,
|
|
31
|
+
* width?: number,
|
|
32
|
+
* }} PlotSeries
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* width?: number,
|
|
35
|
+
* height?: number,
|
|
36
|
+
* grid?: boolean,
|
|
37
|
+
* samples?: number,
|
|
38
|
+
* x: PlotAxis,
|
|
39
|
+
* y: PlotAxis,
|
|
40
|
+
* series: PlotSeries[],
|
|
41
|
+
* }} PlotModel
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {Partial<PlotModel> | null | undefined} plot
|
|
46
|
+
* @returns {PlotModel}
|
|
47
|
+
*/
|
|
48
|
+
export function normalizePlot(plot) {
|
|
49
|
+
const x = plot?.x || { min: -10, max: 10, step: 2, label: "x" };
|
|
50
|
+
const y = plot?.y || { min: 0, max: 1, step: 0.2, label: "y" };
|
|
51
|
+
return {
|
|
52
|
+
width: positiveOr(plot?.width, DEFAULT_WIDTH),
|
|
53
|
+
height: positiveOr(plot?.height, DEFAULT_HEIGHT),
|
|
54
|
+
grid: plot?.grid !== false,
|
|
55
|
+
samples: Math.max(32, Math.min(2000, Math.round(finiteOr(plot?.samples, DEFAULT_SAMPLES)))),
|
|
56
|
+
x: normalizeAxis(x, "x"),
|
|
57
|
+
y: normalizeAxis(y, "y"),
|
|
58
|
+
series: (plot?.series || []).map(normalizeSeries).filter((s) => s.expression),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {Partial<PlotAxis>} axis
|
|
64
|
+
* @param {string} fallbackLabel
|
|
65
|
+
* @returns {PlotAxis}
|
|
66
|
+
*/
|
|
67
|
+
function normalizeAxis(axis, fallbackLabel) {
|
|
68
|
+
let min = Number(axis?.min);
|
|
69
|
+
let max = Number(axis?.max);
|
|
70
|
+
if (!Number.isFinite(min)) min = fallbackLabel === "y" ? 0 : -10;
|
|
71
|
+
if (!Number.isFinite(max)) max = fallbackLabel === "y" ? 1 : 10;
|
|
72
|
+
if (min === max) max = min + 1;
|
|
73
|
+
if (min > max) {
|
|
74
|
+
const t = min;
|
|
75
|
+
min = max;
|
|
76
|
+
max = t;
|
|
77
|
+
}
|
|
78
|
+
let step = axis?.step !== undefined ? Number(axis.step) : autoStep(min, max);
|
|
79
|
+
if (!Number.isFinite(step) || step <= 0) step = autoStep(min, max);
|
|
80
|
+
return {
|
|
81
|
+
min,
|
|
82
|
+
max,
|
|
83
|
+
step,
|
|
84
|
+
label: axis?.label != null && String(axis.label).length ? String(axis.label) : fallbackLabel,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {Partial<PlotSeries>} series
|
|
90
|
+
* @returns {PlotSeries}
|
|
91
|
+
*/
|
|
92
|
+
function normalizeSeries(series) {
|
|
93
|
+
const fillOpacity = Number(series?.fillOpacity);
|
|
94
|
+
const width = Number(series?.width);
|
|
95
|
+
return {
|
|
96
|
+
id: series?.id,
|
|
97
|
+
expression: String(series?.expression || "").trim(),
|
|
98
|
+
stroke: series?.stroke || DEFAULT_STROKE,
|
|
99
|
+
fill: series?.fill || DEFAULT_FILL,
|
|
100
|
+
fillOpacity: Number.isFinite(fillOpacity) ? clamp(fillOpacity, 0, 1) : DEFAULT_FILL_OPACITY,
|
|
101
|
+
width: Number.isFinite(width) && width > 0 ? width : DEFAULT_STROKE_WIDTH,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build SVG markup for a plot against the current variable environment.
|
|
107
|
+
* Free variable `x` is sampled across the X axis; other names come from env.
|
|
108
|
+
* @param {Partial<PlotModel>} plot
|
|
109
|
+
* @param {Record<string, unknown>} env
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function buildPlotSvg(plot, env = {}) {
|
|
113
|
+
const model = normalizePlot(plot);
|
|
114
|
+
const W = model.width;
|
|
115
|
+
const H = model.height;
|
|
116
|
+
const m = MARGIN;
|
|
117
|
+
const plotW = Math.max(10, W - m.left - m.right);
|
|
118
|
+
const plotH = Math.max(10, H - m.top - m.bottom);
|
|
119
|
+
|
|
120
|
+
const xToPx = (x) => m.left + ((x - model.x.min) / (model.x.max - model.x.min)) * plotW;
|
|
121
|
+
const yToPx = (y) => m.top + plotH - ((y - model.y.min) / (model.y.max - model.y.min)) * plotH;
|
|
122
|
+
|
|
123
|
+
const parts = [];
|
|
124
|
+
parts.push(
|
|
125
|
+
`<svg viewBox="0 0 ${fmt(W)} ${fmt(H)}" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="plot">`
|
|
126
|
+
);
|
|
127
|
+
parts.push(` <rect width="${fmt(W)}" height="${fmt(H)}" fill="#ffffff"/>`);
|
|
128
|
+
|
|
129
|
+
// Clip series to the plot frame
|
|
130
|
+
parts.push(` <defs>`);
|
|
131
|
+
parts.push(
|
|
132
|
+
` <clipPath id="im-plot-clip"><rect x="${fmt(m.left)}" y="${fmt(m.top)}" width="${fmt(plotW)}" height="${fmt(plotH)}"/></clipPath>`
|
|
133
|
+
);
|
|
134
|
+
parts.push(` </defs>`);
|
|
135
|
+
|
|
136
|
+
if (model.grid) {
|
|
137
|
+
parts.push(` <g class="im-plot-grid" stroke="${GRID_COLOR}" stroke-width="1" shape-rendering="crispEdges">`);
|
|
138
|
+
for (const tx of ticks(model.x.min, model.x.max, model.x.step)) {
|
|
139
|
+
const px = xToPx(tx);
|
|
140
|
+
parts.push(` <line x1="${fmt(px)}" y1="${fmt(m.top)}" x2="${fmt(px)}" y2="${fmt(m.top + plotH)}"/>`);
|
|
141
|
+
}
|
|
142
|
+
for (const ty of ticks(model.y.min, model.y.max, model.y.step)) {
|
|
143
|
+
const py = yToPx(ty);
|
|
144
|
+
parts.push(` <line x1="${fmt(m.left)}" y1="${fmt(py)}" x2="${fmt(m.left + plotW)}" y2="${fmt(py)}"/>`);
|
|
145
|
+
}
|
|
146
|
+
parts.push(` </g>`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Series under axes so axes stay crisp on top
|
|
150
|
+
parts.push(` <g class="im-plot-series" clip-path="url(#im-plot-clip)">`);
|
|
151
|
+
for (const series of model.series) {
|
|
152
|
+
const points = sampleSeries(series.expression, model, env);
|
|
153
|
+
if (points.length < 2) continue;
|
|
154
|
+
const lineD = pathFromPoints(points, xToPx, yToPx);
|
|
155
|
+
if (series.fill && series.fillOpacity > 0) {
|
|
156
|
+
const baseY = clamp(model.y.min, model.y.min, model.y.max);
|
|
157
|
+
const first = points[0];
|
|
158
|
+
const last = points[points.length - 1];
|
|
159
|
+
const areaD =
|
|
160
|
+
lineD +
|
|
161
|
+
` L ${fmt(xToPx(last.x))} ${fmt(yToPx(baseY))}` +
|
|
162
|
+
` L ${fmt(xToPx(first.x))} ${fmt(yToPx(baseY))} Z`;
|
|
163
|
+
parts.push(
|
|
164
|
+
` <path d="${areaD}" fill="${escapeAttr(series.fill)}" fill-opacity="${fmt(series.fillOpacity)}" stroke="none"/>`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
parts.push(
|
|
168
|
+
` <path d="${lineD}" fill="none" stroke="${escapeAttr(series.stroke)}" stroke-width="${fmt(series.width)}" stroke-linejoin="round" stroke-linecap="round"/>`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
parts.push(` </g>`);
|
|
172
|
+
|
|
173
|
+
// Axes sit on the bottom and left of the plot frame (matplotlib-style spines)
|
|
174
|
+
const axisY = m.top + plotH;
|
|
175
|
+
const axisX = m.left;
|
|
176
|
+
|
|
177
|
+
parts.push(` <g class="im-plot-axes" stroke="${AXIS_COLOR}" stroke-width="1.25" fill="none" shape-rendering="crispEdges">`);
|
|
178
|
+
parts.push(` <line x1="${fmt(m.left)}" y1="${fmt(axisY)}" x2="${fmt(m.left + plotW)}" y2="${fmt(axisY)}"/>`);
|
|
179
|
+
parts.push(` <line x1="${fmt(axisX)}" y1="${fmt(m.top)}" x2="${fmt(axisX)}" y2="${fmt(m.top + plotH)}"/>`);
|
|
180
|
+
parts.push(` </g>`);
|
|
181
|
+
|
|
182
|
+
// Ticks + numeric labels
|
|
183
|
+
parts.push(
|
|
184
|
+
` <g class="im-plot-ticks" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="11" fill="${TICK_COLOR}">`
|
|
185
|
+
);
|
|
186
|
+
for (const tx of ticks(model.x.min, model.x.max, model.x.step)) {
|
|
187
|
+
const px = xToPx(tx);
|
|
188
|
+
parts.push(
|
|
189
|
+
` <line x1="${fmt(px)}" y1="${fmt(axisY)}" x2="${fmt(px)}" y2="${fmt(axisY + 5)}" stroke="${AXIS_COLOR}" stroke-width="1"/>`
|
|
190
|
+
);
|
|
191
|
+
parts.push(
|
|
192
|
+
` <text x="${fmt(px)}" y="${fmt(axisY + 18)}" text-anchor="middle">${escapeText(formatTick(tx))}</text>`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
for (const ty of ticks(model.y.min, model.y.max, model.y.step)) {
|
|
196
|
+
const py = yToPx(ty);
|
|
197
|
+
parts.push(
|
|
198
|
+
` <line x1="${fmt(axisX - 5)}" y1="${fmt(py)}" x2="${fmt(axisX)}" y2="${fmt(py)}" stroke="${AXIS_COLOR}" stroke-width="1"/>`
|
|
199
|
+
);
|
|
200
|
+
parts.push(
|
|
201
|
+
` <text x="${fmt(axisX - 8)}" y="${fmt(py + 3.5)}" text-anchor="end">${escapeText(formatTick(ty))}</text>`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
parts.push(` </g>`);
|
|
205
|
+
|
|
206
|
+
// Axis titles
|
|
207
|
+
parts.push(
|
|
208
|
+
` <g class="im-plot-labels" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="13" font-weight="600" fill="${LABEL_COLOR}">`
|
|
209
|
+
);
|
|
210
|
+
parts.push(
|
|
211
|
+
` <text x="${fmt(m.left + plotW / 2)}" y="${fmt(H - 12)}" text-anchor="middle">${escapeText(model.x.label)}</text>`
|
|
212
|
+
);
|
|
213
|
+
const yLabelX = 16;
|
|
214
|
+
const yLabelY = m.top + plotH / 2;
|
|
215
|
+
parts.push(
|
|
216
|
+
` <text x="${fmt(yLabelX)}" y="${fmt(yLabelY)}" text-anchor="middle" transform="rotate(-90 ${fmt(yLabelX)} ${fmt(yLabelY)})">${escapeText(model.y.label)}</text>`
|
|
217
|
+
);
|
|
218
|
+
parts.push(` </g>`);
|
|
219
|
+
|
|
220
|
+
parts.push(`</svg>`);
|
|
221
|
+
return parts.join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {string} expression
|
|
226
|
+
* @param {PlotModel} model
|
|
227
|
+
* @param {Record<string, unknown>} env
|
|
228
|
+
* @returns {{ x: number, y: number }[]}
|
|
229
|
+
*/
|
|
230
|
+
function sampleSeries(expression, model, env) {
|
|
231
|
+
const n = model.samples;
|
|
232
|
+
const span = model.x.max - model.x.min;
|
|
233
|
+
/** @type {{ x: number, y: number }[]} */
|
|
234
|
+
const points = [];
|
|
235
|
+
for (let i = 0; i <= n; i++) {
|
|
236
|
+
const x = model.x.min + (span * i) / n;
|
|
237
|
+
/** @type {Record<string, unknown>} */
|
|
238
|
+
const local = Object.create(null);
|
|
239
|
+
Object.assign(local, env);
|
|
240
|
+
local.x = x;
|
|
241
|
+
try {
|
|
242
|
+
const y = Number(evaluate(expression, local));
|
|
243
|
+
if (!Number.isFinite(y)) continue;
|
|
244
|
+
points.push({ x, y });
|
|
245
|
+
} catch (_) {
|
|
246
|
+
// skip bad samples; keep the rest of the curve
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return points;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {{ x: number, y: number }[]} points
|
|
254
|
+
* @param {(x: number) => number} xToPx
|
|
255
|
+
* @param {(y: number) => number} yToPx
|
|
256
|
+
*/
|
|
257
|
+
function pathFromPoints(points, xToPx, yToPx) {
|
|
258
|
+
let d = "";
|
|
259
|
+
for (let i = 0; i < points.length; i++) {
|
|
260
|
+
const cmd = i === 0 ? "M" : "L";
|
|
261
|
+
d += `${cmd}${fmt(xToPx(points[i].x))} ${fmt(yToPx(points[i].y))}`;
|
|
262
|
+
if (i < points.length - 1) d += " ";
|
|
263
|
+
}
|
|
264
|
+
return d;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {number} min
|
|
269
|
+
* @param {number} max
|
|
270
|
+
* @param {number} step
|
|
271
|
+
* @returns {number[]}
|
|
272
|
+
*/
|
|
273
|
+
export function ticks(min, max, step) {
|
|
274
|
+
const out = [];
|
|
275
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(step) || step <= 0) return out;
|
|
276
|
+
const start = Math.ceil((min - 1e-12) / step) * step;
|
|
277
|
+
for (let v = start; v <= max + 1e-9; v += step) {
|
|
278
|
+
const rounded = Math.round(v / step) * step;
|
|
279
|
+
const clamped = Math.abs(rounded) < step * 1e-9 ? 0 : rounded;
|
|
280
|
+
if (clamped < min - 1e-9 || clamped > max + 1e-9) continue;
|
|
281
|
+
out.push(clamped);
|
|
282
|
+
if (out.length > 200) break;
|
|
283
|
+
}
|
|
284
|
+
if (!out.length || out[0] > min + 1e-9) out.unshift(min);
|
|
285
|
+
if (out[out.length - 1] < max - 1e-9) out.push(max);
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function autoStep(min, max) {
|
|
290
|
+
const span = Math.abs(max - min) || 1;
|
|
291
|
+
const rough = span / 8;
|
|
292
|
+
const pow = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
293
|
+
const err = rough / pow;
|
|
294
|
+
let nice = 1;
|
|
295
|
+
if (err >= 5) nice = 5;
|
|
296
|
+
else if (err >= 2) nice = 2;
|
|
297
|
+
return nice * pow;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function formatTick(n) {
|
|
301
|
+
if (!Number.isFinite(n)) return "";
|
|
302
|
+
if (Math.abs(n) < 1e-12) return "0";
|
|
303
|
+
const abs = Math.abs(n);
|
|
304
|
+
if (abs >= 1000 || (abs > 0 && abs < 0.001)) return String(Number(n.toPrecision(4)));
|
|
305
|
+
const rounded = Math.round(n * 1e6) / 1e6;
|
|
306
|
+
return String(rounded);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function finiteOr(value, fallback) {
|
|
310
|
+
const n = Number(value);
|
|
311
|
+
return Number.isFinite(n) ? n : fallback;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Positive finite size, else fallback. Rejects null/undefined/0/NaN (Number(null)===0). */
|
|
315
|
+
function positiveOr(value, fallback) {
|
|
316
|
+
const n = Number(value);
|
|
317
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {number} width
|
|
322
|
+
* @param {number} height
|
|
323
|
+
*/
|
|
324
|
+
function plotSizeSuffix(width, height) {
|
|
325
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "";
|
|
326
|
+
if (width === DEFAULT_WIDTH && height === DEFAULT_HEIGHT) return "";
|
|
327
|
+
return ` ${fmt(width)}x${fmt(height)}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function clamp(n, lo, hi) {
|
|
331
|
+
return Math.min(hi, Math.max(lo, n));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function fmt(n) {
|
|
335
|
+
if (!Number.isFinite(n)) return "0";
|
|
336
|
+
const rounded = Math.round(n * 1000) / 1000;
|
|
337
|
+
return String(rounded);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function escapeAttr(value) {
|
|
341
|
+
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function escapeText(value) {
|
|
345
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Serialize a plot model (+ optional controls already handled by caller) to fence lines.
|
|
350
|
+
* @param {Partial<PlotModel>} plot
|
|
351
|
+
* @returns {string[]}
|
|
352
|
+
*/
|
|
353
|
+
export function serializePlotLines(plot) {
|
|
354
|
+
const model = normalizePlot(plot);
|
|
355
|
+
/** @type {string[]} */
|
|
356
|
+
const lines = [];
|
|
357
|
+
// Only emit WxH when both are positive finite and differ from defaults.
|
|
358
|
+
// Missing / 0 / NaN sizes normalize to defaults and stay as bare @plot (never 0x0).
|
|
359
|
+
const size = plotSizeSuffix(model.width, model.height);
|
|
360
|
+
const gridFlag = model.grid === false ? " nogrid" : "";
|
|
361
|
+
lines.push(`@plot${size}${gridFlag}`);
|
|
362
|
+
lines.push(serializeAxis("x", model.x));
|
|
363
|
+
lines.push(serializeAxis("y", model.y));
|
|
364
|
+
for (const series of model.series) {
|
|
365
|
+
let head = "@series";
|
|
366
|
+
if (series.id) head += ` ${series.id}`;
|
|
367
|
+
head += ` stroke=${series.stroke}`;
|
|
368
|
+
if (series.fillOpacity > 0) {
|
|
369
|
+
head += ` fill=${series.fill}`;
|
|
370
|
+
head += ` fillOpacity=${fmt(series.fillOpacity)}`;
|
|
371
|
+
}
|
|
372
|
+
if (series.width !== DEFAULT_STROKE_WIDTH) head += ` width=${fmt(series.width)}`;
|
|
373
|
+
lines.push(head);
|
|
374
|
+
lines.push(` y = ${series.expression}`);
|
|
375
|
+
}
|
|
376
|
+
return lines;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @param {"x"|"y"} which
|
|
381
|
+
* @param {PlotAxis} axis
|
|
382
|
+
*/
|
|
383
|
+
function serializeAxis(which, axis) {
|
|
384
|
+
let line = `@axis ${which}: ${fmt(axis.min)}..${fmt(axis.max)}`;
|
|
385
|
+
if (axis.step != null) line += ` step=${fmt(axis.step)}`;
|
|
386
|
+
if (axis.label) line += ` ${quoteLabel(axis.label)}`;
|
|
387
|
+
return line;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function quoteLabel(label) {
|
|
391
|
+
return `"${String(label).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export {
|
|
395
|
+
DEFAULT_WIDTH,
|
|
396
|
+
DEFAULT_HEIGHT,
|
|
397
|
+
DEFAULT_STROKE,
|
|
398
|
+
DEFAULT_FILL,
|
|
399
|
+
DEFAULT_FILL_OPACITY,
|
|
400
|
+
DEFAULT_STROKE_WIDTH,
|
|
401
|
+
};
|
package/src/render.js
CHANGED
|
@@ -6,6 +6,7 @@ import { evaluate, substitute } from "./expr.js";
|
|
|
6
6
|
import { parseIvm } from "./parse-ivm.js";
|
|
7
7
|
import { sanitizeSvg } from "./sanitize-svg.js";
|
|
8
8
|
import { mountGraphEditor, serializeGraph } from "./graph-editor.js";
|
|
9
|
+
import { buildPlotSvg, normalizePlot } from "./plot.js";
|
|
9
10
|
|
|
10
11
|
/** @type {Map<string, Record<string, unknown>>} */
|
|
11
12
|
const namespaces = new Map();
|
|
@@ -65,6 +66,8 @@ export function mountIvm(container, fenceBody, options = {}) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (parsed.plot) container.classList.add("im-ivm-plot");
|
|
70
|
+
|
|
68
71
|
const nsState = getNamespaceState(parsed.namespace);
|
|
69
72
|
/** @type {Record<string, unknown>} */
|
|
70
73
|
const local = Object.create(null);
|
|
@@ -183,6 +186,21 @@ export function mountIvm(container, fenceBody, options = {}) {
|
|
|
183
186
|
el.textContent =
|
|
184
187
|
typeof v === "number" ? String(Math.round(v * 1000) / 1000) : String(v ?? "");
|
|
185
188
|
}
|
|
189
|
+
if (parsed.plot) {
|
|
190
|
+
const model = normalizePlot({
|
|
191
|
+
width: parsed.plot.width ?? undefined,
|
|
192
|
+
height: parsed.plot.height ?? undefined,
|
|
193
|
+
grid: parsed.plot.grid,
|
|
194
|
+
x: parsed.plot.x || undefined,
|
|
195
|
+
y: parsed.plot.y || undefined,
|
|
196
|
+
series: parsed.plot.series || [],
|
|
197
|
+
});
|
|
198
|
+
const filled = buildPlotSvg(model, env);
|
|
199
|
+
if (filled === lastFilled) return;
|
|
200
|
+
lastFilled = filled;
|
|
201
|
+
paintSvg(sanitizeSvg(filled));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
186
204
|
if (!parsed.templateSvg) {
|
|
187
205
|
if (lastFilled !== "") {
|
|
188
206
|
lastFilled = "";
|
package/styles/app.css
CHANGED
|
@@ -385,6 +385,18 @@ body {
|
|
|
385
385
|
overflow: hidden;
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/* XY plots: white chart card, no dark SVG stage */
|
|
389
|
+
.im-ivm-plot .im-svg-host {
|
|
390
|
+
background: #ffffff;
|
|
391
|
+
border: 1px solid #e5e7eb;
|
|
392
|
+
padding: 0.35rem;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.im-ivm-plot {
|
|
396
|
+
max-width: 640px;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
388
400
|
.im-errors {
|
|
389
401
|
white-space: pre-wrap;
|
|
390
402
|
color: var(--danger);
|