@gemx-dev/clarity-visualize 0.8.39

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 (65) hide show
  1. package/README.md +1 -0
  2. package/build/clarity.visualize.js +2128 -0
  3. package/build/clarity.visualize.min.js +1 -0
  4. package/build/clarity.visualize.module.js +2125 -0
  5. package/package.json +45 -0
  6. package/rollup.config.ts +79 -0
  7. package/src/clarity.ts +3 -0
  8. package/src/data.ts +103 -0
  9. package/src/enrich.ts +80 -0
  10. package/src/global.ts +9 -0
  11. package/src/heatmap.ts +299 -0
  12. package/src/index.ts +2 -0
  13. package/src/interaction.ts +507 -0
  14. package/src/layout.ts +727 -0
  15. package/src/styles/blobUnavailable/chineseSimplified.svg +5 -0
  16. package/src/styles/blobUnavailable/chineseTraditional.svg +5 -0
  17. package/src/styles/blobUnavailable/dutch.svg +5 -0
  18. package/src/styles/blobUnavailable/english.svg +5 -0
  19. package/src/styles/blobUnavailable/french.svg +5 -0
  20. package/src/styles/blobUnavailable/german.svg +5 -0
  21. package/src/styles/blobUnavailable/iconOnly.svg +4 -0
  22. package/src/styles/blobUnavailable/italian.svg +5 -0
  23. package/src/styles/blobUnavailable/japanese.svg +5 -0
  24. package/src/styles/blobUnavailable/korean.svg +5 -0
  25. package/src/styles/blobUnavailable/portuguese.svg +5 -0
  26. package/src/styles/blobUnavailable/russian.svg +5 -0
  27. package/src/styles/blobUnavailable/spanish.svg +5 -0
  28. package/src/styles/blobUnavailable/turkish.svg +5 -0
  29. package/src/styles/iframeUnavailable/chineseSimplified.svg +5 -0
  30. package/src/styles/iframeUnavailable/chineseTraditional.svg +5 -0
  31. package/src/styles/iframeUnavailable/dutch.svg +5 -0
  32. package/src/styles/iframeUnavailable/english.svg +5 -0
  33. package/src/styles/iframeUnavailable/french.svg +5 -0
  34. package/src/styles/iframeUnavailable/german.svg +5 -0
  35. package/src/styles/iframeUnavailable/iconOnly.svg +4 -0
  36. package/src/styles/iframeUnavailable/italian.svg +5 -0
  37. package/src/styles/iframeUnavailable/japanese.svg +5 -0
  38. package/src/styles/iframeUnavailable/korean.svg +5 -0
  39. package/src/styles/iframeUnavailable/portuguese.svg +5 -0
  40. package/src/styles/iframeUnavailable/russian.svg +5 -0
  41. package/src/styles/iframeUnavailable/spanish.svg +5 -0
  42. package/src/styles/iframeUnavailable/turkish.svg +5 -0
  43. package/src/styles/imageMasked/chineseSimplified.svg +5 -0
  44. package/src/styles/imageMasked/chineseTraditional.svg +5 -0
  45. package/src/styles/imageMasked/dutch.svg +5 -0
  46. package/src/styles/imageMasked/english.svg +5 -0
  47. package/src/styles/imageMasked/french.svg +5 -0
  48. package/src/styles/imageMasked/german.svg +5 -0
  49. package/src/styles/imageMasked/iconOnly.svg +4 -0
  50. package/src/styles/imageMasked/italian.svg +5 -0
  51. package/src/styles/imageMasked/japanese.svg +5 -0
  52. package/src/styles/imageMasked/korean.svg +5 -0
  53. package/src/styles/imageMasked/portuguese.svg +5 -0
  54. package/src/styles/imageMasked/russian.svg +5 -0
  55. package/src/styles/imageMasked/spanish.svg +5 -0
  56. package/src/styles/imageMasked/turkish.svg +5 -0
  57. package/src/styles/pointer/click.css +31 -0
  58. package/src/styles/pointer/pointerIcon.svg +18 -0
  59. package/src/styles/shared.css +6 -0
  60. package/src/visualizer.ts +260 -0
  61. package/tsconfig.json +21 -0
  62. package/tslint.json +33 -0
  63. package/types/index.d.ts +10 -0
  64. package/types/string-import.d.ts +9 -0
  65. package/types/visualize.d.ts +236 -0
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@gemx-dev/clarity-visualize",
3
+ "version": "0.8.39",
4
+ "description": "Clarity visualize",
5
+ "author": "Microsoft Corp.",
6
+ "license": "MIT",
7
+ "main": "build/clarity.visualize.js",
8
+ "module": "build/clarity.visualize.module.js",
9
+ "unpkg": "build/clarity.visualize.min.js",
10
+ "types": "types/index.d.ts",
11
+ "dependencies": {
12
+ "@gemx-dev/clarity-decode": "^0.8.39"
13
+ },
14
+ "devDependencies": {
15
+ "@rollup/plugin-commonjs": "^24.0.0",
16
+ "@rollup/plugin-node-resolve": "^15.0.0",
17
+ "@rollup/plugin-terser": "^0.4.0",
18
+ "@rollup/plugin-typescript": "^11.0.0",
19
+ "rollup-plugin-string-import": "^1.2.5",
20
+ "del-cli": "^5.0.0",
21
+ "husky": "^8.0.0",
22
+ "lint-staged": "^13.1.0",
23
+ "rollup": "^3.0.0",
24
+ "ts-node": "^10.1.0",
25
+ "tslint": "^6.1.3",
26
+ "typescript": "^4.3.5"
27
+ },
28
+ "scripts": {
29
+ "build": "yarn build:clean && yarn build:main",
30
+ "build:main": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript",
31
+ "build:clean": "del-cli build/*",
32
+ "tslint": "tslint --project ./",
33
+ "tslint:fix": "tslint --fix --project ./ --force"
34
+ },
35
+ "husky": {
36
+ "hooks": {
37
+ "pre-commit": "lint-staged"
38
+ }
39
+ },
40
+ "lint-staged": {
41
+ "*.ts": [
42
+ "tslint --format codeFrame"
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,79 @@
1
+ import commonjs from "@rollup/plugin-commonjs";
2
+ import resolve from "@rollup/plugin-node-resolve";
3
+ import terser from "@rollup/plugin-terser";
4
+ import typescript from "@rollup/plugin-typescript";
5
+ import { importAsString } from 'rollup-plugin-string-import';
6
+ import { readFileSync } from "fs";
7
+
8
+ const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
9
+
10
+ function wrapWithBackground(svg){
11
+ return `background: url("data:image/svg+xml,${svg}") no-repeat center center;`;
12
+ }
13
+
14
+ function regexEncode(svg){
15
+ svg = svg.replace(/>\s{1,}</g, `><`);
16
+ svg = svg.replace(/\s{2,}/g, ` `);
17
+ return svg.replace(/[\r\n%#()<>?[\\\]^`{|}]/g, encodeURIComponent)
18
+ }
19
+
20
+ function doublesToSingles(svg){
21
+ return svg.replace(/"/g, "'")
22
+ }
23
+
24
+ function preprocessFile(content: string, fileName: string): string {
25
+ if (fileName.endsWith(".css")) {
26
+ // remove comments/newlines
27
+ return content.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "").replace(/\r\n/g, "");
28
+ }
29
+ else if (fileName.endsWith(".svg")) {
30
+ // removes comments/newlines while also encoding the svg and adding some scaffolding to import it
31
+ return wrapWithBackground(regexEncode(doublesToSingles(content)));
32
+ }
33
+ return "";
34
+ }
35
+
36
+ export default [
37
+ {
38
+ input: "src/index.ts",
39
+ output: [
40
+ { file: pkg.main, format: "cjs", exports: "named" },
41
+ { file: pkg.module, format: "es", exports: "named" }
42
+ ],
43
+ plugins: [
44
+ resolve(),
45
+ importAsString({
46
+ include: ["**/*.css", "**/*.svg"],
47
+ transform: preprocessFile,
48
+ }),
49
+ typescript(),
50
+ commonjs({ include: ["node_modules/**"] })
51
+ ],
52
+ onwarn(message, warn) {
53
+ if (message.code === 'NON_EXISTENT_EXPORT') { return; }
54
+ if (message.code === 'CIRCULAR_DEPENDENCY') { return; }
55
+ if (message.code === 'SOURCEMAP_ERROR') { return; }
56
+ warn(message);
57
+ }
58
+ },
59
+ {
60
+ input: "src/global.ts",
61
+ output: [ { file: pkg.unpkg, format: "iife", exports: "named" } ],
62
+ plugins: [
63
+ resolve(),
64
+ importAsString({
65
+ include: ["**/*.css", "**/*.svg"],
66
+ transform: preprocessFile,
67
+ }),
68
+ typescript(),
69
+ terser({output: {comments: false}}),
70
+ commonjs({ include: ["node_modules/**"] })
71
+ ],
72
+ onwarn(message, warn) {
73
+ if (message.code === 'NON_EXISTENT_EXPORT') { return; }
74
+ if (message.code === 'CIRCULAR_DEPENDENCY') { return; }
75
+ if (message.code === 'SOURCEMAP_ERROR') { return; }
76
+ warn(message);
77
+ }
78
+ }
79
+ ];
package/src/clarity.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Visualizer } from "./visualizer";
2
+
3
+ export const { state, dom, get, html, time, clickmap, clearmap, scrollmap, merge, setup, render } = new Visualizer();
package/src/data.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { Data, Layout } from "clarity-js";
2
+ import type { Data as DecodedData, Layout as DecodedLayout } from "clarity-decode";
3
+ import { PlaybackState, RegionState } from "@clarity-types/visualize";
4
+
5
+ export class DataHelper {
6
+ regionMap = {};
7
+ regions: { [key: string]: RegionState} = {};
8
+ metrics: {[key: number]: number} = {};
9
+ lean = false;
10
+ state: PlaybackState;
11
+
12
+ constructor(state: PlaybackState) {
13
+ this.state = state;
14
+ }
15
+
16
+ static METRIC_MAP = {
17
+ [Data.Metric.TotalBytes]: { name: "Total Bytes", unit: "KB" },
18
+ [Data.Metric.TotalCost]: { name: "Total Cost", unit: "ms" },
19
+ [Data.Metric.LayoutCost]: { name: "Layout Cost", unit: "ms" },
20
+ [Data.Metric.LargestPaint]: { name: "LCP", unit: "s" },
21
+ [Data.Metric.CumulativeLayoutShift]: { name: "CLS", unit: "cls" },
22
+ [Data.Metric.LongTaskCount]: { name: "Long Tasks" },
23
+ [Data.Metric.CartTotal]: { name: "Cart Total", unit: "html-price" },
24
+ [Data.Metric.ProductPrice]: { name: "Product Price", unit: "ld-price" },
25
+ [Data.Metric.ThreadBlockedTime]: { name: "Thread Blocked", unit: "ms" },
26
+ [Data.Dimension.InteractionNextPaint]: { name: "INP", unit: "ms" }
27
+ };
28
+
29
+ public reset = (): void => {
30
+ this.metrics = {};
31
+ this.lean = false;
32
+ this.regions = {};
33
+ this.regionMap = {};
34
+ }
35
+
36
+ public metric = (event: DecodedData.MetricEvent): void => {
37
+ if (this.state.options.metadata) {
38
+ let metricMarkup = [];
39
+ let regionMarkup = [];
40
+ // Copy over metrics for future reference
41
+ for (let m in event.data) {
42
+ const eventType = typeof event.data[m];
43
+ if (eventType === "number" || (event.event === Data.Event.Dimension && m === Data.Dimension.InteractionNextPaint.toString())) {
44
+ if (!(m in this.metrics)) { this.metrics[m] = 0; }
45
+ let key = parseInt(m, 10);
46
+ let value = eventType === "object" ? Number(event.data[m]?.[0]) : event.data[m];
47
+ if (m in DataHelper.METRIC_MAP && (DataHelper.METRIC_MAP[m].unit === "html-price" ||DataHelper.METRIC_MAP[m].unit === "ld-price")) {
48
+ this.metrics[m] = value;
49
+ } else { this.metrics[m] += value; }
50
+ this.lean = key === Data.Metric.Playback && value === 0 ? true : this.lean;
51
+ }
52
+ }
53
+
54
+ for (let entry in this.metrics) {
55
+ if (entry in DataHelper.METRIC_MAP) {
56
+ let m = this.metrics[entry];
57
+ let map = DataHelper.METRIC_MAP[entry];
58
+ let unit = "unit" in map ? map.unit : Data.Constant.Empty;
59
+ metricMarkup.push(`<li><h2>${this.value(m, unit)}<span>${this.key(unit)}</span></h2>${map.name}</li>`);
60
+ }
61
+ }
62
+
63
+ // Append region information to metadata
64
+ for (let name in this.regions) {
65
+ let r = this.regions[name];
66
+ let className = (r.visibility === Layout.RegionVisibility.Visible ? "visible" : (r.interaction === Layout.InteractionState.Clicked ? "clicked" : Data.Constant.Empty));
67
+ regionMarkup.push(`<span class="${className}">${name}</span>`);
68
+ }
69
+
70
+ this.state.options.metadata.innerHTML = `<ul>${metricMarkup.join(Data.Constant.Empty)}</ul><div>${regionMarkup.join(Data.Constant.Empty)}</div>`;
71
+ }
72
+ }
73
+
74
+ public region(event: DecodedLayout.RegionEvent): void {
75
+ let data = event.data;
76
+ for (let r of data) {
77
+ if (!(r.name in this.regions)) {
78
+ this.regions[r.name] = { interaction: r.interaction , visibility: r.visibility }
79
+ }
80
+ this.regionMap[r.id] = r.name;
81
+ }
82
+ }
83
+
84
+ private key = (unit: string): string => {
85
+ switch (unit) {
86
+ case "html-price":
87
+ case "ld-price":
88
+ case "cls":
89
+ return Data.Constant.Empty;
90
+ default: return unit;
91
+ }
92
+ }
93
+
94
+ private value = (num: number, unit: string): number => {
95
+ switch (unit) {
96
+ case "KB": return Math.round(num / 1024);
97
+ case "s": return Math.round(num / 10) / 100;
98
+ case "cls": return num / 1000;
99
+ case "html-price": return num / 100;
100
+ default: return num;
101
+ }
102
+ }
103
+ }
package/src/enrich.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { helper, Layout } from "clarity-js";
2
+ import { Layout as DecodedLayout } from "clarity-decode";
3
+ import { NodeData } from "@clarity-types/visualize";
4
+
5
+ export class EnrichHelper {
6
+
7
+ children: { [key: number]: number[] };
8
+ nodes: { [key: number]: NodeData };
9
+
10
+ constructor() {
11
+ this.reset();
12
+ }
13
+
14
+ public reset = (): void => {
15
+ this.children = {};
16
+ this.nodes = {};
17
+ helper.selector.reset();
18
+ }
19
+
20
+ public selectors = (event: DecodedLayout.DomEvent): DecodedLayout.DomEvent => {
21
+ event.data.forEach && event.data.forEach(d => {
22
+ let parent = this.nodes[d.parent];
23
+ let children = this.children[d.parent] || [];
24
+ let node = this.nodes[d.id] || { tag: d.tag, parent: d.parent, previous: d.previous };
25
+ let attributes = d.attributes || {};
26
+
27
+ /* Track parent-child relationship for this element */
28
+ if (node.parent !== d.parent) {
29
+ let childIndex = d.previous === null ? 0 : children.indexOf(d.previous) + 1;
30
+ children.splice(childIndex, 0, d.id);
31
+
32
+ // Stop tracking this node from children of previous parent
33
+ if (node.parent !== d.parent) {
34
+ let exParent = this.children[node.parent];
35
+ let nodeIndex = exParent ? exParent.indexOf(d.id) : -1;
36
+ if (nodeIndex >= 0) {
37
+ this.children[node.parent].splice(nodeIndex, 1);
38
+ }
39
+ }
40
+ node.parent = d.parent;
41
+ } else if (children.indexOf(d.id) < 0) { children.push(d.id); }
42
+
43
+ /* Get current position */
44
+ node.position = this.position(d.id, d.tag, node, children, children.map(c => this.nodes[c]));
45
+
46
+ /* For backward compatibility, continue populating current selector and hash like before in addition to beta selector and hash */
47
+ let input: Layout.SelectorInput = { id: d.id, tag: d.tag, prefix: parent ? [parent.alpha, parent.beta] : null, position: node.position, attributes };
48
+
49
+ // Get stable selector
50
+ // We intentionally use "null" value for empty selectors to keep parity with v0.6.25 and before.
51
+ let selectorAlpha = helper.selector.get(input, Layout.Selector.Alpha);
52
+ d.selectorAlpha = selectorAlpha.length > 0 ? selectorAlpha : null;
53
+ d.hashAlpha = selectorAlpha.length > 0 ? helper.hash(d.selectorAlpha) : null;
54
+
55
+ // Get beta selector
56
+ let selectorBeta = helper.selector.get(input, Layout.Selector.Beta);
57
+ d.selectorBeta = selectorBeta.length > 0 ? selectorBeta : null;
58
+ d.hashBeta = selectorBeta.length > 0 ? helper.hash(d.selectorBeta) : null;
59
+
60
+ /* Track state for future reference */
61
+ node.alpha = selectorAlpha;
62
+ node.beta = selectorBeta;
63
+ this.nodes[d.id] = node;
64
+ if (d.parent) { this.children[d.parent] = children; }
65
+ });
66
+ return event;
67
+ }
68
+
69
+ private position = (id: number, tag: string, child: NodeData, children: number[], siblings: NodeData[]): number => {
70
+ child.position = 1;
71
+ let idx = children ? children.indexOf(id) : -1;
72
+ while (idx-- > 0) {
73
+ if (tag === siblings[idx].tag) {
74
+ child.position = siblings[idx].position + 1;
75
+ break;
76
+ }
77
+ }
78
+ return child.position;
79
+ }
80
+ }
package/src/global.ts ADDED
@@ -0,0 +1,9 @@
1
+ import * as visualize from "@src/clarity";
2
+
3
+ // Expose clarity variable globally to allow access to public interface in a browser
4
+ if (typeof window !== "undefined") {
5
+ if ((window as any).clarity === undefined || (window as any).clarity === null) {
6
+ (window as any).clarity = {};
7
+ }
8
+ (window as any).clarity.visualize = visualize;
9
+ }
package/src/heatmap.ts ADDED
@@ -0,0 +1,299 @@
1
+ import { Activity, Constant, Heatmap, Setting, ScrollMapInfo, PlaybackState } from "@clarity-types/visualize";
2
+ import { Data } from "clarity-js";
3
+ import { LayoutHelper } from "./layout";
4
+
5
+ export class HeatmapHelper {
6
+ static COLORS = ["blue", "cyan", "lime", "yellow", "red"];
7
+ data: Activity = null;
8
+ scrollData: ScrollMapInfo[] = null;
9
+ max: number = null;
10
+ offscreenRing: HTMLCanvasElement = null;
11
+ gradientPixels: ImageData = null;
12
+ timeout = null;
13
+ observer: ResizeObserver = null;
14
+ state: PlaybackState = null;
15
+ layout: LayoutHelper = null;
16
+ scrollAvgFold: number = null;
17
+ addScrollMakers: boolean = false;
18
+
19
+ constructor(state: PlaybackState, layout: LayoutHelper) {
20
+ this.state = state;
21
+ this.layout = layout;
22
+ }
23
+
24
+ public reset = (): void => {
25
+ this.data = null;
26
+ this.scrollData = null;
27
+ this.max = null;
28
+ this.offscreenRing = null;
29
+ this.gradientPixels = null;
30
+ this.timeout = null;
31
+
32
+ // Reset resize observer
33
+ if (this.observer) {
34
+ this.observer.disconnect();
35
+ this.observer = null;
36
+ }
37
+
38
+ // Remove scroll and resize event listeners
39
+ if (this.state && this.state.window) {
40
+ let win = this.state.window;
41
+ win.removeEventListener("scroll", this.redraw, true);
42
+ win.removeEventListener("resize", this.redraw, true);
43
+ }
44
+ }
45
+
46
+ public clear = (): void => {
47
+ let doc = this.state.window.document;
48
+ let win = this.state.window;
49
+ let canvas = doc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
50
+ let de = doc.documentElement;
51
+ if (canvas) {
52
+ canvas.width = de.clientWidth;
53
+ canvas.height = de.clientHeight;
54
+ canvas.style.left = win.pageXOffset + Constant.Pixel;
55
+ canvas.style.top = win.pageYOffset + Constant.Pixel;
56
+ canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
57
+ }
58
+ this.reset();
59
+ }
60
+
61
+ public scroll = (activity: ScrollMapInfo[], avgFold: number, addMarkers: boolean): void => {
62
+ this.scrollData = this.scrollData || activity;
63
+ this.scrollAvgFold = avgFold != null ? avgFold : this.scrollAvgFold;
64
+ this.addScrollMakers = addMarkers != null ? addMarkers : this.addScrollMakers;
65
+
66
+ let canvas = this.overlay();
67
+ let context = canvas.getContext(Constant.Context);
68
+ let doc = this.state.window.document;
69
+ var body = doc.body;
70
+ var de = doc.documentElement;
71
+ var height = Math.max(body.scrollHeight, body.offsetHeight,
72
+ de.clientHeight, de.scrollHeight, de.offsetHeight);
73
+ canvas.height = Math.min(height, Setting.ScrollCanvasMaxHeight);
74
+ canvas.style.top = 0 + Constant.Pixel;
75
+ if (canvas.width > 0 && canvas.height > 0) {
76
+ if (this.scrollData) {
77
+ const grd = context.createLinearGradient(0, 0, 0, canvas.height);
78
+ for (const currentCombination of this.scrollData) {
79
+ const huePercentView = 1 - (currentCombination.cumulativeSum / this.scrollData[0].cumulativeSum);
80
+ const percentView = (currentCombination.scrollReachY / 100) * (height / canvas.height);
81
+ const hue = huePercentView * Setting.MaxHue;
82
+ if (percentView <= 1) {
83
+ grd.addColorStop(percentView, `hsla(${hue}, 100%, 50%, 0.6)`);
84
+ }
85
+ }
86
+
87
+ // Fill with gradient
88
+ context.fillStyle = grd;
89
+ context.fillRect(0, 0, canvas.width, canvas.height);
90
+ if (this.addScrollMakers) {
91
+ this.addInfoMarkers(context, this.scrollData, canvas.width, canvas.height, this.scrollAvgFold);
92
+ }
93
+ }
94
+ };
95
+ }
96
+
97
+ private addInfoMarkers = (context: CanvasRenderingContext2D, scrollMapInfo: ScrollMapInfo[], width: number, height: number, avgFold: number): void => {
98
+ this.addMarker(context, width, Constant.AverageFold, avgFold, Setting.MarkerMediumWidth);
99
+ const markers = [75, 50, 25];
100
+ for (const marker of markers) {
101
+ const closest = scrollMapInfo.reduce((prev: ScrollMapInfo, curr: ScrollMapInfo): ScrollMapInfo => {
102
+ return ((Math.abs(curr.percUsers - marker)) < (Math.abs(prev.percUsers - marker)) ? curr : prev);
103
+ });
104
+ if (closest.percUsers >= marker - Setting.MarkerRange && closest.percUsers <= marker + Setting.MarkerRange) {
105
+ const markerLine = (closest.scrollReachY / 100) * height;
106
+ this.addMarker(context, width, `${marker}%`, markerLine, Setting.MarkerSmallWidth);
107
+ }
108
+ }
109
+ }
110
+
111
+ private addMarker = (context: CanvasRenderingContext2D, heatmapWidth: number, label: string, markerY: number, markerWidth: number): void => {
112
+ context.beginPath();
113
+ context.moveTo(0, markerY);
114
+ context.lineTo(heatmapWidth, markerY);
115
+ context.setLineDash([2, 2]);
116
+ context.lineWidth = Setting.MarkerLineHeight;
117
+ context.strokeStyle = Setting.MarkerColor;
118
+ context.stroke();
119
+ context.fillStyle = Setting.CanvasTextColor;
120
+ context.fillRect(0, (markerY - Setting.MarkerHeight / 2), markerWidth, Setting.MarkerHeight);
121
+ context.fillStyle = Setting.MarkerColor;
122
+ context.font = Setting.CanvasTextFont;
123
+ context.fillText(label, Setting.MarkerPadding, markerY + Setting.MarkerPadding);
124
+ }
125
+
126
+ public click = (activity: Activity): void => {
127
+ this.data = this.data || activity;
128
+ let heat = this.transform();
129
+ let canvas = this.overlay();
130
+ let ctx = canvas.getContext(Constant.Context);
131
+
132
+ if (canvas.width > 0 && canvas.height > 0) {
133
+ // To speed up canvas rendering, we draw ring & gradient on an offscreen canvas, so we can use drawImage API
134
+ // Canvas performance tips: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
135
+ // Pre-render similar primitives or repeating objects on an offscreen canvas
136
+ let ring = this.getRing();
137
+ let gradient = this.getGradient();
138
+
139
+ // Render activity for each (x,y) coordinate in our data
140
+ for (let entry of heat) {
141
+ ctx.globalAlpha = entry.a;
142
+ ctx.drawImage(ring, entry.x - Setting.Radius, entry.y - Setting.Radius);
143
+ }
144
+
145
+ // Add color to the canvas based on alpha value of each pixel
146
+ let pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
147
+ for (let i = 0; i < pixels.data.length; i += 4) {
148
+ // For each pixel, we have 4 entries in data array: (r,g,b,a)
149
+ // To pick the right color from gradient pixels, we look at the alpha value of the pixel
150
+ // Alpha value ranges from 0-255
151
+ let alpha = pixels.data[i + 3];
152
+ if (alpha > 0) {
153
+ let offset = (alpha - 1) * 4;
154
+ pixels.data[i] = gradient.data[offset];
155
+ pixels.data[i + 1] = gradient.data[offset + 1];
156
+ pixels.data[i + 2] = gradient.data[offset + 2];
157
+ }
158
+ }
159
+ ctx.putImageData(pixels, 0, 0);
160
+ };
161
+ }
162
+
163
+ private overlay = (): HTMLCanvasElement => {
164
+ // Create canvas for visualizing heatmap
165
+ let doc = this.state.window.document;
166
+ let win = this.state.window;
167
+ let de = doc.documentElement;
168
+ let canvas = doc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
169
+ if (canvas === null) {
170
+ canvas = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
171
+ canvas.id = Constant.HeatmapCanvas;
172
+ canvas.width = 0;
173
+ canvas.height = 0;
174
+ canvas.style.position = Constant.Absolute;
175
+ canvas.style.zIndex = `${Setting.ZIndex}`;
176
+ de.appendChild(canvas);
177
+ win.addEventListener("scroll", this.redraw, true);
178
+ win.addEventListener("resize", this.redraw, true);
179
+ this.observer = this.state.window["ResizeObserver"] ? new ResizeObserver(this.redraw) : null;
180
+
181
+ if (this.observer) { this.observer.observe(doc.body); }
182
+ }
183
+
184
+ // Ensure canvas is positioned correctly
185
+ canvas.width = de.clientWidth;
186
+ canvas.height = de.clientHeight;
187
+ canvas.style.left = win.pageXOffset + Constant.Pixel;
188
+ canvas.style.top = win.pageYOffset + Constant.Pixel;
189
+ canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
190
+
191
+ return canvas;
192
+ }
193
+
194
+ private getRing = (): HTMLCanvasElement => {
195
+ if (this.offscreenRing === null) {
196
+ let doc = this.state.window.document;
197
+ this.offscreenRing = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
198
+ this.offscreenRing.width = Setting.Radius * 2;
199
+ this.offscreenRing.height = Setting.Radius * 2;
200
+ let ctx = this.offscreenRing.getContext(Constant.Context);
201
+ ctx.shadowOffsetX = Setting.Radius * 2;
202
+ ctx.shadowBlur = Setting.Radius / 2;
203
+ ctx.shadowColor = Constant.Black;
204
+ ctx.beginPath();
205
+ ctx.arc(-Setting.Radius, Setting.Radius, Setting.Radius / 2, 0, Math.PI * 2, true);
206
+ ctx.closePath();
207
+ ctx.fill();
208
+ }
209
+ return this.offscreenRing;
210
+ }
211
+
212
+ private getGradient = (): ImageData => {
213
+ if (this.gradientPixels === null) {
214
+ let doc = this.state.window.document;
215
+ let offscreenGradient = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
216
+ offscreenGradient.width = 1;
217
+ offscreenGradient.height = Setting.Colors;
218
+ let ctx = offscreenGradient.getContext(Constant.Context);
219
+ let gradient = ctx.createLinearGradient(0, 0, 0, Setting.Colors);
220
+ let step = 1 / HeatmapHelper.COLORS.length;
221
+ for (let i = 0; i < HeatmapHelper.COLORS.length; i++) {
222
+ gradient.addColorStop(step * (i + 1), HeatmapHelper.COLORS[i]);
223
+ }
224
+ ctx.fillStyle = gradient;
225
+ ctx.fillRect(0, 0, 1, Setting.Colors);
226
+ this.gradientPixels = ctx.getImageData(0, 0, 1, Setting.Colors);
227
+ }
228
+ return this.gradientPixels;
229
+ }
230
+
231
+ private redraw = (event): void => {
232
+ if (this.data) {
233
+ if (this.timeout) { clearTimeout(this.timeout); }
234
+ this.timeout = setTimeout(this.click, Setting.Interval);
235
+ }
236
+ else if (this.scrollData) {
237
+ if (event.type != 'scroll') {
238
+ if (this.timeout) { clearTimeout(this.timeout); }
239
+ this.timeout = setTimeout(this.scroll, Setting.Interval);
240
+ }
241
+ }
242
+ }
243
+
244
+ private transform = (): Heatmap[] => {
245
+ let output: Heatmap[] = [];
246
+ let points: { [key: string]: number } = {};
247
+ let localMax = 0;
248
+ let height = this.state.window && this.state.window.document ? this.state.window.document.documentElement.clientHeight : 0;
249
+ for (let element of this.data) {
250
+ let el = this.layout.get(element.hash) as HTMLElement;
251
+ if (el && typeof el.getBoundingClientRect === "function") {
252
+ let r = el.getBoundingClientRect();
253
+ let v = this.visible(el, r, height);
254
+ // Process clicks for only visible elements
255
+ if (this.max === null || v) {
256
+ for (let i = 0; i < element.points; i++) {
257
+ let x = Math.round(r.left + (element.x[i] / Data.Setting.ClickPrecision) * r.width);
258
+ let y = Math.round(r.top + (element.y[i] / Data.Setting.ClickPrecision) * r.height);
259
+ let k = `${x}${Constant.Separator}${y}${Constant.Separator}${v ? 1 : 0}`;
260
+ points[k] = k in points ? points[k] + element.clicks[i] : element.clicks[i];
261
+ localMax = Math.max(points[k], localMax);
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ // Set the max value from the firs t
268
+ this.max = this.max ? this.max : localMax;
269
+
270
+ // Once all points are accounted for, convert everything into absolute (x, y)
271
+ for (let coordinates of Object.keys(points)) {
272
+ let parts = coordinates.split(Constant.Separator);
273
+ let alpha = Math.min((points[coordinates] / this.max) + Setting.AlphaBoost, 1);
274
+ if (parts[2] === "1") { output.push({ x: parseInt(parts[0], 10), y: parseInt(parts[1], 10), a: alpha }); }
275
+ }
276
+
277
+ return output;
278
+ }
279
+
280
+ private visible = (el: HTMLElement, r: DOMRect, height: number): boolean => {
281
+ let doc: Document | ShadowRoot = this.state.window.document;
282
+ let visibility = r.height > height ? true : false;
283
+ if (visibility === false && r.width > 0 && r.height > 0) {
284
+ while (!visibility && doc) {
285
+ let shadowElement = null;
286
+ let elements = doc.elementsFromPoint(r.left + (r.width / 2), r.top + (r.height / 2));
287
+ for (let e of elements) {
288
+ // Ignore if top element ends up being the canvas element we added for heatmap visualization
289
+ if (e.tagName === Constant.Canvas || (e.id && e.id.indexOf(Constant.ClarityPrefix) === 0)) { continue; }
290
+ visibility = e === el;
291
+ shadowElement = e.shadowRoot && e.shadowRoot != doc ? e.shadowRoot : null;
292
+ break;
293
+ }
294
+ doc = shadowElement;
295
+ }
296
+ }
297
+ return visibility && r.bottom >= 0 && r.top <= height;
298
+ }
299
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * as visualize from "./clarity";
2
+ export { Visualizer } from "./visualizer";