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 (!event.isPrimary || !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 (!event.isPrimary) {
390 return;
391 }
392 if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
393 // Stop when primary button not pressed or multiple buttons pressed
394 this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
395 return;
396 }
397
398 event.preventDefault();
399 this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
400 };
401
402 private _handlePointerUp = (event: PointerEvent): void => {
403 if (!event.isPrimary || this._isLeftButtonPressed(event)) {
404 return;
405 }
406
407 event.preventDefault();
408 this._strokeEnd(this._pointerEventToSignatureEvent(event));
409 };
410
411 private _getPointGroupOptions(group?: PointGroup): PointGroupOptions {
412 return {
413 penColor: group && 'penColor' in group ? group.penColor : this.penColor,
414 dotSize: group && 'dotSize' in group ? group.dotSize : this.dotSize,
415 minWidth: group && 'minWidth' in group ? group.minWidth : this.minWidth,
416 maxWidth: group && 'maxWidth' in group ? group.maxWidth : this.maxWidth,
417 velocityFilterWeight:
418 group && 'velocityFilterWeight' in group
419 ? group.velocityFilterWeight
420 : this.velocityFilterWeight,
421 compositeOperation:
422 group && 'compositeOperation' in group
423 ? group.compositeOperation
424 : this.compositeOperation,
425 };
426 }
427
428 // Private methods
429 private _strokeBegin(event: SignatureEvent): void {
430 const cancelled = !this.dispatchEvent(
431 new CustomEvent('beginStroke', { detail: event, cancelable: true }),
432 );
433 if (cancelled) {
434 return;
435 }
436
437 const { addEventListener } = this._getListenerFunctions();
438 switch (event.event.type) {
439 case 'mousedown':
440 addEventListener('mousemove', this._handleMouseMove);
441 addEventListener('mouseup', this._handleMouseUp);
442 break;
443 case 'touchstart':
444 addEventListener('touchmove', this._handleTouchMove);
445 addEventListener('touchend', this._handleTouchEnd);
446 break;
447 case 'pointerdown':
448 addEventListener('pointermove', this._handlePointerMove);
449 addEventListener('pointerup', this._handlePointerUp);
450 break;
451 default:
452 // do nothing
453 }
454
455 this._drawingStroke = true;
456
457 const pointGroupOptions = this._getPointGroupOptions();
458
459 const newPointGroup: PointGroup = {
460 ...pointGroupOptions,
461 points: [],
462 };
463
464 this._data.push(newPointGroup);
465 this._reset(pointGroupOptions);
466 this._strokeUpdate(event);
467 }
468
469 private _strokeUpdate(event: SignatureEvent): void {
470 if (!this._drawingStroke) {
471 return;
472 }
473
474 if (this._data.length === 0) {
475 // This can happen if clear() was called while a signature is still in progress,
476 // or if there is a race condition between start/update events.
477 this._strokeBegin(event);
478 return;
479 }
480
481 this.dispatchEvent(
482 new CustomEvent('beforeUpdateStroke', { detail: event }),
483 );
484
485 const point = this._createPoint(event.x, event.y, event.pressure);
486 const lastPointGroup = this._data[this._data.length - 1];
487 const lastPoints = lastPointGroup.points;
488 const lastPoint =
489 lastPoints.length > 0 && lastPoints[lastPoints.length - 1];
490 const isLastPointTooClose = lastPoint
491 ? point.distanceTo(lastPoint) <= this.minDistance
492 : false;
493 const pointGroupOptions = this._getPointGroupOptions(lastPointGroup);
494
495 // Skip this point if it's too close to the previous one
496 if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
497 const curve = this._addPoint(point, pointGroupOptions);
498
499 if (!lastPoint) {
500 this._drawDot(point, pointGroupOptions);
501 } else if (curve) {
502 this._drawCurve(curve, pointGroupOptions);
503 }
504
505 lastPoints.push({
506 time: point.time,
507 x: point.x,
508 y: point.y,
509 pressure: point.pressure,
510 });
511 }
512
513 this.dispatchEvent(new CustomEvent('afterUpdateStroke', { detail: event }));
514 }
515
516 private _strokeEnd(event: SignatureEvent, shouldUpdate = true): void {
517 this._removeMoveUpEventListeners();
518
519 if (!this._drawingStroke) {
520 return;
521 }
522
523 if (shouldUpdate) {
524 this._strokeUpdate(event);
525 }
526
527 this._drawingStroke = false;
528 this.dispatchEvent(new CustomEvent('endStroke', { detail: event }));
529 }
530
531 private _handlePointerEvents(): void {
532 this._drawingStroke = false;
533
534 this.canvas.addEventListener('pointerdown', this._handlePointerDown);
535 }
536
537 private _handleMouseEvents(): void {
538 this._drawingStroke = false;
539
540 this.canvas.addEventListener('mousedown', this._handleMouseDown);
541 }
542
543 private _handleTouchEvents(): void {
544 this.canvas.addEventListener('touchstart', this._handleTouchStart);
545 }
546
547 // Called when a new line is started
548 private _reset(options: PointGroupOptions): void {
549 this._lastPoints = [];
550 this._lastVelocity = 0;
551 this._lastWidth = (options.minWidth + options.maxWidth) / 2;
552 this._ctx.fillStyle = options.penColor;
553 this._ctx.globalCompositeOperation = options.compositeOperation;
554 }
555
556 private _createPoint(x: number, y: number, pressure: number): Point {
557 const rect = this.canvas.getBoundingClientRect();
558
559 return new Point(
560 x - rect.left,
561 y - rect.top,
562 pressure,
563 new Date().getTime(),
564 );
565 }
566
567 // Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3)
568 private _addPoint(point: Point, options: PointGroupOptions): Bezier | null {
569 const { _lastPoints } = this;
570
571 _lastPoints.push(point);
572
573 if (_lastPoints.length > 2) {
574 // To reduce the initial lag make it work with 3 points
575 // by copying the first point to the beginning.
576 if (_lastPoints.length === 3) {
577 _lastPoints.unshift(_lastPoints[0]);
578 }
579
580 // _points array will always have 4 points here.
581 const widths = this._calculateCurveWidths(
582 _lastPoints[1],
583 _lastPoints[2],
584 options,
585 );
586 const curve = Bezier.fromPoints(_lastPoints, widths);
587
588 // Remove the first element from the list, so that there are no more than 4 points at any time.
589 _lastPoints.shift();
590
591 return curve;
592 }
593
594 return null;
595 }
596
597 private _calculateCurveWidths(
598 startPoint: Point,
599 endPoint: Point,
600 options: PointGroupOptions,
601 ): { start: number; end: number } {
602 const velocity =
603 options.velocityFilterWeight * endPoint.velocityFrom(startPoint) +
604 (1 - options.velocityFilterWeight) * this._lastVelocity;
605
606 const newWidth = this._strokeWidth(velocity, options);
607
608 const widths = {
609 end: newWidth,
610 start: this._lastWidth,
611 };
612
613 this._lastVelocity = velocity;
614 this._lastWidth = newWidth;
615
616 return widths;
617 }
618
619 private _strokeWidth(velocity: number, options: PointGroupOptions): number {
620 return Math.max(options.maxWidth / (velocity + 1), options.minWidth);
621 }
622
623 private _drawCurveSegment(x: number, y: number, width: number): void {
624 const ctx = this._ctx;
625
626 ctx.moveTo(x, y);
627 ctx.arc(x, y, width, 0, 2 * Math.PI, false);
628 this._isEmpty = false;
629 }
630
631 private _drawCurve(curve: Bezier, options: PointGroupOptions): void {
632 const ctx = this._ctx;
633 const widthDelta = curve.endWidth - curve.startWidth;
634 // '2' is just an arbitrary number here. If only length is used, then
635 // there are gaps between curve segments :/
636 const drawSteps = Math.ceil(curve.length()) * 2;
637
638 ctx.beginPath();
639 ctx.fillStyle = options.penColor;
640
641 for (let i = 0; i < drawSteps; i += 1) {
642 // Calculate the Bezier (x, y) coordinate for this step.
643 const t = i / drawSteps;
644 const tt = t * t;
645 const ttt = tt * t;
646 const u = 1 - t;
647 const uu = u * u;
648 const uuu = uu * u;
649
650 let x = uuu * curve.startPoint.x;
651 x += 3 * uu * t * curve.control1.x;
652 x += 3 * u * tt * curve.control2.x;
653 x += ttt * curve.endPoint.x;
654
655 let y = uuu * curve.startPoint.y;
656 y += 3 * uu * t * curve.control1.y;
657 y += 3 * u * tt * curve.control2.y;
658 y += ttt * curve.endPoint.y;
659
660 const width = Math.min(
661 curve.startWidth + ttt * widthDelta,
662 options.maxWidth,
663 );
664 this._drawCurveSegment(x, y, width);
665 }
666
667 ctx.closePath();
668 ctx.fill();
669 }
670
671 private _drawDot(point: BasicPoint, options: PointGroupOptions): void {
672 const ctx = this._ctx;
673 const width =
674 options.dotSize > 0
675 ? options.dotSize
676 : (options.minWidth + options.maxWidth) / 2;
677
678 ctx.beginPath();
679 this._drawCurveSegment(point.x, point.y, width);
680 ctx.closePath();
681 ctx.fillStyle = options.penColor;
682 ctx.fill();
683 }
684
685 private _fromData(
686 pointGroups: PointGroup[],
687 drawCurve: SignaturePad['_drawCurve'],
688 drawDot: SignaturePad['_drawDot'],
689 ): void {
690 for (const group of pointGroups) {
691 const { points } = group;
692 const pointGroupOptions = this._getPointGroupOptions(group);
693
694 if (points.length > 1) {
695 for (let j = 0; j < points.length; j += 1) {
696 const basicPoint = points[j];
697 const point = new Point(
698 basicPoint.x,
699 basicPoint.y,
700 basicPoint.pressure,
701 basicPoint.time,
702 );
703
704 if (j === 0) {
705 this._reset(pointGroupOptions);
706 }
707
708 const curve = this._addPoint(point, pointGroupOptions);
709
710 if (curve) {
711 drawCurve(curve, pointGroupOptions);
712 }
713 }
714 } else {
715 this._reset(pointGroupOptions);
716
717 drawDot(points[0], pointGroupOptions);
718 }
719 }
720 }
721
722 public toSVG({ includeBackgroundColor = false }: ToSVGOptions = {}): string {
723 const pointGroups = this._data;
724 const ratio = Math.max(window.devicePixelRatio || 1, 1);
725 const minX = 0;
726 const minY = 0;
727 const maxX = this.canvas.width / ratio;
728 const maxY = this.canvas.height / ratio;
729 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
730
731 svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
732 svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
733 svg.setAttribute('viewBox', `${minX} ${minY} ${maxX} ${maxY}`);
734 svg.setAttribute('width', maxX.toString());
735 svg.setAttribute('height', maxY.toString());
736
737 if (includeBackgroundColor && this.backgroundColor) {
738 const rect = document.createElement('rect');
739 rect.setAttribute('width', '100%');
740 rect.setAttribute('height', '100%');
741 rect.setAttribute('fill', this.backgroundColor);
742
743 svg.appendChild(rect);
744 }
745
746 this._fromData(
747 pointGroups,
748
749 (curve, { penColor }) => {
750 const path = document.createElement('path');
751
752 // Need to check curve for NaN values, these pop up when drawing
753 // lines on the canvas that are not continuous. E.g. Sharp corners
754 // or stopping mid-stroke and than continuing without lifting mouse.
755 if (
756 !isNaN(curve.control1.x) &&
757 !isNaN(curve.control1.y) &&
758 !isNaN(curve.control2.x) &&
759 !isNaN(curve.control2.y)
760 ) {
761 const attr =
762 `M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(
763 3,
764 )} ` +
765 `C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ` +
766 `${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ` +
767 `${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`;
768 path.setAttribute('d', attr);
769 path.setAttribute('stroke-width', (curve.endWidth * 2.25).toFixed(3));
770 path.setAttribute('stroke', penColor);
771 path.setAttribute('fill', 'none');
772 path.setAttribute('stroke-linecap', 'round');
773
774 svg.appendChild(path);
775 }
776 },
777
778 (point, { penColor, dotSize, minWidth, maxWidth }) => {
779 const circle = document.createElement('circle');
780 const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2;
781 circle.setAttribute('r', size.toString());
782 circle.setAttribute('cx', point.x.toString());
783 circle.setAttribute('cy', point.y.toString());
784 circle.setAttribute('fill', penColor);
785
786 svg.appendChild(circle);
787 },
788 );
789
790 return svg.outerHTML;
791 }
792}