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 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.2.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",
@@ -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 (graph) errors.push("Duplicate @graph");
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
342
+ }
343
+
344
+ function escapeText(value) {
345
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;");
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);