@slxu/graphsx 0.1.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 +21 -0
- package/README.md +588 -0
- package/docs/assets/basic-graph.svg +15 -0
- package/docs/assets/plot-heart.svg +15 -0
- package/docs/assets/tensor-repeat.svg +15 -0
- package/package.json +56 -0
- package/src/codemirror.css +66 -0
- package/src/codemirror.js +225 -0
- package/src/document.js +71 -0
- package/src/errors.js +7 -0
- package/src/index.js +42 -0
- package/src/literals.js +436 -0
- package/src/markdown.css +24 -0
- package/src/markdown.js +111 -0
- package/src/markup.js +219 -0
- package/src/parser.js +1463 -0
- package/src/plot-math.js +459 -0
- package/src/plot-renderer.js +1025 -0
- package/src/plot.js +842 -0
- package/src/renderer.js +987 -0
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
import { regeneratePlotData } from "./plot.js";
|
|
2
|
+
import { applyPointMaps } from "./plot-math.js";
|
|
3
|
+
|
|
4
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
5
|
+
const MATH_LABEL_HEIGHT = 34;
|
|
6
|
+
const MATH_HANGING_INSET = 8;
|
|
7
|
+
let plotClipIdCounter = 0;
|
|
8
|
+
|
|
9
|
+
export function renderPlot(svg, plot, options = {}) {
|
|
10
|
+
const width = Number(plot.attrs.width ?? plot.attrs.w ?? options.minWidth ?? 720);
|
|
11
|
+
const height = Number(plot.attrs.height ?? plot.attrs.h ?? options.minHeight ?? 420);
|
|
12
|
+
const padding = normalizePadding(plot.attrs.padding ?? options.padding ?? 56);
|
|
13
|
+
const bounds = plotBounds(plot);
|
|
14
|
+
const xDomain = domainAttr(plot.attrs.xDomain ?? plot.attrs.xdomain, [bounds.minX, bounds.maxX]);
|
|
15
|
+
const yDomain = domainAttr(plot.attrs.yDomain ?? plot.attrs.ydomain, [bounds.minY, bounds.maxY]);
|
|
16
|
+
const context = {
|
|
17
|
+
document: options.document ?? svg.ownerDocument ?? document,
|
|
18
|
+
katex: options.katex ?? null,
|
|
19
|
+
plot,
|
|
20
|
+
width,
|
|
21
|
+
height,
|
|
22
|
+
padding,
|
|
23
|
+
frame: options.frame ?? {},
|
|
24
|
+
xDomain: expandDomain(xDomain),
|
|
25
|
+
yDomain: expandDomain(yDomain),
|
|
26
|
+
arrowMarkerPrefix: `graphsx-plot-arrow-${plotClipIdCounter + 1}`
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
30
|
+
svg.replaceChildren();
|
|
31
|
+
|
|
32
|
+
const clipId = `graphsx-plot-clip-${plotClipIdCounter += 1}`;
|
|
33
|
+
const defs = el(context, "defs");
|
|
34
|
+
defs.append(drawPlotClipPath(context, clipId));
|
|
35
|
+
appendArrowMarkers(context, defs);
|
|
36
|
+
const frameLayer = el(context, "g", { class: "plot-frame-layer" });
|
|
37
|
+
const axisLayer = el(context, "g", { class: "plot-axes" });
|
|
38
|
+
const dataLayer = el(context, "g", { class: "plot-data", clipPath: `url(#${clipId})` });
|
|
39
|
+
const annotationLayer = el(context, "g", { class: "plot-annotations" });
|
|
40
|
+
const labelLayer = el(context, "g", { class: "plot-labels" });
|
|
41
|
+
const legendLayer = el(context, "g", { class: "plot-legends" });
|
|
42
|
+
svg.append(defs, frameLayer, axisLayer, dataLayer, annotationLayer, labelLayer, legendLayer);
|
|
43
|
+
|
|
44
|
+
if (booleanAttr(plot.attrs.frame, false)) {
|
|
45
|
+
frameLayer.append(drawPlotFrame(context));
|
|
46
|
+
}
|
|
47
|
+
if (booleanAttr(plot.attrs.box, false)) {
|
|
48
|
+
axisLayer.append(drawPlotBox(context));
|
|
49
|
+
}
|
|
50
|
+
drawAxes(context, axisLayer);
|
|
51
|
+
for (const line of plot.lines) dataLayer.append(drawLine(context, line));
|
|
52
|
+
for (const curve of plot.curves) dataLayer.append(drawCurve(context, curve));
|
|
53
|
+
for (const mark of plot.marks) dataLayer.append(drawMark(context, mark));
|
|
54
|
+
drawAnnotations(context, annotationLayer);
|
|
55
|
+
for (const label of plot.labels) labelLayer.append(drawText(context, label));
|
|
56
|
+
for (const legend of plot.legends ?? []) appendMaybe(legendLayer, drawLegend(context, legend));
|
|
57
|
+
|
|
58
|
+
return { width, height, bounds };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function plotSummary(plot) {
|
|
62
|
+
const curveCount = plot.curves.length;
|
|
63
|
+
const lineCount = plot.lines.length;
|
|
64
|
+
const markCount = plot.marks.length;
|
|
65
|
+
return {
|
|
66
|
+
curveCount,
|
|
67
|
+
lineCount,
|
|
68
|
+
markCount,
|
|
69
|
+
text: `${curveCount} ${plural(curveCount, "curve")}, ${lineCount} ${plural(lineCount, "line")}, ${markCount} ${plural(markCount, "mark")}`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function drawAxes(context, layer) {
|
|
74
|
+
const axes = context.plot.axes.length > 0
|
|
75
|
+
? context.plot.axes
|
|
76
|
+
: [{ dim: "x", attrs: { dim: "x" } }, { dim: "y", attrs: { dim: "y" } }];
|
|
77
|
+
|
|
78
|
+
for (const axis of axes) {
|
|
79
|
+
const labelGap = Number(axis.attrs.labelGap ?? axis.attrs.labelgap ?? 40);
|
|
80
|
+
if (axis.dim === "x") {
|
|
81
|
+
const y = context.height - context.padding.bottom;
|
|
82
|
+
layer.append(styledEl(context, "line", axis.attrs.style, {
|
|
83
|
+
class: "plot-axis plot-axis-x",
|
|
84
|
+
stroke: "#26312d",
|
|
85
|
+
strokeWidth: 1.5,
|
|
86
|
+
x1: context.padding.left,
|
|
87
|
+
y1: y,
|
|
88
|
+
x2: context.width - context.padding.right,
|
|
89
|
+
y2: y
|
|
90
|
+
}));
|
|
91
|
+
drawTicks(context, layer, axis);
|
|
92
|
+
appendMaybe(layer, axisLabel(context, axis, plotCenterX(context), y + labelGap, "middle"));
|
|
93
|
+
} else {
|
|
94
|
+
const x = context.padding.left;
|
|
95
|
+
layer.append(styledEl(context, "line", axis.attrs.style, {
|
|
96
|
+
class: "plot-axis plot-axis-y",
|
|
97
|
+
stroke: "#26312d",
|
|
98
|
+
strokeWidth: 1.5,
|
|
99
|
+
x1: x,
|
|
100
|
+
y1: context.padding.top,
|
|
101
|
+
x2: x,
|
|
102
|
+
y2: context.height - context.padding.bottom
|
|
103
|
+
}));
|
|
104
|
+
drawTicks(context, layer, axis);
|
|
105
|
+
appendMaybe(layer, axisLabel(context, axis, x - labelGap, plotCenterY(context), "middle", -90));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function drawTicks(context, layer, axis) {
|
|
111
|
+
const tickSets = axis.ticks?.length > 0
|
|
112
|
+
? axis.ticks.map((tickSet) => ({ attrs: tickSet.attrs, axisShortcut: false }))
|
|
113
|
+
: [{ attrs: axis.attrs, axisShortcut: true }];
|
|
114
|
+
for (const tickSet of tickSets) {
|
|
115
|
+
drawTickSet(context, layer, axis, tickSet.attrs, tickSet.axisShortcut);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function drawTickSet(context, layer, axis, attrs, axisShortcut) {
|
|
120
|
+
const values = tickValues(context, axis, attrs);
|
|
121
|
+
if (values.length === 0) return;
|
|
122
|
+
|
|
123
|
+
const labels = tickLabels(attrs, values);
|
|
124
|
+
const length = Number(attrs.size ?? attrs.tickSize ?? attrs.ticksize ?? 6);
|
|
125
|
+
const labelGap = Number(axisShortcut
|
|
126
|
+
? attrs.tickLabelGap ?? attrs.ticklabelgap ?? 8
|
|
127
|
+
: attrs.labelGap ?? attrs.labelgap ?? attrs.tickLabelGap ?? attrs.ticklabelgap ?? 8);
|
|
128
|
+
const style = attrs.style;
|
|
129
|
+
const labelStyle = attrs.labelStyle ?? attrs.labelstyle ?? tickLabelStyle(style);
|
|
130
|
+
const showGrid = booleanAttr(attrs.grid, false);
|
|
131
|
+
|
|
132
|
+
for (const [index, value] of values.entries()) {
|
|
133
|
+
if (axis.dim === "x") {
|
|
134
|
+
const point = project(context, { x: value, y: context.yDomain[0] });
|
|
135
|
+
if (showGrid) {
|
|
136
|
+
layer.append(drawGridLine(context, axis, attrs, point.x, null));
|
|
137
|
+
}
|
|
138
|
+
layer.append(styledEl(context, "line", style, {
|
|
139
|
+
class: "plot-tick plot-tick-x",
|
|
140
|
+
stroke: "#26312d",
|
|
141
|
+
strokeWidth: 1,
|
|
142
|
+
x1: point.x,
|
|
143
|
+
y1: context.height - context.padding.bottom,
|
|
144
|
+
x2: point.x,
|
|
145
|
+
y2: context.height - context.padding.bottom + length
|
|
146
|
+
}));
|
|
147
|
+
appendMaybe(layer, drawTickLabel(context, labels?.[index], point.x, context.height - context.padding.bottom + length + labelGap, "middle", "hanging", labelStyle));
|
|
148
|
+
} else {
|
|
149
|
+
const point = project(context, { x: context.xDomain[0], y: value });
|
|
150
|
+
if (showGrid) {
|
|
151
|
+
layer.append(drawGridLine(context, axis, attrs, null, point.y));
|
|
152
|
+
}
|
|
153
|
+
layer.append(styledEl(context, "line", style, {
|
|
154
|
+
class: "plot-tick plot-tick-y",
|
|
155
|
+
stroke: "#26312d",
|
|
156
|
+
strokeWidth: 1,
|
|
157
|
+
x1: context.padding.left,
|
|
158
|
+
y1: point.y,
|
|
159
|
+
x2: context.padding.left - length,
|
|
160
|
+
y2: point.y
|
|
161
|
+
}));
|
|
162
|
+
appendMaybe(layer, drawTickLabel(context, labels?.[index], context.padding.left - length - labelGap, point.y, "end", "middle", labelStyle));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function drawGridLine(context, axis, attrs, x, y) {
|
|
168
|
+
const gridStyle = attrs.gridStyle && typeof attrs.gridStyle === "object" ? attrs.gridStyle : {};
|
|
169
|
+
if (axis.dim === "x") {
|
|
170
|
+
return styledEl(context, "line", gridStyle, {
|
|
171
|
+
class: "plot-grid plot-grid-x",
|
|
172
|
+
stroke: "#d8ded8",
|
|
173
|
+
strokeWidth: 1,
|
|
174
|
+
x1: x,
|
|
175
|
+
y1: context.padding.top,
|
|
176
|
+
x2: x,
|
|
177
|
+
y2: context.height - context.padding.bottom
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return styledEl(context, "line", gridStyle, {
|
|
181
|
+
class: "plot-grid plot-grid-y",
|
|
182
|
+
stroke: "#d8ded8",
|
|
183
|
+
strokeWidth: 1,
|
|
184
|
+
x1: context.padding.left,
|
|
185
|
+
y1: y,
|
|
186
|
+
x2: context.width - context.padding.right,
|
|
187
|
+
y2: y
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function drawTickLabel(context, label, x, y, anchor, baseline, style) {
|
|
192
|
+
if (label == null) return null;
|
|
193
|
+
return drawPlotLabel(context, label, x, y, "plot-tick-label", anchor, style, 0, baseline);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function tickValues(context, axis, attrs) {
|
|
197
|
+
const ticks = attrs.values ?? attrs.ticks;
|
|
198
|
+
if (ticks == null || ticks === false || ticks === "false") return [];
|
|
199
|
+
if (Array.isArray(ticks)) return ticks.map(Number).filter(Number.isFinite);
|
|
200
|
+
|
|
201
|
+
const domain = axis.dim === "x" ? context.xDomain : context.yDomain;
|
|
202
|
+
const count = typeof ticks === "number" ? ticks : Number(ticks);
|
|
203
|
+
return niceTicks(domain, Number.isFinite(count) && count > 1 ? count : 5);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function tickLabels(attrs, values) {
|
|
207
|
+
const explicit = attrs.labels ?? attrs.tickLabels ?? attrs.ticklabels;
|
|
208
|
+
if (explicit === false || explicit === "false") return null;
|
|
209
|
+
if (Array.isArray(explicit)) {
|
|
210
|
+
return values.map((value, index) => explicit[index] ?? mathTickLabel(value));
|
|
211
|
+
}
|
|
212
|
+
return values.map(mathTickLabel);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mathTickLabel(value) {
|
|
216
|
+
return `$${formatTick(value)}$`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function niceTicks(domain, targetIntervals) {
|
|
220
|
+
const [min, max] = domain;
|
|
221
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min].filter(Number.isFinite);
|
|
222
|
+
const low = Math.min(min, max);
|
|
223
|
+
const high = Math.max(min, max);
|
|
224
|
+
const step = niceStep((high - low) / targetIntervals);
|
|
225
|
+
const first = Math.ceil(low / step - 1e-10) * step;
|
|
226
|
+
const last = Math.floor(high / step + 1e-10) * step;
|
|
227
|
+
const ticks = [];
|
|
228
|
+
for (let value = first; value <= last + step * 1e-10; value += step) {
|
|
229
|
+
ticks.push(cleanNumber(value));
|
|
230
|
+
}
|
|
231
|
+
return ticks;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function niceStep(roughStep) {
|
|
235
|
+
const exponent = Math.floor(Math.log10(Math.abs(roughStep)));
|
|
236
|
+
const power = 10 ** exponent;
|
|
237
|
+
const fraction = roughStep / power;
|
|
238
|
+
const niceFraction = closestNiceFraction(fraction);
|
|
239
|
+
return niceFraction * power;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function closestNiceFraction(fraction) {
|
|
243
|
+
return [1, 2, 2.5, 5, 10].reduce((best, candidate) => (
|
|
244
|
+
Math.abs(candidate - fraction) < Math.abs(best - fraction) ? candidate : best
|
|
245
|
+
), 1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function cleanNumber(value) {
|
|
249
|
+
return Number(value.toPrecision(12));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function formatTick(value) {
|
|
253
|
+
const clean = cleanNumber(value);
|
|
254
|
+
if (Math.abs(clean) >= 1000 || (Math.abs(clean) > 0 && Math.abs(clean) < 0.001)) {
|
|
255
|
+
return clean.toExponential(2);
|
|
256
|
+
}
|
|
257
|
+
return String(clean);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function tickLabelStyle(style) {
|
|
261
|
+
if (!style || typeof style !== "object") return null;
|
|
262
|
+
const { stroke, strokeWidth, strokeDasharray, strokeLinecap, strokeLinejoin, ...textStyle } = style;
|
|
263
|
+
return textStyle;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function drawPlotBox(context) {
|
|
267
|
+
return el(context, "rect", {
|
|
268
|
+
class: "plot-box",
|
|
269
|
+
x: context.padding.left,
|
|
270
|
+
y: context.padding.top,
|
|
271
|
+
width: plotWidth(context),
|
|
272
|
+
height: plotHeight(context),
|
|
273
|
+
fill: "none",
|
|
274
|
+
stroke: "#26312d",
|
|
275
|
+
strokeWidth: 1.5
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function drawPlotFrame(context) {
|
|
280
|
+
return styledEl(context, "rect", context.plot.attrs.frameStyle ?? context.plot.attrs.framestyle, {
|
|
281
|
+
class: "plot-frame",
|
|
282
|
+
x: 0,
|
|
283
|
+
y: 0,
|
|
284
|
+
width: context.width,
|
|
285
|
+
height: context.height,
|
|
286
|
+
fill: "none",
|
|
287
|
+
stroke: "#cbd5d0",
|
|
288
|
+
strokeWidth: 1
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function drawPlotClipPath(context, id) {
|
|
293
|
+
const clipPath = el(context, "clipPath", { id });
|
|
294
|
+
clipPath.append(el(context, "rect", {
|
|
295
|
+
x: context.padding.left,
|
|
296
|
+
y: context.padding.top,
|
|
297
|
+
width: plotWidth(context),
|
|
298
|
+
height: plotHeight(context)
|
|
299
|
+
}));
|
|
300
|
+
return clipPath;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function appendArrowMarkers(context, defs) {
|
|
304
|
+
const keys = annotationArrowMarkerKeys(context.plot.annotations);
|
|
305
|
+
for (const key of keys) {
|
|
306
|
+
const size = Number(key.replace(/_/g, "."));
|
|
307
|
+
defs.append(annotationArrowMarker(context, "head", key, size), annotationArrowMarker(context, "tail", key, size));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function annotationArrowMarkerKeys(annotations) {
|
|
312
|
+
const keys = new Set();
|
|
313
|
+
for (const item of [...(annotations?.links ?? []), ...(annotations?.paths ?? [])]) {
|
|
314
|
+
if (hasArrow(item.attrs)) {
|
|
315
|
+
keys.add(arrowMarkerKey(arrowSize(item.attrs)));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return keys;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function annotationArrowMarker(context, kind, key, size) {
|
|
322
|
+
const marker = el(context, "marker", {
|
|
323
|
+
id: arrowMarkerId(context, kind, key),
|
|
324
|
+
markerWidth: size,
|
|
325
|
+
markerHeight: size,
|
|
326
|
+
refX: kind === "head" ? size * 5 / 6 : size / 6,
|
|
327
|
+
refY: size / 2,
|
|
328
|
+
orient: "auto",
|
|
329
|
+
markerUnits: "strokeWidth"
|
|
330
|
+
});
|
|
331
|
+
marker.append(el(context, "path", {
|
|
332
|
+
d: kind === "head"
|
|
333
|
+
? `M ${size / 6} ${size / 6} L ${size * 5 / 6} ${size / 2} L ${size / 6} ${size * 5 / 6} z`
|
|
334
|
+
: `M ${size * 5 / 6} ${size / 6} L ${size / 6} ${size / 2} L ${size * 5 / 6} ${size * 5 / 6} z`,
|
|
335
|
+
fill: "context-stroke"
|
|
336
|
+
}));
|
|
337
|
+
return marker;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function arrowMarkerAttrs(context, attrs) {
|
|
341
|
+
const key = arrowMarkerKey(arrowSize(attrs));
|
|
342
|
+
return {
|
|
343
|
+
...(booleanAttr(attrs.tailArrow ?? attrs.tailarrow, false) ? { markerStart: `url(#${arrowMarkerId(context, "tail", key)})` } : {}),
|
|
344
|
+
...(booleanAttr(attrs.headArrow ?? attrs.headarrow, false) ? { markerEnd: `url(#${arrowMarkerId(context, "head", key)})` } : {})
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function hasArrow(attrs = {}) {
|
|
349
|
+
return booleanAttr(attrs.headArrow ?? attrs.headarrow, false) || booleanAttr(attrs.tailArrow ?? attrs.tailarrow, false);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function arrowSize(attrs = {}) {
|
|
353
|
+
const size = Number(attrs.arrowSize ?? attrs.arrowsize ?? 12);
|
|
354
|
+
return Number.isFinite(size) && size > 0 ? size : 12;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function arrowMarkerKey(size) {
|
|
358
|
+
return String(Number(size.toFixed(3))).replace(/[^0-9A-Za-z_-]/g, "_");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function arrowMarkerId(context, kind, key) {
|
|
362
|
+
const suffix = key === "12" ? "" : `-${key}`;
|
|
363
|
+
return `${context.arrowMarkerPrefix}-${kind}${suffix}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function axisLabel(context, axis, x, y, anchor, rotate = 0) {
|
|
367
|
+
const label = axis.attrs.label;
|
|
368
|
+
if (label == null) return null;
|
|
369
|
+
return drawPlotLabel(context, label, x, y, "plot-axis-label", anchor, null, rotate);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function drawPlainLabel(context, label, x, y, className, anchor, style = null, rotate = 0, baseline = null) {
|
|
373
|
+
return styledEl(context, "text", style, {
|
|
374
|
+
class: className,
|
|
375
|
+
fill: "#111111",
|
|
376
|
+
fontSize: 12,
|
|
377
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
378
|
+
x,
|
|
379
|
+
y,
|
|
380
|
+
textAnchor: anchor,
|
|
381
|
+
...(baseline ? { dominantBaseline: baseline } : {}),
|
|
382
|
+
...(rotate ? { transform: `rotate(${rotate} ${x} ${y})` } : {})
|
|
383
|
+
}, String(label));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function drawCurve(context, curve) {
|
|
387
|
+
return drawSeries(context, curve, { className: "plot-curve", defaultLine: true, defaultMarkers: false });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function drawLine(context, line) {
|
|
391
|
+
if (Array.isArray(line.points)) {
|
|
392
|
+
return drawSeries(context, line, { className: "plot-line", defaultLine: true, defaultMarkers: false });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const from = project(context, line.from);
|
|
396
|
+
const to = project(context, line.to);
|
|
397
|
+
const fmt = parseFmt(line.attrs.fmt);
|
|
398
|
+
return styledEl(context, "line", lineStyle(line.attrs.style), {
|
|
399
|
+
class: "plot-line",
|
|
400
|
+
stroke: "#111111",
|
|
401
|
+
strokeWidth: 1.5,
|
|
402
|
+
...(fmt.dash ? { strokeDasharray: fmt.dash } : {}),
|
|
403
|
+
x1: from.x,
|
|
404
|
+
y1: from.y,
|
|
405
|
+
x2: to.x,
|
|
406
|
+
y2: to.y
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function drawMark(context, mark) {
|
|
411
|
+
if (Array.isArray(mark.points)) {
|
|
412
|
+
return drawSeries(context, mark, { className: "plot-scatter", defaultLine: false, defaultMarkers: true });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const point = project(context, mark.at);
|
|
416
|
+
return drawMarker(context, point, mark.attrs, "plot-mark");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function drawSeries(context, series, options) {
|
|
420
|
+
const fmt = parseFmt(series.attrs.fmt);
|
|
421
|
+
const lineVisible = fmt.hasLine ?? options.defaultLine;
|
|
422
|
+
const markerVisible = fmt.hasMarker ?? options.defaultMarkers;
|
|
423
|
+
const points = seriesPoints(context, series).map((point) => project(context, point));
|
|
424
|
+
const group = el(context, "g", { class: options.className });
|
|
425
|
+
|
|
426
|
+
if (lineVisible) {
|
|
427
|
+
group.append(styledEl(context, "path", lineStyle(series.attrs.style), {
|
|
428
|
+
class: options.className,
|
|
429
|
+
fill: "none",
|
|
430
|
+
stroke: "#2d6cdf",
|
|
431
|
+
strokeWidth: 2,
|
|
432
|
+
...(fmt.dash ? { strokeDasharray: fmt.dash } : {}),
|
|
433
|
+
d: pathData(points)
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (markerVisible) {
|
|
438
|
+
for (const point of points) {
|
|
439
|
+
appendMaybe(group, drawMarker(context, point, series.attrs, `${options.className}-marker`));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return group;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function seriesPoints(context, series) {
|
|
447
|
+
const animate = series.attrs.animate;
|
|
448
|
+
if (!animate || !series.dataId) return series.points;
|
|
449
|
+
const source = context.plot.dataSources?.[series.dataId];
|
|
450
|
+
if (!source?.generated) return series.points;
|
|
451
|
+
return applyPointMaps(regeneratePlotData(source, animatedParamValues(animate, context.frame)), series.attrs, "animated series");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function animatedParamValues(animate, frame = {}) {
|
|
455
|
+
const values = {};
|
|
456
|
+
const duration = positiveNumber(animate.duration, 1);
|
|
457
|
+
const rawTime = Number(frame.time ?? 0);
|
|
458
|
+
const loop = booleanAttr(animate.loop, true);
|
|
459
|
+
const progress = loop
|
|
460
|
+
? positiveModulo(rawTime / duration, 1)
|
|
461
|
+
: clamp(rawTime / duration, 0, 1);
|
|
462
|
+
|
|
463
|
+
for (const [key, range] of Object.entries(animate)) {
|
|
464
|
+
if (key === "duration" || key === "loop" || !Array.isArray(range)) continue;
|
|
465
|
+
if (Object.hasOwn(frame, key)) {
|
|
466
|
+
values[key] = Number(frame[key]);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const from = Number(range[0]);
|
|
470
|
+
const to = Number(range[1]);
|
|
471
|
+
values[key] = from + (to - from) * progress;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return values;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function drawMarker(context, point, attrs, className) {
|
|
478
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
|
|
479
|
+
return styledEl(context, "circle", attrs.style, {
|
|
480
|
+
class: className,
|
|
481
|
+
fill: "#111111",
|
|
482
|
+
stroke: "none",
|
|
483
|
+
cx: point.x,
|
|
484
|
+
cy: point.y,
|
|
485
|
+
r: Number(attrs.r ?? 4)
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function drawText(context, label) {
|
|
490
|
+
const point = project(context, label.at);
|
|
491
|
+
return drawPlotLabel(
|
|
492
|
+
context,
|
|
493
|
+
label.text,
|
|
494
|
+
point.x + Number(label.attrs.dx ?? 0),
|
|
495
|
+
point.y + Number(label.attrs.dy ?? -8),
|
|
496
|
+
"plot-text",
|
|
497
|
+
label.attrs.anchor ?? "middle",
|
|
498
|
+
label.attrs.style,
|
|
499
|
+
Number(label.attrs.rotate ?? 0)
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function drawAnnotations(context, layer) {
|
|
504
|
+
const annotations = context.plot.annotations ?? { nodes: [], links: [], paths: [] };
|
|
505
|
+
const nodePositions = new Map(annotations.nodes.map((node) => [node.id, annotationNodePosition(context, node)]));
|
|
506
|
+
const ports = annotationPorts(annotations.nodes, nodePositions);
|
|
507
|
+
|
|
508
|
+
for (const path of annotations.paths) {
|
|
509
|
+
layer.append(drawAnnotationPath(context, path));
|
|
510
|
+
}
|
|
511
|
+
for (const link of annotations.links) {
|
|
512
|
+
const from = ports.get(link.from);
|
|
513
|
+
const to = ports.get(link.to);
|
|
514
|
+
if (from && to) {
|
|
515
|
+
layer.append(drawAnnotationLink(context, link, from, to));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const node of annotations.nodes) {
|
|
519
|
+
appendMaybe(layer, drawAnnotationNode(context, node, nodePositions.get(node.id)));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function annotationNodePosition(context, node) {
|
|
524
|
+
return node.atUnit === "screen" ? { ...node.at } : project(context, node.at);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function annotationPorts(nodes, positions) {
|
|
528
|
+
const ports = new Map();
|
|
529
|
+
for (const node of nodes) {
|
|
530
|
+
const origin = positions.get(node.id);
|
|
531
|
+
if (!origin) continue;
|
|
532
|
+
for (const [id, port] of Object.entries(node.ports ?? {})) {
|
|
533
|
+
ports.set(`${node.id}.${id}`, {
|
|
534
|
+
x: origin.x + port.x,
|
|
535
|
+
y: origin.y + port.y,
|
|
536
|
+
angle: port.angle ?? 0
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return ports;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function drawAnnotationNode(context, node, at) {
|
|
544
|
+
if (node.shape === "anchor") return null;
|
|
545
|
+
if (node.shape === "circle") {
|
|
546
|
+
const group = el(context, "g", { class: "plot-annotation-node plot-annotation-circle" });
|
|
547
|
+
const r = Number(node.attrs.r ?? 5);
|
|
548
|
+
group.append(styledEl(context, "circle", node.attrs.style, {
|
|
549
|
+
class: "plot-annotation-shape",
|
|
550
|
+
fill: "#ffffff",
|
|
551
|
+
stroke: "#111111",
|
|
552
|
+
strokeWidth: 1.5,
|
|
553
|
+
cx: at.x,
|
|
554
|
+
cy: at.y,
|
|
555
|
+
r
|
|
556
|
+
}));
|
|
557
|
+
appendMaybe(group, annotationNodeLabel(context, node, at.x, at.y));
|
|
558
|
+
return group;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const w = Number(node.attrs.w ?? 80);
|
|
562
|
+
const h = Number(node.attrs.h ?? 28);
|
|
563
|
+
const group = el(context, "g", { class: "plot-annotation-node plot-annotation-rect" });
|
|
564
|
+
group.append(styledEl(context, "rect", node.attrs.style, {
|
|
565
|
+
class: "plot-annotation-shape",
|
|
566
|
+
fill: "#ffffff",
|
|
567
|
+
stroke: "#111111",
|
|
568
|
+
strokeWidth: 1.5,
|
|
569
|
+
x: at.x,
|
|
570
|
+
y: at.y,
|
|
571
|
+
width: w,
|
|
572
|
+
height: h,
|
|
573
|
+
rx: Number(node.attrs.corner ?? node.attrs.rx ?? 4)
|
|
574
|
+
}));
|
|
575
|
+
appendMaybe(group, annotationNodeLabel(context, node, at.x + w / 2, at.y + h / 2));
|
|
576
|
+
return group;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function annotationNodeLabel(context, node, x, y) {
|
|
580
|
+
if (node.attrs.label == null) return null;
|
|
581
|
+
return drawPlotLabel(context, node.attrs.label, x, y, "plot-annotation-label", "middle", node.attrs.labelStyle ?? null, 0, "middle");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function drawAnnotationLink(context, link, from, to) {
|
|
585
|
+
return styledEl(context, "path", linkStyle(link.attrs.style), {
|
|
586
|
+
class: "plot-annotation-link",
|
|
587
|
+
fill: "none",
|
|
588
|
+
stroke: "#111111",
|
|
589
|
+
strokeWidth: 1.5,
|
|
590
|
+
...arrowMarkerAttrs(context, link.attrs),
|
|
591
|
+
d: `M ${from.x} ${from.y} L ${to.x} ${to.y}`
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function drawAnnotationPath(context, path) {
|
|
596
|
+
const attrs = {
|
|
597
|
+
class: "plot-annotation-path",
|
|
598
|
+
fill: "none",
|
|
599
|
+
stroke: "#111111",
|
|
600
|
+
strokeWidth: 1.5,
|
|
601
|
+
...arrowMarkerAttrs(context, path.attrs),
|
|
602
|
+
d: annotationPathData(context, path)
|
|
603
|
+
};
|
|
604
|
+
return styledEl(context, "path", path.attrs.style, attrs);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function annotationPathData(context, path) {
|
|
608
|
+
if (Array.isArray(path.points)) {
|
|
609
|
+
const points = path.points.map((point) => (path.atUnit === "screen" ? point : project(context, point)));
|
|
610
|
+
const data = routedAnnotationPathData(points, Number(path.attrs.corner ?? 0));
|
|
611
|
+
return booleanAttr(path.attrs.closed, false) ? `${data} Z` : data;
|
|
612
|
+
}
|
|
613
|
+
return path.attrs.d ?? "";
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function routedAnnotationPathData(points, corner) {
|
|
617
|
+
if (!corner || points.length < 3) return pathData(points);
|
|
618
|
+
return roundedPathData(points, corner);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function roundedPathData(points, radius) {
|
|
622
|
+
if (points.length < 3) return pathData(points);
|
|
623
|
+
const commands = [`M ${points[0].x} ${points[0].y}`];
|
|
624
|
+
for (let index = 1; index < points.length - 1; index += 1) {
|
|
625
|
+
const previous = points[index - 1];
|
|
626
|
+
const current = points[index];
|
|
627
|
+
const next = points[index + 1];
|
|
628
|
+
const inLength = Math.hypot(current.x - previous.x, current.y - previous.y);
|
|
629
|
+
const outLength = Math.hypot(next.x - current.x, next.y - current.y);
|
|
630
|
+
const cut = Math.min(Number(radius), inLength / 2, outLength / 2);
|
|
631
|
+
if (!Number.isFinite(cut) || cut <= 0) {
|
|
632
|
+
commands.push(`L ${current.x} ${current.y}`);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const before = {
|
|
636
|
+
x: current.x - (current.x - previous.x) / inLength * cut,
|
|
637
|
+
y: current.y - (current.y - previous.y) / inLength * cut
|
|
638
|
+
};
|
|
639
|
+
const after = {
|
|
640
|
+
x: current.x + (next.x - current.x) / outLength * cut,
|
|
641
|
+
y: current.y + (next.y - current.y) / outLength * cut
|
|
642
|
+
};
|
|
643
|
+
commands.push(`L ${before.x} ${before.y}`, `Q ${current.x} ${current.y} ${after.x} ${after.y}`);
|
|
644
|
+
}
|
|
645
|
+
const last = points[points.length - 1];
|
|
646
|
+
commands.push(`L ${last.x} ${last.y}`);
|
|
647
|
+
return commands.join(" ");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function drawLegend(context, legend) {
|
|
651
|
+
const entries = legendEntries(context.plot);
|
|
652
|
+
if (entries.length === 0) return null;
|
|
653
|
+
|
|
654
|
+
const fontSize = Number(legend.attrs.fontSize ?? legend.attrs.fontsize ?? legend.attrs.textStyle?.fontSize ?? 12);
|
|
655
|
+
const padding = Number(legend.attrs.padding ?? 10);
|
|
656
|
+
const gap = Number(legend.attrs.gap ?? 8);
|
|
657
|
+
const swatchWidth = Number(legend.attrs.swatchWidth ?? legend.attrs.swatchwidth ?? 26);
|
|
658
|
+
const rowHeight = Number(legend.attrs.rowHeight ?? legend.attrs.rowheight ?? Math.max(18, fontSize + 6));
|
|
659
|
+
const textWidth = Math.max(...entries.map((entry) => estimateTextWidth(entry.label, fontSize)));
|
|
660
|
+
const width = padding * 2 + swatchWidth + gap + textWidth;
|
|
661
|
+
const height = padding * 2 + rowHeight * entries.length;
|
|
662
|
+
const position = legendPosition(context, legend, width, height);
|
|
663
|
+
const group = el(context, "g", {
|
|
664
|
+
class: "plot-legend",
|
|
665
|
+
transform: `translate(${position.x} ${position.y})`
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (booleanAttr(legend.attrs.box, true)) {
|
|
669
|
+
group.append(styledEl(context, "rect", legendBoxStyle(legend.attrs), {
|
|
670
|
+
class: "plot-legend-box",
|
|
671
|
+
x: 0,
|
|
672
|
+
y: 0,
|
|
673
|
+
width,
|
|
674
|
+
height,
|
|
675
|
+
rx: Number(legend.attrs.corner ?? 4),
|
|
676
|
+
fill: legend.attrs.fill ?? "#ffffff",
|
|
677
|
+
fillOpacity: Number(legend.attrs.fillOpacity ?? legend.attrs.fillopacity ?? 0.88),
|
|
678
|
+
stroke: legend.attrs.stroke ?? "#c9d1cc",
|
|
679
|
+
strokeWidth: Number(legend.attrs.strokeWidth ?? legend.attrs.strokewidth ?? 1)
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
entries.forEach((entry, index) => {
|
|
684
|
+
const y = padding + rowHeight * index + rowHeight / 2;
|
|
685
|
+
const swatch = drawLegendSwatch(context, entry, padding, y, swatchWidth);
|
|
686
|
+
group.append(swatch);
|
|
687
|
+
group.append(drawPlotLabel(
|
|
688
|
+
context,
|
|
689
|
+
entry.label,
|
|
690
|
+
padding + swatchWidth + gap,
|
|
691
|
+
y,
|
|
692
|
+
"plot-legend-label",
|
|
693
|
+
"start",
|
|
694
|
+
legendTextStyle(legend.attrs),
|
|
695
|
+
0,
|
|
696
|
+
"middle"
|
|
697
|
+
));
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
return group;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function legendEntries(plot) {
|
|
704
|
+
return [
|
|
705
|
+
...plot.lines.map((line) => ({ type: "line", item: line })),
|
|
706
|
+
...plot.curves.map((curve) => ({ type: "curve", item: curve })),
|
|
707
|
+
...plot.marks.map((mark) => ({ type: "mark", item: mark }))
|
|
708
|
+
].filter((entry) => entry.item.attrs.label != null && entry.item.attrs.label !== "")
|
|
709
|
+
.map((entry) => ({
|
|
710
|
+
...entry,
|
|
711
|
+
label: String(entry.item.attrs.label),
|
|
712
|
+
fmt: parseFmt(entry.item.attrs.fmt),
|
|
713
|
+
style: entry.item.attrs.style
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function drawLegendSwatch(context, entry, x, y, width) {
|
|
718
|
+
const group = el(context, "g", { class: `plot-legend-swatch plot-legend-swatch-${entry.type}` });
|
|
719
|
+
const lineVisible = entry.type === "mark" ? false : (entry.fmt.hasLine ?? true);
|
|
720
|
+
const markerVisible = entry.type === "mark" ? true : (entry.fmt.hasMarker ?? false);
|
|
721
|
+
|
|
722
|
+
if (lineVisible) {
|
|
723
|
+
group.append(styledEl(context, "line", lineStyle(entry.style), {
|
|
724
|
+
class: "plot-legend-line",
|
|
725
|
+
fill: "none",
|
|
726
|
+
stroke: "#2d6cdf",
|
|
727
|
+
strokeWidth: 2,
|
|
728
|
+
...(entry.fmt.dash ? { strokeDasharray: entry.fmt.dash } : {}),
|
|
729
|
+
x1: x,
|
|
730
|
+
y1: y,
|
|
731
|
+
x2: x + width,
|
|
732
|
+
y2: y
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (markerVisible) {
|
|
737
|
+
group.append(drawMarker(context, { x: x + width / 2, y }, entry.item.attrs, "plot-legend-marker"));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!lineVisible && !markerVisible) {
|
|
741
|
+
group.append(styledEl(context, "line", lineStyle(entry.style), {
|
|
742
|
+
class: "plot-legend-line",
|
|
743
|
+
fill: "none",
|
|
744
|
+
stroke: "#2d6cdf",
|
|
745
|
+
strokeWidth: 2,
|
|
746
|
+
x1: x,
|
|
747
|
+
y1: y,
|
|
748
|
+
x2: x + width,
|
|
749
|
+
y2: y
|
|
750
|
+
}));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return group;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function legendPosition(context, legend, width, height) {
|
|
757
|
+
if (Array.isArray(legend.attrs.at)) {
|
|
758
|
+
return { x: Number(legend.attrs.at[0]), y: Number(legend.attrs.at[1]) };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const margin = Number(legend.attrs.margin ?? 12);
|
|
762
|
+
const position = String(legend.attrs.position ?? legend.attrs.pos ?? "top-right").toLowerCase().replace(/\s+/g, "-");
|
|
763
|
+
const left = context.padding.left + margin;
|
|
764
|
+
const right = context.width - context.padding.right - width - margin;
|
|
765
|
+
const top = context.padding.top + margin;
|
|
766
|
+
const bottom = context.height - context.padding.bottom - height - margin;
|
|
767
|
+
|
|
768
|
+
if (position === "top-left" || position === "left-top") return { x: left, y: top };
|
|
769
|
+
if (position === "bottom-left" || position === "left-bottom") return { x: left, y: bottom };
|
|
770
|
+
if (position === "bottom-right" || position === "right-bottom") return { x: right, y: bottom };
|
|
771
|
+
return { x: right, y: top };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function legendBoxStyle(attrs) {
|
|
775
|
+
const dedicated = attrs.boxStyle && typeof attrs.boxStyle === "object" ? attrs.boxStyle : {};
|
|
776
|
+
return dedicated;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function legendTextStyle(attrs) {
|
|
780
|
+
if (!attrs.textStyle || typeof attrs.textStyle !== "object") return null;
|
|
781
|
+
return attrs.textStyle;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function estimateTextWidth(value, fontSize) {
|
|
785
|
+
return String(value).length * fontSize * 0.58;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function drawPlotLabel(context, value, x, y, className, anchor = "middle", style = null, rotate = 0, baseline = null) {
|
|
789
|
+
const label = String(value);
|
|
790
|
+
const math = parseMathLabel(label);
|
|
791
|
+
if (math && context.katex) {
|
|
792
|
+
return drawMathLabel(context, math, x, y, className, anchor, rotate, baseline);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return drawPlainLabel(context, math ?? label, x, y, className, anchor, style, rotate, baseline);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function drawMathLabel(context, source, x, y, className, anchor, rotate = 0, baseline = null) {
|
|
799
|
+
const width = estimateMathWidth(source);
|
|
800
|
+
const height = MATH_LABEL_HEIGHT;
|
|
801
|
+
const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
|
|
802
|
+
const top = baseline === "hanging" ? y - MATH_HANGING_INSET : y - height / 2;
|
|
803
|
+
const foreignObject = el(context, "foreignObject", {
|
|
804
|
+
class: className,
|
|
805
|
+
x: left,
|
|
806
|
+
y: top,
|
|
807
|
+
width,
|
|
808
|
+
height,
|
|
809
|
+
...(rotate ? { transform: `rotate(${rotate} ${x} ${y})` } : {})
|
|
810
|
+
});
|
|
811
|
+
const host = context.document.createElement("div");
|
|
812
|
+
host.style.width = `${width}px`;
|
|
813
|
+
host.style.height = `${height}px`;
|
|
814
|
+
host.style.display = "flex";
|
|
815
|
+
host.style.alignItems = "center";
|
|
816
|
+
host.style.justifyContent = anchor === "middle" ? "center" : anchor === "end" ? "flex-end" : "flex-start";
|
|
817
|
+
host.style.color = "#1e2724";
|
|
818
|
+
context.katex.render(source, host, { throwOnError: false });
|
|
819
|
+
foreignObject.append(host);
|
|
820
|
+
return foreignObject;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function parseMathLabel(label) {
|
|
824
|
+
const trimmed = label.trim();
|
|
825
|
+
if (trimmed.length >= 2 && trimmed.startsWith("$") && trimmed.endsWith("$")) {
|
|
826
|
+
return trimmed.slice(1, -1);
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function estimateMathWidth(source) {
|
|
832
|
+
return Math.max(34, Math.min(220, source.length * 12 + 28));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function plotBounds(plot) {
|
|
836
|
+
const points = [
|
|
837
|
+
...plot.curves.flatMap((curve) => curve.points),
|
|
838
|
+
...plot.lines.flatMap((line) => line.points ?? [line.from, line.to]),
|
|
839
|
+
...plot.marks.flatMap((mark) => mark.points ?? [mark.at]),
|
|
840
|
+
...plot.labels.map((label) => label.at),
|
|
841
|
+
...(plot.annotations?.nodes ?? []).filter((node) => node.atUnit !== "screen").map((node) => node.at),
|
|
842
|
+
...(plot.annotations?.paths ?? []).filter((path) => path.atUnit !== "screen").flatMap((path) => path.points ?? [])
|
|
843
|
+
];
|
|
844
|
+
if (points.length === 0) {
|
|
845
|
+
return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
|
846
|
+
}
|
|
847
|
+
const finitePoints = points
|
|
848
|
+
.map((point) => ({ x: plotNumber(point.x), y: plotNumber(point.y) }))
|
|
849
|
+
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
|
|
850
|
+
if (finitePoints.length === 0) {
|
|
851
|
+
return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
minX: Math.min(...finitePoints.map((point) => point.x)),
|
|
855
|
+
minY: Math.min(...finitePoints.map((point) => point.y)),
|
|
856
|
+
maxX: Math.max(...finitePoints.map((point) => point.x)),
|
|
857
|
+
maxY: Math.max(...finitePoints.map((point) => point.y))
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function project(context, point) {
|
|
862
|
+
const innerWidth = plotWidth(context);
|
|
863
|
+
const innerHeight = plotHeight(context);
|
|
864
|
+
const xValue = plotNumber(point.x);
|
|
865
|
+
const yValue = plotNumber(point.y);
|
|
866
|
+
const xT = (xValue - context.xDomain[0]) / (context.xDomain[1] - context.xDomain[0]);
|
|
867
|
+
const yT = (yValue - context.yDomain[0]) / (context.yDomain[1] - context.yDomain[0]);
|
|
868
|
+
return {
|
|
869
|
+
x: context.padding.left + xT * innerWidth,
|
|
870
|
+
y: context.height - context.padding.bottom - yT * innerHeight
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function plotNumber(value) {
|
|
875
|
+
if (value && typeof value === "object" && Object.hasOwn(value, "re") && Object.hasOwn(value, "im")) {
|
|
876
|
+
return Number(value.re);
|
|
877
|
+
}
|
|
878
|
+
return Number(value);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function plotWidth(context) {
|
|
882
|
+
return context.width - context.padding.left - context.padding.right;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function plotHeight(context) {
|
|
886
|
+
return context.height - context.padding.top - context.padding.bottom;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function plotCenterX(context) {
|
|
890
|
+
return context.padding.left + plotWidth(context) / 2;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function plotCenterY(context) {
|
|
894
|
+
return context.padding.top + plotHeight(context) / 2;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function domainAttr(value, fallback) {
|
|
898
|
+
if (!Array.isArray(value)) return fallback;
|
|
899
|
+
const min = Number(value[0]);
|
|
900
|
+
const max = Number(value[1]);
|
|
901
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return fallback;
|
|
902
|
+
return [min, max];
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function expandDomain(domain) {
|
|
906
|
+
if (domain[0] !== domain[1]) return domain;
|
|
907
|
+
const delta = Math.abs(domain[0]) * 0.1 || 1;
|
|
908
|
+
return [domain[0] - delta, domain[1] + delta];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function normalizePadding(value) {
|
|
912
|
+
if (Array.isArray(value)) {
|
|
913
|
+
if (value.length === 2) {
|
|
914
|
+
return {
|
|
915
|
+
top: Number(value[1]),
|
|
916
|
+
right: Number(value[0]),
|
|
917
|
+
bottom: Number(value[1]),
|
|
918
|
+
left: Number(value[0])
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (value.length === 4) {
|
|
922
|
+
return {
|
|
923
|
+
top: Number(value[0]),
|
|
924
|
+
right: Number(value[1]),
|
|
925
|
+
bottom: Number(value[2]),
|
|
926
|
+
left: Number(value[3])
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const number = Number(value);
|
|
931
|
+
return { top: number, right: number, bottom: number, left: number };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function pathData(points) {
|
|
935
|
+
const commands = [];
|
|
936
|
+
let open = false;
|
|
937
|
+
for (const point of points) {
|
|
938
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
939
|
+
open = false;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
commands.push(`${open ? "L" : "M"} ${point.x} ${point.y}`);
|
|
943
|
+
open = true;
|
|
944
|
+
}
|
|
945
|
+
return commands.join(" ");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function parseFmt(fmt) {
|
|
949
|
+
if (fmt == null || fmt === "") return {};
|
|
950
|
+
const source = String(fmt);
|
|
951
|
+
const hasDashed = source.includes("--");
|
|
952
|
+
const hasDashDot = source.includes("-.");
|
|
953
|
+
const hasDotted = !hasDashDot && source.includes(":");
|
|
954
|
+
const hasSolid = source.includes("-") && !hasDashed && !hasDashDot;
|
|
955
|
+
const hasMarker = /[.o]/.test(source);
|
|
956
|
+
const hasLine = hasDashed || hasDashDot || hasDotted || hasSolid;
|
|
957
|
+
return {
|
|
958
|
+
hasLine,
|
|
959
|
+
hasMarker,
|
|
960
|
+
dash: hasDashed ? "6 4" : hasDashDot ? "8 4 2 4" : hasDotted ? "2 4" : null
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function styledEl(context, tag, style, attrs, text = null) {
|
|
965
|
+
return el(context, tag, { ...attrs, ...(styleAttrs(style)) }, text);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function styleAttrs(style) {
|
|
969
|
+
if (!style || typeof style !== "object") return {};
|
|
970
|
+
return Object.fromEntries(Object.entries(style).map(([key, value]) => [svgAttrName(key), value]));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function lineStyle(style) {
|
|
974
|
+
if (!style || typeof style !== "object") return style;
|
|
975
|
+
const { fill, r, ...lineOnly } = style;
|
|
976
|
+
return lineOnly;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function linkStyle(style) {
|
|
980
|
+
return lineStyle(style);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function svgAttrName(key) {
|
|
984
|
+
const rawSvgAttrs = new Set(["markerWidth", "markerHeight", "refX", "refY", "markerUnits"]);
|
|
985
|
+
if (rawSvgAttrs.has(key)) return key;
|
|
986
|
+
return key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function el(context, tag, attrs = {}, text = null) {
|
|
990
|
+
const node = context.document.createElementNS(SVG_NS, tag);
|
|
991
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
992
|
+
if (value == null || value === false) continue;
|
|
993
|
+
node.setAttribute(svgAttrName(key), String(value));
|
|
994
|
+
}
|
|
995
|
+
if (text != null) node.textContent = text;
|
|
996
|
+
return node;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function appendMaybe(parent, child) {
|
|
1000
|
+
if (child) parent.append(child);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function booleanAttr(value, fallback) {
|
|
1004
|
+
if (value == null) return fallback;
|
|
1005
|
+
if (value === false || value === "false") return false;
|
|
1006
|
+
if (value === true || value === "true") return true;
|
|
1007
|
+
return Boolean(value);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function positiveNumber(value, fallback) {
|
|
1011
|
+
const number = Number(value);
|
|
1012
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function positiveModulo(value, modulus) {
|
|
1016
|
+
return ((value % modulus) + modulus) % modulus;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function clamp(value, min, max) {
|
|
1020
|
+
return Math.min(max, Math.max(min, value));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function plural(count, singular) {
|
|
1024
|
+
return count === 1 ? singular : `${singular}s`;
|
|
1025
|
+
}
|