interactive-markdown 0.1.1 → 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/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
@@ -5,6 +5,8 @@
5
5
  import { evaluate, substitute } from "./expr.js";
6
6
  import { parseIvm } from "./parse-ivm.js";
7
7
  import { sanitizeSvg } from "./sanitize-svg.js";
8
+ import { mountGraphEditor, serializeGraph } from "./graph-editor.js";
9
+ import { buildPlotSvg, normalizePlot } from "./plot.js";
8
10
 
9
11
  /** @type {Map<string, Record<string, unknown>>} */
10
12
  const namespaces = new Map();
@@ -28,9 +30,10 @@ export function resetNamespaces() {
28
30
  * Mount one ```ivm fence body into a container element.
29
31
  * @param {HTMLElement} container
30
32
  * @param {string} fenceBody
33
+ * @param {{ onGraphChange?: (body: string) => void }} [options]
31
34
  * @returns {{ destroy: () => void, getState: () => Record<string, unknown> }}
32
35
  */
33
- export function mountIvm(container, fenceBody) {
36
+ export function mountIvm(container, fenceBody, options = {}) {
34
37
  const parsed = parseIvm(fenceBody);
35
38
  const doc = container.ownerDocument || globalThis.document;
36
39
  container.classList.add("im-ivm");
@@ -43,6 +46,28 @@ export function mountIvm(container, fenceBody) {
43
46
  container.appendChild(err);
44
47
  }
45
48
 
49
+ if (parsed.graph) {
50
+ container.classList.add("im-ivm-graph");
51
+ const graphHost = doc.createElement("div");
52
+ container.appendChild(graphHost);
53
+ const mounted = mountGraphEditor(graphHost, parsed.graph, {
54
+ onChange(model) {
55
+ options.onGraphChange?.(serializeGraph(model));
56
+ },
57
+ });
58
+ return {
59
+ destroy() {
60
+ mounted.destroy();
61
+ container.innerHTML = "";
62
+ },
63
+ getState() {
64
+ return { graph: mounted.getModel() };
65
+ },
66
+ };
67
+ }
68
+
69
+ if (parsed.plot) container.classList.add("im-ivm-plot");
70
+
46
71
  const nsState = getNamespaceState(parsed.namespace);
47
72
  /** @type {Record<string, unknown>} */
48
73
  const local = Object.create(null);
@@ -58,13 +83,14 @@ export function mountIvm(container, fenceBody) {
58
83
  if (parsed.namespace) nsState[c.name] = val;
59
84
  }
60
85
 
86
+ const hasControls = parsed.controls.some((c) => c.kind !== "let" && c.name != null);
87
+ if (!hasControls) container.classList.add("im-ivm-static");
88
+
61
89
  const controlsEl = doc.createElement("div");
62
90
  controlsEl.className = "im-controls";
63
- container.appendChild(controlsEl);
64
91
 
65
92
  const svgHost = doc.createElement("div");
66
93
  svgHost.className = "im-svg-host";
67
- container.appendChild(svgHost);
68
94
 
69
95
  /** @type {Array<() => void>} */
70
96
  const cleanups = [];
@@ -92,6 +118,67 @@ export function mountIvm(container, fenceBody) {
92
118
  return env;
93
119
  }
94
120
 
121
+ function wrapSvgTemplate(markup) {
122
+ const src = String(markup ?? "").trim();
123
+ if (!src) return src;
124
+ const w = parsed.svgWidth;
125
+ const h = parsed.svgHeight;
126
+ if (w == null || h == null || !Number.isFinite(w) || !Number.isFinite(h)) {
127
+ return src;
128
+ }
129
+ // Already a root <svg> — leave author markup alone
130
+ if (/^<svg\b/i.test(src)) return src;
131
+ return (
132
+ `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">\n` +
133
+ src +
134
+ `\n</svg>`
135
+ );
136
+ }
137
+
138
+ /** Skip DOM writes when the substituted source is unchanged so CSS and SMIL keep running. */
139
+ let lastFilled = null;
140
+
141
+ /**
142
+ * Mount markup in a shadow root so <style> in the figure cannot restyle the editor.
143
+ * transform on the host makes position:fixed a local containing block.
144
+ * @param {string} markup
145
+ */
146
+ function paintSvg(markup) {
147
+ let shadow = svgHost.shadowRoot;
148
+ if (!shadow && typeof svgHost.attachShadow === "function") {
149
+ try {
150
+ shadow = svgHost.attachShadow({ mode: "open" });
151
+ } catch (_) {
152
+ shadow = null;
153
+ }
154
+ }
155
+ const target = shadow || svgHost;
156
+ const doc = svgHost.ownerDocument || globalThis.document;
157
+ target.innerHTML = "";
158
+ if (!markup) return;
159
+
160
+ // Scope figure CSS to this widget. transform makes position:fixed local.
161
+ if (shadow && doc?.createElement) {
162
+ const frame = doc.createElement("style");
163
+ frame.textContent =
164
+ ":host{display:block;overflow:hidden;transform:translate(0)}" +
165
+ "svg{display:block;max-width:100%;height:auto;margin:0 auto}" +
166
+ "@media (prefers-reduced-motion:reduce){:host *{animation:none!important}}";
167
+ target.appendChild(frame);
168
+ }
169
+
170
+ // Parse as XML and adopt the node. innerHTML would lowercase camelCase
171
+ // SVG tags (radialGradient, feGaussianBlur, animateMotion) and drop them.
172
+ const Parser = globalThis.DOMParser;
173
+ if (!Parser) return;
174
+ const parsed = new Parser().parseFromString(markup, "image/svg+xml");
175
+ const root = parsed.documentElement;
176
+ const rootName = String(root?.localName || root?.tagName || "").toLowerCase();
177
+ if (!root || rootName === "parsererror" || rootName !== "svg") return;
178
+ const node = typeof doc.importNode === "function" ? doc.importNode(root, true) : root;
179
+ target.appendChild(node);
180
+ }
181
+
95
182
  function rebind() {
96
183
  const env = buildEnv();
97
184
  for (const [name, el] of valueLabels) {
@@ -99,12 +186,32 @@ export function mountIvm(container, fenceBody) {
99
186
  el.textContent =
100
187
  typeof v === "number" ? String(Math.round(v * 1000) / 1000) : String(v ?? "");
101
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
+ }
102
204
  if (!parsed.templateSvg) {
103
- svgHost.innerHTML = "";
205
+ if (lastFilled !== "") {
206
+ lastFilled = "";
207
+ paintSvg("");
208
+ }
104
209
  return;
105
210
  }
106
- const filled = substitute(parsed.templateSvg, env);
107
- svgHost.innerHTML = sanitizeSvg(filled);
211
+ const filled = substitute(wrapSvgTemplate(parsed.templateSvg), env);
212
+ if (filled === lastFilled) return;
213
+ lastFilled = filled;
214
+ paintSvg(sanitizeSvg(filled));
108
215
  }
109
216
 
110
217
  function setVar(name, value) {
@@ -239,6 +346,9 @@ export function mountIvm(container, fenceBody) {
239
346
  }
240
347
  }
241
348
 
349
+ if (hasControls) container.appendChild(controlsEl);
350
+ container.appendChild(svgHost);
351
+
242
352
  rebind();
243
353
 
244
354
  return {