@gem-sdk/clarity-visualize 0.8.94

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -0
  3. package/build/clarity.visualize.js +2753 -0
  4. package/build/clarity.visualize.min.js +1 -0
  5. package/build/clarity.visualize.module.js +2748 -0
  6. package/package.json +45 -0
  7. package/rollup.config.ts +79 -0
  8. package/src/clarity.ts +3 -0
  9. package/src/custom/README.md +87 -0
  10. package/src/custom/canvas-layer.ts +355 -0
  11. package/src/custom/dialog.ts +242 -0
  12. package/src/custom/index.ts +7 -0
  13. package/src/data.ts +103 -0
  14. package/src/enrich.ts +80 -0
  15. package/src/global.ts +9 -0
  16. package/src/heatmap.ts +512 -0
  17. package/src/index.ts +4 -0
  18. package/src/interaction.ts +524 -0
  19. package/src/layout.ts +805 -0
  20. package/src/styles/blobUnavailable/chineseSimplified.svg +5 -0
  21. package/src/styles/blobUnavailable/chineseTraditional.svg +5 -0
  22. package/src/styles/blobUnavailable/dutch.svg +5 -0
  23. package/src/styles/blobUnavailable/english.svg +5 -0
  24. package/src/styles/blobUnavailable/french.svg +5 -0
  25. package/src/styles/blobUnavailable/german.svg +5 -0
  26. package/src/styles/blobUnavailable/iconOnly.svg +4 -0
  27. package/src/styles/blobUnavailable/italian.svg +5 -0
  28. package/src/styles/blobUnavailable/japanese.svg +5 -0
  29. package/src/styles/blobUnavailable/korean.svg +5 -0
  30. package/src/styles/blobUnavailable/portuguese.svg +5 -0
  31. package/src/styles/blobUnavailable/russian.svg +5 -0
  32. package/src/styles/blobUnavailable/spanish.svg +5 -0
  33. package/src/styles/blobUnavailable/turkish.svg +5 -0
  34. package/src/styles/iframeUnavailable/chineseSimplified.svg +5 -0
  35. package/src/styles/iframeUnavailable/chineseTraditional.svg +5 -0
  36. package/src/styles/iframeUnavailable/dutch.svg +5 -0
  37. package/src/styles/iframeUnavailable/english.svg +5 -0
  38. package/src/styles/iframeUnavailable/french.svg +5 -0
  39. package/src/styles/iframeUnavailable/german.svg +5 -0
  40. package/src/styles/iframeUnavailable/iconOnly.svg +4 -0
  41. package/src/styles/iframeUnavailable/italian.svg +5 -0
  42. package/src/styles/iframeUnavailable/japanese.svg +5 -0
  43. package/src/styles/iframeUnavailable/korean.svg +5 -0
  44. package/src/styles/iframeUnavailable/portuguese.svg +5 -0
  45. package/src/styles/iframeUnavailable/russian.svg +5 -0
  46. package/src/styles/iframeUnavailable/spanish.svg +5 -0
  47. package/src/styles/iframeUnavailable/turkish.svg +5 -0
  48. package/src/styles/imageMasked/chineseSimplified.svg +5 -0
  49. package/src/styles/imageMasked/chineseTraditional.svg +5 -0
  50. package/src/styles/imageMasked/dutch.svg +5 -0
  51. package/src/styles/imageMasked/english.svg +5 -0
  52. package/src/styles/imageMasked/french.svg +5 -0
  53. package/src/styles/imageMasked/german.svg +5 -0
  54. package/src/styles/imageMasked/iconOnly.svg +4 -0
  55. package/src/styles/imageMasked/italian.svg +5 -0
  56. package/src/styles/imageMasked/japanese.svg +5 -0
  57. package/src/styles/imageMasked/korean.svg +5 -0
  58. package/src/styles/imageMasked/portuguese.svg +5 -0
  59. package/src/styles/imageMasked/russian.svg +5 -0
  60. package/src/styles/imageMasked/spanish.svg +5 -0
  61. package/src/styles/imageMasked/turkish.svg +5 -0
  62. package/src/styles/pointer/click.css +31 -0
  63. package/src/styles/pointer/pointerIcon.svg +18 -0
  64. package/src/styles/shared.css +6 -0
  65. package/src/visualizer.ts +308 -0
  66. package/tsconfig.json +13 -0
  67. package/tslint.json +33 -0
  68. package/types/heatmap.d.ts +18 -0
  69. package/types/index.d.ts +11 -0
  70. package/types/layout.d.ts +12 -0
  71. package/types/string-import.d.ts +9 -0
  72. package/types/visualize.d.ts +253 -0
@@ -0,0 +1,524 @@
1
+ import { Asset, Constant, PlaybackState, Point, Setting } from "@clarity-types/visualize";
2
+ import { Data, Layout } from "@gem-sdk/clarity-js";
3
+ import type { Interaction } from "@gem-sdk/clarity-decode"
4
+ import { LayoutHelper } from "./layout";
5
+ import pointerSvg from "./styles/pointer/pointerIcon.svg";
6
+ import clickStyle from "./styles/pointer/click.css";
7
+ import { BooleanFlag } from "@gem-sdk/clarity-decode/types/data";
8
+ import { ClickVizualizationData } from "@gem-sdk/clarity-decode/types/interaction";
9
+
10
+ export class InteractionHelper {
11
+ static TRAIL_START_COLOR = [242, 97, 12]; // rgb(242,97,12)
12
+ static TRAIL_END_COLOR = [249, 220, 209]; // rgb(249,220,209)
13
+
14
+ hoverId: number = null;
15
+ targetId: number = null;
16
+ points: Point[] = [];
17
+ scrollPointIndex = 0;
18
+ clickAudio = null;
19
+ layout: LayoutHelper;
20
+ state: PlaybackState;
21
+ vnext: boolean;
22
+ visualizedClicks: Map<HTMLElement, ClickVizualizationData> = new Map();
23
+
24
+ constructor(state: PlaybackState, layout: LayoutHelper, vnext: boolean) {
25
+ this.state = state;
26
+ this.layout = layout;
27
+ this.vnext = vnext;
28
+ }
29
+
30
+ public reset = (): void => {
31
+ this.points = [];
32
+ this.scrollPointIndex = 0;
33
+ this.clickAudio = null;
34
+ this.hoverId = null;
35
+ this.targetId = null;
36
+ this.layout.reset();
37
+ };
38
+
39
+ public scroll = (event: Interaction.ScrollEvent): void => {
40
+ let data = event.data;
41
+ let doc = this.state.window.document;
42
+ let de = doc.documentElement;
43
+ let scrollTarget = this.layout.element(data.target as number) as HTMLElement || doc.body;
44
+ let scrollable = scrollTarget.scrollHeight > scrollTarget.clientHeight || scrollTarget.scrollWidth > scrollTarget.clientWidth;
45
+ if (scrollTarget && scrollable) {
46
+ scrollTarget.scrollTo(data.x, data.y);
47
+ // In an edge case, scrolling API doesn't work when css on HTML element has height:100% and overflow:auto
48
+ // In those cases, we fall back to scrolling the body element.
49
+ if (scrollTarget === de && scrollTarget.offsetTop !== data.y) {
50
+ scrollTarget = doc.body;
51
+ scrollTarget.scrollTo(data.x, data.y);
52
+ }
53
+ }
54
+
55
+ // Position canvas relative to scroll events on the parent page
56
+ if (scrollTarget === de || scrollTarget === doc.body) {
57
+ if (!scrollable) {
58
+ this.state.window.scrollTo(data.x, data.y);
59
+ }
60
+ let canvas = this.overlay();
61
+ if (canvas) {
62
+ canvas.style.left = data.x + Constant.Pixel;
63
+ canvas.style.top = data.y + Constant.Pixel;
64
+ canvas.width = de.clientWidth;
65
+ canvas.height = de.clientHeight;
66
+ }
67
+ this.scrollPointIndex = this.points.length;
68
+ }
69
+ };
70
+
71
+ public resize = (event: Interaction.ResizeEvent): void => {
72
+ let data = event.data;
73
+ let width = data.width;
74
+ let height = data.height;
75
+ if (this.state.options.onresize) {
76
+ this.state.options.onresize(width, height);
77
+ }
78
+ };
79
+
80
+ public visibility = (event: Interaction.VisibilityEvent): void => {
81
+ let doc = this.state.window.document;
82
+ if (doc && doc.documentElement && event.data.visible === BooleanFlag.False) {
83
+ // if the website has styles on the <html> node then we need to save the reference to them before we change them
84
+ // to indicate the window was hidden. This is to ensure that we can restore the original styles when the window is visible again.
85
+ const bg = doc.documentElement.style.backgroundColor;
86
+ if (bg) {
87
+ doc.documentElement.setAttribute(Constant.OriginalBackgroundColor, bg);
88
+ }
89
+ const o = doc.documentElement.style.opacity;
90
+ if (o) {
91
+ doc.documentElement.setAttribute(Constant.OriginalOpacity, o);
92
+ }
93
+ doc.documentElement.style.backgroundColor = Constant.Black;
94
+ doc.documentElement.style.opacity = Constant.HiddenOpacity;
95
+ } else {
96
+ if (doc.documentElement.getAttribute(Constant.OriginalBackgroundColor)) {
97
+ doc.documentElement.style.backgroundColor = doc.documentElement.getAttribute(Constant.OriginalBackgroundColor);
98
+ } else {
99
+ doc.documentElement.style.backgroundColor = '';
100
+ }
101
+ if (doc.documentElement.getAttribute(Constant.OriginalOpacity)) {
102
+ doc.documentElement.style.opacity = doc.documentElement.getAttribute(Constant.OriginalOpacity);
103
+ } else {
104
+ doc.documentElement.style.opacity = '';
105
+ }
106
+ }
107
+ };
108
+
109
+ public input = (event: Interaction.InputEvent): void => {
110
+ let data = event.data;
111
+ let el = this.layout.element(data.target as number) as HTMLInputElement;
112
+ if (el) {
113
+ switch (el.type) {
114
+ case "checkbox":
115
+ case "radio":
116
+ el.checked = data.value === "true";
117
+ break;
118
+ case "file":
119
+ // We cannot set value for files, only allowed to be an empty string when programatically set
120
+ break;
121
+ default:
122
+ el.value = data.value;
123
+ break;
124
+ }
125
+ }
126
+ };
127
+
128
+ public selection = (event: Interaction.SelectionEvent): void => {
129
+ let data = event.data;
130
+ let doc = this.state.window.document;
131
+ let s = doc.getSelection();
132
+ // Wrapping selection code inside a try / catch to avoid throwing errors when dealing with elements inside the shadow DOM.
133
+ try { s.setBaseAndExtent(this.layout.element(data.start as number), data.startOffset, this.layout.element(data.end as number), data.endOffset); } catch (ex) {
134
+ console.warn("Exception encountered while trying to set selection: " + ex);
135
+ }
136
+ };
137
+
138
+ public pointer = (event: Interaction.PointerEvent): void => {
139
+ if (!this.state.options.pointer) {
140
+ return;
141
+ }
142
+ let data = event.data;
143
+ let type = event.event;
144
+ let doc = this.state.window.document;
145
+ let de = doc.documentElement;
146
+ let p = doc.getElementById(Constant.PointerLayer);
147
+ let pointerWidth = Setting.PointerWidth;
148
+ let pointerHeight = Setting.PointerHeight;
149
+
150
+ if (p === null) {
151
+ p = doc.createElement("DIV");
152
+ p.id = Constant.PointerLayer;
153
+ de.appendChild(p);
154
+
155
+ // Add custom styles
156
+ let style = doc.createElement("STYLE");
157
+ style.textContent =
158
+ "@keyframes pulsate-one { 0% { transform: scale(1, 1); opacity: 1; } 100% { transform: scale(3, 3); opacity: 0; } }" +
159
+ "@keyframes pulsate-two { 0% { transform: scale(1, 1); opacity: 1; } 100% { transform: scale(5, 5); opacity: 0; } }" +
160
+ "@keyframes pulsate-touch { 0% { transform: scale(1, 1); opacity: 1; } 100% { transform: scale(2, 2); opacity: 0; } }" +
161
+ "@keyframes disappear { 90% { transform: scale(1, 1); opacity: 1; } 100% { transform: scale(1.3, 1.3); opacity: 0; } }" +
162
+ `#${Constant.InteractionCanvas} { position: absolute; left: 0; top: 0; z-index: ${Setting.ZIndex}; background: none; }` +
163
+ `#${Constant.PointerLayer} { position: absolute; z-index: ${Setting.ZIndex}; url(${Asset.Pointer}) no-repeat left center; width: ${pointerWidth}px; height: ${pointerHeight}px; }` +
164
+ this.getClickLayerStyle() +
165
+ `.${Constant.TouchLayer} { background: radial-gradient(rgba(242,97,12,1), transparent); }` +
166
+ `.${Constant.TouchRing} { background: transparent; border: 1px solid rgba(242,97,12,0.8); }` +
167
+ `.${Constant.PointerClickLayer} { background-image: url(${Asset.Click}); }` +
168
+ `.${Constant.PointerNone} { background: none; }` +
169
+ this.getPointerStyle();
170
+
171
+ p.appendChild(style);
172
+ }
173
+
174
+ p.style.left = (data.x - Setting.PointerOffset) + Constant.Pixel;
175
+ p.style.top = (data.y - Setting.PointerOffset) + Constant.Pixel;
176
+ let title = "Pointer"
177
+ switch (type) {
178
+ case Data.Event.Click:
179
+ title = "Click";
180
+ const clickElement = this.drawClick(doc, data.x, data.y, title);
181
+ this.visualizedClicks.set(clickElement, {
182
+ doc: de,
183
+ click: clickElement,
184
+ time: event.time
185
+ });
186
+ if (this.state.options.onclickMismatch) {
187
+ const originalTarget = this.layout.element(data.target as number);
188
+ let correctTargetHit = false;
189
+ const elementsUnderClick = doc.elementsFromPoint(data.x, data.y);
190
+ for (const elementUnderClick of elementsUnderClick) {
191
+ if (originalTarget === elementUnderClick) {
192
+ correctTargetHit = true;
193
+ }
194
+ }
195
+ if (!correctTargetHit) {
196
+ this.state.options.onclickMismatch({
197
+ time: event.time,
198
+ x: data.x,
199
+ y: data.y,
200
+ nodeId: data.target as number});
201
+ }
202
+ }
203
+
204
+ p.className = Constant.PointerNone;
205
+ break;
206
+ case Data.Event.DoubleClick:
207
+ title = "Click";
208
+ const doubleClickElement = this.drawClick(doc, data.x, data.y, title);
209
+ this.visualizedClicks.set(doubleClickElement, {
210
+ doc: de,
211
+ click: doubleClickElement,
212
+ time: event.time
213
+ });
214
+ p.className = Constant.PointerNone;
215
+ break;
216
+ case Data.Event.TouchStart:
217
+ case Data.Event.TouchEnd:
218
+ case Data.Event.TouchCancel:
219
+ title = "Touch";
220
+ const touchElement = this.drawTouch(doc, data.x, data.y, title);
221
+ this.visualizedClicks.set(touchElement, {
222
+ doc: de,
223
+ click: touchElement,
224
+ time: event.time
225
+ });
226
+ p.className = Constant.PointerNone;
227
+ break;
228
+ case Data.Event.TouchMove:
229
+ title = "Touch Move";
230
+ p.className = Constant.PointerNone;
231
+ break;
232
+ case Data.Event.MouseMove:
233
+ title = "Mouse Move";
234
+ p.className = Constant.PointerMove;
235
+ this.addPoint({ time: event.time, x: data.x, y: data.y });
236
+ this.targetId = data.target as number;
237
+ break;
238
+ default:
239
+ p.className = Constant.PointerMove;
240
+ break;
241
+ }
242
+ p.setAttribute(Constant.Title, `${title} (${data.x}${Constant.Pixel}, ${data.y}${Constant.Pixel})`);
243
+ };
244
+
245
+ public clearOldClickVisualizations = (currentTimestamp: number): void => {
246
+ if (this.vnext) {
247
+ // Track elements to remove to avoid modifying Map during iteration
248
+ const elementsToRemove: HTMLElement[] = [];
249
+
250
+ // Calculate how many clicks exceed the maximum allowed
251
+ const excess = Math.max(0, this.visualizedClicks.size - Setting.MaxClicksDisplayed);
252
+ let count = 0;
253
+
254
+ // Iterate through visualized clicks and mark old ones for removal
255
+ for (const [element, data] of this.visualizedClicks) {
256
+ count++;
257
+ const isTooOld = currentTimestamp - data.time > Setting.MaxClickDisplayDuration;
258
+ const exceedsMaxCount = count <= excess;
259
+
260
+ if (isTooOld || exceedsMaxCount) {
261
+ elementsToRemove.push(element);
262
+ this.fadeOutElement(element, data.doc);
263
+ }
264
+ }
265
+
266
+ // Remove marked elements from the Map
267
+ elementsToRemove.forEach(element => this.visualizedClicks.delete(element));
268
+ }
269
+ }
270
+
271
+ private fadeOutElement = (element: HTMLElement, document: HTMLElement): void => {
272
+ element.classList.add("clarity-click-hidden");
273
+ setTimeout(() => { document.removeChild(element); }, 10000);
274
+ }
275
+
276
+ private hover = (): void => {
277
+ if (this.targetId && this.targetId !== this.hoverId) {
278
+ let depth = 0;
279
+ // First, remove any previous hover class assignments
280
+ let hoverNode = this.hoverId ? this.layout.element(this.hoverId) as HTMLElement : null;
281
+ while (hoverNode && depth < Setting.HoverDepth) {
282
+ if ("removeAttribute" in hoverNode) { hoverNode.removeAttribute(Constant.HoverAttribute); }
283
+ hoverNode = hoverNode.parentElement;
284
+ depth++;
285
+ }
286
+ // Then, add hover class on elements that are below the pointer
287
+ depth = 0;
288
+ let targetNode = this.targetId ? this.layout.element(this.targetId) as HTMLElement : null;
289
+ while (targetNode && depth < Setting.HoverDepth) {
290
+ if ("setAttribute" in targetNode) { targetNode.setAttribute(Constant.HoverAttribute, Layout.Constant.Empty); }
291
+ targetNode = targetNode.parentElement;
292
+ depth++;
293
+ }
294
+ // Finally, update hoverId to reflect the new node
295
+ this.hoverId = this.targetId;
296
+ }
297
+ };
298
+
299
+ private addPoint = (point: Point): void => {
300
+ let last = this.points.length > 0 ? this.points[this.points.length - 1] : null;
301
+ if (last && point.x === last.x && point.y === last.y) {
302
+ last.time = point.time;
303
+ } else { this.points.push(point); }
304
+ }
305
+
306
+ private drawTouch = (doc: Document, x: number, y: number, title: string): HTMLElement => {
307
+ let de = doc.documentElement;
308
+ let touch = doc.createElement("DIV");
309
+ touch.className = Constant.TouchLayer;
310
+ touch.setAttribute(Constant.Title, `${title} (${x}${Constant.Pixel}, ${y}${Constant.Pixel})`);
311
+ touch.style.left = (x - Setting.ClickRadius / 2) + Constant.Pixel;
312
+ touch.style.top = (y - Setting.ClickRadius / 2) + Constant.Pixel
313
+ touch.style.animation = "disappear 1 1s";
314
+ touch.style.animationFillMode = "forwards";
315
+ de.appendChild(touch);
316
+
317
+ // First pulsating ring
318
+ let ringOne = touch.cloneNode() as HTMLElement;
319
+ ringOne.className = Constant.TouchRing;
320
+ ringOne.style.left = "-0.5" + Constant.Pixel;
321
+ ringOne.style.top = "-0.5" + Constant.Pixel;
322
+ ringOne.style.animation = "pulsate-touch 1 1s";
323
+ ringOne.style.animationFillMode = "forwards";
324
+ touch.appendChild(ringOne);
325
+
326
+ return touch;
327
+ };
328
+
329
+ private drawClick = (doc: Document, x: number, y: number, title: string): HTMLElement => {
330
+ let de = doc.documentElement;
331
+ let click = doc.createElement("DIV");
332
+ click.className = Constant.ClickLayer;
333
+
334
+ click.setAttribute(Constant.Title, `${title} (${x}${Constant.Pixel}, ${y}${Constant.Pixel})`);
335
+ click.style.left = (x - Setting.ClickRadius / 2) + Constant.Pixel;
336
+ click.style.top = (y - Setting.ClickRadius / 2) + Constant.Pixel
337
+
338
+ // First pulsating ring
339
+ let ringOne = click.cloneNode() as HTMLElement;
340
+ ringOne.className = Constant.ClickRing;
341
+ ringOne.style.left = "-0.5" + Constant.Pixel;
342
+ ringOne.style.top = "-0.5" + Constant.Pixel;
343
+ ringOne.style.animation = "pulsate-one 1 1s";
344
+ ringOne.style.animationFillMode = "forwards";
345
+ click.appendChild(ringOne);
346
+
347
+ if (this.vnext) {
348
+ let center = doc.createElement("DIV");
349
+ center.className = `${Constant.ClickLayer}-center`;
350
+ click.appendChild(center);
351
+ } else {
352
+ // Second pulsating ring
353
+ let ringTwo = ringOne.cloneNode() as HTMLElement;
354
+ ringTwo.style.animation = "pulsate-two 1 1s";
355
+ click.appendChild(ringTwo);
356
+ }
357
+ de.appendChild(click);
358
+
359
+ // Play sound
360
+ if (typeof Audio !== Constant.Undefined) {
361
+ if (this.clickAudio === null) {
362
+ this.clickAudio = new Audio(Asset.Sound);
363
+ click.appendChild(this.clickAudio);
364
+ }
365
+ this.clickAudio.play();
366
+ }
367
+ return click;
368
+ };
369
+
370
+ private overlay = (): HTMLCanvasElement => {
371
+ // Create canvas for visualizing interactions
372
+ let doc = this.state.window.document;
373
+ let de = doc.documentElement;
374
+ let canvas = doc.getElementById(Constant.InteractionCanvas) as HTMLCanvasElement;
375
+ if (canvas === null) {
376
+ canvas = doc.createElement("canvas");
377
+ canvas.id = Constant.InteractionCanvas;
378
+ canvas.width = 0;
379
+ canvas.height = 0;
380
+ de.appendChild(canvas);
381
+ }
382
+
383
+ if (canvas.width !== de.clientWidth || canvas.height !== de.clientHeight) {
384
+ canvas.width = de.clientWidth;
385
+ canvas.height = de.clientHeight;
386
+ }
387
+
388
+ return canvas;
389
+ };
390
+
391
+ private match = (time: number): Point[] => {
392
+ let p = [];
393
+ for (let i = this.points.length - 1; i > 0; i--) {
394
+ // Each pixel in the trail has a pixel life of 3s. The only exception to this is if the user scrolled.
395
+ // We reset the trail after every scroll event to avoid drawing weird looking trail.
396
+ if (i >= this.scrollPointIndex && time - this.points[i].time < Setting.PixelLife) {
397
+ p.push(this.points[i]);
398
+ } else { break; }
399
+ }
400
+ return p.slice(0, Setting.MaxTrailPoints);
401
+ };
402
+
403
+ public trail = (now: number): void => {
404
+ const canvas = this.overlay();
405
+ if (this.state.options.canvas && canvas) {
406
+ const ctx = canvas.getContext('2d');
407
+ const path = this.state.options.keyframes ? this.curve(this.points.reverse()) : this.curve(this.match(now));
408
+ // Update hovered elements
409
+ this.hover();
410
+ // We need at least two points to create a line
411
+ if (path.length > 1) {
412
+ let last = path[0];
413
+ // Start off by clearing whatever was on the canvas before
414
+ // The current implementation is inefficient. We have to redraw canvas all over again for every point.
415
+ // In future we should batch pointer events and minimize the number of times we have to redraw canvas.
416
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
417
+ let count = path.length;
418
+ let offsetX = canvas.offsetLeft;
419
+ let offsetY = canvas.offsetTop;
420
+ for (let i = 1; i < count; i++) {
421
+ let current = path[i];
422
+
423
+ // Compute percentage position of these points compared to all points in the path
424
+ let lastFactor = 1 - ((i - 1) / count);
425
+ let currentFactor = 1 - (i / count);
426
+
427
+ // Generate a color gradient that goes from red -> yellow -> green -> light blue -> blue
428
+ let gradient = ctx.createLinearGradient(last.x, last.y, current.x, current.y);
429
+ gradient.addColorStop(1, this.color(currentFactor))
430
+ gradient.addColorStop(0, this.color(lastFactor))
431
+
432
+ // Line width of the trail shrinks as the position of the point goes farther away.
433
+ ctx.lineWidth = Setting.TrailWidth * currentFactor;
434
+ ctx.lineCap = Constant.Round;
435
+ ctx.lineJoin = Constant.Round;
436
+ ctx.strokeStyle = gradient;
437
+ ctx.beginPath();
438
+
439
+ // The coordinates need to be relative to where canvas is rendered.
440
+ // In case of scrolling on the page, canvas may be relative to viewport
441
+ // while trail points are relative to screen origin (0, 0). We make the adjustment so trail looks right.
442
+ ctx.moveTo(last.x - offsetX, last.y - offsetY);
443
+ ctx.lineTo(current.x - offsetX, current.y - offsetY);
444
+ ctx.stroke();
445
+ ctx.closePath();
446
+ last = current;
447
+ }
448
+ }
449
+ // If we are only rendering key frames, clear points array after each key frame
450
+ if (this.state.options.keyframes) { this.points = []; }
451
+ }
452
+ };
453
+
454
+ private color = (factor: number): string => {
455
+ let s = InteractionHelper.TRAIL_START_COLOR;
456
+ let e = InteractionHelper.TRAIL_END_COLOR;
457
+ let c = [];
458
+ for (let i = 0; i < 3; i++) { c[i] = Math.round(e[i] + factor * (s[i] - e[i])); }
459
+ return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${factor})`;
460
+ };
461
+
462
+ // Reference: https://en.wikipedia.org/wiki/Cubic_Hermite_spline#Cardinal_spline
463
+ private curve = (path: Point[]): Point[] => {
464
+ const tension = 0.5;
465
+ let p = [];
466
+ let output = [];
467
+
468
+ // Make a copy of the input points so we don't make any side effects
469
+ p = path.slice(0);
470
+ // The algorithm require a valid previous and next point for each point in the original input
471
+ // Duplicate first and last point in the path to the beginning and the end of the array respectively
472
+ // E.g. [{x:37,y:45}, {x:54,y:34}] => [{x:37,y:45}, {x:37,y:45}, {x:54,y:34}, {x:54,y:34}]
473
+ p.unshift(path[0]);
474
+ p.push(path[path.length - 1]);
475
+ // Loop through the points, and generate intermediate points to make a smooth trail
476
+ for (let i = 1; i < p.length - 2; i++) {
477
+ const time = p[i].time;
478
+ const segments = Math.max(Math.min(Math.round(this.distance(p[i], p[i - 1])), 10), 1);
479
+ for (let t = 0; t <= segments; t++) {
480
+
481
+ // Compute tension vectors
482
+ let t1: Point = { time, x: (p[i + 1].x - p[i - 1].x) * tension, y: (p[i + 1].y - p[i - 1].y) * tension };
483
+ let t2: Point = { time, x: (p[i + 2].x - p[i].x) * tension, y: (p[i + 2].y - p[i].y) * tension };
484
+ let step = t / segments;
485
+
486
+ // Compute cardinals
487
+ let c1 = 2 * Math.pow(step, 3) - 3 * Math.pow(step, 2) + 1;
488
+ let c2 = -(2 * Math.pow(step, 3)) + 3 * Math.pow(step, 2);
489
+ let c3 = Math.pow(step, 3) - 2 * Math.pow(step, 2) + step;
490
+ let c4 = Math.pow(step, 3) - Math.pow(step, 2);
491
+
492
+ // Compute new point with common control vectors
493
+ let x = c1 * p[i].x + c2 * p[i + 1].x + c3 * t1.x + c4 * t2.x;
494
+ let y = c1 * p[i].y + c2 * p[i + 1].y + c3 * t1.y + c4 * t2.y;
495
+
496
+ output.push({ time, x, y });
497
+ }
498
+ }
499
+ return output;
500
+ };
501
+
502
+ private distance = (a: Point, b: Point): number => {
503
+ const dx = a.x - b.x;
504
+ const dy = a.y - b.y;
505
+ return Math.sqrt(dx * dx + dy * dy);
506
+ };
507
+
508
+ private getPointerStyle = (): string => {
509
+ if (this.vnext) {
510
+ return `.${Constant.PointerMove} { ${pointerSvg} }`;
511
+ } else {
512
+ return `.${Constant.PointerMove} { background-image: url(${Asset.Pointer}); }`;
513
+ }
514
+ }
515
+
516
+ private getClickLayerStyle = (): string => {
517
+ if (this.vnext) {
518
+ return clickStyle;
519
+ } else {
520
+ return `.${Constant.ClickLayer}, .${Constant.ClickRing}, .${Constant.TouchLayer}, .${Constant.TouchRing} { position: absolute; z-index: ${Setting.ZIndex}; border-radius: 50%; background: radial-gradient(rgba(0,90,158,0.8), transparent); width: ${Setting.ClickRadius}px; height: ${Setting.ClickRadius}px;}` +
521
+ `.${Constant.ClickRing} { background: transparent; border: 1px solid rgba(0,90,158,0.8); }`;
522
+ }
523
+ }
524
+ }