UNPKG

23.8 kBPlain TextView Raw
1/**
2 * The main idea and some parts of the code (e.g. drawing variable width Bézier curve) are taken from:
3 * http://corner.squareup.com/2012/07/smoother-signatures.html
4 *
5 * Implementation of interpolation using cubic Bézier curves is taken from:
6 * https://web.archive.org/web/20160323213433/http://www.benknowscode.com/2012/09/path-interpolation-using-cubic-bezier_9742.html
7 *
8 * Algorithm for approximated length of a Bézier curve is taken from:
9 * http://www.lemoda.net/maths/bezier-length/index.html
10 */
11
12import { Bezier } from './bezier';
13import { BasicPoint, Point } from './point';
14import { SignatureEventTarget } from './signature_event_target';
15import { throttle } from './throttle';
16
17export interface SignatureEvent {
18 event: MouseEvent | TouchEvent | PointerEvent;
19 type: string;
20 x: number;
21 y: number;
22 pressure: number;
23}
24
25export interface FromDataOptions {
26 clear?: boolean;
27}
28
29export interface ToSVGOptions {
30 includeBackgroundColor?: boolean;
31}
32
33export interface PointGroupOptions {
34 dotSize: number;
35 minWidth: number;
36 maxWidth: number;
37 penColor: string;
38 velocityFilterWeight: number;
39 /**
40 * This is the globalCompositeOperation for the line.
41 * *default: 'source-over'*
42 * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
43 */
44 compositeOperation: GlobalCompositeOperation;
45}
46
47export interface Options extends Partial<PointGroupOptions> {
48 minDistance?: number;
49 backgroundColor?: string;
50 throttle?: number;
51 canvasContextOptions?: CanvasRenderingContext2DSettings;
52}
53
54export interface PointGroup extends PointGroupOptions {
55 points: BasicPoint[];
56}
57
58export default class SignaturePad extends SignatureEventTarget {
59 // Public stuff
60 public dotSize: number;
61 public minWidth: number;
62 public maxWidth: number;
63 public penColor: string;
64 public minDistance: number;
65 public velocityFilterWeight: number;
66 public compositeOperation: GlobalCompositeOperation;
67 public backgroundColor: string;
68 public throttle: number;
69 public canvasContextOptions: CanvasRenderingContext2DSettings;
70
71 // Private stuff
72 /* tslint:disable: variable-name */
73 private _ctx: CanvasRenderingContext2D;
74 private _drawingStroke = false;
75 private _isEmpty = true;
76 private _lastPoints: Point[] = []; // Stores up to 4 most recent points; used to generate a new curve
77 private _data: PointGroup[] = []; // Stores all points in groups (one group per line or dot)
78 private _lastVelocity = 0;
79 private _lastWidth = 0;
80 private _strokeMoveUpdate: (event: SignatureEvent) => void;
81 /* tslint:enable: variable-name */
82
83 constructor(
84 private canvas: HTMLCanvasElement,
85 options: Options = {},
86 ) {
87 super();
88 this.velocityFilterWeight = options.velocityFilterWeight || 0.7;
89 this.minWidth = options.minWidth || 0.5;
90 this.maxWidth = options.maxWidth || 2.5;
91
92 // We need to handle 0 value, so use `??` instead of `||`
93 this.throttle = options.throttle ?? 16; // in milliseconds
94 this.minDistance = options.minDistance ?? 5; // in pixels
95 this.dotSize = options.dotSize || 0;
96 this.penColor = options.penColor || 'black';
97 this.backgroundColor = options.backgroundColor || 'rgba(0,0,0,0)';
98 this.compositeOperation = options.compositeOperation || 'source-over';
99 this.canvasContextOptions = options.canvasContextOptions ?? {};
100
101 this._strokeMoveUpdate = this.throttle
102 ? throttle(SignaturePad.prototype._strokeUpdate, this.throttle)
103 : SignaturePad.prototype._strokeUpdate;
104 this._ctx = canvas.getContext(
105 '2d',
106 this.canvasContextOptions,
107 ) as CanvasRenderingContext2D;
108
109 this.clear();
110
111 // Enable mouse and touch event handlers
112 this.on();
113 }
114
115 public clear(): void {
116 const { _ctx: ctx, canvas } = this;
117
118 // Clear canvas using background color
119 ctx.fillStyle = this.backgroundColor;
120 ctx.clearRect(0, 0, canvas.width, canvas.height);
121 ctx.fillRect(0, 0, canvas.width, canvas.height);
122
123 this._data = [];
124 this._reset(this._getPointGroupOptions());
125 this._isEmpty = true;
126 }
127
128 public fromDataURL(
129 dataUrl: string,
130 options: {
131 ratio?: number;
132 width?: number;
133 height?: number;
134 xOffset?: number;
135 yOffset?: number;
136 } = {},
137 ): Promise<void> {
138 return new Promise((resolve, reject) => {
139 const image = new Image();
140 const ratio = options.ratio || window.devicePixelRatio || 1;
141 const width = options.width || this.canvas.width / ratio;
142 const height = options.height || this.canvas.height / ratio;
143 const xOffset = options.xOffset || 0;
144 const yOffset = options.yOffset || 0;
145
146 this._reset(this._getPointGroupOptions());
147
148 image.onload = (): void => {
149 this._ctx.drawImage(image, xOffset, yOffset, width, height);
150 resolve();
151 };
152 image.onerror = (error): void => {
153 reject(error);
154 };
155 image.crossOrigin = 'anonymous';
156 image.src = dataUrl;
157
158 this._isEmpty = false;
159 });
160 }
161
162 public toDataURL(
163 type: 'image/svg+xml',
164 encoderOptions?: ToSVGOptions,
165 ): string;
166 public toDataURL(type?: string, encoderOptions?: number): string;
167 public toDataURL(
168 type = 'image/png',
169 encoderOptions?: number | ToSVGOptions | undefined,
170 ): string {
171 switch (type) {
172 case 'image/svg+xml':
173 if (typeof encoderOptions !== 'object') {
174 encoderOptions = undefined;
175 }
176 return `data:image/svg+xml;base64,${btoa(
177 this.toSVG(encoderOptions as ToSVGOptions),
178 )}`;
179 default:
180 if (typeof encoderOptions !== 'number') {
181 encoderOptions = undefined;
182 }
183 return this.canvas.toDataURL(type, encoderOptions);
184 }
185 }
186
187 public on(): void {
188 // Disable panning/zooming when touching canvas element
189 this.canvas.style.touchAction = 'none';
190 (this.canvas.style as CSSStyleDeclaration & { msTouchAction: string | null }).msTouchAction = 'none';
191 this.canvas.style.userSelect = 'none';
192
193 const isIOS =
194 /Macintosh/.test(navigator.userAgent) && 'ontouchstart' in document;
195
196 // The "Scribble" feature of iOS intercepts point events. So that we can
197 // lose some of them when tapping rapidly. Use touch events for iOS
198 // platforms to prevent it. See
199 // https://developer.apple.com/forums/thread/664108 for more information.
200 if (window.PointerEvent && !isIOS) {
201 this._handlePointerEvents();
202 } else {
203 this._handleMouseEvents();
204
205 if ('ontouchstart' in window) {
206 this._handleTouchEvents();
207 }
208 }
209 }
210
211 public off(): void {
212 // Enable panning/zooming when touching canvas element
213 this.canvas.style.touchAction = 'auto';
214 (this.canvas.style as CSSStyleDeclaration & { msTouchAction: string | null }).msTouchAction = 'auto';
215 this.canvas.style.userSelect = 'auto';
216
217 this.canvas.removeEventListener('pointerdown', this._handlePointerDown);
218 this.canvas.removeEventListener('mousedown', this._handleMouseDown);
219 this.canvas.removeEventListener('touchstart', this._handleTouchStart);
220
221 this._removeMoveUpEventListeners();
222 }
223
224 private _getListenerFunctions() {
225 const canvasWindow =
226 window.document === this.canvas.ownerDocument
227 ? window
228 : this.canvas.ownerDocument.defaultView ?? this.canvas.ownerDocument;
229
230 return {
231 addEventListener: canvasWindow.addEventListener.bind(
232 canvasWindow,
233 ) as typeof window.addEventListener,
234 removeEventListener: canvasWindow.removeEventListener.bind(
235 canvasWindow,
236 ) as typeof window.removeEventListener,
237 };
238 }
239
240 private _removeMoveUpEventListeners(): void {
241 const { removeEventListener } = this._getListenerFunctions();
242 removeEventListener('pointermove', this._handlePointerMove);
243 removeEventListener('pointerup', this._handlePointerUp);
244
245 removeEventListener('mousemove', this._handleMouseMove);
246 removeEventListener('mouseup', this._handleMouseUp);
247
248 removeEventListener('touchmove', this._handleTouchMove);
249 removeEventListener('touchend', this._handleTouchEnd);
250 }
251
252 public isEmpty(): boolean {
253 return this._isEmpty;
254 }
255
256 public fromData(
257 pointGroups: PointGroup[],
258 { clear = true }: FromDataOptions = {},
259 ): void {
260 if (clear) {
261 this.clear();
262 }
263
264 this._fromData(
265 pointGroups,
266 this._drawCurve.bind(this),
267 this._drawDot.bind(this),
268 );
269
270 this._data = this._data.concat(pointGroups);
271 }
272
273 public toData(): PointGroup[] {
274 return this._data;
275 }
276
277 public _isLeftButtonPressed(event: MouseEvent, only?: boolean): boolean {
278 if (only) {
279 return event.buttons === 1;
280 }
281
282 return (event.buttons & 1) === 1;
283 }
284 private _pointerEventToSignatureEvent(
285 event: MouseEvent | PointerEvent,
286 ): SignatureEvent {
287 return {
288 event: event,
289 type: event.type,
290 x: event.clientX,
291 y: event.clientY,
292 pressure: 'pressure' in event ? event.pressure : 0,
293 };
294 }
295
296 private _touchEventToSignatureEvent(event: TouchEvent): SignatureEvent {
297 const touch = event.changedTouches[0];
298 return {
299 event: event,
300 type: event.type,
301 x: touch.clientX,
302 y: touch.clientY,
303 pressure: touch.force,
304 };
305 }
306
307 // Event handlers
308 private _handleMouseDown = (event: MouseEvent): void => {
309 if (!this._isLeftButtonPressed(event, true) || this._drawingStroke) {
310 return;
311 }
312 this._strokeBegin(this._pointerEventToSignatureEvent(event));
313 };
314
315 private _handleMouseMove = (event: MouseEvent): void => {
316 if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
317 // Stop when not pressing primary button or pressing multiple buttons
318 this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
319 return;
320 }
321
322 this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
323 };
324
325 private _handleMouseUp = (event: MouseEvent): void => {
326 if (this._isLeftButtonPressed(event)) {
327 return;
328 }
329
330 this._strokeEnd(this._pointerEventToSignatureEvent(event));
331 };
332
333 private _handleTouchStart = (event: TouchEvent): void => {
334 if (event.targetTouches.length !== 1 || this._drawingStroke) {
335 return;
336 }
337
338 // Prevent scrolling.
339 if (event.cancelable) {
340 event.preventDefault();
341 }
342
343 this._strokeBegin(this._touchEventToSignatureEvent(event));
344 };
345
346 private _handleTouchMove = (event: TouchEvent): void => {
347 if (event.targetTouches.length !== 1) {
348 return;
349 }
350
351 // Prevent scrolling.
352 if (event.cancelable) {
353 event.preventDefault();
354 }
355
356 if (!this._drawingStroke) {
357 this._strokeEnd(this._touchEventToSignatureEvent(event), false);
358 return;
359 }
360
361 this._strokeMoveUpdate(this._touchEventToSignatureEvent(event));
362 };
363
364 private _handleTouchEnd = (event: TouchEvent): void => {
365 if (event.targetTouches.length !== 0) {
366 return;
367 }
368
369 if (event.cancelable) {
370 event.preventDefault();
371 }
372
373 this.canvas.removeEventListener('touchmove', this._handleTouchMove);
374
375 this._strokeEnd(this._touchEventToSignatureEvent(event));
376 };
377
378 private _handlePointerDown = (event: PointerEvent): void => {
379 if (!this._isLeftButtonPressed(event) || this._drawingStroke) {
380 return;
381 }
382
383 event.preventDefault();
384
385 this._strokeBegin(this._pointerEventToSignatureEvent(event));
386 };
387
388 private _handlePointerMove = (event: PointerEvent): void => {
389 if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
390 // Stop when primary button not pressed or multiple buttons pressed
391 this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
392 return;
393 }
394
395 event.preventDefault();
396 this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
397 };
398
399 private _handlePointerUp = (event: PointerEvent): void => {
400 if (this._isLeftButtonPressed(event)) {
401 return;
402 }
403
404 event.preventDefault();
405 this._strokeEnd(this._pointerEventToSignatureEvent(event));
406 };
407
408 private _getPointGroupOptions(group?: PointGroup): PointGroupOptions {
409 return {
410 penColor: group && 'penColor' in group ? group.penColor : this.penColor,
411 dotSize: group && 'dotSize' in group ? group.dotSize : this.dotSize,
412 minWidth: group && 'minWidth' in group ? group.minWidth : this.minWidth,
413 maxWidth: group && 'maxWidth' in group ? group.maxWidth : this.maxWidth,
414 velocityFilterWeight:
415 group && 'velocityFilterWeight' in group
416 ? group.velocityFilterWeight
417 : this.velocityFilterWeight,
418 compositeOperation:
419 group && 'compositeOperation' in group
420 ? group.compositeOperation
421 : this.compositeOperation,
422 };
423 }
424
425 // Private methods
426 private _strokeBegin(event: SignatureEvent): void {
427 const cancelled = !this.dispatchEvent(
428 new CustomEvent('beginStroke', { detail: event, cancelable: true }),
429 );
430 if (cancelled) {
431 return;
432 }
433
434 const { addEventListener } = this._getListenerFunctions();
435 switch (event.event.type) {
436 case 'mousedown':
437 addEventListener('mousemove', this._handleMouseMove);
438 addEventListener('mouseup', this._handleMouseUp);
439 break;
440 case 'touchstart':
441 addEventListener('touchmove', this._handleTouchMove);
442 addEventListener('touchend', this._handleTouchEnd);
443 break;
444 case 'pointerdown':
445 addEventListener('pointermove', this._handlePointerMove);
446 addEventListener('pointerup', this._handlePointerUp);
447 break;
448 default:
449 // do nothing
450 }
451
452 this._drawingStroke = true;
453
454 const pointGroupOptions = this._getPointGroupOptions();
455
456 const newPointGroup: PointGroup = {
457 ...pointGroupOptions,
458 points: [],
459 };
460
461 this._data.push(newPointGroup);
462 this._reset(pointGroupOptions);
463 this._strokeUpdate(event);
464 }
465
466 private _strokeUpdate(event: SignatureEvent): void {
467 if (!this._drawingStroke) {
468 return;
469 }
470
471 if (this._data.length === 0) {
472 // This can happen if clear() was called while a signature is still in progress,
473 // or if there is a race condition between start/update events.
474 this._strokeBegin(event);
475 return;
476 }
477
478 this.dispatchEvent(
479 new CustomEvent('beforeUpdateStroke', { detail: event }),
480 );
481
482 const point = this._createPoint(event.x, event.y, event.pressure);
483 const lastPointGroup = this._data[this._data.length - 1];
484 const lastPoints = lastPointGroup.points;
485 const lastPoint =
486 lastPoints.length > 0 && lastPoints[lastPoints.length - 1];
487 const isLastPointTooClose = lastPoint
488 ? point.distanceTo(lastPoint) <= this.minDistance
489 : false;
490 const pointGroupOptions = this._getPointGroupOptions(lastPointGroup);
491
492 // Skip this point if it's too close to the previous one
493 if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
494 const curve = this._addPoint(point, pointGroupOptions);
495
496 if (!lastPoint) {
497 this._drawDot(point, pointGroupOptions);
498 } else if (curve) {
499 this._drawCurve(curve, pointGroupOptions);
500 }
501
502 lastPoints.push({
503 time: point.time,
504 x: point.x,
505 y: point.y,
506 pressure: point.pressure,
507 });
508 }
509
510 this.dispatchEvent(new CustomEvent('afterUpdateStroke', { detail: event }));
511 }
512
513 private _strokeEnd(event: SignatureEvent, shouldUpdate = true): void {
514 this._removeMoveUpEventListeners();
515
516 if (!this._drawingStroke) {
517 return;
518 }
519
520 if (shouldUpdate) {
521 this._strokeUpdate(event);
522 }
523
524 this._drawingStroke = false;
525 this.dispatchEvent(new CustomEvent('endStroke', { detail: event }));
526 }
527
528 private _handlePointerEvents(): void {
529 this._drawingStroke = false;
530
531 this.canvas.addEventListener('pointerdown', this._handlePointerDown);
532 }
533
534 private _handleMouseEvents(): void {
535 this._drawingStroke = false;
536
537 this.canvas.addEventListener('mousedown', this._handleMouseDown);
538 }
539
540 private _handleTouchEvents(): void {
541 this.canvas.addEventListener('touchstart', this._handleTouchStart);
542 }
543
544 // Called when a new line is started
545 private _reset(options: PointGroupOptions): void {
546 this._lastPoints = [];
547 this._lastVelocity = 0;
548 this._lastWidth = (options.minWidth + options.maxWidth) / 2;
549 this._ctx.fillStyle = options.penColor;
550 this._ctx.globalCompositeOperation = options.compositeOperation;
551 }
552
553 private _createPoint(x: number, y: number, pressure: number): Point {
554 const rect = this.canvas.getBoundingClientRect();
555
556 return new Point(
557 x - rect.left,
558 y - rect.top,
559 pressure,
560 new Date().getTime(),
561 );
562 }
563
564 // Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3)
565 private _addPoint(point: Point, options: PointGroupOptions): Bezier | null {
566 const { _lastPoints } = this;
567
568 _lastPoints.push(point);
569
570 if (_lastPoints.length > 2) {
571 // To reduce the initial lag make it work with 3 points
572 // by copying the first point to the beginning.
573 if (_lastPoints.length === 3) {
574 _lastPoints.unshift(_lastPoints[0]);
575 }
576
577 // _points array will always have 4 points here.
578 const widths = this._calculateCurveWidths(
579 _lastPoints[1],
580 _lastPoints[2],
581 options,
582 );
583 const curve = Bezier.fromPoints(_lastPoints, widths);
584
585 // Remove the first element from the list, so that there are no more than 4 points at any time.
586 _lastPoints.shift();
587
588 return curve;
589 }
590
591 return null;
592 }
593
594 private _calculateCurveWidths(
595 startPoint: Point,
596 endPoint: Point,
597 options: PointGroupOptions,
598 ): { start: number; end: number } {
599 const velocity =
600 options.velocityFilterWeight * endPoint.velocityFrom(startPoint) +
601 (1 - options.velocityFilterWeight) * this._lastVelocity;
602
603 const newWidth = this._strokeWidth(velocity, options);
604
605 const widths = {
606 end: newWidth,
607 start: this._lastWidth,
608 };
609
610 this._lastVelocity = velocity;
611 this._lastWidth = newWidth;
612
613 return widths;
614 }
615
616 private _strokeWidth(velocity: number, options: PointGroupOptions): number {
617 return Math.max(options.maxWidth / (velocity + 1), options.minWidth);
618 }
619
620 private _drawCurveSegment(x: number, y: number, width: number): void {
621 const ctx = this._ctx;
622
623 ctx.moveTo(x, y);
624 ctx.arc(x, y, width, 0, 2 * Math.PI, false);
625 this._isEmpty = false;
626 }
627
628 private _drawCurve(curve: Bezier, options: PointGroupOptions): void {
629 const ctx = this._ctx;
630 const widthDelta = curve.endWidth - curve.startWidth;
631 // '2' is just an arbitrary number here. If only length is used, then
632 // there are gaps between curve segments :/
633 const drawSteps = Math.ceil(curve.length()) * 2;
634
635 ctx.beginPath();
636 ctx.fillStyle = options.penColor;
637
638 for (let i = 0; i < drawSteps; i += 1) {
639 // Calculate the Bezier (x, y) coordinate for this step.
640 const t = i / drawSteps;
641 const tt = t * t;
642 const ttt = tt * t;
643 const u = 1 - t;
644 const uu = u * u;
645 const uuu = uu * u;
646
647 let x = uuu * curve.startPoint.x;
648 x += 3 * uu * t * curve.control1.x;
649 x += 3 * u * tt * curve.control2.x;
650 x += ttt * curve.endPoint.x;
651
652 let y = uuu * curve.startPoint.y;
653 y += 3 * uu * t * curve.control1.y;
654 y += 3 * u * tt * curve.control2.y;
655 y += ttt * curve.endPoint.y;
656
657 const width = Math.min(
658 curve.startWidth + ttt * widthDelta,
659 options.maxWidth,
660 );
661 this._drawCurveSegment(x, y, width);
662 }
663
664 ctx.closePath();
665 ctx.fill();
666 }
667
668 private _drawDot(point: BasicPoint, options: PointGroupOptions): void {
669 const ctx = this._ctx;
670 const width =
671 options.dotSize > 0
672 ? options.dotSize
673 : (options.minWidth + options.maxWidth) / 2;
674
675 ctx.beginPath();
676 this._drawCurveSegment(point.x, point.y, width);
677 ctx.closePath();
678 ctx.fillStyle = options.penColor;
679 ctx.fill();
680 }
681
682 private _fromData(
683 pointGroups: PointGroup[],
684 drawCurve: SignaturePad['_drawCurve'],
685 drawDot: SignaturePad['_drawDot'],
686 ): void {
687 for (const group of pointGroups) {
688 const { points } = group;
689 const pointGroupOptions = this._getPointGroupOptions(group);
690
691 if (points.length > 1) {
692 for (let j = 0; j < points.length; j += 1) {
693 const basicPoint = points[j];
694 const point = new Point(
695 basicPoint.x,
696 basicPoint.y,
697 basicPoint.pressure,
698 basicPoint.time,
699 );
700
701 if (j === 0) {
702 this._reset(pointGroupOptions);
703 }
704
705 const curve = this._addPoint(point, pointGroupOptions);
706
707 if (curve) {
708 drawCurve(curve, pointGroupOptions);
709 }
710 }
711 } else {
712 this._reset(pointGroupOptions);
713
714 drawDot(points[0], pointGroupOptions);
715 }
716 }
717 }
718
719 public toSVG({ includeBackgroundColor = false }: ToSVGOptions = {}): string {
720 const pointGroups = this._data;
721 const ratio = Math.max(window.devicePixelRatio || 1, 1);
722 const minX = 0;
723 const minY = 0;
724 const maxX = this.canvas.width / ratio;
725 const maxY = this.canvas.height / ratio;
726 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
727
728 svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
729 svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
730 svg.setAttribute('viewBox', `${minX} ${minY} ${maxX} ${maxY}`);
731 svg.setAttribute('width', maxX.toString());
732 svg.setAttribute('height', maxY.toString());
733
734 if (includeBackgroundColor && this.backgroundColor) {
735 const rect = document.createElement('rect');
736 rect.setAttribute('width', '100%');
737 rect.setAttribute('height', '100%');
738 rect.setAttribute('fill', this.backgroundColor);
739
740 svg.appendChild(rect);
741 }
742
743 this._fromData(
744 pointGroups,
745
746 (curve, { penColor }) => {
747 const path = document.createElement('path');
748
749 // Need to check curve for NaN values, these pop up when drawing
750 // lines on the canvas that are not continuous. E.g. Sharp corners
751 // or stopping mid-stroke and than continuing without lifting mouse.
752 /* eslint-disable no-restricted-globals */
753 if (
754 !isNaN(curve.control1.x) &&
755 !isNaN(curve.control1.y) &&
756 !isNaN(curve.control2.x) &&
757 !isNaN(curve.control2.y)
758 ) {
759 const attr =
760 `M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(
761 3,
762 )} ` +
763 `C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ` +
764 `${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ` +
765 `${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`;
766 path.setAttribute('d', attr);
767 path.setAttribute('stroke-width', (curve.endWidth * 2.25).toFixed(3));
768 path.setAttribute('stroke', penColor);
769 path.setAttribute('fill', 'none');
770 path.setAttribute('stroke-linecap', 'round');
771
772 svg.appendChild(path);
773 }
774 /* eslint-enable no-restricted-globals */
775 },
776
777 (point, { penColor, dotSize, minWidth, maxWidth }) => {
778 const circle = document.createElement('circle');
779 const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2;
780 circle.setAttribute('r', size.toString());
781 circle.setAttribute('cx', point.x.toString());
782 circle.setAttribute('cy', point.y.toString());
783 circle.setAttribute('fill', penColor);
784
785 svg.appendChild(circle);
786 },
787 );
788
789 return svg.outerHTML;
790 }
791}