@gemx-dev/clarity-visualize 3.5.1

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