pts 0.12.8 → 1.0.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/Svg.ts ADDED
@@ -0,0 +1,1573 @@
1
+ /*! Pts.js is licensed under Apache License 2.0. Copyright © 2017-current William Ngan and contributors. (https://github.com/williamngan/pts) */
2
+
3
+ import { VisualForm, Font } from "./Form";
4
+ import { CanvasForm } from "./Canvas";
5
+ import { Geom } from "./Num";
6
+ import { Const, Util } from "./Util";
7
+ import { Pt, Group, type Bound } from "./Pt";
8
+ import { Rectangle } from "./Op";
9
+ import { DOMSpace } from "./Dom";
10
+ import {
11
+ type PtLike,
12
+ type PtLikeIterable,
13
+ type IPlayer,
14
+ type DOMFormContext,
15
+ type RenderingContext2D,
16
+ } from "./Types";
17
+
18
+ const SVG_NS = "http://www.w3.org/2000/svg";
19
+
20
+ // Canvas composite operations that map directly onto CSS mix-blend-mode.
21
+ // The Porter-Duff subset ("source-in" etc.) has no SVG equivalent per-element.
22
+ const BLEND_MODES = new Set([
23
+ "multiply",
24
+ "screen",
25
+ "overlay",
26
+ "darken",
27
+ "lighten",
28
+ "color-dodge",
29
+ "color-burn",
30
+ "hard-light",
31
+ "soft-light",
32
+ "difference",
33
+ "exclusion",
34
+ "hue",
35
+ "saturation",
36
+ "color",
37
+ "luminosity",
38
+ ]);
39
+
40
+ // module-level so the class stays tree-shakable (a static field would
41
+ // downlevel to a post-class assignment, which bundlers keep)
42
+ let _gradientCount = 0;
43
+
44
+ /**
45
+ * A gradient handle returned by [`SVGContext2D`](#link)'s `createLinearGradient` and
46
+ * `createRadialGradient`. It is structurally compatible with `CanvasGradient` (it has
47
+ * `addColorStop`), and materializes into an SVG `<defs>` gradient when first painted.
48
+ */
49
+ class SVGGradient {
50
+ readonly id: string;
51
+ readonly kind: "linear" | "radial";
52
+ readonly coords: number[];
53
+ stops: [number, string][] = [];
54
+ protected _elem: SVGElement | null = null;
55
+
56
+ constructor(kind: "linear" | "radial", coords: number[]) {
57
+ this.kind = kind;
58
+ this.coords = coords;
59
+ this.id = `pts_grad_${_gradientCount++}`;
60
+ }
61
+
62
+ addColorStop(offset: number, color: string): void {
63
+ this.stops.push([offset, color]);
64
+ if (this._elem) this._render(this._elem); // already materialized; keep it in sync
65
+ }
66
+
67
+ /** Create or update the `<defs>` element for this gradient and return its paint url. */
68
+ materialize(defs: SVGElement): string {
69
+ if (!this._elem) {
70
+ this._elem = document.createElementNS(
71
+ SVG_NS,
72
+ this.kind === "linear" ? "linearGradient" : "radialGradient",
73
+ ) as SVGElement;
74
+ this._elem.setAttribute("id", this.id);
75
+ this._elem.setAttribute("gradientUnits", "userSpaceOnUse");
76
+ if (this.kind === "linear") {
77
+ const [x1, y1, x2, y2] = this.coords;
78
+ DOMSpace.setAttr(this._elem, { x1, y1, x2, y2 });
79
+ } else {
80
+ const [x0, y0, r0, x1, y1, r1] = this.coords;
81
+ DOMSpace.setAttr(this._elem, { cx: x1, cy: y1, r: r1, fx: x0, fy: y0 });
82
+ if (r0) this._elem.setAttribute("fr", `${r0}`);
83
+ }
84
+ this._render(this._elem);
85
+ }
86
+ if (this._elem.parentNode !== defs) defs.appendChild(this._elem);
87
+ return `url(#${this.id})`;
88
+ }
89
+
90
+ protected _render(elem: SVGElement) {
91
+ elem.textContent = "";
92
+ for (const [offset, color] of this.stops) {
93
+ const stop = document.createElementNS(SVG_NS, "stop");
94
+ stop.setAttribute("offset", `${offset}`);
95
+ stop.setAttribute("stop-color", color);
96
+ elem.appendChild(stop);
97
+ }
98
+ }
99
+ }
100
+
101
+ /** A pending draw record produced by SVGContext2D, consumed by the frame commit. */
102
+ type SVGRun = {
103
+ tag: "path" | "text" | "image";
104
+ attrs: Record<string, string | number>;
105
+ text?: string;
106
+ shapeEnds?: number[]; // per-shape boundaries in the d string, for expanded export
107
+ };
108
+
109
+ // module-level state for the same tree-shaking reason as _gradientCount above
110
+ let _svgMeasurer: CanvasRenderingContext2D | null = null;
111
+ const _svgWarned: { [k: string]: boolean } = {};
112
+
113
+ /**
114
+ * **`SVGContext2D`** implements the subset of `CanvasRenderingContext2D` that
115
+ * [`CanvasForm`](#link) draws through, and renders it as SVG. Consecutive shapes that share
116
+ * paint state are merged into single `<path>` elements ("style runs"), so the DOM cost per
117
+ * frame is proportional to the number of style changes, not the number of shapes. This is
118
+ * what lets sketches using the supported subset run unchanged on canvas and SVG.
119
+ *
120
+ * Capability notes: blend-mode composites map to `mix-blend-mode`; Porter-Duff composites,
121
+ * `clip`, and `putImageData` warn once and no-op. Text metrics come from a hidden canvas, so
122
+ * `textBox` layout matches canvas exactly. When shapes with both fill and stroke are merged,
123
+ * all fills in a run paint before its strokes — visible only for overlapping same-styled
124
+ * shapes.
125
+ *
126
+ * **Writing your own renderer**: this class is the reference implementation of the rendering
127
+ * contract — any object implementing the same context surface can be handed to
128
+ * [`CanvasForm`](#link)'s constructor to become a Pts renderer (a PDF writer, a command
129
+ * recorder, a test snapshotter, and so on). The surface is the subset of
130
+ * `CanvasRenderingContext2D` that `CanvasForm` draws through:
131
+ * - path verbs: `beginPath`, `moveTo`, `lineTo`, `quadraticCurveTo`, `bezierCurveTo`,
132
+ * `rect`, `arc`, `ellipse`, `closePath`
133
+ * - paint: `fill`, `stroke`, `fillRect`, `clearRect`
134
+ * - state: `save`, `restore`, `clip`, `scale`
135
+ * - style fields: `fillStyle`, `strokeStyle`, `lineWidth`, `lineJoin`, `lineCap`,
136
+ * `globalAlpha`, `globalCompositeOperation`, `setLineDash`, `lineDashOffset`
137
+ * - text: `font`, `textAlign`, `textBaseline`, `fillText`, `measureText`
138
+ * - images: `drawImage`, `putImageData`
139
+ * - gradients: `createLinearGradient`, `createRadialGradient`
140
+ *
141
+ * A renderer driven by a Space should also expose a frame lifecycle like
142
+ * [`SVGContext2D.beginFrame`](#link) / [`SVGContext2D.commitFrame`](#link), called around the
143
+ * players' animate callbacks. The unit test "implements every context member that CanvasForm
144
+ * uses" is the compatibility alarm: it fails when a new `CanvasForm` feature touches a
145
+ * context member a renderer does not implement.
146
+ */
147
+ export class SVGContext2D {
148
+ // ---- canvas-compatible state ----
149
+ fillStyle: string | SVGGradient = "#f03";
150
+ strokeStyle: string | SVGGradient = "#fff";
151
+ lineWidth: number = 1;
152
+ lineJoin: string = "bevel";
153
+ lineCap: string = "butt";
154
+ globalAlpha: number = 1;
155
+ globalCompositeOperation: string = "source-over";
156
+ font: string = "10px sans-serif";
157
+ textAlign: string = "start";
158
+ textBaseline: string = "alphabetic";
159
+ lineDashOffset: number = 0;
160
+
161
+ protected _dash: number[] = [];
162
+ protected _stateStack: object[] = [];
163
+
164
+ /** Optional CSS class applied to emitted elements (see `SVGForm.cls`). */
165
+ className: string = "";
166
+
167
+ // ---- current path & shape ----
168
+ protected _d: string = "";
169
+ protected _shapeFill: string | null = null; // resolved paint or null
170
+ protected _shapeStroke: string | null = null;
171
+ protected _shapeStrokeStyle: Record<string, string | number> = {};
172
+ protected _shapePainted: boolean = false;
173
+ // class/alpha/blend are captured at paint time (fill/stroke), not at flush
174
+ // time, so a style change between shapes cannot apply retroactively
175
+ protected _shapeClass: string = "";
176
+ protected _shapeAlpha: number = 1;
177
+ protected _shapeBlend: string = "source-over";
178
+
179
+ // ---- frame state ----
180
+ protected _runs: SVGRun[] = [];
181
+ protected _drawCount: number = 0;
182
+
183
+ // ---- DOM ----
184
+ protected _host: SVGElement; // the <svg> element
185
+ protected _group: SVGElement | null = null; // managed <g> holding this context's output
186
+ protected _defs: SVGElement | null = null;
187
+ protected _pool: SVGElement[] = []; // pooled elements, index-aligned with runs
188
+ protected _attrCache: Record<string, string>[] = [];
189
+
190
+ constructor(host: SVGElement) {
191
+ this._host = host;
192
+ }
193
+
194
+ protected static _warnOnce(key: string, msg: string) {
195
+ if (!_svgWarned[key]) {
196
+ _svgWarned[key] = true;
197
+ Util.warn(msg);
198
+ }
199
+ }
200
+
201
+ // -------------------------------------------------------------- lifecycle
202
+
203
+ /** Start a new frame: subsequent draws build a fresh run list. */
204
+ beginFrame(): void {
205
+ this._runs = [];
206
+ this._d = "";
207
+ this._shapeFill = null;
208
+ this._shapeStroke = null;
209
+ this._shapePainted = false;
210
+ this._drawCount = 0;
211
+ }
212
+
213
+ /** Number of paint calls since `beginFrame` — used to skip empty commits. */
214
+ get drawCount(): number {
215
+ return this._drawCount;
216
+ }
217
+
218
+ /** The `<g>` element holding this context's rendered output. */
219
+ get group(): SVGElement | null {
220
+ return this._group;
221
+ }
222
+
223
+ /**
224
+ * Commit the frame: reconcile the run list against the pooled elements, patching only
225
+ * changed attributes, and truncate unused elements.
226
+ */
227
+ commitFrame(): void {
228
+ this._flushShape();
229
+ if (!this._group) {
230
+ this._group = document.createElementNS(SVG_NS, "g") as SVGElement;
231
+ this._group.setAttribute("class", "pts-svgform");
232
+ this._host.appendChild(this._group);
233
+ }
234
+
235
+ const runs = this._runs;
236
+ for (let i = 0; i < runs.length; i++) {
237
+ const run = runs[i];
238
+ let elem = this._pool[i];
239
+ if (!elem || elem.nodeName !== run.tag) {
240
+ const fresh = document.createElementNS(SVG_NS, run.tag) as SVGElement;
241
+ if (elem) {
242
+ this._group.replaceChild(fresh, elem);
243
+ } else {
244
+ this._group.appendChild(fresh);
245
+ }
246
+ elem = fresh;
247
+ this._pool[i] = elem;
248
+ this._attrCache[i] = {};
249
+ }
250
+ const cache = this._attrCache[i];
251
+ // conditional attributes (eg, stroke-dasharray, mix-blend-mode) must not
252
+ // survive from an earlier frame's run on this pooled element
253
+ for (const k in cache) {
254
+ if (!(k in run.attrs)) {
255
+ elem.removeAttribute(k);
256
+ delete cache[k];
257
+ }
258
+ }
259
+ for (const k in run.attrs) {
260
+ const v = `${run.attrs[k]}`;
261
+ if (cache[k] !== v) {
262
+ elem.setAttribute(k, v);
263
+ cache[k] = v;
264
+ }
265
+ }
266
+ if (run.tag === "text" && elem.textContent !== run.text) {
267
+ elem.textContent = run.text!;
268
+ }
269
+ }
270
+
271
+ // truncate the unused tail
272
+ for (let i = this._pool.length - 1; i >= runs.length; i--) {
273
+ this._group.removeChild(this._pool[i]);
274
+ this._pool.pop();
275
+ this._attrCache.pop();
276
+ }
277
+
278
+ // Definitions belong to the committed scene, not to every gradient ever
279
+ // created. A retained gradient handle can materialize again when reused.
280
+ if (this._defs) {
281
+ const paints = new Set<string | number>();
282
+ for (const run of runs) {
283
+ paints.add(run.attrs.fill);
284
+ paints.add(run.attrs.stroke);
285
+ }
286
+ for (const elem of Array.from(this._defs.children)) {
287
+ if (!paints.has(`url(#${elem.id})`)) elem.remove();
288
+ }
289
+ }
290
+ }
291
+
292
+ /** The current frame's run list (used by expanded export). */
293
+ get runs(): SVGRun[] {
294
+ return this._runs;
295
+ }
296
+
297
+ /** Forget cached DOM references, eg after the host's contents were removed externally. */
298
+ resetDom(): void {
299
+ this._group = null;
300
+ this._defs = null;
301
+ this._pool = [];
302
+ this._attrCache = [];
303
+ }
304
+
305
+ /**
306
+ * Remove this context's managed elements from the DOM and forget them. Used when a space
307
+ * is disposed so that a re-mounted space on the same element starts clean.
308
+ */
309
+ disposeDom(): void {
310
+ if (this._group && this._group.parentNode) {
311
+ this._group.parentNode.removeChild(this._group);
312
+ }
313
+ if (this._defs && this._defs.parentNode) {
314
+ this._defs.parentNode.removeChild(this._defs);
315
+ }
316
+ this.resetDom();
317
+ }
318
+
319
+ // ------------------------------------------------------------ path verbs
320
+
321
+ beginPath(): void {
322
+ this._flushShape();
323
+ this._d = "";
324
+ }
325
+
326
+ closePath(): void {
327
+ this._d += "Z";
328
+ }
329
+
330
+ moveTo(x: number, y: number): void {
331
+ this._d += `M${round2(x)} ${round2(y)}`;
332
+ }
333
+
334
+ lineTo(x: number, y: number): void {
335
+ this._d += `L${round2(x)} ${round2(y)}`;
336
+ }
337
+
338
+ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void {
339
+ this._d += `Q${round2(cpx)} ${round2(cpy)} ${round2(x)} ${round2(y)}`;
340
+ }
341
+
342
+ bezierCurveTo(
343
+ cp1x: number,
344
+ cp1y: number,
345
+ cp2x: number,
346
+ cp2y: number,
347
+ x: number,
348
+ y: number,
349
+ ): void {
350
+ this._d += `C${round2(cp1x)} ${round2(cp1y)} ${round2(cp2x)} ${round2(cp2y)} ${round2(x)} ${round2(y)}`;
351
+ }
352
+
353
+ rect(x: number, y: number, w: number, h: number): void {
354
+ this._d += `M${round2(x)} ${round2(y)}h${round2(w)}v${round2(h)}h${round2(-w)}Z`;
355
+ }
356
+
357
+ arc(
358
+ x: number,
359
+ y: number,
360
+ radius: number,
361
+ startAngle: number,
362
+ endAngle: number,
363
+ ccw: boolean = false,
364
+ ): void {
365
+ this.ellipse(x, y, radius, radius, 0, startAngle, endAngle, ccw);
366
+ }
367
+
368
+ ellipse(
369
+ x: number,
370
+ y: number,
371
+ rx: number,
372
+ ry: number,
373
+ rotation: number,
374
+ startAngle: number,
375
+ endAngle: number,
376
+ ccw: boolean = false,
377
+ ): void {
378
+ // canvas sweep semantics: direction-signed delta, wrapped into [0, 2π),
379
+ // where a delta of 2π or more is a full ellipse
380
+ let delta = ccw ? startAngle - endAngle : endAngle - startAngle;
381
+ const full = delta >= Const.two_pi;
382
+ if (!full) delta = ((delta % Const.two_pi) + Const.two_pi) % Const.two_pi;
383
+
384
+ const cosR = Math.cos(rotation);
385
+ const sinR = Math.sin(rotation);
386
+ const ptAt = (angle: number): [number, number] => {
387
+ const px = rx * Math.cos(angle);
388
+ const py = ry * Math.sin(angle);
389
+ return [x + px * cosR - py * sinR, y + px * sinR + py * cosR];
390
+ };
391
+
392
+ const dir = ccw ? -1 : 1;
393
+ const sweepFlag = ccw ? 0 : 1;
394
+ const rotDeg = round2((rotation * 180) / Math.PI);
395
+ const [sx, sy] = ptAt(startAngle);
396
+
397
+ // connect from the current point like canvas does
398
+ this._d +=
399
+ this._d.length > 0
400
+ ? `L${round2(sx)} ${round2(sy)}`
401
+ : `M${round2(sx)} ${round2(sy)}`;
402
+
403
+ // emit in half-turn segments so large-arc flags stay unambiguous
404
+ const sweep = full ? Const.two_pi : delta;
405
+ const segments = Math.max(1, Math.ceil(sweep / Const.pi - 0.000001));
406
+ let angle = startAngle;
407
+ for (let s = 1; s <= segments; s++) {
408
+ const target =
409
+ s === segments ? startAngle + dir * sweep : angle + dir * Const.pi;
410
+ const [ex, ey] = ptAt(target);
411
+ this._d += `A${round2(rx)} ${round2(ry)} ${rotDeg} 0 ${sweepFlag} ${round2(ex)} ${round2(ey)}`;
412
+ angle = target;
413
+ }
414
+ if (full) this._d += "Z";
415
+ }
416
+
417
+ // ----------------------------------------------------------------- paint
418
+
419
+ fill(): void {
420
+ this._shapeFill = this._resolvePaint(this.fillStyle);
421
+ this._capturePaintState();
422
+ }
423
+
424
+ stroke(): void {
425
+ this._shapeStroke = this._resolvePaint(this.strokeStyle);
426
+ this._shapeStrokeStyle = {
427
+ "stroke-width": this.lineWidth,
428
+ "stroke-linejoin": this.lineJoin,
429
+ "stroke-linecap": this.lineCap,
430
+ };
431
+ if (this._dash.length > 0) {
432
+ this._shapeStrokeStyle["stroke-dasharray"] = this._dash.join(" ");
433
+ if (this.lineDashOffset)
434
+ this._shapeStrokeStyle["stroke-dashoffset"] = this.lineDashOffset;
435
+ }
436
+ this._capturePaintState();
437
+ }
438
+
439
+ protected _capturePaintState(): void {
440
+ this._shapePainted = true;
441
+ this._shapeClass = this.className;
442
+ this._shapeAlpha = this.globalAlpha;
443
+ this._shapeBlend = this.globalCompositeOperation;
444
+ this._drawCount++;
445
+ }
446
+
447
+ fillRect(x: number, y: number, w: number, h: number): void {
448
+ this.beginPath();
449
+ this.rect(x, y, w, h);
450
+ this.fill();
451
+ }
452
+
453
+ clearRect(): void {
454
+ // background clearing is handled by SVGSpace.clear; nothing to erase mid-frame
455
+ }
456
+
457
+ fillText(txt: string, x: number, y: number, maxWidth?: number): void {
458
+ this._flushShape();
459
+ const anchor =
460
+ this.textAlign === "center"
461
+ ? "middle"
462
+ : this.textAlign === "right" || this.textAlign === "end"
463
+ ? "end"
464
+ : "start";
465
+ const baseline =
466
+ this.textBaseline === "top"
467
+ ? "text-before-edge"
468
+ : this.textBaseline === "middle"
469
+ ? "central"
470
+ : this.textBaseline === "bottom"
471
+ ? "text-after-edge"
472
+ : this.textBaseline; // alphabetic, hanging, ideographic pass through
473
+ const attrs: Record<string, string | number> = {
474
+ x: round2(x),
475
+ y: round2(y),
476
+ fill: this._resolvePaint(this.fillStyle),
477
+ "text-anchor": anchor,
478
+ "dominant-baseline": baseline,
479
+ style: `font: ${this.font}`,
480
+ "pointer-events": "none",
481
+ };
482
+ // canvas maxWidth semantics: compress to fit only when text is wider
483
+ if (maxWidth! > 0 && this.measureText(txt).width > maxWidth!) {
484
+ attrs.textLength = round2(maxWidth!);
485
+ attrs.lengthAdjust = "spacingAndGlyphs";
486
+ }
487
+ this._applyCommon(attrs);
488
+ this._runs.push({ tag: "text", attrs, text: txt });
489
+ this._drawCount++;
490
+ }
491
+
492
+ measureText(txt: string): TextMetrics {
493
+ if (!_svgMeasurer) {
494
+ _svgMeasurer = document.createElement("canvas").getContext("2d")!;
495
+ }
496
+ _svgMeasurer!.font = this.font;
497
+ return _svgMeasurer!.measureText(txt);
498
+ }
499
+
500
+ drawImage(
501
+ img: CanvasImageSource,
502
+ x: number,
503
+ y: number,
504
+ w?: number,
505
+ h?: number,
506
+ ...rest: number[]
507
+ ): void {
508
+ if (rest.length > 0) {
509
+ SVGContext2D._warnOnce(
510
+ "drawImage9",
511
+ "SVG output does not support the 9-argument (source-cropped) drawImage",
512
+ );
513
+ return;
514
+ }
515
+ this._flushShape();
516
+ const src =
517
+ (img as HTMLImageElement).src ??
518
+ ((img as HTMLCanvasElement).toDataURL
519
+ ? (img as HTMLCanvasElement).toDataURL()
520
+ : null);
521
+ if (!src) {
522
+ SVGContext2D._warnOnce(
523
+ "drawImageSrc",
524
+ "SVG output supports images from <img> elements or canvases only",
525
+ );
526
+ return;
527
+ }
528
+ const attrs: Record<string, string | number> = {
529
+ href: src,
530
+ x: round2(x),
531
+ y: round2(y),
532
+ };
533
+ const iw = w ?? (img as HTMLImageElement).width;
534
+ const ih = h ?? (img as HTMLImageElement).height;
535
+ if (iw != null) attrs.width = round2(iw as number);
536
+ if (ih != null) attrs.height = round2(ih as number);
537
+ this._applyCommon(attrs);
538
+ this._runs.push({ tag: "image", attrs });
539
+ this._drawCount++;
540
+ }
541
+
542
+ putImageData(): void {
543
+ SVGContext2D._warnOnce(
544
+ "putImageData",
545
+ "putImageData is not supported in SVG output",
546
+ );
547
+ }
548
+
549
+ // ----------------------------------------------------------- state & misc
550
+
551
+ save(): void {
552
+ this._stateStack.push({
553
+ fillStyle: this.fillStyle,
554
+ strokeStyle: this.strokeStyle,
555
+ lineWidth: this.lineWidth,
556
+ lineJoin: this.lineJoin,
557
+ lineCap: this.lineCap,
558
+ globalAlpha: this.globalAlpha,
559
+ globalCompositeOperation: this.globalCompositeOperation,
560
+ font: this.font,
561
+ textAlign: this.textAlign,
562
+ textBaseline: this.textBaseline,
563
+ lineDashOffset: this.lineDashOffset,
564
+ _dash: this._dash.slice(),
565
+ });
566
+ }
567
+
568
+ restore(): void {
569
+ const s = this._stateStack.pop();
570
+ if (s) Object.assign(this, s);
571
+ }
572
+
573
+ scale(): void {
574
+ // SVG output is resolution-independent; the canvas pixel-density scale is a no-op here
575
+ }
576
+
577
+ clip(): void {
578
+ SVGContext2D._warnOnce("clip", "clip is not yet supported in SVG output");
579
+ }
580
+
581
+ setLineDash(segments: number[]): void {
582
+ this._dash = segments;
583
+ }
584
+
585
+ getLineDash(): number[] {
586
+ return this._dash;
587
+ }
588
+
589
+ createLinearGradient(
590
+ x1: number,
591
+ y1: number,
592
+ x2: number,
593
+ y2: number,
594
+ ): SVGGradient {
595
+ return new SVGGradient("linear", [x1, y1, x2, y2]);
596
+ }
597
+
598
+ createRadialGradient(
599
+ x0: number,
600
+ y0: number,
601
+ r0: number,
602
+ x1: number,
603
+ y1: number,
604
+ r1: number,
605
+ ): SVGGradient {
606
+ return new SVGGradient("radial", [x0, y0, r0, x1, y1, r1]);
607
+ }
608
+
609
+ // -------------------------------------------------------------- internals
610
+
611
+ protected _resolvePaint(style: string | SVGGradient): string {
612
+ if (style instanceof SVGGradient) {
613
+ if (!this._defs) {
614
+ this._defs = document.createElementNS(SVG_NS, "defs") as SVGElement;
615
+ this._host.insertBefore(this._defs, this._host.firstChild);
616
+ }
617
+ return style.materialize(this._defs);
618
+ }
619
+ if (typeof style !== "string") {
620
+ // a CanvasPattern (or other canvas paint object) has no SVG equivalent;
621
+ // "[object CanvasPattern]" would otherwise be emitted as an invalid paint
622
+ SVGContext2D._warnOnce(
623
+ "pattern",
624
+ "canvas patterns are not supported in SVG output; use CanvasSpace",
625
+ );
626
+ return "none";
627
+ }
628
+ return style;
629
+ }
630
+
631
+ /** Add class, alpha, and blend attributes shared by all run kinds. */
632
+ protected _applyCommon(attrs: Record<string, string | number>): void {
633
+ attrs.class = this.className
634
+ ? `pts-svgform ${this.className}`
635
+ : "pts-svgform";
636
+ if (this.globalAlpha !== 1) attrs.opacity = this.globalAlpha;
637
+ else attrs.opacity = 1;
638
+ const op = this.globalCompositeOperation;
639
+ if (op !== "source-over") {
640
+ if (BLEND_MODES.has(op)) {
641
+ // text runs already carry their font in `style`; append, never replace
642
+ const blend = `mix-blend-mode: ${op}`;
643
+ attrs.style = attrs.style ? `${attrs.style}; ${blend}` : blend;
644
+ } else {
645
+ SVGContext2D._warnOnce(
646
+ `composite-${op}`,
647
+ `composite operation "${op}" has no SVG equivalent`,
648
+ );
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Commit the current shape (its path and fill/stroke usage) into the run list, merging
655
+ * with the previous run when the paint state matches.
656
+ */
657
+ protected _flushShape(): void {
658
+ if (!this._shapePainted || this._d.length === 0) {
659
+ this._shapePainted = false;
660
+ return;
661
+ }
662
+
663
+ const attrs: Record<string, string | number> = {
664
+ d: this._d,
665
+ fill: this._shapeFill ?? "none",
666
+ stroke: this._shapeStroke ?? "none",
667
+ };
668
+ if (this._shapeStroke) {
669
+ Object.assign(attrs, this._shapeStrokeStyle);
670
+ }
671
+ attrs.class = this._shapeClass
672
+ ? `pts-svgform ${this._shapeClass}`
673
+ : "pts-svgform";
674
+ attrs.opacity = this._shapeAlpha;
675
+ if (this._shapeBlend !== "source-over") {
676
+ if (BLEND_MODES.has(this._shapeBlend)) {
677
+ attrs.style = `mix-blend-mode: ${this._shapeBlend}`;
678
+ } else {
679
+ SVGContext2D._warnOnce(
680
+ `composite-${this._shapeBlend}`,
681
+ `composite operation "${this._shapeBlend}" has no SVG equivalent`,
682
+ );
683
+ }
684
+ }
685
+
686
+ const prev = this._runs[this._runs.length - 1];
687
+ if (prev && prev.tag === "path" && sameRunStyle(prev.attrs, attrs)) {
688
+ prev.shapeEnds!.push((prev.attrs.d as string).length);
689
+ prev.attrs.d = (prev.attrs.d as string) + this._d;
690
+ } else {
691
+ this._runs.push({ tag: "path", attrs, shapeEnds: [] });
692
+ }
693
+
694
+ this._d = "";
695
+ this._shapeFill = null;
696
+ this._shapeStroke = null;
697
+ this._shapePainted = false;
698
+ }
699
+ }
700
+
701
+ function round2(n: number): number {
702
+ return Math.round(n * 100) / 100;
703
+ }
704
+
705
+ /** Compare two path-run attribute sets for style equality (everything but the path data). */
706
+ function sameRunStyle(
707
+ a: Record<string, string | number>,
708
+ b: Record<string, string | number>,
709
+ ): boolean {
710
+ const keysA = Object.keys(a);
711
+ const keysB = Object.keys(b);
712
+ if (keysA.length !== keysB.length) return false;
713
+ for (const k of keysA) {
714
+ if (k === "d") continue;
715
+ if (a[k] !== b[k]) return false;
716
+ }
717
+ return true;
718
+ }
719
+
720
+ /**
721
+ * SVGSpace extends [`DOMSpace`](#link) to support SVG elements. Use it with [`SVGForm`](#link),
722
+ * which shares its drawing API and semantics with [`CanvasForm`](#link) — a sketch written for
723
+ * canvas can run on SVG with the supported subset. Check out the [Space guide](../guide/Space-0500.html) for details.
724
+ */
725
+ export class SVGSpace extends DOMSpace {
726
+ protected _bgcolor: string = "#999";
727
+ protected _svgContexts: SVGContext2D[] = [];
728
+ protected _bgElem: SVGElement | null = null;
729
+ protected _svgRefresh: boolean = true; // mirrors Space's private refresh flag
730
+
731
+ /**
732
+ * Create a SVGSpace which represents a Space for SVG elements.
733
+ * @param elem Specify an element by its "id" attribute as string, or by the element object itself. An element can be an existing `<svg>`, or a `<div>` container in which a new `<svg>` will be created. If left empty, a `<div id="pt_container"><svg id="pt" /></div>` will be added to DOM. Use css to customize its appearance if needed.
734
+ * @param callback an optional callback `function(boundingBox, spaceElement)` to be called when canvas is appended and ready. Alternatively, a "ready" event will also be fired from the `<svg>` element when it's appended, which can be traced with `spaceInstance.canvas.addEventListener("ready")`
735
+ * @example `new SVGSpace( "#myElementID" )`
736
+ */
737
+ constructor(
738
+ elem: string | Element | null = "pt",
739
+ callback?: (bound: Bound, elem: Element) => void,
740
+ ) {
741
+ super(elem, callback);
742
+
743
+ if (this._canvas.nodeName.toLowerCase() != "svg") {
744
+ let s = SVGSpace.svgElement(this._canvas, "svg", `${this.id}_svg`);
745
+ this._container = this._canvas;
746
+ this._canvas = s as SVGElement;
747
+ }
748
+
749
+ // immediate-mode cycle like canvas: redraw and reconcile every frame
750
+ this.refresh(true);
751
+ }
752
+
753
+ /**
754
+ * For a missing target, create the documented `<svg id="pt">` inside the created container.
755
+ */
756
+ protected _createDefaultElement(container: Element, id: string): SVGElement {
757
+ return SVGSpace.svgElement(container, "svg", id);
758
+ }
759
+
760
+ /**
761
+ * Get a new [`SVGForm`](#link) for drawing. The form shares its API with
762
+ * [`CanvasForm`](#link), rendered through a [`SVGContext2D`](#link).
763
+ * @see `SVGForm`
764
+ */
765
+ getForm(): SVGForm {
766
+ return new SVGForm(this);
767
+ }
768
+
769
+ /**
770
+ * Get the DOM element.
771
+ */
772
+ get element(): Element {
773
+ return this._canvas;
774
+ }
775
+
776
+ /** Register a rendering context so its frame lifecycle follows this space's play cycle. */
777
+ registerContext(ctx: SVGContext2D): void {
778
+ this._svgContexts.push(ctx);
779
+ }
780
+
781
+ /**
782
+ * This overrides Space's `resize` function. It's used as a callback function for window's resize event and not usually called directly.
783
+ * You can keep track of resize events with `resize: (bound ,evt)` callback in your [`IPlayer`](#link) objects (See [`Space.add`](#link)).
784
+ * @param b a Bound object to resize to
785
+ * @param evt Optionally pass a resize event
786
+ */
787
+ resize(b: Bound, evt?: Event | null): this {
788
+ super.resize(b, evt);
789
+ SVGSpace.setAttr(this.element, {
790
+ viewBox: `0 0 ${this.bound.width} ${this.bound.height}`,
791
+ width: `${this.bound.width}`,
792
+ height: `${this.bound.height}`,
793
+ xmlns: SVG_NS,
794
+ version: "1.1",
795
+ });
796
+ this._updateBackground();
797
+ return this;
798
+ }
799
+
800
+ /**
801
+ * Clear the drawing. In SVG this maintains a background rectangle rather than erasing
802
+ * elements — the per-frame reconciliation removes stale shapes.
803
+ * @param bg Optionally specify a custom background color in hex or rgba string, or "transparent"
804
+ */
805
+ clear(bg?: string): this {
806
+ if (bg) this._bgcolor = bg;
807
+ this._updateBackground();
808
+ return this;
809
+ }
810
+
811
+ protected _updateBackground(): void {
812
+ const svg = this._canvas as SVGElement;
813
+ if (!this._bgElem) {
814
+ this._bgElem = document.createElementNS(SVG_NS, "rect") as SVGElement;
815
+ this._bgElem.setAttribute("class", "pts-svg-bg");
816
+ svg.insertBefore(this._bgElem, svg.firstChild);
817
+ }
818
+ DOMSpace.setAttr(this._bgElem, {
819
+ x: 0,
820
+ y: 0,
821
+ width: this.bound.width,
822
+ height: this.bound.height,
823
+ fill:
824
+ !this._bgcolor || this._bgcolor === "transparent"
825
+ ? "none"
826
+ : this._bgcolor,
827
+ });
828
+ }
829
+
830
+ /**
831
+ * The per-frame cycle: begin all registered contexts' frames, run the players, then
832
+ * commit — reconciling the SVG DOM against what was drawn this frame.
833
+ */
834
+ protected playItems(time: number) {
835
+ const ctxs = this._svgContexts;
836
+ for (let i = 0, len = ctxs.length; i < len; i++) ctxs[i].beginFrame();
837
+ super.playItems(time);
838
+ for (let i = 0, len = ctxs.length; i < len; i++) {
839
+ // skip empty commits so scenes drawn once (with refresh off) persist
840
+ if (this._svgRefresh || ctxs[i].drawCount > 0) ctxs[i].commitFrame();
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Set whether the rendering should be repainted on each frame.
846
+ * @param b a boolean value to set whether to repaint each frame
847
+ */
848
+ refresh(b: boolean): this {
849
+ this._svgRefresh = b;
850
+ return super.refresh(b);
851
+ }
852
+
853
+ /**
854
+ * Serialize the current SVG output to a string.
855
+ * @param expand if `true`, split merged style runs into one element per shape — a
856
+ * semantic export suited for editing in vector tools. Default is `false`.
857
+ */
858
+ toSVG(expand: boolean = false): string {
859
+ const svg = this._canvas as SVGElement;
860
+ if (!expand) return svg.outerHTML;
861
+
862
+ const clone = svg.cloneNode(true) as SVGElement;
863
+ // rebuild each form's group with one element per shape
864
+ const groups = clone.querySelectorAll("g.pts-svgform");
865
+ let gi = 0;
866
+ for (const ctx of this._svgContexts) {
867
+ const group = groups[gi++];
868
+ if (!group) continue;
869
+ group.textContent = "";
870
+ for (const run of ctx.runs) {
871
+ if (run.tag !== "path") {
872
+ const elem = document.createElementNS(SVG_NS, run.tag);
873
+ DOMSpace.setAttr(elem, run.attrs);
874
+ if (run.text) elem.textContent = run.text;
875
+ group.appendChild(elem);
876
+ continue;
877
+ }
878
+ const d = run.attrs.d as string;
879
+ const bounds = [...run.shapeEnds!, d.length];
880
+ let begin = 0;
881
+ for (const end of bounds) {
882
+ const elem = document.createElementNS(SVG_NS, "path");
883
+ DOMSpace.setAttr(elem, { ...run.attrs, d: d.slice(begin, end) });
884
+ group.appendChild(elem);
885
+ begin = end;
886
+ }
887
+ }
888
+ }
889
+ return clone.outerHTML;
890
+ }
891
+
892
+ /**
893
+ * A static function to add a svg element inside a node. Usually you don't need to call this directly. See methods in [`SVGForm`](#link) instead.
894
+ * @param parent the parent element, or `null` to use current `<svg>` as parent.
895
+ * @param name a string of element name, such as `rect` or `circle`
896
+ * @param id id attribute of the new element
897
+ */
898
+ static svgElement(
899
+ parent: Element | null | undefined,
900
+ name: string,
901
+ id?: string,
902
+ ): SVGElement {
903
+ if (!parent || !parent.appendChild)
904
+ throw new Error("parent is not a valid DOM element");
905
+
906
+ // O(1) id lookup, then verify it's inside the parent so a same-id
907
+ // element elsewhere in the document is never silently adopted
908
+ let elem: Element | null = document.getElementById(id!);
909
+ if (elem && !parent.contains(elem)) elem = null;
910
+
911
+ if (!elem) {
912
+ elem = document.createElementNS(SVG_NS, name);
913
+ elem.setAttribute("id", id!);
914
+
915
+ parent.appendChild(elem);
916
+ }
917
+ return elem as SVGElement;
918
+ }
919
+
920
+ /**
921
+ * Remove an item from this Space.
922
+ * @param player a player item with an auto-assigned `animateID` property
923
+ */
924
+ remove(player: IPlayer): this {
925
+ let temp = this._container.querySelectorAll("." + SVGForm.scopeID(player));
926
+
927
+ temp.forEach((el: Element) => {
928
+ el.parentNode!.removeChild(el);
929
+ });
930
+
931
+ return super.remove(player);
932
+ }
933
+
934
+ /**
935
+ * Remove all items from this Space. This clears the contents of the space's
936
+ * `<svg>` element but never touches its container, so the space keeps
937
+ * rendering after items are re-added.
938
+ */
939
+ removeAll(): this {
940
+ this._canvas.innerHTML = "";
941
+ this._bgElem = null;
942
+ for (const ctx of this._svgContexts) ctx.resetDom();
943
+ return super.removeAll();
944
+ }
945
+
946
+ /**
947
+ * Dispose of browser resources held by this space: listeners, the animation loop, and the
948
+ * elements this space manages inside the `<svg>`. Call this before unmounting, eg in a
949
+ * framework component's cleanup callback. A new space can be mounted on the same element
950
+ * afterwards (as happens under React's StrictMode).
951
+ */
952
+ dispose(): this {
953
+ super.dispose();
954
+ for (const ctx of this._svgContexts) ctx.disposeDom();
955
+ this._svgContexts = [];
956
+ if (this._bgElem && this._bgElem.parentNode) {
957
+ this._bgElem.parentNode.removeChild(this._bgElem);
958
+ }
959
+ this._bgElem = null;
960
+ return this;
961
+ }
962
+ }
963
+
964
+ let _svgFormGroupID = 0;
965
+ let _svgFormDomID = 0;
966
+
967
+ // rendering-context style keys and their legacy inline-style names
968
+ const _legacyStyleKeys: Record<string, string> = {
969
+ fillStyle: "fill",
970
+ strokeStyle: "stroke",
971
+ lineWidth: "stroke-width",
972
+ lineJoin: "stroke-linejoin",
973
+ lineCap: "stroke-linecap",
974
+ globalAlpha: "opacity",
975
+ font: "font",
976
+ };
977
+
978
+ /**
979
+ * SVGForm is a [`CanvasForm`](#link) rendered through a [`SVGContext2D`](#link): it inherits
980
+ * the canvas drawing API — shapes, gradients, dashes, images, `textBox` — with SVG
981
+ * output, subject to the capability notes in `SVGContext2D`. Sketches using this subset
982
+ * can swap between `CanvasSpace` and `SVGSpace`. The legacy per-element static helpers and `scope()` workflow are retained
983
+ * for compatibility but are no longer needed.
984
+ */
985
+ export class SVGForm extends CanvasForm<SVGSpace> {
986
+ protected _svgSpace: SVGSpace;
987
+ protected _svgCtx: SVGContext2D;
988
+ protected _formID: number = _svgFormGroupID++;
989
+
990
+ protected _legacyCtx: DOMFormContext = {
991
+ group: null,
992
+ groupID: "pts",
993
+ groupCount: 0,
994
+ currentID: "pts0",
995
+ currentClass: "",
996
+ style: {},
997
+ };
998
+
999
+ // mutable statics are stored at module level and exposed through accessors so
1000
+ // no post-class assignment is emitted (which would defeat tree-shaking)
1001
+ static get groupID(): number {
1002
+ return _svgFormGroupID;
1003
+ }
1004
+ static set groupID(n: number) {
1005
+ _svgFormGroupID = n;
1006
+ }
1007
+ static get domID(): number {
1008
+ return _svgFormDomID;
1009
+ }
1010
+ static set domID(n: number) {
1011
+ _svgFormDomID = n;
1012
+ }
1013
+
1014
+ /**
1015
+ * Create a new SVGForm. You may also use [`SVGSpace.getForm`](#link) to get a default form directly.
1016
+ * @param space an instance of SVGSpace
1017
+ */
1018
+ constructor(space: SVGSpace) {
1019
+ super();
1020
+ this._svgSpace = space;
1021
+ this._svgCtx = new SVGContext2D(space.element as SVGElement);
1022
+ space.registerContext(this._svgCtx);
1023
+
1024
+ this._ctx = this._svgCtx as unknown as RenderingContext2D;
1025
+ // Same initial state as CanvasForm, so a sketch renders alike on both:
1026
+ // in particular, text without an explicit font() is 14px, not the
1027
+ // context's 10px default.
1028
+ this._set("fillStyle", this._style.fillStyle);
1029
+ this._set("strokeStyle", this._style.strokeStyle);
1030
+ this._set("lineJoin", "bevel");
1031
+ this._set("font", this._font.value);
1032
+ this._ready = true;
1033
+
1034
+ this._legacyCtx.group = space.element;
1035
+ }
1036
+
1037
+ /**
1038
+ * Mirror style writes into the legacy scope context, so the static per-element helpers
1039
+ * (`SVGForm.circle( form.scope(player), ... )`) draw with the form's current fill, stroke,
1040
+ * alpha, and font as they did before the rendering-context path existed.
1041
+ */
1042
+ protected _set(key: string, value: unknown): void {
1043
+ const legacy = _legacyStyleKeys[key];
1044
+ if (legacy) {
1045
+ // gradient and pattern objects have no inline-style form; keep the last color
1046
+ if (typeof value === "string" || typeof value === "number") {
1047
+ this._legacyCtx.style[legacy] = value;
1048
+ }
1049
+ }
1050
+ super._set(key, value);
1051
+ }
1052
+
1053
+ get filled(): boolean {
1054
+ return this._filled;
1055
+ }
1056
+ set filled(b: boolean) {
1057
+ this._filled = b;
1058
+ this._legacyCtx.style.filled = b;
1059
+ }
1060
+
1061
+ get stroked(): boolean {
1062
+ return this._stroked;
1063
+ }
1064
+ set stroked(b: boolean) {
1065
+ this._stroked = b;
1066
+ this._legacyCtx.style.stroked = b;
1067
+ }
1068
+
1069
+ /**
1070
+ * Offscreen buffers require Canvas output. In SVG this warns once and draws directly.
1071
+ */
1072
+ useOffscreen(_off: boolean = true, _clear: boolean | string = false): this {
1073
+ SVGForm._warnOffscreen();
1074
+ return this;
1075
+ }
1076
+
1077
+ /**
1078
+ * Offscreen buffers require Canvas output. In SVG this warns once and does nothing.
1079
+ */
1080
+ renderOffscreen(_offset: PtLike = [0, 0]): void {
1081
+ SVGForm._warnOffscreen();
1082
+ }
1083
+
1084
+ private static _offscreenWarned = false;
1085
+ private static _warnOffscreen() {
1086
+ if (SVGForm._offscreenWarned) return;
1087
+ SVGForm._offscreenWarned = true;
1088
+ Util.warn(
1089
+ "offscreen canvases are not supported in SVG output; use CanvasSpace",
1090
+ );
1091
+ }
1092
+
1093
+ /**
1094
+ * Get the [`SVGSpace`](#link) instance that this form is associated with.
1095
+ */
1096
+ get space(): SVGSpace {
1097
+ return this._svgSpace;
1098
+ }
1099
+
1100
+ /**
1101
+ * The underlying [`SVGContext2D`](#link), for advanced use.
1102
+ */
1103
+ get svgContext(): SVGContext2D {
1104
+ return this._svgCtx;
1105
+ }
1106
+
1107
+ /**
1108
+ * Add custom class to the created element(s). In batched rendering the class applies to
1109
+ * the current style run.
1110
+ * @param c custom class name or `false` to reset it
1111
+ * @example `form.fill("#f00").cls("myClass").rects(r)` `form.cls(false).circles(c)`
1112
+ */
1113
+ cls(c: string | boolean) {
1114
+ const cls = typeof c == "boolean" ? "" : c;
1115
+ this._legacyCtx.currentClass = cls;
1116
+ this._svgCtx.className = cls;
1117
+ return this;
1118
+ }
1119
+
1120
+ // ------------------------------------------------- legacy scope workflow
1121
+
1122
+ /**
1123
+ * @deprecated No longer needed: elements are reconciled automatically each frame. Kept
1124
+ * for compatibility with code that pairs it with the legacy static helpers.
1125
+ */
1126
+ updateScope(group_id: string, group?: Element): DOMFormContext {
1127
+ this._legacyCtx.group = group;
1128
+ this._legacyCtx.groupID = group_id;
1129
+ this._legacyCtx.groupCount = 0;
1130
+ this.nextID();
1131
+ return this._legacyCtx;
1132
+ }
1133
+
1134
+ /**
1135
+ * @deprecated No longer needed: elements are reconciled automatically each frame. Kept
1136
+ * for compatibility; returns the legacy context used by the static helpers.
1137
+ */
1138
+ scope(item: IPlayer) {
1139
+ if (!item || item.animateID == null)
1140
+ throw new Error("item not defined or not yet added to Space");
1141
+ // two forms scoped to the same player must not generate the same ids
1142
+ return this.updateScope(
1143
+ `${SVGForm.scopeID(item)}-f${this._formID}`,
1144
+ this._svgSpace.element,
1145
+ );
1146
+ }
1147
+
1148
+ /**
1149
+ * @deprecated Part of the legacy scope workflow.
1150
+ */
1151
+ nextID(): string {
1152
+ this._legacyCtx.groupCount++;
1153
+ this._legacyCtx.currentID = `${this._legacyCtx.groupID}-${this._legacyCtx.groupCount}`;
1154
+ return this._legacyCtx.currentID;
1155
+ }
1156
+
1157
+ /**
1158
+ * A static function to generate an ID string based on a context object.
1159
+ * @param ctx a context object for an SVGForm
1160
+ */
1161
+ static getID(ctx: DOMFormContext): string {
1162
+ return ctx.currentID || `p-${SVGForm.domID++}`;
1163
+ }
1164
+
1165
+ /**
1166
+ * A static function to generate an ID string for a scope, based on an [`IPlayer`](#link) object in the Space.
1167
+ * @param item a [`IPlayer`](#link) object that's added to space (see [`Space.add`](#link)) and has an `animateID` property
1168
+ */
1169
+ static scopeID(item: IPlayer): string {
1170
+ return `item-${item.animateID}`;
1171
+ }
1172
+
1173
+ // --------------------------------------- legacy per-element static helpers
1174
+ // These draw one SVG element per call using id-based lookup, exactly as
1175
+ // before. They are retained for compatibility and for expanded exports.
1176
+
1177
+ /**
1178
+ * A static function to help adding style object to an element.
1179
+ * Note that this put all styles into `style` attribute instead of individual svg attributes, so that the styles can be parsed by Adobe Illustrator.
1180
+ * @param elem A DOM element to add to
1181
+ * @param styles an object of style properties
1182
+ * @example `SVGForm.style(elem, {fill: "#f90", stroke: false})`
1183
+ * @returns this DOM element
1184
+ */
1185
+ static style(elem: SVGElement, styles: Record<string, any>) {
1186
+ let st = [];
1187
+
1188
+ if (!styles["filled"]) st.push("fill: none");
1189
+ if (!styles["stroked"]) st.push("stroke: none");
1190
+
1191
+ for (let k in styles) {
1192
+ if (styles.hasOwnProperty(k) && k != "filled" && k != "stroked") {
1193
+ let v = styles[k];
1194
+ if (v) {
1195
+ if (!styles["filled"] && k.indexOf("fill") === 0) {
1196
+ continue;
1197
+ } else if (!styles["stroked"] && k.indexOf("stroke") === 0) {
1198
+ continue;
1199
+ } else {
1200
+ st.push(`${k}: ${v}`);
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ return DOMSpace.setAttr(elem, { style: st.join(";") });
1207
+ }
1208
+
1209
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1210
+ static point(
1211
+ ctx: DOMFormContext,
1212
+ pt: PtLike,
1213
+ radius?: number,
1214
+ shape?: string,
1215
+ ): SVGElement;
1216
+ static point(
1217
+ ctx: RenderingContext2D,
1218
+ pt: PtLike,
1219
+ radius?: number,
1220
+ shape?: string,
1221
+ ): void;
1222
+ static point(
1223
+ ctx: DOMFormContext | RenderingContext2D,
1224
+ pt: PtLike,
1225
+ radius = 5,
1226
+ shape = "square",
1227
+ ) {
1228
+ return "style" in ctx
1229
+ ? SVGForm.pointElement(ctx, pt, radius, shape)
1230
+ : CanvasForm.point(ctx, pt, radius, shape);
1231
+ }
1232
+
1233
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1234
+ static circle(ctx: DOMFormContext, pt: PtLike, radius?: number): SVGElement;
1235
+ static circle(ctx: RenderingContext2D, pt: PtLike, radius?: number): void;
1236
+ static circle(
1237
+ ctx: DOMFormContext | RenderingContext2D,
1238
+ pt: PtLike,
1239
+ radius = 10,
1240
+ ) {
1241
+ return "style" in ctx
1242
+ ? SVGForm.circleElement(ctx, pt, radius)
1243
+ : CanvasForm.circle(ctx, pt, radius);
1244
+ }
1245
+
1246
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1247
+ static arc(
1248
+ ctx: DOMFormContext,
1249
+ pt: PtLike,
1250
+ radius: number,
1251
+ startAngle: number,
1252
+ endAngle: number,
1253
+ cc?: boolean,
1254
+ ): SVGElement;
1255
+ static arc(
1256
+ ctx: RenderingContext2D,
1257
+ pt: PtLike,
1258
+ radius: number,
1259
+ startAngle: number,
1260
+ endAngle: number,
1261
+ cc?: boolean,
1262
+ ): void;
1263
+ static arc(
1264
+ ctx: DOMFormContext | RenderingContext2D,
1265
+ pt: PtLike,
1266
+ radius: number,
1267
+ startAngle: number,
1268
+ endAngle: number,
1269
+ cc?: boolean,
1270
+ ) {
1271
+ return "style" in ctx
1272
+ ? SVGForm.arcElement(ctx, pt, radius, startAngle, endAngle, cc)
1273
+ : CanvasForm.arc(ctx, pt, radius, startAngle, endAngle, cc);
1274
+ }
1275
+
1276
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1277
+ static square(ctx: DOMFormContext, pt: PtLike, halfsize: number): SVGElement;
1278
+ static square(ctx: RenderingContext2D, pt: PtLike, halfsize: number): void;
1279
+ static square(
1280
+ ctx: DOMFormContext | RenderingContext2D,
1281
+ pt: PtLike,
1282
+ halfsize: number,
1283
+ ) {
1284
+ return "style" in ctx
1285
+ ? SVGForm.squareElement(ctx, pt, halfsize)
1286
+ : CanvasForm.square(ctx, pt, halfsize);
1287
+ }
1288
+
1289
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1290
+ static line(ctx: DOMFormContext, pts: PtLikeIterable): SVGElement | undefined;
1291
+ static line(ctx: RenderingContext2D, pts: PtLikeIterable): void;
1292
+ static line(ctx: DOMFormContext | RenderingContext2D, pts: PtLikeIterable) {
1293
+ return "style" in ctx
1294
+ ? SVGForm.lineElement(ctx, pts)
1295
+ : CanvasForm.line(ctx, pts);
1296
+ }
1297
+
1298
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1299
+ static polygon(ctx: DOMFormContext, pts: PtLikeIterable): SVGElement;
1300
+ static polygon(ctx: RenderingContext2D, pts: PtLikeIterable): void;
1301
+ static polygon(
1302
+ ctx: DOMFormContext | RenderingContext2D,
1303
+ pts: PtLikeIterable,
1304
+ ) {
1305
+ return "style" in ctx
1306
+ ? SVGForm.polygonElement(ctx, pts)
1307
+ : CanvasForm.polygon(ctx, pts);
1308
+ }
1309
+
1310
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1311
+ static rect(ctx: DOMFormContext, pts: PtLikeIterable): SVGElement | undefined;
1312
+ static rect(ctx: RenderingContext2D, pts: PtLikeIterable): void;
1313
+ static rect(ctx: DOMFormContext | RenderingContext2D, pts: PtLikeIterable) {
1314
+ return "style" in ctx
1315
+ ? SVGForm.rectElement(ctx, pts)
1316
+ : CanvasForm.rect(ctx, pts);
1317
+ }
1318
+
1319
+ /** Draw through a rendering context, or use the legacy per-element DOM context. */
1320
+ static text(ctx: DOMFormContext, pt: PtLike, txt: string): SVGElement;
1321
+ static text(
1322
+ ctx: RenderingContext2D,
1323
+ pt: PtLike,
1324
+ txt: string,
1325
+ maxWidth?: number,
1326
+ ): void;
1327
+ static text(
1328
+ ctx: DOMFormContext | RenderingContext2D,
1329
+ pt: PtLike,
1330
+ txt: string,
1331
+ maxWidth?: number,
1332
+ ) {
1333
+ return "style" in ctx
1334
+ ? SVGForm.textElement(ctx, pt, txt)
1335
+ : CanvasForm.text(ctx, pt, txt, maxWidth);
1336
+ }
1337
+
1338
+ /**
1339
+ * A static function to draw a point as a circle or square element.
1340
+ * @param ctx a context object of SVGForm
1341
+ * @param pt a Pt object or numeric array
1342
+ * @param radius radius of the point. Default is 5.
1343
+ * @param shape The shape of the point. Defaults to "square", but it can be "circle" or a custom shape function in your own implementation.
1344
+ * @example `SVGForm.point( ctx, p )`, `SVGForm.point( ctx, p, 10, "circle" )`
1345
+ */
1346
+ static pointElement(
1347
+ ctx: DOMFormContext,
1348
+ pt: PtLike,
1349
+ radius: number = 5,
1350
+ shape: string = "square",
1351
+ ): SVGElement {
1352
+ if (shape === "circle") {
1353
+ return SVGForm.circleElement(ctx, pt, radius);
1354
+ } else {
1355
+ return SVGForm.squareElement(ctx, pt, radius);
1356
+ }
1357
+ }
1358
+
1359
+ /**
1360
+ * A static function to draw a circle element.
1361
+ * @param ctx a context object of SVGForm
1362
+ * @param pt center position of the circle
1363
+ * @param radius radius of the circle
1364
+ */
1365
+ static circleElement(
1366
+ ctx: DOMFormContext,
1367
+ pt: PtLike,
1368
+ radius: number = 10,
1369
+ ): SVGElement {
1370
+ let elem = SVGSpace.svgElement(ctx.group, "circle", SVGForm.getID(ctx));
1371
+
1372
+ DOMSpace.setAttr(elem, {
1373
+ cx: pt[0],
1374
+ cy: pt[1],
1375
+ r: radius,
1376
+ class: `pts-svgform pts-circle ${ctx.currentClass}`,
1377
+ });
1378
+
1379
+ SVGForm.style(elem, ctx.style);
1380
+ return elem;
1381
+ }
1382
+
1383
+ /**
1384
+ * A static function to draw an arc element.
1385
+ * @param ctx a context object of SVGForm
1386
+ * @param pt center position
1387
+ * @param radius radius of the arc circle
1388
+ * @param startAngle start angle of the arc
1389
+ * @param endAngle end angle of the arc
1390
+ * @param cc an optional boolean value to specify if it should be drawn clockwise (`false`) or counter-clockwise (`true`). Default is clockwise.
1391
+ */
1392
+ static arcElement(
1393
+ ctx: DOMFormContext,
1394
+ pt: PtLike,
1395
+ radius: number,
1396
+ startAngle: number,
1397
+ endAngle: number,
1398
+ cc?: boolean,
1399
+ ): SVGElement {
1400
+ let elem = SVGSpace.svgElement(ctx.group, "path", SVGForm.getID(ctx));
1401
+
1402
+ const start = new Pt(pt).toAngle(startAngle, radius, true);
1403
+ const end = new Pt(pt).toAngle(endAngle, radius, true);
1404
+ const diff = Geom.boundAngle(endAngle) - Geom.boundAngle(startAngle);
1405
+ let largeArc = diff > Const.pi ? true : false;
1406
+ if (cc) largeArc = !largeArc;
1407
+ const sweep = cc ? "0" : "1";
1408
+
1409
+ const d = `M ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${largeArc ? "1" : "0"} ${sweep} ${end[0]} ${end[1]}`;
1410
+
1411
+ DOMSpace.setAttr(elem, {
1412
+ d: d,
1413
+ class: `pts-svgform pts-arc ${ctx.currentClass}`,
1414
+ });
1415
+ SVGForm.style(elem, ctx.style);
1416
+ return elem;
1417
+ }
1418
+
1419
+ /**
1420
+ * A static function to draw a square element.
1421
+ * @param ctx a context object of SVGForm
1422
+ * @param pt center position of the square
1423
+ * @param halfsize half size of the square
1424
+ */
1425
+ static squareElement(ctx: DOMFormContext, pt: PtLike, halfsize: number) {
1426
+ let elem = SVGSpace.svgElement(ctx.group, "rect", SVGForm.getID(ctx));
1427
+ DOMSpace.setAttr(elem, {
1428
+ x: pt[0] - halfsize,
1429
+ y: pt[1] - halfsize,
1430
+ width: halfsize * 2,
1431
+ height: halfsize * 2,
1432
+ class: `pts-svgform pts-square ${ctx.currentClass}`,
1433
+ });
1434
+ SVGForm.style(elem, ctx.style);
1435
+ return elem;
1436
+ }
1437
+
1438
+ /**
1439
+ * A static function to draw a line or polyline element.
1440
+ * @param ctx a context object of SVGForm
1441
+ * @param pts a Group or an Iterable<PtLike>
1442
+ */
1443
+ static lineElement(
1444
+ ctx: DOMFormContext,
1445
+ pts: PtLikeIterable,
1446
+ ): SVGElement | undefined {
1447
+ let points = SVGForm.pointsString(pts);
1448
+ if (points.count < 2) return;
1449
+
1450
+ // if count > 2, treat it as poly-line
1451
+ if (points.count > 2) return SVGForm._poly(ctx, points.string, false);
1452
+
1453
+ // if count == 2, treat it as line
1454
+ let elem = SVGSpace.svgElement(ctx.group, "line", SVGForm.getID(ctx));
1455
+ let p = Util.iterToArray(pts);
1456
+
1457
+ DOMSpace.setAttr(elem, {
1458
+ x1: p[0][0],
1459
+ y1: p[0][1],
1460
+ x2: p[1][0],
1461
+ y2: p[1][1],
1462
+ class: `pts-svgform pts-line ${ctx.currentClass}`,
1463
+ });
1464
+
1465
+ SVGForm.style(elem, ctx.style);
1466
+ return elem;
1467
+ }
1468
+
1469
+ /**
1470
+ * A static helper function to draw polyline or polygon.
1471
+ * @param ctx a context object of SVGForm
1472
+ * @param points a string of points' positions. See `SVGForm.pointsString` for conversion.
1473
+ * @param closePath a boolean to specify if the polygon path should be closed
1474
+ */
1475
+ protected static _poly(
1476
+ ctx: DOMFormContext,
1477
+ points: string,
1478
+ closePath: boolean = true,
1479
+ ) {
1480
+ let elem = SVGSpace.svgElement(
1481
+ ctx.group,
1482
+ closePath ? "polygon" : "polyline",
1483
+ SVGForm.getID(ctx),
1484
+ );
1485
+
1486
+ DOMSpace.setAttr(elem, {
1487
+ points: points,
1488
+ class: `pts-svgform pts-polygon ${ctx.currentClass}`,
1489
+ });
1490
+ SVGForm.style(elem, ctx.style);
1491
+ return elem;
1492
+ }
1493
+
1494
+ /**
1495
+ * Given a list of points, return a space-separated string
1496
+ * @param pts a Group or an Iterable<PtLike>
1497
+ * @returns an object of {string, count}
1498
+ */
1499
+ protected static pointsString(pts: PtLikeIterable): {
1500
+ string: string;
1501
+ count: number;
1502
+ } {
1503
+ let points: string = "";
1504
+ let count = 0;
1505
+ for (let p of pts) {
1506
+ points += `${p[0]},${p[1]} `;
1507
+ count++;
1508
+ }
1509
+ return { string: points, count: count };
1510
+ }
1511
+
1512
+ /**
1513
+ * A static function to draw a polygon element.
1514
+ * @param ctx a context object of SVGForm
1515
+ * @param pts a Group or an Iterable<PtLike> representing a polygon
1516
+ */
1517
+ static polygonElement(ctx: DOMFormContext, pts: PtLikeIterable): SVGElement {
1518
+ let points = SVGForm.pointsString(pts);
1519
+ return SVGForm._poly(ctx, points.string, true);
1520
+ }
1521
+
1522
+ /**
1523
+ * A static function to draw a rectangle element.
1524
+ * @param ctx a context object of SVGForm
1525
+ * @param pts a Group or an Iterable<PtLike> with 2 Pt specifying the top-left and bottom-right positions.
1526
+ */
1527
+ static rectElement(
1528
+ ctx: DOMFormContext,
1529
+ pts: PtLikeIterable,
1530
+ ): SVGElement | undefined {
1531
+ if (!Util.arrayCheck(pts)) return;
1532
+
1533
+ let elem = SVGSpace.svgElement(ctx.group, "rect", SVGForm.getID(ctx));
1534
+ let bound = Group.fromArray(pts).boundingBox();
1535
+ let size = Rectangle.size(bound);
1536
+
1537
+ DOMSpace.setAttr(elem, {
1538
+ x: bound[0][0],
1539
+ y: bound[0][1],
1540
+ width: size[0],
1541
+ height: size[1],
1542
+ class: `pts-svgform pts-rect ${ctx.currentClass}`,
1543
+ });
1544
+
1545
+ SVGForm.style(elem, ctx.style);
1546
+ return elem;
1547
+ }
1548
+
1549
+ /**
1550
+ * A static function to draw a text element.
1551
+ * @param ctx a context object of SVGForm
1552
+ * @param pt a Point object to specify the anchor point
1553
+ * @param txt a string of text to draw
1554
+ */
1555
+ static textElement(ctx: DOMFormContext, pt: PtLike, txt: string): SVGElement {
1556
+ let elem = SVGSpace.svgElement(ctx.group, "text", SVGForm.getID(ctx));
1557
+
1558
+ DOMSpace.setAttr(elem, {
1559
+ "pointer-events": "none",
1560
+ x: pt[0],
1561
+ y: pt[1],
1562
+ dx: 0,
1563
+ dy: 0,
1564
+ class: `pts-svgform pts-text ${ctx.currentClass}`,
1565
+ });
1566
+
1567
+ elem.textContent = txt;
1568
+
1569
+ SVGForm.style(elem, ctx.style);
1570
+
1571
+ return elem;
1572
+ }
1573
+ }