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/README.md +92 -80
- package/dist/index.d.mts +6207 -1245
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +6207 -1245
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9314 -10671
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9266 -10602
- package/dist/index.mjs.map +1 -0
- package/dist/pts.js +9446 -10768
- 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/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
|
+
}
|