pts 0.12.9 → 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/Canvas.ts ADDED
@@ -0,0 +1,1642 @@
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 { MultiTouchSpace } from "./Space";
4
+ import { VisualForm, Font } from "./Form";
5
+ import { Pt, Group, Bound } from "./Pt";
6
+ import { Const, Util } from "./Util";
7
+ import { Typography as Typo } from "./Typography";
8
+ import { Rectangle } from "./Op";
9
+ import { Img } from "./Image";
10
+ import {
11
+ type PtLike,
12
+ type GroupLike,
13
+ type RenderingContext2D,
14
+ type DefaultFormStyle,
15
+ type PtLikeIterable,
16
+ type PtIterable,
17
+ type CanvasSpaceOptions,
18
+ type TextMeasure,
19
+ type TextVerticalAlign,
20
+ } from "./Types";
21
+
22
+ /**
23
+ * CanvasSpace is an implementation of the abstract class [`Space`](#link). It represents a space for HTML Canvas.
24
+ * Learn more about the concept of Space in [this guide](../guide/Space-0500.html).
25
+ */
26
+ export class CanvasSpace extends MultiTouchSpace {
27
+ protected _canvas: HTMLCanvasElement;
28
+ protected _container!: Element;
29
+
30
+ protected _pixelScale = 1;
31
+ protected _bgcolor = "#e1e9f0";
32
+ protected _ctx: CanvasRenderingContext2D;
33
+
34
+ protected _offscreen = false;
35
+ protected _offCanvas!: HTMLCanvasElement;
36
+ protected _offCtx!: RenderingContext2D;
37
+
38
+ protected _resizeObserver: ResizeObserver | undefined;
39
+ protected _autoResize = true;
40
+ protected _initialResize = false;
41
+
42
+ private _readyObserver: MutationObserver | undefined;
43
+ private _readyTimer: number | undefined;
44
+ private _disposed = false;
45
+ private _ownsCanvas = false;
46
+ private _ownsContainer = false;
47
+
48
+ /**
49
+ * Create a CanvasSpace which represents a HTML Canvas Space
50
+ * @param elem Specify an element by its "id" attribute as string, or by the element object itself. An element can be an existing `<canvas>`, or a `<div>` container in which a new `<canvas>` will be created. If left empty, a `<div id="pt_container"><canvas id="pt" /></div>` will be added to DOM. Use css to customize its appearance if needed.
51
+ * @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 `<canvas>` element when it's appended, which can be traced with `spaceInstance.canvas.addEventListener("ready")`
52
+ * @example `new CanvasSpace( "#myElementID" )`
53
+ */
54
+ constructor(
55
+ elem: string | Element | null = "pt",
56
+ callback?: (bound: Bound, elem: EventTarget) => void,
57
+ ) {
58
+ super();
59
+
60
+ let _selector: Element | null = null;
61
+ let _existed = false;
62
+ this.id = Util.uniqueId();
63
+
64
+ // get selector element depending on whether `elem` is an element id string or an element object
65
+ if (elem instanceof Element) {
66
+ _selector = elem;
67
+ this.id = _selector.id || this.id;
68
+ } else {
69
+ const target = elem || "pt";
70
+ const id = target[0] === "#" || target[0] === "." ? target : "#" + target;
71
+ _selector = document.querySelector(id);
72
+ this.id = id.substr(1);
73
+ }
74
+
75
+ // if selector is not defined, create a default canvas
76
+ if (!_selector) {
77
+ this._ownsContainer = true;
78
+ this._container = this._createElement("div", this.id + "_container");
79
+ this._canvas = this._createElement(
80
+ "canvas",
81
+ this.id,
82
+ ) as HTMLCanvasElement;
83
+ document.body.appendChild(this._container);
84
+
85
+ // if selector is element but not canvas, create a canvas inside it
86
+ } else if (_selector.nodeName.toLowerCase() != "canvas") {
87
+ this._container = _selector;
88
+ this._canvas = this._createElement(
89
+ "canvas",
90
+ this.id + "_canvas",
91
+ ) as HTMLCanvasElement;
92
+ this._initialResize = true;
93
+
94
+ // if selector is an existing canvas
95
+ } else {
96
+ this._canvas = _selector as HTMLCanvasElement;
97
+ this._container = _selector.parentElement!;
98
+ this._autoResize = false;
99
+ _existed = true;
100
+ }
101
+
102
+ // store canvas 2d rendering context
103
+ this._ctx = this._canvas.getContext("2d")!;
104
+
105
+ // if we created the canvas, add it to the container and observe mutation for readiness
106
+ if (!_existed) {
107
+ this._ownsCanvas = true;
108
+ this._readyObserver = new MutationObserver((mutations) => {
109
+ mutations.forEach((mutation) => {
110
+ if (mutation.type === "childList" && mutation.addedNodes.length) {
111
+ for (let node of mutation.addedNodes) {
112
+ if (node === this._canvas) {
113
+ this._ready(callback);
114
+ this._readyObserver?.disconnect();
115
+ this._readyObserver = undefined;
116
+ return;
117
+ }
118
+ }
119
+ }
120
+ });
121
+ });
122
+
123
+ this._readyObserver.observe(this._container, { childList: true });
124
+ this._container.appendChild(this._canvas);
125
+ } else {
126
+ // Wait one turn for .setup() to be called before firing ready event
127
+ this._readyTimer = window.setTimeout(() => this._ready(callback), 100);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Helper function to create a DOM element
133
+ * @param elem element tag name
134
+ * @param id element id attribute
135
+ */
136
+ protected _createElement(elem = "div", id: string) {
137
+ const d = document.createElement(elem);
138
+ d.setAttribute("id", id);
139
+ return d;
140
+ }
141
+
142
+ /**
143
+ * Handle callbacks after element is mounted in DOM
144
+ * @param callback
145
+ */
146
+ private _ready(callback?: (bound: Bound, elem: EventTarget) => void) {
147
+ if (this._disposed) return;
148
+
149
+ this._readyTimer = undefined;
150
+ if (!this._container)
151
+ throw new Error(`Cannot initiate #${this.id} element`);
152
+
153
+ this._isReady = true;
154
+
155
+ this._resizeHandler(null);
156
+
157
+ this.clear(this._bgcolor);
158
+ this._canvas.dispatchEvent(new Event("ready"));
159
+
160
+ for (const k in this.players) {
161
+ if (this.players.hasOwnProperty(k)) {
162
+ if (this.players[k].start)
163
+ this.players[k].start(this.bound.clone(), this);
164
+ }
165
+ }
166
+
167
+ this._pointer = this.center;
168
+ this._initialResize = false; // unset
169
+
170
+ if (callback) callback(this.bound, this._canvas);
171
+ }
172
+
173
+ /**
174
+ * Set up various options for CanvasSpace. The `opt` parameter is an object with the following fields. This is usually set during instantiation, eg `new CanvasSpace(...).setup( { opt } )`
175
+ * @param opt a [`CanvasSpaceOptions`](#link) object with optional settings, ie `{ bgcolor:string, resize:boolean, retina:boolean, offscreen:boolean, pixelDensity:number }`. Note that omitting `bgcolor` sets a transparent background (a long-standing behavior that differs from `DOMSpace.setup`, which keeps the current background when the option is absent).
176
+ * @example `space.setup({ bgcolor: "#f00", retina: true, resize: true })`
177
+ */
178
+ setup(opt: CanvasSpaceOptions): this {
179
+ this._bgcolor = opt.bgcolor ? opt.bgcolor : "transparent";
180
+
181
+ this.autoResize = opt.resize != undefined ? opt.resize : false;
182
+
183
+ if (opt.retina !== false) {
184
+ const r1 = window ? window.devicePixelRatio || 1 : 1;
185
+ this._pixelScale = Math.max(1, r1);
186
+ }
187
+
188
+ if (opt.offscreen) {
189
+ this._offscreen = true;
190
+ this._offCanvas = this._createElement(
191
+ "canvas",
192
+ this.id + "_offscreen",
193
+ ) as HTMLCanvasElement;
194
+ this._offCtx = this._offCanvas.getContext("2d")!;
195
+ } else {
196
+ this._offscreen = false;
197
+ }
198
+
199
+ if (opt.pixelDensity) {
200
+ this._pixelScale = opt.pixelDensity;
201
+ }
202
+
203
+ return this;
204
+ }
205
+
206
+ /**
207
+ * Set whether the canvas element should resize when its container is resized.
208
+ * @param auto a boolean value indicating if auto size is set
209
+ */
210
+ set autoResize(auto) {
211
+ if (this._autoResize === auto && (!auto || this._resizeObserver)) return;
212
+
213
+ if (this._resizeObserver) {
214
+ this._resizeObserver.disconnect();
215
+ this._resizeObserver = undefined;
216
+ }
217
+
218
+ this._autoResize = auto;
219
+
220
+ if (auto) {
221
+ this._resizeObserver = new ResizeObserver((entries) => {
222
+ this._resizeHandler(null);
223
+ });
224
+ this._resizeObserver.observe(this._container);
225
+ }
226
+ }
227
+ get autoResize(): boolean {
228
+ return this._autoResize;
229
+ }
230
+
231
+ /**
232
+ * This overrides Space's `resize` function. It's used as a callback function for window's resize event and not usually called directly. You can keep track of resize events with `resize: (bound ,evt)` callback in your player objects.
233
+ * @param b a Bound object to resize to
234
+ * @param evt Optionally pass a resize event
235
+ * @see Space.add
236
+ */
237
+ resize(b: Bound, evt?: Event | null): this {
238
+ this.bound = b;
239
+
240
+ // The buffer needs whole device pixels, so round up to cover the bound.
241
+ this._canvas.width = Math.ceil(this.bound.size.x) * this._pixelScale;
242
+ this._canvas.height = Math.ceil(this.bound.size.y) * this._pixelScale;
243
+ // The CSS size must not round up: a canvas even a fraction of a pixel
244
+ // larger than its container overflows it, and on systems whose scrollbars
245
+ // take up layout space that summons scrollbars, shrinking the container —
246
+ // which the resize observer answers by shrinking the canvas, letting the
247
+ // scrollbars retract, growing the container again, and so on forever. The
248
+ // oscillation clears the canvas on every pass (it looks blank) and the
249
+ // endless relayout can eventually crash the tab.
250
+ this._canvas.style.width = this.bound.size.x + "px";
251
+ this._canvas.style.height = this.bound.size.y + "px";
252
+
253
+ if (this._offscreen) {
254
+ this._offCanvas.width = Math.ceil(this.bound.size.x) * this._pixelScale;
255
+ this._offCanvas.height = Math.ceil(this.bound.size.y) * this._pixelScale;
256
+ // this._offCanvas.style.width = Math.floor(this.bound.size.x) + "px";
257
+ // this._offCanvas.style.height = Math.floor(this.bound.size.y) + "px";
258
+ }
259
+
260
+ if (this._pixelScale != 1) {
261
+ this._ctx.scale(this._pixelScale, this._pixelScale);
262
+
263
+ if (this._offscreen) {
264
+ this._offCtx.scale(this._pixelScale, this._pixelScale);
265
+ }
266
+ }
267
+
268
+ // Assigning canvas width/height resets the context's entire state to
269
+ // defaults, so any cached style values no longer describe the context —
270
+ // without this, a form's style write matching the stale cache would be
271
+ // skipped and the context would stay at its black defaults.
272
+ CanvasForm.resetStyleCache(this._ctx);
273
+ if (this._offscreen) CanvasForm.resetStyleCache(this._offCtx);
274
+
275
+ for (const k in this.players) {
276
+ if (this.players.hasOwnProperty(k)) {
277
+ const p = this.players[k];
278
+ if (p.resize) p.resize(this.bound, evt);
279
+ }
280
+ }
281
+
282
+ this.render(this._ctx);
283
+
284
+ // if it's a valid resize event and space is not playing, repaint the canvas once
285
+ if (evt && !this.isPlaying) this.playOnce(0);
286
+
287
+ return this;
288
+ }
289
+
290
+ /**
291
+ * Window resize handling
292
+ * @param evt
293
+ */
294
+ protected _resizeHandler(evt: Event | null) {
295
+ const b =
296
+ this._autoResize || this._initialResize
297
+ ? this._container.getBoundingClientRect()
298
+ : this._canvas.getBoundingClientRect();
299
+
300
+ if (b) {
301
+ const box = Bound.fromBoundingRect(b);
302
+
303
+ // Need to compute offset from window scroll. See outerBound calculation in Space's _mouseAction
304
+ box.center = box.center.add(window?.scrollX || 0, window?.scrollY || 0);
305
+ this.resize(box, evt);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Set a background color for this canvas. Alternatively, you may use `clear()` function.
311
+ @param bg background color as hex or rgba string
312
+ */
313
+ set background(bg: string) {
314
+ this._bgcolor = bg;
315
+ }
316
+ get background(): string {
317
+ return this._bgcolor;
318
+ }
319
+
320
+ /**
321
+ * `pixelScale` property returns a number that let you determine if the screen is "retina" (when value >= 2)
322
+ */
323
+ public get pixelScale(): number {
324
+ return this._pixelScale;
325
+ }
326
+
327
+ /**
328
+ * Check if an offscreen canvas is created
329
+ */
330
+ public get hasOffscreen(): boolean {
331
+ return this._offscreen;
332
+ }
333
+
334
+ /**
335
+ * Get the rendering context of offscreen canvas (if created via `setup()`)
336
+ */
337
+ public get offscreenCtx(): RenderingContext2D {
338
+ return this._offCtx;
339
+ }
340
+
341
+ /**
342
+ * Get the offscreen canvas element
343
+ */
344
+ public get offscreenCanvas(): HTMLCanvasElement {
345
+ return this._offCanvas;
346
+ }
347
+
348
+ /**
349
+ * Get a new `CanvasForm` for drawing
350
+ * @see `CanvasForm`
351
+ */
352
+ public getForm(): CanvasForm {
353
+ return new CanvasForm(this);
354
+ }
355
+
356
+ /**
357
+ * Get the html canvas element
358
+ */
359
+ get element(): HTMLCanvasElement {
360
+ return this._canvas;
361
+ }
362
+
363
+ /**
364
+ * Get the parent element that contains the canvas element
365
+ */
366
+ get parent(): Element {
367
+ return this._container;
368
+ }
369
+
370
+ /**
371
+ * A property to indicate if the Space is ready
372
+ */
373
+ get ready(): boolean {
374
+ return this._isReady;
375
+ }
376
+
377
+ /**
378
+ * Get the rendering context of canvas
379
+ * @example `form.ctx.clip()`
380
+ */
381
+ public get ctx(): CanvasRenderingContext2D {
382
+ return this._ctx;
383
+ }
384
+
385
+ /**
386
+ * Clear the canvas with its background color. Overrides Space's `clear` function.
387
+ * @param bg Optionally specify a custom background color in hex or rgba string, or "transparent". If not defined, it will use its `bgcolor` property as background color to clear the canvas.
388
+ */
389
+ clear(bg?: string): this {
390
+ if (bg) this._bgcolor = bg;
391
+ const lastColor = this._ctx.fillStyle;
392
+ const px = Math.ceil(this.pixelScale);
393
+
394
+ if (!this._bgcolor || this._bgcolor === "transparent") {
395
+ this._ctx.clearRect(
396
+ -px,
397
+ -px,
398
+ this._canvas.width + px,
399
+ this._canvas.height + px,
400
+ );
401
+ } else {
402
+ // semi-transparent bg needs to be cleared first
403
+ if (
404
+ this._bgcolor.indexOf("rgba") === 0 ||
405
+ (this._bgcolor.length === 9 && this._bgcolor.indexOf("#") === 0)
406
+ ) {
407
+ this._ctx.clearRect(
408
+ -px,
409
+ -px,
410
+ this._canvas.width + px,
411
+ this._canvas.height + px,
412
+ );
413
+ }
414
+ this._ctx.fillStyle = this._bgcolor;
415
+ this._ctx.fillRect(
416
+ -px,
417
+ -px,
418
+ this._canvas.width + px,
419
+ this._canvas.height + px,
420
+ );
421
+ }
422
+
423
+ this._ctx.fillStyle = lastColor;
424
+ return this;
425
+ }
426
+
427
+ /**
428
+ * Similiar to `clear()` but clear the offscreen canvas instead
429
+ * @param bg Optionally specify a custom background color in hex or rgba string, or "transparent". If not defined, it will use its `bgcolor` property as background color to clear the canvas.
430
+ */
431
+ clearOffscreen(bg?: string | null): this {
432
+ if (this._offscreen) {
433
+ const px = Math.ceil(this.pixelScale);
434
+ if (bg) {
435
+ this._offCtx.fillStyle = bg;
436
+ this._offCtx.fillRect(
437
+ -px,
438
+ -px,
439
+ this._canvas.width + px,
440
+ this._canvas.height + px,
441
+ );
442
+ } else {
443
+ this._offCtx.clearRect(
444
+ -px,
445
+ -px,
446
+ this._offCanvas.width + px,
447
+ this._offCanvas.height + px,
448
+ );
449
+ }
450
+ }
451
+ return this;
452
+ }
453
+
454
+ /**
455
+ * Main animation function.
456
+ * @param time current time
457
+ */
458
+ protected playItems(time: number) {
459
+ if (this._isReady) {
460
+ this._ctx.save();
461
+ if (this._offscreen) this._offCtx.save();
462
+ super.playItems(time);
463
+ this._ctx.restore();
464
+ if (this._offscreen) this._offCtx.restore();
465
+ // restore() reverted the context's style state without going through the
466
+ // forms' style cache, so the cached values no longer describe the context
467
+ CanvasForm.resetStyleCache(this._ctx);
468
+ if (this._offscreen) CanvasForm.resetStyleCache(this._offCtx);
469
+ this.render(this._ctx);
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Dispose of browser resources held by this space and remove all players. Call this before unmounting the canvas.
475
+ */
476
+ dispose(): this {
477
+ if (this._disposed) return this;
478
+ this._disposed = true;
479
+
480
+ if (this._readyTimer !== undefined) {
481
+ window.clearTimeout(this._readyTimer);
482
+ this._readyTimer = undefined;
483
+ }
484
+ if (this._readyObserver) {
485
+ this._readyObserver.disconnect();
486
+ this._readyObserver = undefined;
487
+ }
488
+ if (this._resizeObserver) {
489
+ this._resizeObserver.disconnect();
490
+ this._resizeObserver = undefined;
491
+ }
492
+
493
+ this._unbindAll();
494
+ this._cancelAnimation();
495
+ this.removeAll();
496
+ this._isReady = false;
497
+ if (this._ownsCanvas) this._canvas.remove();
498
+ if (this._ownsContainer && this._container.childNodes.length === 0)
499
+ this._container.remove();
500
+
501
+ return this;
502
+ }
503
+
504
+ /**
505
+ * Get a [`MediaRecorder`](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder) to record the current CanvasSpace. You can then call its `start()` function to start recording, and `stop()` to either download the video file or handle the blob data in the callback function you provided.
506
+ * @param downloadOrCallback Either `true` to download the video, or provide a callback function to handle the Blob data, when recording is completed.
507
+ * @param filetype video format. Default is "webm".
508
+ * @param bitrate bitrate per second
509
+ * @example `let rec = space.recorder(true); rec.start(); setTimeout( () => rec.stop(), 5000); // record 5s of video and download the file`
510
+ */
511
+ recorder(
512
+ downloadOrCallback: boolean | ((blobURL: string) => {}),
513
+ filetype: string = "webm",
514
+ bitrate: number = 15000000,
515
+ ): MediaRecorder {
516
+ const stream = this._canvas.captureStream();
517
+ const recorder = new MediaRecorder(stream, {
518
+ mimeType: `video/${filetype}`,
519
+ bitsPerSecond: bitrate,
520
+ });
521
+
522
+ recorder.ondataavailable = function (d) {
523
+ const url = URL.createObjectURL(
524
+ new Blob([d.data], { type: `video/${filetype}` }),
525
+ );
526
+
527
+ if (typeof downloadOrCallback === "function") {
528
+ downloadOrCallback(url);
529
+ } else if (downloadOrCallback) {
530
+ const a = document.createElement("a");
531
+ a.href = url;
532
+ a.download = `canvas_video.${filetype}`;
533
+ a.click();
534
+ a.remove();
535
+ // the download has started from the blob by now; release the URL
536
+ // (callback consumers own the URL and release it themselves)
537
+ setTimeout(() => URL.revokeObjectURL(url), 100);
538
+ }
539
+ };
540
+
541
+ return recorder;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * CanvasForm is an implementation of abstract class [`VisualForm`](#link). It provide methods to express Pts on [`CanvasSpace`](#link).
547
+ * You may extend CanvasForm to implement your own expressions for CanvasSpace.
548
+ */
549
+ // Last-written style values per rendering context, so identical values are
550
+ // not re-applied (color parsing in particular is costly). Keyed by context —
551
+ // not by form — because multiple forms can share one context (see `reset()`),
552
+ // and a per-form cache would skip writes another form made stale.
553
+ const _ctxStyleCache = new WeakMap<object, Record<string, unknown>>();
554
+
555
+ export class CanvasForm<
556
+ S extends MultiTouchSpace = CanvasSpace,
557
+ > extends VisualForm {
558
+ protected _space!: CanvasSpace;
559
+ protected _ctx!: RenderingContext2D;
560
+ protected _estimateTextWidth: TextMeasure | undefined;
561
+ protected _estimateMode: "sample" | "char" | undefined;
562
+
563
+ // the shared cache object for this._ctx, revalidated only when the context
564
+ // changes so the hot path avoids a WeakMap lookup per style write
565
+ private _styleCache: Record<string, unknown> | null = null;
566
+ private _styleCacheCtx: RenderingContext2D | null = null;
567
+
568
+ /** Get the style cache shared by all forms drawing on this context. */
569
+ protected _cacheForCtx(): Record<string, unknown> {
570
+ if (this._styleCacheCtx !== this._ctx) {
571
+ let cache = _ctxStyleCache.get(this._ctx);
572
+ if (!cache) {
573
+ cache = {};
574
+ _ctxStyleCache.set(this._ctx, cache);
575
+ }
576
+ this._styleCache = cache;
577
+ this._styleCacheCtx = this._ctx;
578
+ }
579
+ return this._styleCache!;
580
+ }
581
+
582
+ /**
583
+ * Forget the cached style values for a rendering context, so every
584
+ * subsequent style write applies. Call this after anything that resets or
585
+ * desyncs a context's state outside a form — most commonly assigning a
586
+ * canvas's `width` or `height`, which resets the context to its defaults.
587
+ * (Within a form, [`CanvasForm.reset`](#link) is the equivalent recovery.)
588
+ * @param ctx the rendering context to forget
589
+ */
590
+ static resetStyleCache(ctx: RenderingContext2D | object): void {
591
+ // clear the shared object in place: forms cache a reference to it, so
592
+ // deleting the WeakMap entry would leave them holding stale values
593
+ const cache = _ctxStyleCache.get(ctx);
594
+ if (cache) {
595
+ for (const k in cache) delete cache[k];
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Write a context style property only when it differs from the last value
601
+ * written to this context. After setting style properties directly on
602
+ * [`CanvasForm.ctx`](#link), call [`CanvasForm.reset`](#link) to resync.
603
+ */
604
+ protected _set(key: string, value: unknown): void {
605
+ const cache = this._cacheForCtx();
606
+ if (cache[key] !== value) {
607
+ cache[key] = value;
608
+ (this._ctx as any)[key] = value;
609
+ }
610
+ }
611
+
612
+ /**
613
+ * store common styles so that they can be restored to canvas context when using multiple forms. See `reset()`.
614
+ */
615
+ protected _style: DefaultFormStyle = {
616
+ fillStyle: "#f03",
617
+ strokeStyle: "#fff",
618
+ lineWidth: 1,
619
+ lineJoin: "bevel",
620
+ lineCap: "butt",
621
+ globalAlpha: 1,
622
+ };
623
+
624
+ /**
625
+ * Create a new CanvasForm. You may also use [`CanvasSpace.getForm()`](#link) to get the default form.
626
+ * @param space an instance of CanvasSpace, or a rendering context. Passing a context is the
627
+ * renderer extension point: any object implementing the context surface documented in
628
+ * [`SVGContext2D`](#link) (the reference implementation) receives this form's full drawing
629
+ * API — this is how SVG output works, and how custom renderers can be built.
630
+ */
631
+ constructor(space?: CanvasSpace | RenderingContext2D) {
632
+ super();
633
+
634
+ // allow for undefined context to support custom contexts via subclassing.
635
+ if (!space) return this;
636
+
637
+ const _setup = (ctx: RenderingContext2D) => {
638
+ this._ctx = ctx;
639
+ this._set("fillStyle", this._style.fillStyle);
640
+ this._set("strokeStyle", this._style.strokeStyle);
641
+ this._set("lineJoin", "bevel");
642
+ this._set("font", this._font.value);
643
+ this._ready = true;
644
+ };
645
+
646
+ if (space instanceof CanvasSpace) {
647
+ this._space = space;
648
+ if (this._space.ready && this._space.ctx) {
649
+ _setup(this._space.ctx);
650
+ } else {
651
+ this._space.add({
652
+ start: () => {
653
+ _setup(this._space.ctx);
654
+ },
655
+ });
656
+ }
657
+ } else {
658
+ _setup(space);
659
+ }
660
+ }
661
+
662
+ /**
663
+ * get the CanvasSpace instance that this form is associated with
664
+ */
665
+ get space(): S {
666
+ // subclasses for other backends (eg, SVGForm) narrow S to their own space type
667
+ return this._space as unknown as S;
668
+ }
669
+
670
+ /**
671
+ * Get the rendering context of canvas to perform other canvas functions.
672
+ * @example `form.ctx.clip()`
673
+ */
674
+ get ctx(): RenderingContext2D {
675
+ return this._ctx;
676
+ }
677
+
678
+ /**
679
+ * Toggle whether to draw on offscreen canvas (if offscreen is set in CanvasSpace)
680
+ * @param off if `true`, draw on offscreen canvas instead of the visible canvas. Default is `true`
681
+ * @param clear optionally provide a valid color string to fill a bg color. see CanvasSpace's `clearOffscreen` function.
682
+ */
683
+ useOffscreen(off: boolean = true, clear: boolean | string = false) {
684
+ if (clear)
685
+ this._space.clearOffscreen(typeof clear == "string" ? clear : null);
686
+ this._ctx =
687
+ this._space.hasOffscreen && off
688
+ ? this._space.offscreenCtx
689
+ : this._space.ctx;
690
+ return this;
691
+ }
692
+
693
+ /**
694
+ * Render the offscreen canvas's content on the visible canvas
695
+ * @param offset Optional offset on the top-left position when drawing on the visible canvas
696
+ */
697
+ renderOffscreen(offset: PtLike = [0, 0]) {
698
+ if (this._space.hasOffscreen) {
699
+ this._space.ctx.drawImage(
700
+ this._space.offscreenCanvas,
701
+ offset[0],
702
+ offset[1],
703
+ this._space.width,
704
+ this._space.height,
705
+ );
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Set current alpha value.
711
+ * @example `form.alpha(0.6)`
712
+ * @param a alpha value between 0 and 1
713
+ */
714
+ alpha(a: number): this {
715
+ this._set("globalAlpha", a);
716
+ this._style.globalAlpha = a;
717
+ return this;
718
+ }
719
+
720
+ /**
721
+ * Set current fill style. Provide a valid color string such as `"#FFF"` or `"rgba(255,0,100,0.5)"` or `false` to specify no fill color.
722
+ * @example `form.fill("#F90")`, `form.fill("rgba(0,0,0,.5")`, `form.fill(false)`
723
+ * @param c fill color which can be as color, gradient, or pattern. (See [canvas documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle))
724
+ */
725
+ fill(c: string | boolean | CanvasGradient | CanvasPattern): this {
726
+ if (typeof c == "boolean") {
727
+ this.filled = c;
728
+ } else {
729
+ this.filled = true;
730
+ this._style.fillStyle = c;
731
+ this._set("fillStyle", c);
732
+ }
733
+ return this;
734
+ }
735
+
736
+ /**
737
+ * Set current fill style and remove stroke style.
738
+ * @param c fill color which can be as color, gradient, or pattern. (See [canvas documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle))
739
+ */
740
+ fillOnly(c: string | boolean | CanvasGradient | CanvasPattern): this {
741
+ this.stroke(false);
742
+ return this.fill(c);
743
+ }
744
+
745
+ /**
746
+ * Set current stroke style. Provide a valid color string or `false` to specify no stroke color.
747
+ * @example `form.stroke("#F90")`, `form.stroke("rgba(0,0,0,.5")`, `form.stroke(false)`, `form.stroke("#000", 0.5, 'round', 'square')`
748
+ * @param c stroke color which can be as color, gradient, or pattern. (See [canvas documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeStyle))
749
+ * @param width Optional value (can be floating point) to set line width
750
+ * @param linejoin Optional string to set line joint style. Can be "miter", "bevel", or "round".
751
+ * @param linecap Optional string to set line cap style. Can be "butt", "round", or "square".
752
+ */
753
+ stroke(
754
+ c: string | boolean | CanvasGradient | CanvasPattern,
755
+ width?: number,
756
+ linejoin?: CanvasLineJoin,
757
+ linecap?: CanvasLineCap,
758
+ ): this {
759
+ if (typeof c == "boolean") {
760
+ this.stroked = c;
761
+ } else {
762
+ this.stroked = true;
763
+ this._style.strokeStyle = c;
764
+ this._set("strokeStyle", c);
765
+ if (width) {
766
+ this._set("lineWidth", width);
767
+ this._style.lineWidth = width;
768
+ }
769
+ if (linejoin) {
770
+ this._set("lineJoin", linejoin);
771
+ this._style.lineJoin = linejoin;
772
+ }
773
+ if (linecap) {
774
+ this._set("lineCap", linecap);
775
+ this._style.lineCap = linecap;
776
+ }
777
+ }
778
+ return this;
779
+ }
780
+
781
+ /**
782
+ * Set stroke style and remove fill style.
783
+ * @param c stroke color which can be as color, gradient, or pattern. (See [canvas documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeStyle))
784
+ * @param width Optional value (can be floating point) to set line width
785
+ * @param linejoin Optional string to set line joint style. Can be "miter", "bevel", or "round".
786
+ * @param linecap Optional string to set line cap style. Can be "butt", "round", or "square".
787
+ */
788
+ strokeOnly(
789
+ c: string | boolean | CanvasGradient | CanvasPattern,
790
+ width?: number,
791
+ linejoin?: CanvasLineJoin,
792
+ linecap?: CanvasLineCap,
793
+ ): this {
794
+ this.fill(false);
795
+ return this.stroke(c, width, linejoin, linecap);
796
+ }
797
+
798
+ /**
799
+ * A convenient function to apply fill and/or stroke after custom drawings using canvas context (eg, `form.ctx.ellipse(...)`).
800
+ * You don't need to call this function if you're using Pts' drawing functions like `form.point` or `form.rect`
801
+ * @param filled apply fill when set to `true`
802
+ * @param stroked apply stroke when set to `true`
803
+ * @param strokeWidth optionally set a stroke width
804
+ * @example `form.ctx.beginPath(); form.ctx.ellipse(...); form.applyFillStroke();`
805
+ */
806
+ applyFillStroke(
807
+ filled: boolean | string = true,
808
+ stroked: boolean | string = true,
809
+ strokeWidth: number = 1,
810
+ ) {
811
+ if (filled) {
812
+ if (typeof filled === "string") this.fill(filled);
813
+ this._ctx.fill();
814
+ }
815
+
816
+ if (stroked) {
817
+ if (typeof stroked === "string") this.stroke(stroked, strokeWidth);
818
+ this._ctx.stroke();
819
+ }
820
+
821
+ return this;
822
+ }
823
+
824
+ /**
825
+ * This function takes an array of gradient colors, and returns a function to define the areas of the gradient fill. See demo code in [CanvasForm.gradient](https://ptsjs.org/demo/?name=canvasform.textBox).
826
+ * @param stops an array of gradient stops. This can be an array of colors `["#f00", "#0f0", ...]` for evenly distributed gradient, or an array of [stop, color] like `[[0.1, "#f00"], [0.7, "#0f0"]]`
827
+ * @returns a function that takes 1 or 2 `Group` as parameters. Use a single `Group` to specify a rectangular area for linear gradient, or use 2 `Groups` to specify 2 `Circles` for radial gradient.
828
+ * @example `c1 = Circle.fromCenter(...); grad = form.gradient(["#f00", "#00f"]); form.fill( grad( c1, c2 ) ).circle( c1 )`
829
+ */
830
+ gradient(
831
+ stops: [number, string][] | string[],
832
+ ): (area1: GroupLike, area2?: GroupLike) => CanvasGradient {
833
+ const vals: [number, string][] = [];
834
+ // copy before padding so the caller's array is never mutated
835
+ if (stops.length < 2) {
836
+ stops = [...stops, [0.99, "#000"], [1, "#000"]] as
837
+ [number, string][] | string[];
838
+ }
839
+
840
+ for (let i = 0, len = stops.length; i < len; i++) {
841
+ const t: number =
842
+ typeof stops[i] === "string"
843
+ ? i * (1 / (stops.length - 1))
844
+ : (stops[i][0] as number);
845
+ const v: string =
846
+ typeof stops[i] === "string"
847
+ ? (stops[i] as string)
848
+ : (stops[i][1] as string);
849
+ vals.push([t, v]);
850
+ }
851
+
852
+ return (area1: GroupLike, area2?: GroupLike) => {
853
+ const grad = area2
854
+ ? this._ctx.createRadialGradient(
855
+ area1[0][0],
856
+ area1[0][1],
857
+ Math.abs(area1[1][0]),
858
+ area2[0][0],
859
+ area2[0][1],
860
+ Math.abs(area2[1][0]),
861
+ )
862
+ : this._ctx.createLinearGradient(
863
+ area1[0][0],
864
+ area1[0][1],
865
+ area1[1][0],
866
+ area1[1][1],
867
+ );
868
+
869
+ for (let i = 0, len = vals.length; i < len; i++) {
870
+ grad.addColorStop(vals[i][0], vals[i][1]);
871
+ }
872
+
873
+ return grad;
874
+ };
875
+ }
876
+
877
+ /**
878
+ * Set composite operation (also known as blend mode). You can also call this function without parameters to get back to default 'source-over' mode. See [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation) for the full list of operations you can use.
879
+ * @param mode a composite operation such as 'lighten', 'multiply', 'overlay', and 'color-burn'.
880
+ */
881
+ composite(mode: GlobalCompositeOperation = "source-over"): this {
882
+ this._set("globalCompositeOperation", mode);
883
+ return this;
884
+ }
885
+
886
+ /**
887
+ * Create a clipping mask from the current path. See [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/clip) for details.
888
+ */
889
+ clip(): this {
890
+ this._ctx.clip();
891
+ return this;
892
+ }
893
+
894
+ /**
895
+ * Activate dashed stroke and set dash style. You can customize the segments and offset.
896
+ * @example `form.dash()`, `form.dash([5, 10])`, `form.dash([5, 5], 5)`, `form.dash(false)`
897
+ * @param segments Dash segments. Defaults to `true` which corresponds to `[5, 5]`. Pass `false` to deactivate dashes. (See [canvas documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setLineDash))
898
+ * @param offset Dash offset. Defaults to 0. (See [canvas documentation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineDashOffset)
899
+ */
900
+ dash(segments: PtLike | boolean = true, offset: number = 0): this {
901
+ // dedupe via a compact key since getLineDash() allocates
902
+ const cache = this._cacheForCtx();
903
+ if (segments === false || (segments !== true && segments.length === 0)) {
904
+ // false or [], deactivate dashed strokes
905
+ if (cache.dash !== "/0") {
906
+ cache.dash = "/0";
907
+ this._ctx.setLineDash([]);
908
+ this._ctx.lineDashOffset = 0;
909
+ }
910
+ } else {
911
+ const seg: PtLike = segments === true ? [5, 5] : segments;
912
+ const key = `${Array.prototype.join.call(seg, ",")}/${offset}`;
913
+ if (cache.dash !== key) {
914
+ cache.dash = key;
915
+ this._ctx.setLineDash(seg as number[]);
916
+ this._ctx.lineDashOffset = offset;
917
+ }
918
+ }
919
+ return this;
920
+ }
921
+
922
+ /**
923
+ * Set the current font.
924
+ * @param sizeOrFont either a number to specify font-size, or a `Font` object to specify all font properties
925
+ * @param weight Optional font-weight string such as "bold"
926
+ * @param style Optional font-style string such as "italic"
927
+ * @param lineHeight Optional line-height number suchas 1.5
928
+ * @param family Optional font-family such as "Helvetica, sans-serif"
929
+ * @example `form.font( myFont )`, `form.font(14, "bold")`
930
+ */
931
+ font(
932
+ sizeOrFont: number | Font,
933
+ weight?: string,
934
+ style?: string,
935
+ lineHeight?: number,
936
+ family?: string,
937
+ ): this {
938
+ if (typeof sizeOrFont == "number") {
939
+ this._font.size = sizeOrFont;
940
+ if (family) this._font.face = family;
941
+ if (weight) this._font.weight = weight;
942
+ if (style) this._font.style = style;
943
+ if (lineHeight) this._font.lineHeight = lineHeight;
944
+ } else {
945
+ this._font = sizeOrFont;
946
+ }
947
+
948
+ this._set("font", this._font.value);
949
+
950
+ // If using estimate, reapply the same mode with the new font's metrics.
951
+ if (this._estimateMode) this.fontWidthEstimate(this._estimateMode);
952
+
953
+ return this;
954
+ }
955
+
956
+ /**
957
+ * Set whether to use html canvas' [`measureText`](#link) function, or a faster but less accurate estimate.
958
+ * @param estimate `false` to use ctx.measureText; `true` or `"sample"` to use a sampled-average estimator (fastest); `"char"` to use a per-character width cache (nearly as accurate as measureText for most texts, and much faster after warmup)
959
+ */
960
+ fontWidthEstimate(estimate: boolean | "sample" | "char" = true): this {
961
+ if (!estimate) {
962
+ this._estimateMode = undefined;
963
+ this._estimateTextWidth = undefined;
964
+ } else {
965
+ const measure: TextMeasure = (c: string) =>
966
+ this._ctx.measureText(c).width;
967
+ this._estimateMode = estimate === true ? "sample" : estimate;
968
+ this._estimateTextWidth =
969
+ this._estimateMode === "char"
970
+ ? Typo.charWidthCache(measure)
971
+ : Typo.textWidthEstimator(measure);
972
+ }
973
+ return this;
974
+ }
975
+
976
+ /**
977
+ * Get the width of this text. It will return an actual measurement or an estimate based on [`fontWidthEstimate`](#link) setting. Default is an actual measurement using canvas context's measureText.
978
+ * @param c a string of text contents
979
+ */
980
+ getTextWidth(c: string): number {
981
+ return !this._estimateTextWidth
982
+ ? this._ctx.measureText(c).width
983
+ : this._estimateTextWidth(c);
984
+ }
985
+
986
+ /**
987
+ * Truncate text to fit width.
988
+ * @param str text to truncate
989
+ * @param width width to fit
990
+ * @param tail text to indicate overflow such as "...". Default is empty "".
991
+ */
992
+ protected _textTruncate(
993
+ str: string,
994
+ width: number,
995
+ tail: string = "",
996
+ hint?: number,
997
+ ): [string, number] {
998
+ return Typo.truncate(this.getTextWidth.bind(this), str, width, tail, hint);
999
+ }
1000
+
1001
+ /**
1002
+ * Align text within a rectangle box.
1003
+ * @param box a Group or an Iterable<PtLike> that defines a rectangular box
1004
+ * @param vertical a string that specifies the vertical alignment in the box: "top", "bottom", "middle", "start", "end"
1005
+ * @param offset Optional offset from the edge (like padding)
1006
+ * @param center Optional center position
1007
+ */
1008
+ protected _textAlign(
1009
+ box: PtLikeIterable,
1010
+ vertical: TextVerticalAlign,
1011
+ offset?: PtLike,
1012
+ center?: Pt,
1013
+ ): Pt | undefined {
1014
+ const _box = Util.iterToArray(box);
1015
+ if (!Util.arrayCheck(_box)) return;
1016
+
1017
+ if (!center) center = Rectangle.center(_box);
1018
+
1019
+ let px = _box[0][0];
1020
+ if (this._ctx.textAlign == "end" || this._ctx.textAlign == "right") {
1021
+ px = _box[1][0];
1022
+ } else if (
1023
+ this._ctx.textAlign == "center" ||
1024
+ // @ts-expect-error CanvasTextAlign omits the legacy "middle" value supported here.
1025
+ this._ctx.textAlign == "middle"
1026
+ ) {
1027
+ px = center[0];
1028
+ }
1029
+
1030
+ let py = center[1];
1031
+ if (vertical == "top" || vertical == "start") {
1032
+ py = _box[0][1];
1033
+ } else if (vertical == "end" || vertical == "bottom") {
1034
+ py = _box[1][1];
1035
+ }
1036
+
1037
+ return offset ? new Pt(px + offset[0], py + offset[1]) : new Pt(px, py);
1038
+ }
1039
+
1040
+ /**
1041
+ * Reset the rendering context's common styles to this form's styles. This supports using multiple forms on the same canvas context.
1042
+ */
1043
+ reset(): this {
1044
+ // force-write and resync the cache — this is the documented recovery
1045
+ // point after styles were set directly on the context
1046
+ const cache = this._cacheForCtx();
1047
+ for (const k in this._style) {
1048
+ if (this._style.hasOwnProperty(k)) {
1049
+ (this._ctx as any)[k] = (this._style as any)[k];
1050
+ cache[k] = (this._style as any)[k];
1051
+ }
1052
+ }
1053
+ // same default as a fresh VisualForm (14px sans-serif)
1054
+ this._font = new Font(14, "sans-serif");
1055
+ this._ctx.font = this._font.value;
1056
+ cache.font = this._font.value;
1057
+ return this;
1058
+ }
1059
+
1060
+ protected _paint() {
1061
+ if (this._filled) this._ctx.fill();
1062
+ if (this._stroked) this._ctx.stroke();
1063
+ }
1064
+
1065
+ /**
1066
+ * A static function to draw a point.
1067
+ * @param ctx canvas rendering context
1068
+ * @param p a Pt object
1069
+ * @param radius radius of the point. Default is 5.
1070
+ * @param shape The shape of the point. Defaults to "square", but it can be "circle" or a custom shape function in your own implementation.
1071
+ * @example `form.point( p )`, `form.point( p, 10, "circle" )`
1072
+ */
1073
+ static point(
1074
+ ctx: RenderingContext2D,
1075
+ p: PtLike,
1076
+ radius: number = 5,
1077
+ shape: string = "square",
1078
+ ) {
1079
+ if (!p) return;
1080
+ if (!(CanvasForm as any)[shape])
1081
+ throw new Error(`${shape} is not a static function of CanvasForm`);
1082
+ (CanvasForm as any)[shape](ctx, p, radius);
1083
+ }
1084
+
1085
+ /**
1086
+ * Draws a point.
1087
+ * @param p a Pt object
1088
+ * @param radius radius of the point. Default is 5.
1089
+ * @param shape The shape of the point. Defaults to "square", but it can be "circle" or a custom shape function in your own implementation.
1090
+ * @example `form.point( p )`, `form.point( p, 10, "circle" )`
1091
+ */
1092
+ point(p: PtLike, radius: number = 5, shape: string = "square"): this {
1093
+ CanvasForm.point(this._ctx, p, radius, shape);
1094
+ this._paint();
1095
+ return this;
1096
+ }
1097
+
1098
+ /**
1099
+ * A static function to draw a circle.
1100
+ * @param ctx canvas rendering context
1101
+ * @param pt center position of the circle
1102
+ * @param radius radius of the circle
1103
+ */
1104
+ static circle(ctx: RenderingContext2D, pt: PtLike, radius: number = 10) {
1105
+ if (!pt) return;
1106
+ ctx.beginPath();
1107
+ ctx.arc(pt[0], pt[1], radius, 0, Const.two_pi, false);
1108
+ ctx.closePath();
1109
+ }
1110
+
1111
+ /**
1112
+ * Draw a circle. See also [`Circle.fromCenter`](#link)
1113
+ * @param pts usually a Group or an Iterable<PtLike> with 2 Pt, but it can also take an array of two numeric arrays [ [position], [size] ]
1114
+ */
1115
+ circle(pts: PtLikeIterable): this {
1116
+ const p = Util.iterToArray(pts);
1117
+ CanvasForm.circle(this._ctx, p[0], p[1][0]);
1118
+ this._paint();
1119
+ return this;
1120
+ }
1121
+
1122
+ /**
1123
+ * A static function to draw an ellipse.
1124
+ * @param ctx canvas rendering context
1125
+ * @param pt center position
1126
+ * @param radius radius [x, y] of the ellipse
1127
+ * @param rotation rotation of the ellipse in radian. Default is 0.
1128
+ * @param startAngle start angle of the ellipse. Default is 0.
1129
+ * @param endAngle end angle of the ellipse. Default is 2 PI.
1130
+ * @param cc an optional boolean value to specify if it should be drawn clockwise (`false`) or counter-clockwise (`true`). Default is clockwise.
1131
+ */
1132
+ static ellipse(
1133
+ ctx: RenderingContext2D,
1134
+ pt: PtLike,
1135
+ radius: PtLike,
1136
+ rotation: number = 0,
1137
+ startAngle: number = 0,
1138
+ endAngle: number = Const.two_pi,
1139
+ cc: boolean = false,
1140
+ ) {
1141
+ if (!pt || !radius) return;
1142
+ ctx.beginPath();
1143
+ ctx.ellipse(
1144
+ pt[0],
1145
+ pt[1],
1146
+ radius[0],
1147
+ radius[1],
1148
+ rotation,
1149
+ startAngle,
1150
+ endAngle,
1151
+ cc,
1152
+ );
1153
+ }
1154
+
1155
+ /**
1156
+ * Draw an ellipse.
1157
+ * @param pt center position
1158
+ * @param radius radius [x, y] of the ellipse
1159
+ * @param rotation rotation of the ellipse in radian. Default is 0.
1160
+ * @param startAngle start angle of the ellipse. Default is 0.
1161
+ * @param endAngle end angle of the ellipse. Default is 2 PI.
1162
+ * @param cc an optional boolean value to specify if it should be drawn clockwise (`false`) or counter-clockwise (`true`). Default is clockwise.
1163
+ */
1164
+ ellipse(
1165
+ pt: PtLike,
1166
+ radius: PtLike,
1167
+ rotation: number = 0,
1168
+ startAngle: number = 0,
1169
+ endAngle: number = Const.two_pi,
1170
+ cc: boolean = false,
1171
+ ) {
1172
+ CanvasForm.ellipse(
1173
+ this._ctx,
1174
+ pt,
1175
+ radius,
1176
+ rotation,
1177
+ startAngle,
1178
+ endAngle,
1179
+ cc,
1180
+ );
1181
+ this._paint();
1182
+ return this;
1183
+ }
1184
+
1185
+ /**
1186
+ * A static function to draw an arc.
1187
+ * @param ctx canvas rendering context
1188
+ * @param pt center position
1189
+ * @param radius radius of the arc circle
1190
+ * @param startAngle start angle of the arc
1191
+ * @param endAngle end angle of the arc
1192
+ * @param cc an optional boolean value to specify if it should be drawn clockwise (`false`) or counter-clockwise (`true`). Default is clockwise.
1193
+ */
1194
+ static arc(
1195
+ ctx: RenderingContext2D,
1196
+ pt: PtLike,
1197
+ radius: number,
1198
+ startAngle: number,
1199
+ endAngle: number,
1200
+ cc?: boolean,
1201
+ ) {
1202
+ if (!pt) return;
1203
+ ctx.beginPath();
1204
+ ctx.arc(pt[0], pt[1], radius, startAngle, endAngle, cc);
1205
+ }
1206
+
1207
+ /**
1208
+ * Draw an arc.
1209
+ * @param pt center position
1210
+ * @param radius radius of the arc circle
1211
+ * @param startAngle start angle of the arc
1212
+ * @param endAngle end angle of the arc
1213
+ * @param cc an optional boolean value to specify if it should be drawn clockwise (`false`) or counter-clockwise (`true`). Default is clockwise.
1214
+ */
1215
+ arc(
1216
+ pt: PtLike,
1217
+ radius: number,
1218
+ startAngle: number,
1219
+ endAngle: number,
1220
+ cc?: boolean,
1221
+ ): this {
1222
+ CanvasForm.arc(this._ctx, pt, radius, startAngle, endAngle, cc);
1223
+ this._paint();
1224
+ return this;
1225
+ }
1226
+
1227
+ /**
1228
+ * A static function to draw a square.
1229
+ * @param ctx canvas rendering context
1230
+ * @param pt center position of the square
1231
+ * @param halfsize half size of the square
1232
+ */
1233
+ static square(ctx: RenderingContext2D, pt: PtLike, halfsize: number) {
1234
+ if (!pt) return;
1235
+ const x1 = pt[0] - halfsize;
1236
+ const y1 = pt[1] - halfsize;
1237
+ const x2 = pt[0] + halfsize;
1238
+ const y2 = pt[1] + halfsize;
1239
+
1240
+ // faster than using `rect`
1241
+ ctx.beginPath();
1242
+ ctx.moveTo(x1, y1);
1243
+ ctx.lineTo(x1, y2);
1244
+ ctx.lineTo(x2, y2);
1245
+ ctx.lineTo(x2, y1);
1246
+ ctx.closePath();
1247
+ }
1248
+
1249
+ /**
1250
+ * Draw a square, given a center and its half-size.
1251
+ * @param pt center Pt
1252
+ * @param halfsize half-size
1253
+ */
1254
+ square(pt: PtLike, halfsize: number) {
1255
+ CanvasForm.square(this._ctx, pt, halfsize);
1256
+ this._paint();
1257
+ return this;
1258
+ }
1259
+
1260
+ /**
1261
+ * A static function to draw a line or polyline.
1262
+ * @param ctx canvas rendering context
1263
+ * @param pts a Group or an Iterable<PtLike> representing a line
1264
+ */
1265
+ static line(ctx: RenderingContext2D, pts: PtLikeIterable) {
1266
+ if (!Util.arrayCheck(pts)) return;
1267
+ ctx.beginPath();
1268
+ let i = 0;
1269
+ if (Array.isArray(pts)) {
1270
+ // indexed fast path — inputs are nearly always Groups (Array subclass)
1271
+ for (let k = 0, len = pts.length; k < len; k++) {
1272
+ const it = pts[k];
1273
+ if (it) {
1274
+ if (i++ > 0) {
1275
+ ctx.lineTo(it[0], it[1]);
1276
+ } else {
1277
+ ctx.moveTo(it[0], it[1]);
1278
+ }
1279
+ }
1280
+ }
1281
+ } else {
1282
+ for (const it of pts) {
1283
+ if (it) {
1284
+ if (i++ > 0) {
1285
+ ctx.lineTo(it[0], it[1]);
1286
+ } else {
1287
+ ctx.moveTo(it[0], it[1]);
1288
+ }
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ /**
1295
+ * Draw a line or polyline.
1296
+ * @param pts a Group or an Iterable<PtLike> representing a line
1297
+ */
1298
+ line(pts: PtLikeIterable): this {
1299
+ CanvasForm.line(this._ctx, pts);
1300
+ this._paint();
1301
+ return this;
1302
+ }
1303
+
1304
+ /**
1305
+ * A static function to draw a polygon.
1306
+ * @param ctx canvas rendering context
1307
+ * @param pts a Group or an Iterable<PtLike> representing a polygon
1308
+ */
1309
+ static polygon(ctx: RenderingContext2D, pts: PtLikeIterable) {
1310
+ if (!Util.arrayCheck(pts)) return;
1311
+ CanvasForm.line(ctx, pts);
1312
+ ctx.closePath();
1313
+ }
1314
+
1315
+ /**
1316
+ * Draw a polygon.
1317
+ * @param pts a Group or an Iterable<PtLike> representingg a polygon
1318
+ */
1319
+ polygon(pts: PtLikeIterable): this {
1320
+ CanvasForm.polygon(this._ctx, pts);
1321
+ this._paint();
1322
+ return this;
1323
+ }
1324
+
1325
+ /**
1326
+ * A static function to draw a rectangle.
1327
+ * @param ctx canvas rendering context
1328
+ * @param pts a Group or an Iterable<PtLike> with 2 Pt specifying the top-left and bottom-right positions.
1329
+ */
1330
+ static rect(ctx: RenderingContext2D, pts: PtLikeIterable) {
1331
+ const p = Util.iterToArray(pts);
1332
+ if (!Util.arrayCheck(p)) return;
1333
+ ctx.beginPath();
1334
+ ctx.moveTo(p[0][0], p[0][1]);
1335
+ ctx.lineTo(p[0][0], p[1][1]);
1336
+ ctx.lineTo(p[1][0], p[1][1]);
1337
+ ctx.lineTo(p[1][0], p[0][1]);
1338
+ ctx.closePath();
1339
+ }
1340
+
1341
+ /**
1342
+ * Draw a rectangle.
1343
+ * @param pts a Group or an Iterable<PtLike> with 2 Pt specifying the top-left and bottom-right positions.
1344
+ */
1345
+ rect(pts: PtLikeIterable): this {
1346
+ CanvasForm.rect(this._ctx, pts);
1347
+ this._paint();
1348
+ return this;
1349
+ }
1350
+
1351
+ /**
1352
+ * A static function to draw an image.
1353
+ * @param ctx canvas rendering context
1354
+ * @param img either an [Img](#link) instance or an [`CanvasImageSource`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasImageSource) instance (eg the image from `<img>`, `<video>` or `<canvas>`)
1355
+ * @param ptOrRect a target area to place the image. Either a Pt or numeric array specifying a position, or a Group or an Iterable<PtLike> with 2 Pt (top-left, bottom-right) that specifies a bounding box for resizing. Default is (0,0) at top-left.
1356
+ * @param orig optionally a Group or an Iterable<PtLike> with 2 Pt (top-left, bottom-right) that specifies a cropping box in the original target.
1357
+ */
1358
+ static image(
1359
+ ctx: RenderingContext2D,
1360
+ ptOrRect: PtLike | PtLikeIterable,
1361
+ img: CanvasImageSource | Img,
1362
+ orig?: PtLikeIterable,
1363
+ ) {
1364
+ const t = Util.iterToArray(ptOrRect);
1365
+ let pos: number[];
1366
+
1367
+ if (typeof t[0] === "number") {
1368
+ // no crop
1369
+ pos = t as number[];
1370
+ } else {
1371
+ if (orig) {
1372
+ // crop
1373
+ const o = Util.iterToArray(orig);
1374
+ pos = [
1375
+ o[0][0],
1376
+ o[0][1],
1377
+ o[1][0] - o[0][0],
1378
+ o[1][1] - o[0][1],
1379
+ t[0][0],
1380
+ t[0][1],
1381
+ t[1][0] - t[0][0],
1382
+ t[1][1] - t[0][1],
1383
+ ];
1384
+ } else {
1385
+ // bounding box resize
1386
+ pos = [t[0][0], t[0][1], t[1][0] - t[0][0], t[1][1] - t[0][1]];
1387
+ }
1388
+ }
1389
+
1390
+ if (img instanceof Img) {
1391
+ if (img.loaded) {
1392
+ // @ts-expect-error legacy DOM lib types omit these vendor/optional members
1393
+ ctx.drawImage(img.image, ...pos);
1394
+ }
1395
+ } else {
1396
+ // @ts-expect-error legacy DOM lib types omit these vendor/optional members
1397
+ ctx.drawImage(img, ...pos);
1398
+ }
1399
+ }
1400
+
1401
+ /**
1402
+ * Draw an image.
1403
+ * @param img either an [Img](#link) instance or an [`CanvasImageSource`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasImageSource) instance (eg the image from `<img>`, `<video>` or `<canvas>`)
1404
+ * @param ptOrRect a target area to place the image. Either a PtLike specifying a position, or a Group or an Iterable<PtLike> with 2 Pt (top-left position, bottom-right position) that specifies a bounding box. Default is (0,0) at top-left.
1405
+ * @param orig optionally a Group or an Iterable<PtLike> with 2 Pt (top-left position, bottom-right position) that specifies a cropping box in the original target.
1406
+ */
1407
+ image(
1408
+ ptOrRect: PtLike | PtLikeIterable,
1409
+ img: CanvasImageSource | Img,
1410
+ orig?: PtLikeIterable,
1411
+ ) {
1412
+ if (img instanceof Img) {
1413
+ if (img.loaded) {
1414
+ CanvasForm.image(this._ctx, ptOrRect, img.image, orig);
1415
+ }
1416
+ } else {
1417
+ CanvasForm.image(this._ctx, ptOrRect, img, orig);
1418
+ }
1419
+ return this;
1420
+ }
1421
+
1422
+ /**
1423
+ * A static function to draw ImageData on canvas
1424
+ * @param ctx canvas rendering context
1425
+ * @param ptOrRect a target area to place the image. Either a Pt or numeric array specifying a position, or a Group or an Iterable<PtLike> with 2 Pt (top-left, bottom-right) that places a region of the image data of that size at that position. Note that `putImageData` cannot resize: the rect clips, not scales. Default is (0,0) at top-left.
1426
+ * @param img an ImageData object
1427
+ */
1428
+ static imageData(
1429
+ ctx: RenderingContext2D,
1430
+ ptOrRect: PtLike | PtLikeIterable,
1431
+ img: ImageData,
1432
+ ) {
1433
+ const t = Util.iterToArray(ptOrRect);
1434
+ if (typeof t[0] === "number") {
1435
+ // Pt
1436
+ ctx.putImageData(img, t[0], t[1]);
1437
+ } else {
1438
+ // rect: place the image data's top-left region of the rect's size at
1439
+ // the rect's position (dirty coordinates are relative to the data)
1440
+ ctx.putImageData(
1441
+ img,
1442
+ t[0][0],
1443
+ t[0][1],
1444
+ 0,
1445
+ 0,
1446
+ t[1][0] - t[0][0],
1447
+ t[1][1] - t[0][1],
1448
+ );
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * Draw ImageData on canvas using ImageData
1454
+ * @param ptOrRect a target area to place the image. Either a Pt or numeric array specifying a position, or a Group or an Iterable<PtLike> with 2 Pt (top-left, bottom-right) that specifies a bounding box for resizing. Default is (0,0) at top-left.
1455
+ * @param img an ImageData object
1456
+ */
1457
+ imageData(ptOrRect: PtLike | PtLikeIterable, img: ImageData) {
1458
+ CanvasForm.imageData(this._ctx, ptOrRect, img);
1459
+ return this;
1460
+ }
1461
+
1462
+ /**
1463
+ * A static function to draw text.
1464
+ * @param ctx canvas rendering context
1465
+ * @param pt a Point object to specify the anchor point
1466
+ * @param txt a string of text to draw
1467
+ * @param maxWidth specify a maximum width per line
1468
+ */
1469
+ static text(
1470
+ ctx: RenderingContext2D,
1471
+ pt: PtLike,
1472
+ txt: string,
1473
+ maxWidth?: number,
1474
+ ) {
1475
+ if (!pt) return;
1476
+ ctx.fillText(txt, pt[0], pt[1], maxWidth);
1477
+ }
1478
+
1479
+ /**
1480
+ * Draw text on canvas.
1481
+ * @param pt a Pt or numeric array to specify the anchor point
1482
+ * @param txt text
1483
+ * @param maxWidth specify a maximum width per line
1484
+ */
1485
+ text(pt: PtLike, txt: string, maxWidth?: number): this {
1486
+ CanvasForm.text(this._ctx, pt, txt, maxWidth);
1487
+ return this;
1488
+ }
1489
+
1490
+ /**
1491
+ * Fit a single-line text in a rectangular box.
1492
+ * @param box a rectangle box defined by a Group or an Iterable<Pt>
1493
+ * @param txt string of text
1494
+ * @param tail text to indicate overflow such as "...". Default is empty "".
1495
+ * @param verticalAlign "top", "middle", or "bottom" to specify vertical alignment inside the box
1496
+ * @param overrideBaseline If `true`, use the corresponding baseline as verticalAlign. If `false`, use the current canvas context's textBaseline setting. Default is `true`.
1497
+ */
1498
+ textBox(
1499
+ box: PtIterable,
1500
+ txt: string,
1501
+ verticalAlign: TextVerticalAlign = "middle",
1502
+ tail: string = "",
1503
+ overrideBaseline: boolean = true,
1504
+ ): this {
1505
+ // "center", "start", and "end" are box alignments, not canvas baselines;
1506
+ // assigning them to textBaseline is silently ignored by the context
1507
+ if (overrideBaseline) {
1508
+ this._ctx.textBaseline =
1509
+ verticalAlign === "center"
1510
+ ? "middle"
1511
+ : verticalAlign === "start"
1512
+ ? "top"
1513
+ : verticalAlign === "end"
1514
+ ? "bottom"
1515
+ : verticalAlign;
1516
+ }
1517
+ const size = Rectangle.size(box);
1518
+ const t = this._textTruncate(txt, size[0], tail);
1519
+ this.text(this._textAlign(box, verticalAlign)!, t[0]);
1520
+ return this;
1521
+ }
1522
+
1523
+ /**
1524
+ * Fit multi-line text in a rectangular box. Note that this will also set canvas context's textBaseline to "top".
1525
+ * @param box a Group or an Iterable<PtLike> with 2 Pt that represents a bounding box
1526
+ * @param txt string of text
1527
+ * @param lineHeight line height as a ratio of font size. Default is 1.2.
1528
+ * @param verticalAlign "top", "middle", or "bottom" to specify vertical alignment inside the box
1529
+ * @param crop a boolean to specify whether to crop text when overflowing
1530
+ */
1531
+ paragraphBox(
1532
+ box: PtLikeIterable,
1533
+ txt: string,
1534
+ lineHeight: number = 1.2,
1535
+ verticalAlign: TextVerticalAlign = "top",
1536
+ crop: boolean = true,
1537
+ ): this {
1538
+ const b = Group.fromArray(box);
1539
+ const size = Rectangle.size(b);
1540
+ this._ctx.textBaseline = "top"; // override textBaseline
1541
+
1542
+ const lstep = this._font.size * lineHeight;
1543
+
1544
+ // Seed each line's cut search with the previous line's fitted length, so
1545
+ // truncate probes near the boundary instead of measuring the whole
1546
+ // remainder (which made wrapping quadratic in text length). The first
1547
+ // line's seed is estimated from a single character sample.
1548
+ let hint = Math.ceil(size[0] / Math.max(1, this.getTextWidth("n")));
1549
+
1550
+ const lines: string[] = [];
1551
+ let sub = txt;
1552
+ while (sub) {
1553
+ // crop when the next line would no longer fit inside the box
1554
+ if (crop && (lines.length + 1) * lstep > size[1]) break;
1555
+
1556
+ const t = this._textTruncate(sub, size[0], "", hint);
1557
+ if (t[1] > 0) hint = t[1];
1558
+
1559
+ // new line
1560
+ const newln = t[0].indexOf("\n");
1561
+ if (newln >= 0) {
1562
+ lines.push(t[0].slice(0, newln));
1563
+ sub = sub.slice(newln + 1);
1564
+ continue;
1565
+ }
1566
+
1567
+ // word wrap
1568
+ const consumedAll = t[1] === sub.length;
1569
+ let dt: number | undefined;
1570
+ if (consumedAll) {
1571
+ dt = undefined;
1572
+ } else if (sub[t[1]] === " ") {
1573
+ // the line ends exactly at a word boundary: keep the whole line and
1574
+ // drop the space, instead of wrapping before its last word
1575
+ dt = t[1] + 1;
1576
+ } else {
1577
+ dt = t[0].lastIndexOf(" ") + 1;
1578
+ if (dt <= 0) dt = undefined;
1579
+ }
1580
+ lines.push(dt === undefined ? t[0] : t[0].slice(0, dt));
1581
+
1582
+ if (t[1] <= 0 || consumedAll) break;
1583
+ sub = sub.slice(dt ?? t[1]);
1584
+ }
1585
+ const lsize = lines.length * lstep; // total height
1586
+ let lbox = b;
1587
+
1588
+ if (verticalAlign == "middle" || verticalAlign == "center") {
1589
+ let lpad = (size[1] - lsize) / 2;
1590
+ if (crop) lpad = Math.max(0, lpad);
1591
+ lbox = new Group(b[0].$add(0, lpad), b[1].$subtract(0, lpad));
1592
+ } else if (verticalAlign == "bottom") {
1593
+ lbox = new Group(b[0].$add(0, size[1] - lsize), b[1]);
1594
+ } else {
1595
+ lbox = new Group(b[0], b[0].$add(size[0], lsize));
1596
+ }
1597
+
1598
+ const center = Rectangle.center(lbox);
1599
+ for (let i = 0, len = lines.length; i < len; i++) {
1600
+ this.text(
1601
+ this._textAlign(lbox, "top", [0, i * lstep], center)!,
1602
+ lines[i],
1603
+ );
1604
+ }
1605
+
1606
+ return this;
1607
+ }
1608
+
1609
+ /**
1610
+ * Set text alignment and baseline (eg, vertical-align).
1611
+ * @param alignment HTML canvas' textAlign option: "left", "right", "center", "start", or "end"
1612
+ * @param baseline HTML canvas' textBaseline option: "top", "hanging", "middle", "alphabetic", "ideographic", "bottom". For convenience, you can also use "center" (same as "middle"), and "baseline" (same as "alphabetic")
1613
+ */
1614
+ alignText(
1615
+ alignment: CanvasTextAlign = "left",
1616
+ baseline: CanvasTextBaseline = "alphabetic",
1617
+ ) {
1618
+ // @ts-expect-error legacy DOM lib types omit these vendor/optional members
1619
+ if (baseline == "center") baseline = "middle";
1620
+ // @ts-expect-error legacy DOM lib types omit these vendor/optional members
1621
+ if (baseline == "baseline") baseline = "alphabetic";
1622
+ this._ctx.textAlign = alignment;
1623
+ this._ctx.textBaseline = baseline;
1624
+ return this;
1625
+ }
1626
+
1627
+ /**
1628
+ * A convenient way to draw some text on canvas for logging or debugging. It'll be draw on the top-left of the canvas as an overlay.
1629
+ * @param txt text
1630
+ */
1631
+ log(txt: any): this {
1632
+ const w = this._ctx.measureText(txt).width + 20;
1633
+ this.stroke(false)
1634
+ .fill("rgba(0,0,0,.4)")
1635
+ .rect([
1636
+ [0, 0],
1637
+ [w, 20],
1638
+ ]);
1639
+ this.fill("#fff").text([10, 14], txt);
1640
+ return this;
1641
+ }
1642
+ }