clarity-visualize 0.6.23
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.
- package/README.md +26 -0
- package/build/clarity.visualize.js +1305 -0
- package/build/clarity.visualize.min.js +1 -0
- package/build/clarity.visualize.module.js +1301 -0
- package/package.json +62 -0
- package/rollup.config.ts +42 -0
- package/src/clarity.ts +159 -0
- package/src/data.ts +90 -0
- package/src/global.ts +9 -0
- package/src/heatmap.ts +279 -0
- package/src/index.ts +2 -0
- package/src/interaction.ts +380 -0
- package/src/layout.ts +351 -0
- package/tsconfig.json +20 -0
- package/tslint.json +33 -0
- package/types/index.d.ts +19 -0
- package/types/visualize.d.ts +154 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clarity-visualize",
|
|
3
|
+
"version": "0.6.23",
|
|
4
|
+
"description": "An analytics library that uses web page interactions to generate aggregated insights",
|
|
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
|
+
"keywords": [
|
|
12
|
+
"clarity",
|
|
13
|
+
"Microsoft",
|
|
14
|
+
"interactions",
|
|
15
|
+
"cursor",
|
|
16
|
+
"pointer",
|
|
17
|
+
"instrumentation",
|
|
18
|
+
"analytics",
|
|
19
|
+
"visualization"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/microsoft/clarity.git",
|
|
24
|
+
"directory": "packages/clarity-visualize"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/Microsoft/clarity/issues"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"clarity-decode": "^0.6.23"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@rollup/plugin-commonjs": "^19.0.1",
|
|
34
|
+
"@rollup/plugin-node-resolve": "^13.0.2",
|
|
35
|
+
"del-cli": "^4.0.1",
|
|
36
|
+
"husky": "^7.0.1",
|
|
37
|
+
"lint-staged": "^11.0.1",
|
|
38
|
+
"rollup": "^2.53.2",
|
|
39
|
+
"rollup-plugin-terser": "^7.0.2",
|
|
40
|
+
"rollup-plugin-typescript2": "^0.30.0",
|
|
41
|
+
"ts-node": "^10.1.0",
|
|
42
|
+
"tslint": "^6.1.3",
|
|
43
|
+
"typescript": "^4.3.5"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "yarn build:clean && yarn build:main",
|
|
47
|
+
"build:main": "rollup -c rollup.config.ts",
|
|
48
|
+
"build:clean": "del-cli build/*",
|
|
49
|
+
"tslint": "tslint --project ./",
|
|
50
|
+
"tslint:fix": "tslint --fix --project ./ --force"
|
|
51
|
+
},
|
|
52
|
+
"husky": {
|
|
53
|
+
"hooks": {
|
|
54
|
+
"pre-commit": "lint-staged"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"lint-staged": {
|
|
58
|
+
"*.ts": [
|
|
59
|
+
"tslint --format codeFrame"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
}
|
package/rollup.config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import commonjs from "@rollup/plugin-commonjs";
|
|
2
|
+
import resolve from "@rollup/plugin-node-resolve";
|
|
3
|
+
import typescript from "rollup-plugin-typescript2";
|
|
4
|
+
import { terser } from "rollup-plugin-terser";
|
|
5
|
+
import pkg from "./package.json";
|
|
6
|
+
|
|
7
|
+
export default [
|
|
8
|
+
{
|
|
9
|
+
input: "src/index.ts",
|
|
10
|
+
output: [
|
|
11
|
+
{ file: pkg.main, format: "cjs", exports: "named" },
|
|
12
|
+
{ file: pkg.module, format: "es", exports: "named" }
|
|
13
|
+
],
|
|
14
|
+
plugins: [
|
|
15
|
+
resolve(),
|
|
16
|
+
typescript({ rollupCommonJSResolveHack: true, clean: true }),
|
|
17
|
+
commonjs({ include: ["node_modules/**"] })
|
|
18
|
+
],
|
|
19
|
+
onwarn(message, warn) {
|
|
20
|
+
if (message.code === 'NON_EXISTENT_EXPORT') { return; }
|
|
21
|
+
if (message.code === 'CIRCULAR_DEPENDENCY') { return; }
|
|
22
|
+
if (message.code === 'SOURCEMAP_ERROR') { return; }
|
|
23
|
+
warn(message);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
input: "src/global.ts",
|
|
28
|
+
output: [ { file: pkg.unpkg, format: "iife", exports: "named" } ],
|
|
29
|
+
plugins: [
|
|
30
|
+
resolve(),
|
|
31
|
+
typescript({ rollupCommonJSResolveHack: true, clean: true }),
|
|
32
|
+
terser({output: {comments: false}}),
|
|
33
|
+
commonjs({ include: ["node_modules/**"] })
|
|
34
|
+
],
|
|
35
|
+
onwarn(message, warn) {
|
|
36
|
+
if (message.code === 'NON_EXISTENT_EXPORT') { return; }
|
|
37
|
+
if (message.code === 'CIRCULAR_DEPENDENCY') { return; }
|
|
38
|
+
if (message.code === 'SOURCEMAP_ERROR') { return; }
|
|
39
|
+
warn(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
];
|
package/src/clarity.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Visualize } from "@clarity-types/index";
|
|
2
|
+
import { Activity, Constant, MergedPayload, Options, PlaybackState, ScrollMapInfo } from "@clarity-types/visualize";
|
|
3
|
+
import { Data, Interaction, Layout } from "clarity-decode";
|
|
4
|
+
import * as data from "./data";
|
|
5
|
+
import * as heatmap from "./heatmap";
|
|
6
|
+
import * as interaction from "./interaction";
|
|
7
|
+
import * as layout from "./layout";
|
|
8
|
+
export { dom, get } from "./layout";
|
|
9
|
+
|
|
10
|
+
export let state: PlaybackState = null;
|
|
11
|
+
let renderTime = 0;
|
|
12
|
+
|
|
13
|
+
export function html(decoded: Data.DecodedPayload[], target: Window, hash: string = null, time : number): Visualize {
|
|
14
|
+
if (decoded && decoded.length > 0 && target) {
|
|
15
|
+
// Flatten the payload and parse all events out of them, sorted by time
|
|
16
|
+
let merged = merge(decoded);
|
|
17
|
+
|
|
18
|
+
setup(target, { version: decoded[0].envelope.version, dom: merged.dom });
|
|
19
|
+
|
|
20
|
+
// Render all mutations on top of the initial markup
|
|
21
|
+
while (merged.events.length > 0 && layout.exists(hash) === false) {
|
|
22
|
+
let entry = merged.events.shift();
|
|
23
|
+
switch (entry.event) {
|
|
24
|
+
case Data.Event.Mutation:
|
|
25
|
+
let domEvent = entry as Layout.DomEvent;
|
|
26
|
+
renderTime = domEvent.time;
|
|
27
|
+
if (time && renderTime > time) {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
layout.markup(domEvent);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function time(): number {
|
|
41
|
+
return renderTime;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clickmap(activity: Activity): void {
|
|
45
|
+
if (state === null) { throw new Error(`Initialize heatmap by calling "html" or "setup" prior to making this call.`); }
|
|
46
|
+
heatmap.click(activity);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearmap(): void {
|
|
50
|
+
if (state === null) { throw new Error(`Initialize heatmap by calling "html" or "setup" prior to making this call.`); }
|
|
51
|
+
heatmap.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function scrollmap(scrollData: ScrollMapInfo[], avgFold: number): void {
|
|
55
|
+
if (state === null) { throw new Error(`Initialize heatmap by calling "html" or "setup" prior to making this call.`); }
|
|
56
|
+
heatmap.scroll(scrollData, avgFold);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function merge(decoded: Data.DecodedPayload[]): MergedPayload {
|
|
60
|
+
let merged: MergedPayload = { timestamp: null, envelope: null, dom: null, events: [] };
|
|
61
|
+
for (let payload of decoded) {
|
|
62
|
+
merged.timestamp = merged.timestamp ? merged.timestamp : payload.timestamp;
|
|
63
|
+
merged.envelope = payload.envelope;
|
|
64
|
+
for (let key of Object.keys(payload)) {
|
|
65
|
+
let p = payload[key];
|
|
66
|
+
if (Array.isArray(p)) {
|
|
67
|
+
for (let entry of p) {
|
|
68
|
+
if (key === Constant.Dom && entry.event === Data.Event.Discover) {
|
|
69
|
+
merged.dom = entry;
|
|
70
|
+
} else { merged.events.push(entry); }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
merged.events = merged.events.sort(sort);
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function setup(target: Window, options: Options): Visualize {
|
|
80
|
+
reset();
|
|
81
|
+
// Infer options
|
|
82
|
+
options.canvas = "canvas" in options ? options.canvas : true;
|
|
83
|
+
options.keyframes = "keyframes" in options ? options.keyframes : false;
|
|
84
|
+
|
|
85
|
+
// Set visualization state
|
|
86
|
+
state = { window: target, options };
|
|
87
|
+
|
|
88
|
+
// If discover event was passed, render it now
|
|
89
|
+
if (options.dom) { layout.dom(options.dom); }
|
|
90
|
+
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function render(events: Data.DecodedEvent[]): void {
|
|
95
|
+
if (state === null) { throw new Error(`Initialize visualization by calling "setup" prior to making this call.`); }
|
|
96
|
+
let time = 0;
|
|
97
|
+
for (let entry of events) {
|
|
98
|
+
time = entry.time;
|
|
99
|
+
switch (entry.event) {
|
|
100
|
+
case Data.Event.Metric:
|
|
101
|
+
data.metric(entry as Data.MetricEvent);
|
|
102
|
+
break;
|
|
103
|
+
case Data.Event.Region:
|
|
104
|
+
data.region(entry as Layout.RegionEvent);
|
|
105
|
+
break;
|
|
106
|
+
case Data.Event.Box:
|
|
107
|
+
layout.box(entry as Layout.BoxEvent);
|
|
108
|
+
break;
|
|
109
|
+
case Data.Event.Mutation:
|
|
110
|
+
layout.markup(entry as Layout.DomEvent);
|
|
111
|
+
break;
|
|
112
|
+
case Data.Event.MouseDown:
|
|
113
|
+
case Data.Event.MouseUp:
|
|
114
|
+
case Data.Event.MouseMove:
|
|
115
|
+
case Data.Event.MouseWheel:
|
|
116
|
+
case Data.Event.Click:
|
|
117
|
+
case Data.Event.DoubleClick:
|
|
118
|
+
case Data.Event.TouchStart:
|
|
119
|
+
case Data.Event.TouchCancel:
|
|
120
|
+
case Data.Event.TouchEnd:
|
|
121
|
+
case Data.Event.TouchMove:
|
|
122
|
+
interaction.pointer(entry as Interaction.PointerEvent);
|
|
123
|
+
break;
|
|
124
|
+
case Data.Event.Visibility:
|
|
125
|
+
interaction.visibility(entry as Interaction.VisibilityEvent);
|
|
126
|
+
break;
|
|
127
|
+
case Data.Event.Input:
|
|
128
|
+
interaction.input(entry as Interaction.InputEvent);
|
|
129
|
+
break;
|
|
130
|
+
case Data.Event.Selection:
|
|
131
|
+
interaction.selection(entry as Interaction.SelectionEvent);
|
|
132
|
+
break;
|
|
133
|
+
case Data.Event.Resize:
|
|
134
|
+
interaction.resize(entry as Interaction.ResizeEvent);
|
|
135
|
+
break;
|
|
136
|
+
case Data.Event.Scroll:
|
|
137
|
+
interaction.scroll(entry as Interaction.ScrollEvent);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (events.length > 0) {
|
|
143
|
+
// Update pointer trail at the end of every frame
|
|
144
|
+
interaction.trail(time);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function reset(): void {
|
|
149
|
+
data.reset();
|
|
150
|
+
interaction.reset();
|
|
151
|
+
layout.reset();
|
|
152
|
+
heatmap.reset();
|
|
153
|
+
state = null;
|
|
154
|
+
renderTime = 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sort(a: Data.DecodedEvent, b: Data.DecodedEvent): number {
|
|
158
|
+
return a.time - b.time;
|
|
159
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Data, Layout } from "clarity-decode";
|
|
2
|
+
import { state } from "./clarity";
|
|
3
|
+
|
|
4
|
+
export let lean = false;
|
|
5
|
+
|
|
6
|
+
let regionMap = {};
|
|
7
|
+
let regions: { [key: string]: Layout.Interaction } = {};
|
|
8
|
+
let metrics: {[key: number]: number} = null;
|
|
9
|
+
const METRIC_MAP = {};
|
|
10
|
+
METRIC_MAP[Data.Metric.TotalBytes] = { name: "Total Bytes", unit: "KB" };
|
|
11
|
+
METRIC_MAP[Data.Metric.TotalCost] = { name: "Total Cost", unit: "ms" };
|
|
12
|
+
METRIC_MAP[Data.Metric.LayoutCost] = { name: "Layout Cost", unit: "ms" };
|
|
13
|
+
METRIC_MAP[Data.Metric.LargestPaint] = { name: "LCP", unit: "s" };
|
|
14
|
+
METRIC_MAP[Data.Metric.CumulativeLayoutShift] = { name: "CLS", unit: "cls" };
|
|
15
|
+
METRIC_MAP[Data.Metric.LongTaskCount] = { name: "Long Tasks" };
|
|
16
|
+
METRIC_MAP[Data.Metric.CartTotal] = { name: "Cart Total", unit: "html-price" };
|
|
17
|
+
METRIC_MAP[Data.Metric.ProductPrice] = { name: "Product Price", unit: "ld-price" };
|
|
18
|
+
METRIC_MAP[Data.Metric.ThreadBlockedTime] = { name: "Thread Blocked", unit: "ms" };
|
|
19
|
+
|
|
20
|
+
export function reset(): void {
|
|
21
|
+
metrics = {};
|
|
22
|
+
lean = false;
|
|
23
|
+
regions = {};
|
|
24
|
+
regionMap = {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function metric(event: Data.MetricEvent): void {
|
|
28
|
+
if (state.options.metadata) {
|
|
29
|
+
let metricMarkup = [];
|
|
30
|
+
let regionMarkup = [];
|
|
31
|
+
// Copy over metrics for future reference
|
|
32
|
+
for (let m in event.data) {
|
|
33
|
+
if (typeof event.data[m] === "number") {
|
|
34
|
+
if (!(m in metrics)) { metrics[m] = 0; }
|
|
35
|
+
let key = parseInt(m, 10);
|
|
36
|
+
if (m in METRIC_MAP && (METRIC_MAP[m].unit === "html-price" || METRIC_MAP[m].unit === "ld-price")) {
|
|
37
|
+
metrics[m] = event.data[m];
|
|
38
|
+
} else { metrics[m] += event.data[m]; }
|
|
39
|
+
lean = key === Data.Metric.Playback && event.data[m] === 0 ? true : lean;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (let entry in metrics) {
|
|
44
|
+
if (entry in METRIC_MAP) {
|
|
45
|
+
let m = metrics[entry];
|
|
46
|
+
let map = METRIC_MAP[entry];
|
|
47
|
+
let unit = "unit" in map ? map.unit : Data.Constant.Empty;
|
|
48
|
+
metricMarkup.push(`<li><h2>${value(m, unit)}<span>${key(unit)}</span></h2>${map.name}</li>`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Append region information to metadata
|
|
53
|
+
for (let name in regions) {
|
|
54
|
+
let r = regions[name];
|
|
55
|
+
let className = r === Layout.Interaction.Visible ? "visible" : (r === Layout.Interaction.Clicked ? "clicked" : Data.Constant.Empty);
|
|
56
|
+
regionMarkup.push(`<span class="${className}">${name}</span>`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
state.options.metadata.innerHTML = `<ul>${metricMarkup.join(Data.Constant.Empty)}</ul><div>${regionMarkup.join(Data.Constant.Empty)}</div>`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function region(event: Layout.RegionEvent): void {
|
|
64
|
+
let data = event.data;
|
|
65
|
+
for (let r of data) {
|
|
66
|
+
if (!(r.name in regions)) { regions[r.name] = Layout.Interaction.Rendered; }
|
|
67
|
+
regions[r.name] = r.state;
|
|
68
|
+
regionMap[r.id] = r.name;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function key(unit: string): string {
|
|
73
|
+
switch (unit) {
|
|
74
|
+
case "html-price":
|
|
75
|
+
case "ld-price":
|
|
76
|
+
case "cls":
|
|
77
|
+
return Data.Constant.Empty;
|
|
78
|
+
default: return unit;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function value(num: number, unit: string): number {
|
|
83
|
+
switch (unit) {
|
|
84
|
+
case "KB": return Math.round(num / 1024);
|
|
85
|
+
case "s": return Math.round(num / 10) / 100;
|
|
86
|
+
case "cls": return num / 1000;
|
|
87
|
+
case "html-price": return num / 100;
|
|
88
|
+
default: return num;
|
|
89
|
+
}
|
|
90
|
+
}
|
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,279 @@
|
|
|
1
|
+
import { Activity, Constant, Heatmap, Setting, ScrollMapInfo } from "@clarity-types/visualize";
|
|
2
|
+
import { Data } from "clarity-decode";
|
|
3
|
+
import { state } from "./clarity";
|
|
4
|
+
import * as layout from "./layout";
|
|
5
|
+
|
|
6
|
+
const COLORS = ["blue", "cyan", "lime", "yellow", "red"];
|
|
7
|
+
|
|
8
|
+
let data: Activity = null;
|
|
9
|
+
let scrollData: ScrollMapInfo[] = null;
|
|
10
|
+
let max: number = null;
|
|
11
|
+
let offscreenRing: HTMLCanvasElement = null;
|
|
12
|
+
let gradientPixels: ImageData = null;
|
|
13
|
+
let timeout = null;
|
|
14
|
+
let observer: ResizeObserver = null;
|
|
15
|
+
|
|
16
|
+
export function reset(): void {
|
|
17
|
+
data = null;
|
|
18
|
+
scrollData = null;
|
|
19
|
+
max = null;
|
|
20
|
+
offscreenRing = null;
|
|
21
|
+
gradientPixels = null;
|
|
22
|
+
timeout = null;
|
|
23
|
+
|
|
24
|
+
// Reset resize observer
|
|
25
|
+
if (observer) {
|
|
26
|
+
observer.disconnect();
|
|
27
|
+
observer = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Remove scroll and resize event listeners
|
|
31
|
+
if (state && state.window) {
|
|
32
|
+
let win = state.window;
|
|
33
|
+
win.removeEventListener("scroll", redraw, true);
|
|
34
|
+
win.removeEventListener("resize", redraw, true);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clear() : void {
|
|
39
|
+
let doc = state.window.document;
|
|
40
|
+
let win = state.window;
|
|
41
|
+
let canvas = doc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
|
|
42
|
+
let de = doc.documentElement;
|
|
43
|
+
if (canvas) {
|
|
44
|
+
canvas.width = de.clientWidth;
|
|
45
|
+
canvas.height = de.clientHeight;
|
|
46
|
+
canvas.style.left = win.pageXOffset + Constant.Pixel;
|
|
47
|
+
canvas.style.top = win.pageYOffset + Constant.Pixel;
|
|
48
|
+
canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
|
|
49
|
+
}
|
|
50
|
+
reset();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scroll(activity: ScrollMapInfo[], avgFold: number): void {
|
|
54
|
+
scrollData = scrollData || activity;
|
|
55
|
+
let canvas = overlay();
|
|
56
|
+
let context = canvas.getContext(Constant.Context);
|
|
57
|
+
let doc = state.window.document;
|
|
58
|
+
var body = doc.body;
|
|
59
|
+
var de = doc.documentElement;
|
|
60
|
+
var height = Math.max( body.scrollHeight, body.offsetHeight,
|
|
61
|
+
de.clientHeight, de.scrollHeight, de.offsetHeight );
|
|
62
|
+
canvas.height = Math.min(height, Setting.ScrollCanvasMaxHeight);
|
|
63
|
+
if (canvas.width > 0 && canvas.height > 0) {
|
|
64
|
+
if (scrollData) {
|
|
65
|
+
const grd = context.createLinearGradient(0, 0, 0, canvas.height);
|
|
66
|
+
for (const currentCombination of scrollData) {
|
|
67
|
+
const huePercentView = 1 - (currentCombination.cumulativeSum / scrollData[0].cumulativeSum);
|
|
68
|
+
const percentView = (currentCombination.scrollReachY / 100) * (height / canvas.height);
|
|
69
|
+
const hue = huePercentView * Setting.MaxHue;
|
|
70
|
+
if (percentView < 1) {
|
|
71
|
+
grd.addColorStop(percentView, `hsla(${hue}, 100%, 50%, 0.6)`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fill with gradient
|
|
76
|
+
context.fillStyle = grd;
|
|
77
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
78
|
+
addInfoMarkers(context, scrollData, canvas.width, canvas.height, avgFold);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addInfoMarkers(context: CanvasRenderingContext2D, scrollMapInfo: ScrollMapInfo[], width: number, height: number, avgFold: number): void {
|
|
84
|
+
addMarker(context, width, Constant.AverageFold, avgFold, Setting.MarkerMediumWidth);
|
|
85
|
+
const markers = [75, 50, 25];
|
|
86
|
+
for (const marker of markers) {
|
|
87
|
+
const closest = scrollMapInfo.reduce((prev: ScrollMapInfo, curr: ScrollMapInfo): ScrollMapInfo => {
|
|
88
|
+
return ((Math.abs(curr.percUsers - marker)) < (Math.abs(prev.percUsers - marker)) ? curr : prev);
|
|
89
|
+
});
|
|
90
|
+
if (closest.percUsers >= marker - Setting.MarkerRange || closest.percUsers <= marker + Setting.MarkerRange) {
|
|
91
|
+
const markerLine = (closest.scrollReachY / 100) * height;
|
|
92
|
+
addMarker(context, width, `${marker}%`, markerLine, Setting.MarkerSmallWidth);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function addMarker(context: CanvasRenderingContext2D, heatmapWidth: number, label: string, markerY: number, markerWidth: number): void
|
|
98
|
+
{
|
|
99
|
+
context.beginPath();
|
|
100
|
+
context.moveTo(0, markerY);
|
|
101
|
+
context.lineTo(heatmapWidth, markerY);
|
|
102
|
+
context.setLineDash([2, 2]);
|
|
103
|
+
context.lineWidth = Setting.MarkerLineHeight;
|
|
104
|
+
context.strokeStyle = Setting.MarkerColor;
|
|
105
|
+
context.stroke();
|
|
106
|
+
context.fillStyle = Setting.CanvasTextColor;
|
|
107
|
+
context.fillRect(0, (markerY - Setting.MarkerHeight / 2), markerWidth, Setting.MarkerHeight);
|
|
108
|
+
context.fillStyle = Setting.MarkerColor;
|
|
109
|
+
context.font = Setting.CanvasTextFont;
|
|
110
|
+
context.fillText(label, Setting.MarkerPadding, markerY + Setting.MarkerPadding);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function click(activity: Activity): void {
|
|
114
|
+
data = data || activity;
|
|
115
|
+
let heat = transform();
|
|
116
|
+
let canvas = overlay();
|
|
117
|
+
let ctx = canvas.getContext(Constant.Context);
|
|
118
|
+
|
|
119
|
+
if (canvas.width > 0 && canvas.height > 0) {
|
|
120
|
+
// To speed up canvas rendering, we draw ring & gradient on an offscreen canvas, so we can use drawImage API
|
|
121
|
+
// Canvas performance tips: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
|
|
122
|
+
// Pre-render similar primitives or repeating objects on an offscreen canvas
|
|
123
|
+
let ring = getRing();
|
|
124
|
+
let gradient = getGradient();
|
|
125
|
+
|
|
126
|
+
// Render activity for each (x,y) coordinate in our data
|
|
127
|
+
for (let entry of heat) {
|
|
128
|
+
ctx.globalAlpha = entry.a;
|
|
129
|
+
ctx.drawImage(ring, entry.x - Setting.Radius, entry.y - Setting.Radius);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Add color to the canvas based on alpha value of each pixel
|
|
133
|
+
let pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
134
|
+
for (let i = 0; i < pixels.data.length; i += 4) {
|
|
135
|
+
// For each pixel, we have 4 entries in data array: (r,g,b,a)
|
|
136
|
+
// To pick the right color from gradient pixels, we look at the alpha value of the pixel
|
|
137
|
+
// Alpha value ranges from 0-255
|
|
138
|
+
let alpha = pixels.data[i+3];
|
|
139
|
+
if (alpha > 0) {
|
|
140
|
+
let offset = (alpha - 1) * 4;
|
|
141
|
+
pixels.data[i] = gradient.data[offset];
|
|
142
|
+
pixels.data[i + 1] = gradient.data[offset + 1];
|
|
143
|
+
pixels.data[i + 2] = gradient.data[offset + 2];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
ctx.putImageData(pixels, 0, 0);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function overlay(): HTMLCanvasElement {
|
|
151
|
+
// Create canvas for visualizing heatmap
|
|
152
|
+
let doc = state.window.document;
|
|
153
|
+
let win = state.window;
|
|
154
|
+
let de = doc.documentElement;
|
|
155
|
+
let canvas = doc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
|
|
156
|
+
if (canvas === null) {
|
|
157
|
+
canvas = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
|
|
158
|
+
canvas.id = Constant.HeatmapCanvas;
|
|
159
|
+
canvas.width = 0;
|
|
160
|
+
canvas.height = 0;
|
|
161
|
+
canvas.style.position = Constant.Absolute;
|
|
162
|
+
canvas.style.zIndex = `${Setting.ZIndex}`;
|
|
163
|
+
de.appendChild(canvas);
|
|
164
|
+
win.addEventListener("scroll", redraw, true);
|
|
165
|
+
win.addEventListener("resize", redraw, true);
|
|
166
|
+
observer = state.window["ResizeObserver"] ? new ResizeObserver(redraw) : null;
|
|
167
|
+
if (observer) { observer.observe(doc.body); }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Ensure canvas is positioned correctly
|
|
171
|
+
canvas.width = de.clientWidth;
|
|
172
|
+
canvas.height = de.clientHeight;
|
|
173
|
+
canvas.style.left = win.pageXOffset + Constant.Pixel;
|
|
174
|
+
canvas.style.top = win.pageYOffset + Constant.Pixel;
|
|
175
|
+
canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
|
|
176
|
+
|
|
177
|
+
return canvas;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getRing(): HTMLCanvasElement {
|
|
181
|
+
if (offscreenRing === null) {
|
|
182
|
+
let doc = state.window.document;
|
|
183
|
+
offscreenRing = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
|
|
184
|
+
offscreenRing.width = Setting.Radius * 2;
|
|
185
|
+
offscreenRing.height = Setting.Radius * 2;
|
|
186
|
+
let ctx = offscreenRing.getContext(Constant.Context);
|
|
187
|
+
ctx.shadowOffsetX = Setting.Radius * 2;
|
|
188
|
+
ctx.shadowBlur = Setting.Radius / 2;
|
|
189
|
+
ctx.shadowColor = Constant.Black;
|
|
190
|
+
ctx.beginPath();
|
|
191
|
+
ctx.arc(-Setting.Radius, Setting.Radius, Setting.Radius / 2, 0, Math.PI * 2, true);
|
|
192
|
+
ctx.closePath();
|
|
193
|
+
ctx.fill();
|
|
194
|
+
}
|
|
195
|
+
return offscreenRing;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getGradient(): ImageData {
|
|
199
|
+
if (gradientPixels === null) {
|
|
200
|
+
let doc = state.window.document;
|
|
201
|
+
let offscreenGradient = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
|
|
202
|
+
offscreenGradient.width = 1;
|
|
203
|
+
offscreenGradient.height = Setting.Colors;
|
|
204
|
+
let ctx = offscreenGradient.getContext(Constant.Context);
|
|
205
|
+
let gradient = ctx.createLinearGradient(0, 0, 0, Setting.Colors);
|
|
206
|
+
let step = 1 / COLORS.length;
|
|
207
|
+
for (let i = 0; i < COLORS.length; i++) {
|
|
208
|
+
gradient.addColorStop(step * (i + 1), COLORS[i]);
|
|
209
|
+
}
|
|
210
|
+
ctx.fillStyle = gradient;
|
|
211
|
+
ctx.fillRect(0, 0, 1, Setting.Colors);
|
|
212
|
+
gradientPixels = ctx.getImageData(0, 0, 1, Setting.Colors);
|
|
213
|
+
}
|
|
214
|
+
return gradientPixels;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function redraw(): void {
|
|
218
|
+
if (data) {
|
|
219
|
+
if (timeout) { clearTimeout(timeout); }
|
|
220
|
+
timeout = setTimeout(click, Setting.Interval);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function transform(): Heatmap[] {
|
|
225
|
+
let output: Heatmap[] = [];
|
|
226
|
+
let points: { [key: string]: number } = {};
|
|
227
|
+
let localMax = 0;
|
|
228
|
+
let height = state.window && state.window.document ? state.window.document.documentElement.clientHeight : 0;
|
|
229
|
+
for (let element of data) {
|
|
230
|
+
let el = layout.get(element.hash) as HTMLElement;
|
|
231
|
+
if (el && typeof el.getBoundingClientRect === "function") {
|
|
232
|
+
let r = el.getBoundingClientRect();
|
|
233
|
+
let v = visible(el, r, height);
|
|
234
|
+
// Process clicks for only visible elements
|
|
235
|
+
if (max === null || v) {
|
|
236
|
+
for(let i = 0; i < element.points; i++) {
|
|
237
|
+
let x = Math.round(r.left + (element.x[i] / Data.Setting.ClickPrecision) * r.width);
|
|
238
|
+
let y = Math.round(r.top + (element.y[i] / Data.Setting.ClickPrecision) * r.height);
|
|
239
|
+
let k = `${x}${Constant.Separator}${y}${Constant.Separator}${v ? 1 : 0}`;
|
|
240
|
+
points[k] = k in points ? points[k] + element.clicks[i] : element.clicks[i];
|
|
241
|
+
localMax = Math.max(points[k], localMax);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Set the max value from the firs t
|
|
248
|
+
max = max ? max : localMax;
|
|
249
|
+
|
|
250
|
+
// Once all points are accounted for, convert everything into absolute (x, y)
|
|
251
|
+
for (let coordinates of Object.keys(points)) {
|
|
252
|
+
let parts = coordinates.split(Constant.Separator);
|
|
253
|
+
let alpha = Math.min((points[coordinates] / max) + Setting.AlphaBoost, 1);
|
|
254
|
+
if (parts[2] === "1") { output.push({ x: parseInt(parts[0], 10), y: parseInt(parts[1], 10), a: alpha }); }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return output;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function visible(el: HTMLElement, r: DOMRect, height: number): boolean {
|
|
261
|
+
let doc: Document | ShadowRoot = state.window.document;
|
|
262
|
+
let visibility = r.height > height ? true : false;
|
|
263
|
+
if (visibility === false && r.width > 0 && r.height > 0) {
|
|
264
|
+
while (!visibility && doc)
|
|
265
|
+
{
|
|
266
|
+
let shadowElement = null;
|
|
267
|
+
let elements = doc.elementsFromPoint(r.left + (r.width / 2), r.top + (r.height / 2));
|
|
268
|
+
for (let e of elements) {
|
|
269
|
+
// Ignore if top element ends up being the canvas element we added for heatmap visualization
|
|
270
|
+
if (e.tagName === Constant.Canvas || (e.id && e.id.indexOf(Constant.ClarityPrefix) === 0)) { continue; }
|
|
271
|
+
visibility = e === el;
|
|
272
|
+
shadowElement = e.shadowRoot && e.shadowRoot != doc ? e.shadowRoot : null;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
doc = shadowElement;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return visibility && r.bottom >= 0 && r.top <= height;
|
|
279
|
+
}
|
package/src/index.ts
ADDED