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/LICENSE +1 -1
- package/README.md +122 -26
- package/examples/agent-loop.md +26 -0
- package/examples/amplitude.md +11 -14
- package/examples/circular-motion.md +10 -14
- package/examples/graph.md +18 -0
- package/examples/normal-density.md +19 -0
- package/index.html +8 -1
- package/package.json +13 -5
- package/src/app.js +85 -8
- package/src/document-editor.js +1062 -0
- package/src/document.js +771 -0
- package/src/expr.js +6 -0
- package/src/graph-editor.js +1117 -0
- package/src/index.js +1 -0
- package/src/markdown.js +10 -2
- package/src/parse-ivm.js +488 -67
- package/src/plot.js +401 -0
- package/src/render.js +116 -6
- package/src/sanitize-svg.js +240 -9
- package/styles/app.css +659 -1
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
|
@@ -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
|
-
|
|
205
|
+
if (lastFilled !== "") {
|
|
206
|
+
lastFilled = "";
|
|
207
|
+
paintSvg("");
|
|
208
|
+
}
|
|
104
209
|
return;
|
|
105
210
|
}
|
|
106
|
-
const filled = substitute(parsed.templateSvg, env);
|
|
107
|
-
|
|
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 {
|