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/README.md +92 -80
- package/dist/index.d.mts +6208 -1254
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +6208 -1254
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9314 -10680
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9266 -10611
- package/dist/index.mjs.map +1 -0
- package/dist/pts.js +9446 -10777
- package/dist/pts.js.map +1 -0
- package/dist/pts.min.js +3 -5
- package/dist/pts.min.js.map +1 -0
- package/package.json +91 -31
- package/src/Canvas.ts +1642 -0
- package/src/Color.ts +1109 -0
- package/src/Create.ts +1547 -0
- package/src/Dom.ts +940 -0
- package/src/Form.ts +312 -0
- package/src/Image.ts +722 -0
- package/src/LinearAlgebra.ts +530 -0
- package/src/Num.ts +1091 -0
- package/src/Op.ts +2127 -0
- package/src/Physics.ts +1233 -0
- package/src/Play.ts +861 -0
- package/src/Pt.ts +1303 -0
- package/src/Space.ts +898 -0
- package/src/Svg.ts +1573 -0
- package/src/Types.ts +301 -0
- package/src/Typography.ts +228 -0
- package/src/UI.ts +757 -0
- package/src/Util.ts +454 -0
- package/src/_module.ts +18 -0
- package/src/_script.ts +94 -0
- package/src/_triangulate.ts +884 -0
- package/src/uheprng.ts +153 -0
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
|
+
}
|