@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.
- package/CHANGELOG.md +7 -0
- package/README.md +1 -0
- package/build/clarity.visualize.js +2753 -0
- package/build/clarity.visualize.min.js +1 -0
- package/build/clarity.visualize.module.js +2748 -0
- package/package.json +45 -0
- package/rollup.config.ts +79 -0
- package/src/clarity.ts +3 -0
- package/src/custom/README.md +87 -0
- package/src/custom/canvas-layer.ts +355 -0
- package/src/custom/dialog.ts +242 -0
- package/src/custom/index.ts +7 -0
- package/src/data.ts +103 -0
- package/src/enrich.ts +80 -0
- package/src/global.ts +9 -0
- package/src/heatmap.ts +512 -0
- package/src/index.ts +4 -0
- package/src/interaction.ts +524 -0
- package/src/layout.ts +805 -0
- package/src/styles/blobUnavailable/chineseSimplified.svg +5 -0
- package/src/styles/blobUnavailable/chineseTraditional.svg +5 -0
- package/src/styles/blobUnavailable/dutch.svg +5 -0
- package/src/styles/blobUnavailable/english.svg +5 -0
- package/src/styles/blobUnavailable/french.svg +5 -0
- package/src/styles/blobUnavailable/german.svg +5 -0
- package/src/styles/blobUnavailable/iconOnly.svg +4 -0
- package/src/styles/blobUnavailable/italian.svg +5 -0
- package/src/styles/blobUnavailable/japanese.svg +5 -0
- package/src/styles/blobUnavailable/korean.svg +5 -0
- package/src/styles/blobUnavailable/portuguese.svg +5 -0
- package/src/styles/blobUnavailable/russian.svg +5 -0
- package/src/styles/blobUnavailable/spanish.svg +5 -0
- package/src/styles/blobUnavailable/turkish.svg +5 -0
- package/src/styles/iframeUnavailable/chineseSimplified.svg +5 -0
- package/src/styles/iframeUnavailable/chineseTraditional.svg +5 -0
- package/src/styles/iframeUnavailable/dutch.svg +5 -0
- package/src/styles/iframeUnavailable/english.svg +5 -0
- package/src/styles/iframeUnavailable/french.svg +5 -0
- package/src/styles/iframeUnavailable/german.svg +5 -0
- package/src/styles/iframeUnavailable/iconOnly.svg +4 -0
- package/src/styles/iframeUnavailable/italian.svg +5 -0
- package/src/styles/iframeUnavailable/japanese.svg +5 -0
- package/src/styles/iframeUnavailable/korean.svg +5 -0
- package/src/styles/iframeUnavailable/portuguese.svg +5 -0
- package/src/styles/iframeUnavailable/russian.svg +5 -0
- package/src/styles/iframeUnavailable/spanish.svg +5 -0
- package/src/styles/iframeUnavailable/turkish.svg +5 -0
- package/src/styles/imageMasked/chineseSimplified.svg +5 -0
- package/src/styles/imageMasked/chineseTraditional.svg +5 -0
- package/src/styles/imageMasked/dutch.svg +5 -0
- package/src/styles/imageMasked/english.svg +5 -0
- package/src/styles/imageMasked/french.svg +5 -0
- package/src/styles/imageMasked/german.svg +5 -0
- package/src/styles/imageMasked/iconOnly.svg +4 -0
- package/src/styles/imageMasked/italian.svg +5 -0
- package/src/styles/imageMasked/japanese.svg +5 -0
- package/src/styles/imageMasked/korean.svg +5 -0
- package/src/styles/imageMasked/portuguese.svg +5 -0
- package/src/styles/imageMasked/russian.svg +5 -0
- package/src/styles/imageMasked/spanish.svg +5 -0
- package/src/styles/imageMasked/turkish.svg +5 -0
- package/src/styles/pointer/click.css +31 -0
- package/src/styles/pointer/pointerIcon.svg +18 -0
- package/src/styles/shared.css +6 -0
- package/src/visualizer.ts +308 -0
- package/tsconfig.json +13 -0
- package/tslint.json +33 -0
- package/types/heatmap.d.ts +18 -0
- package/types/index.d.ts +11 -0
- package/types/layout.d.ts +12 -0
- package/types/string-import.d.ts +9 -0
- package/types/visualize.d.ts +253 -0
package/src/heatmap.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { Activity, Constant, Heatmap, Setting, ScrollMapInfo, PlaybackState } from "@clarity-types/visualize";
|
|
2
|
+
import { Data } from "@gem-sdk/clarity-js";
|
|
3
|
+
import { LayoutHelper } from "./layout";
|
|
4
|
+
|
|
5
|
+
import * as canvasLayer from "./custom/canvas-layer";
|
|
6
|
+
import * as dialogCustom from "./custom/dialog";
|
|
7
|
+
|
|
8
|
+
export class HeatmapHelper {
|
|
9
|
+
static COLORS = ["blue", "cyan", "lime", "yellow", "red"];
|
|
10
|
+
data: Activity = null;
|
|
11
|
+
scrollData: ScrollMapInfo[] = null;
|
|
12
|
+
max: number = null;
|
|
13
|
+
offscreenRing: HTMLCanvasElement = null;
|
|
14
|
+
gradientPixels: ImageData = null;
|
|
15
|
+
timeout = null;
|
|
16
|
+
observer: ResizeObserver = null;
|
|
17
|
+
state: PlaybackState = null;
|
|
18
|
+
layout: LayoutHelper = null;
|
|
19
|
+
scrollAvgFold: number = null;
|
|
20
|
+
addScrollMakers: boolean = false;
|
|
21
|
+
parentCanvasInfo: canvasLayer.ParentCanvasInfo | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(state: PlaybackState, layout: LayoutHelper) {
|
|
24
|
+
this.state = state;
|
|
25
|
+
this.layout = layout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public reset = (): void => {
|
|
29
|
+
this.data = null;
|
|
30
|
+
this.scrollData = null;
|
|
31
|
+
this.max = null;
|
|
32
|
+
this.offscreenRing = null;
|
|
33
|
+
this.gradientPixels = null;
|
|
34
|
+
this.timeout = null;
|
|
35
|
+
|
|
36
|
+
// Cleanup parent canvas if it exists
|
|
37
|
+
if (this.parentCanvasInfo) {
|
|
38
|
+
this.parentCanvasInfo.cleanup();
|
|
39
|
+
this.parentCanvasInfo = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Reset resize observer
|
|
43
|
+
if (this.observer) {
|
|
44
|
+
this.observer.disconnect();
|
|
45
|
+
this.observer = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Remove scroll, resize, and animation event listeners
|
|
49
|
+
if (this.state && this.state.window) {
|
|
50
|
+
let win = this.state.window;
|
|
51
|
+
win.removeEventListener("scroll", this.redraw, { capture: true });
|
|
52
|
+
win.removeEventListener("resize", this.redraw, { capture: true });
|
|
53
|
+
win.removeEventListener("animationend", this.redraw, true);
|
|
54
|
+
win.removeEventListener("transitionend", this.redraw, true);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public clear = (): void => {
|
|
59
|
+
let doc = this.state.window.document;
|
|
60
|
+
let win = this.state.window;
|
|
61
|
+
let canvas = doc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
|
|
62
|
+
let de = doc.documentElement;
|
|
63
|
+
if (canvas) {
|
|
64
|
+
canvas.width = de.clientWidth;
|
|
65
|
+
canvas.height = de.clientHeight;
|
|
66
|
+
canvas.style.left = win.pageXOffset + Constant.Pixel;
|
|
67
|
+
canvas.style.top = win.pageYOffset + Constant.Pixel;
|
|
68
|
+
canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Cleanup parent canvas before reset
|
|
72
|
+
if (this.parentCanvasInfo) {
|
|
73
|
+
this.parentCanvasInfo.cleanup();
|
|
74
|
+
this.parentCanvasInfo = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.reset();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public scroll = async (activity: ScrollMapInfo[], avgFold: number, addMarkers: boolean): Promise<void> => {
|
|
81
|
+
await this.waitForDialogs();
|
|
82
|
+
await this.waitForAllAnimations();
|
|
83
|
+
|
|
84
|
+
this.scrollData = this.scrollData || activity;
|
|
85
|
+
this.scrollAvgFold = avgFold != null ? avgFold : this.scrollAvgFold;
|
|
86
|
+
this.addScrollMakers = addMarkers != null ? addMarkers : this.addScrollMakers;
|
|
87
|
+
|
|
88
|
+
let canvas = this.overlay();
|
|
89
|
+
let context = canvas.getContext(Constant.Context);
|
|
90
|
+
let doc = this.state.window.document;
|
|
91
|
+
var body = doc.body;
|
|
92
|
+
var de = doc.documentElement;
|
|
93
|
+
var height = Math.max(body.scrollHeight, body.offsetHeight,
|
|
94
|
+
de.clientHeight, de.scrollHeight, de.offsetHeight);
|
|
95
|
+
canvas.height = Math.min(height, Setting.ScrollCanvasMaxHeight);
|
|
96
|
+
canvas.style.top = 0 + Constant.Pixel;
|
|
97
|
+
if (canvas.width > 0 && canvas.height > 0) {
|
|
98
|
+
if (this.scrollData) {
|
|
99
|
+
const grd = context.createLinearGradient(0, 0, 0, canvas.height);
|
|
100
|
+
for (const currentCombination of this.scrollData) {
|
|
101
|
+
const huePercentView = 1 - (currentCombination.cumulativeSum / this.scrollData[0].cumulativeSum);
|
|
102
|
+
const percentView = (currentCombination.scrollReachY / 100) * (height / canvas.height);
|
|
103
|
+
const hue = huePercentView * Setting.MaxHue;
|
|
104
|
+
if (percentView <= 1) {
|
|
105
|
+
if (!isNaN(hue)) {
|
|
106
|
+
grd.addColorStop(percentView, `hsla(${hue}, 100%, 50%, 0.6)`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fill with gradient
|
|
112
|
+
context.fillStyle = grd;
|
|
113
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
114
|
+
if (this.addScrollMakers) {
|
|
115
|
+
this.addInfoMarkers(context, this.scrollData, canvas.width, canvas.height, this.scrollAvgFold);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private addInfoMarkers = (context: CanvasRenderingContext2D, scrollMapInfo: ScrollMapInfo[], width: number, height: number, avgFold: number): void => {
|
|
122
|
+
this.addMarker(context, width, Constant.AverageFold, avgFold, Setting.MarkerMediumWidth);
|
|
123
|
+
const markers = [75, 50, 25];
|
|
124
|
+
for (const marker of markers) {
|
|
125
|
+
const closest = scrollMapInfo.reduce((prev: ScrollMapInfo, curr: ScrollMapInfo): ScrollMapInfo => {
|
|
126
|
+
return ((Math.abs(curr.percUsers - marker)) < (Math.abs(prev.percUsers - marker)) ? curr : prev);
|
|
127
|
+
});
|
|
128
|
+
if (closest.percUsers >= marker - Setting.MarkerRange && closest.percUsers <= marker + Setting.MarkerRange) {
|
|
129
|
+
const markerLine = (closest.scrollReachY / 100) * height;
|
|
130
|
+
this.addMarker(context, width, `${marker}%`, markerLine, Setting.MarkerSmallWidth);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private addMarker = (context: CanvasRenderingContext2D, heatmapWidth: number, label: string, markerY: number, markerWidth: number): void => {
|
|
136
|
+
context.beginPath();
|
|
137
|
+
context.moveTo(0, markerY);
|
|
138
|
+
context.lineTo(heatmapWidth, markerY);
|
|
139
|
+
context.setLineDash([2, 2]);
|
|
140
|
+
context.lineWidth = Setting.MarkerLineHeight;
|
|
141
|
+
context.strokeStyle = Setting.MarkerColor;
|
|
142
|
+
context.stroke();
|
|
143
|
+
context.fillStyle = Setting.CanvasTextColor;
|
|
144
|
+
context.fillRect(0, (markerY - Setting.MarkerHeight / 2), markerWidth, Setting.MarkerHeight);
|
|
145
|
+
context.fillStyle = Setting.MarkerColor;
|
|
146
|
+
context.font = Setting.CanvasTextFont;
|
|
147
|
+
context.fillText(label, Setting.MarkerPadding, markerY + Setting.MarkerPadding);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public click = async (activity: Activity): Promise<void> => {
|
|
151
|
+
await this.waitForDialogs();
|
|
152
|
+
await this.waitForAllAnimations();
|
|
153
|
+
|
|
154
|
+
this.data = this.data || activity;
|
|
155
|
+
let heat = this.transform();
|
|
156
|
+
let canvas = this.overlay();
|
|
157
|
+
let ctx = canvas.getContext(Constant.Context);
|
|
158
|
+
|
|
159
|
+
if (canvas.width > 0 && canvas.height > 0) {
|
|
160
|
+
// TODO: GEMX DEBUG Draw a test rectangle to verify canvas is working
|
|
161
|
+
// ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
|
|
162
|
+
// ctx.fillRect(10, 10, 100, 100);
|
|
163
|
+
|
|
164
|
+
// To speed up canvas rendering, we draw ring & gradient on an offscreen canvas, so we can use drawImage API
|
|
165
|
+
// Canvas performance tips: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas
|
|
166
|
+
// Pre-render similar primitives or repeating objects on an offscreen canvas
|
|
167
|
+
let ring = this.getRing();
|
|
168
|
+
let gradient = this.getGradient();
|
|
169
|
+
|
|
170
|
+
// Render activity for each (x,y) coordinate in our data
|
|
171
|
+
for (let entry of heat) {
|
|
172
|
+
ctx.globalAlpha = entry.a;
|
|
173
|
+
ctx.drawImage(ring, entry.x - Setting.Radius, entry.y - Setting.Radius);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Add color to the canvas based on alpha value of each pixel
|
|
177
|
+
let pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
178
|
+
for (let i = 0; i < pixels.data.length; i += 4) {
|
|
179
|
+
// For each pixel, we have 4 entries in data array: (r,g,b,a)
|
|
180
|
+
// To pick the right color from gradient pixels, we look at the alpha value of the pixel
|
|
181
|
+
// Alpha value ranges from 0-255
|
|
182
|
+
let alpha = pixels.data[i + 3];
|
|
183
|
+
if (alpha > 0) {
|
|
184
|
+
let offset = (alpha - 1) * 4;
|
|
185
|
+
pixels.data[i] = gradient.data[offset];
|
|
186
|
+
pixels.data[i + 1] = gradient.data[offset + 1];
|
|
187
|
+
pixels.data[i + 2] = gradient.data[offset + 2];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
ctx.putImageData(pixels, 0, 0);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public overlay = (): HTMLCanvasElement => {
|
|
195
|
+
// Create canvas for visualizing heatmap
|
|
196
|
+
let doc = this.state.window.document;
|
|
197
|
+
let win = this.state.window;
|
|
198
|
+
let de = doc.documentElement;
|
|
199
|
+
|
|
200
|
+
const isPortalCanvas = !!this.state.options.portalCanvasId;
|
|
201
|
+
if (isPortalCanvas) return this.createPortalCanvas(doc, win, de);
|
|
202
|
+
|
|
203
|
+
let canvas = doc.getElementById(Constant.HeatmapCanvas) as HTMLCanvasElement;
|
|
204
|
+
if (canvas === null) {
|
|
205
|
+
canvas = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
|
|
206
|
+
canvas.id = Constant.HeatmapCanvas;
|
|
207
|
+
canvas.classList.add(Constant.HeatmapCanvas);
|
|
208
|
+
canvas.width = 0;
|
|
209
|
+
canvas.height = 0;
|
|
210
|
+
canvas.style.position = Constant.Absolute;
|
|
211
|
+
canvas.style.zIndex = `${Setting.ZIndex}`;
|
|
212
|
+
de.appendChild(canvas);
|
|
213
|
+
win.addEventListener("scroll", this.redraw, { passive: true, capture: true });
|
|
214
|
+
win.addEventListener("resize", this.redraw, { passive: true, capture: true });
|
|
215
|
+
win.addEventListener("animationend", this.redraw, true);
|
|
216
|
+
win.addEventListener("transitionend", this.redraw, true);
|
|
217
|
+
this.observer = this.state.window["ResizeObserver"] ? new ResizeObserver(this.redraw) : null;
|
|
218
|
+
|
|
219
|
+
if (this.observer) { this.observer.observe(doc.body); }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Ensure canvas is positioned correctly
|
|
223
|
+
canvas.width = de.clientWidth;
|
|
224
|
+
canvas.height = de.clientHeight;
|
|
225
|
+
canvas.style.left = win.pageXOffset + Constant.Pixel;
|
|
226
|
+
canvas.style.top = win.pageYOffset + Constant.Pixel;
|
|
227
|
+
canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
|
|
228
|
+
|
|
229
|
+
return canvas;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private getRing = (): HTMLCanvasElement => {
|
|
233
|
+
if (this.offscreenRing === null) {
|
|
234
|
+
let doc = this.state.window.document;
|
|
235
|
+
this.offscreenRing = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
|
|
236
|
+
this.offscreenRing.width = Setting.Radius * 2;
|
|
237
|
+
this.offscreenRing.height = Setting.Radius * 2;
|
|
238
|
+
let ctx = this.offscreenRing.getContext(Constant.Context);
|
|
239
|
+
ctx.shadowOffsetX = Setting.Radius * 2;
|
|
240
|
+
ctx.shadowBlur = Setting.Radius / 2;
|
|
241
|
+
ctx.shadowColor = Constant.Black;
|
|
242
|
+
ctx.beginPath();
|
|
243
|
+
ctx.arc(-Setting.Radius, Setting.Radius, Setting.Radius / 2, 0, Math.PI * 2, true);
|
|
244
|
+
ctx.closePath();
|
|
245
|
+
ctx.fill();
|
|
246
|
+
}
|
|
247
|
+
return this.offscreenRing;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private getGradient = (): ImageData => {
|
|
251
|
+
if (this.gradientPixels === null) {
|
|
252
|
+
let doc = this.state.window.document;
|
|
253
|
+
let offscreenGradient = doc.createElement(Constant.Canvas) as HTMLCanvasElement;
|
|
254
|
+
offscreenGradient.width = 1;
|
|
255
|
+
offscreenGradient.height = Setting.Colors;
|
|
256
|
+
let ctx = offscreenGradient.getContext(Constant.Context);
|
|
257
|
+
let gradient = ctx.createLinearGradient(0, 0, 0, Setting.Colors);
|
|
258
|
+
let step = 1 / HeatmapHelper.COLORS.length;
|
|
259
|
+
for (let i = 0; i < HeatmapHelper.COLORS.length; i++) {
|
|
260
|
+
gradient.addColorStop(step * (i + 1), HeatmapHelper.COLORS[i]);
|
|
261
|
+
}
|
|
262
|
+
ctx.fillStyle = gradient;
|
|
263
|
+
ctx.fillRect(0, 0, 1, Setting.Colors);
|
|
264
|
+
this.gradientPixels = ctx.getImageData(0, 0, 1, Setting.Colors);
|
|
265
|
+
}
|
|
266
|
+
return this.gradientPixels;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private redraw = (event): void => {
|
|
270
|
+
if (this.data) {
|
|
271
|
+
if (this.timeout) { clearTimeout(this.timeout); }
|
|
272
|
+
this.timeout = setTimeout(this.click, Setting.Interval);
|
|
273
|
+
}
|
|
274
|
+
else if (this.scrollData) {
|
|
275
|
+
if (event.type != 'scroll') {
|
|
276
|
+
if (this.timeout) { clearTimeout(this.timeout); }
|
|
277
|
+
this.timeout = setTimeout(this.scroll, Setting.Interval);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private transform = (): Heatmap[] => {
|
|
283
|
+
let output: Heatmap[] = [];
|
|
284
|
+
let points: { [key: string]: number } = {};
|
|
285
|
+
let localMax = 0;
|
|
286
|
+
let height = this.state.window && this.state.window.document ? this.state.window.document.documentElement.clientHeight : 0;
|
|
287
|
+
|
|
288
|
+
// DEBUG LOG
|
|
289
|
+
console.groupCollapsed(`[Heatmap] transform — total elements: ${this.data.length}, height: ${height}, max: ${this.max}`);
|
|
290
|
+
|
|
291
|
+
for (let element of this.data) {
|
|
292
|
+
let el = this.layout.get(element.hash) as HTMLElement;
|
|
293
|
+
if (!el) {
|
|
294
|
+
console.warn(`[Heatmap] SKIP hash="${element.hash}" — element not found in DOM`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (typeof el.getBoundingClientRect !== "function") {
|
|
298
|
+
console.warn(`[Heatmap] SKIP hash="${element.hash}" — getBoundingClientRect not available`, el);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let r = el.getBoundingClientRect();
|
|
303
|
+
let v = this.visible(el, r, height, this.state.options.debugHash);
|
|
304
|
+
|
|
305
|
+
if (!v && this.max !== null) {
|
|
306
|
+
console.warn(`[Heatmap] SKIP hash="${element.hash}" — not visible on re-render`, { rect: { top: r.top, left: r.left, width: r.width, height: r.height }, maxIsSet: this.max !== null });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (el && typeof el.getBoundingClientRect === "function") {
|
|
311
|
+
console.log(`[Heatmap] PROCESS hash="${element.hash}" visible=${v} points=${element.points}`, { rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
|
|
312
|
+
// Process clicks for only visible elements
|
|
313
|
+
if (this.max === null || v) {
|
|
314
|
+
for (let i = 0; i < element.points; i++) {
|
|
315
|
+
let x = Math.round(r.left + (element.x[i] / Data.Setting.ClickPrecision) * r.width);
|
|
316
|
+
let y = Math.round(r.top + (element.y[i] / Data.Setting.ClickPrecision) * r.height);
|
|
317
|
+
let k = `${x}${Constant.Separator}${y}${Constant.Separator}${v ? 1 : 0}`;
|
|
318
|
+
points[k] = k in points ? points[k] + element.clicks[i] : element.clicks[i];
|
|
319
|
+
localMax = Math.max(points[k], localMax);
|
|
320
|
+
console.log(` point[${i}] x=${x} y=${y} visible=${v} clicks=${element.clicks[i]}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Set the max value from the firs t
|
|
327
|
+
this.max = this.max ? this.max : localMax;
|
|
328
|
+
|
|
329
|
+
// Once all points are accounted for, convert everything into absolute (x, y)
|
|
330
|
+
let skippedInvisible = 0;
|
|
331
|
+
for (let coordinates of Object.keys(points)) {
|
|
332
|
+
let parts = coordinates.split(Constant.Separator);
|
|
333
|
+
let alpha = Math.min((points[coordinates] / this.max) + Setting.AlphaBoost, 1);
|
|
334
|
+
if (parts[2] === "1") {
|
|
335
|
+
output.push({ x: parseInt(parts[0], 10), y: parseInt(parts[1], 10), a: alpha });
|
|
336
|
+
} else {
|
|
337
|
+
skippedInvisible++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log(`[Heatmap] Result — drawn: ${output.length}, skipped invisible: ${skippedInvisible}, max: ${this.max}`);
|
|
342
|
+
console.groupEnd();
|
|
343
|
+
|
|
344
|
+
return output;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private visible = (el: HTMLElement, r: DOMRect, height: number, debugHash?: string): boolean => {
|
|
348
|
+
let doc: Document | ShadowRoot = this.state.window.document;
|
|
349
|
+
let visibility = r.height > height ? true : false;
|
|
350
|
+
|
|
351
|
+
if (debugHash) {
|
|
352
|
+
console.groupCollapsed(`[Heatmap visible] hash=${debugHash} tag=${el?.tagName}`);
|
|
353
|
+
console.log(` rect: top=${r.top.toFixed(1)} bottom=${r.bottom.toFixed(1)} left=${r.left.toFixed(1)} width=${r.width.toFixed(1)} height=${r.height.toFixed(1)}`);
|
|
354
|
+
console.log(` viewport height=${height} | r.height > height? ${r.height > height}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (visibility === false && r.width > 0 && r.height > 0) {
|
|
358
|
+
const checkX = r.left + (r.width / 2);
|
|
359
|
+
const checkY = r.top + (r.height / 2);
|
|
360
|
+
if (debugHash) { console.log(` elementsFromPoint(${checkX.toFixed(1)}, ${checkY.toFixed(1)})`); }
|
|
361
|
+
|
|
362
|
+
while (!visibility && doc) {
|
|
363
|
+
let shadowElement = null;
|
|
364
|
+
let caretEl: Node | null = null;
|
|
365
|
+
if ('caretPositionFromPoint' in doc) {
|
|
366
|
+
caretEl = (doc as any).caretPositionFromPoint(checkX, checkY)?.offsetNode ?? null;
|
|
367
|
+
}
|
|
368
|
+
const elementAtPoint = caretEl as Element | null;
|
|
369
|
+
let elements = doc.elementsFromPoint(checkX, checkY);
|
|
370
|
+
if (elementAtPoint && !elements.includes(elementAtPoint)) { elements = [elementAtPoint, ...elements]; }
|
|
371
|
+
|
|
372
|
+
if (debugHash) {
|
|
373
|
+
const topElements = elements.slice(0, 5).map(e => `${e.tagName}${e.id ? '#' + e.id : ''}${e.className ? '.' + String(e.className).trim().split(/\s+/).slice(0, 2).join('.') : ''}`);
|
|
374
|
+
console.log(` top elements at center: [${topElements.join(', ')}]`);
|
|
375
|
+
console.log(` is el in top elements? ${elements.includes(el)} | el matches first non-canvas? ${elements.find(e => !(e.tagName === Constant.Canvas || (e.id && e.id.indexOf(Constant.ClarityPrefix) === 0))) === el}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (let e of elements) {
|
|
379
|
+
// Ignore if top element ends up being the canvas element we added for heatmap visualization
|
|
380
|
+
if (e.tagName === Constant.Canvas || (e.id && e.id.indexOf(Constant.ClarityPrefix) === 0)) { continue; }
|
|
381
|
+
// Consider visible if the top element is the element itself OR a descendant of it
|
|
382
|
+
// (e.g. a BUTTON containing P/SPAN children — children will appear on top in elementsFromPoint)
|
|
383
|
+
visibility = e === el || el.contains(e) || e.contains(el);
|
|
384
|
+
shadowElement = e.shadowRoot && e.shadowRoot != doc ? e.shadowRoot : null;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
doc = shadowElement;
|
|
388
|
+
}
|
|
389
|
+
} else if (debugHash) {
|
|
390
|
+
if (r.width === 0 || r.height === 0) {
|
|
391
|
+
console.log(` SKIP elementsFromPoint — zero dimension: width=${r.width} height=${r.height}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const result = visibility && r.bottom >= 0 && r.top <= height;
|
|
396
|
+
if (debugHash) {
|
|
397
|
+
console.log(` visibility=${visibility} | r.bottom(${r.bottom.toFixed(1)}) >= 0? ${r.bottom >= 0} | r.top(${r.top.toFixed(1)}) <= height(${height})? ${r.top <= height}`);
|
|
398
|
+
console.log(` => final result: ${result}`);
|
|
399
|
+
console.groupEnd();
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
public waitForDialogs = async (): Promise<void> => {
|
|
405
|
+
const isPortalCanvas = !!this.state.options.portalCanvasId;
|
|
406
|
+
if (!isPortalCanvas) return ;
|
|
407
|
+
|
|
408
|
+
await dialogCustom.waitForDialogsRendered();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private waitForAllAnimations = async (): Promise<void> => {
|
|
413
|
+
const win = this.state.window;
|
|
414
|
+
if (!win || !win.document) return;
|
|
415
|
+
|
|
416
|
+
const animations = win.document.getAnimations().filter(a =>
|
|
417
|
+
a.playState === 'running' &&
|
|
418
|
+
(a.effect as KeyframeEffect)?.getTiming().iterations !== Infinity
|
|
419
|
+
);
|
|
420
|
+
if (animations.length === 0) return;
|
|
421
|
+
|
|
422
|
+
const timeout = new Promise<void>(resolve => setTimeout(resolve, 1500));
|
|
423
|
+
await Promise.race([
|
|
424
|
+
Promise.allSettled(animations.map(a => a.finished)),
|
|
425
|
+
timeout
|
|
426
|
+
]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
protected createPortalCanvas = (doc: Document, win: Window, de: HTMLElement): HTMLCanvasElement => {
|
|
430
|
+
let canvas = null;
|
|
431
|
+
try {
|
|
432
|
+
canvas = this.parentCanvasInfo?.canvas;
|
|
433
|
+
if (!canvas) {
|
|
434
|
+
this.parentCanvasInfo = canvasLayer.createParentWindowCanvas(doc, this.state.options.portalCanvasId);
|
|
435
|
+
canvas = this.parentCanvasInfo.canvas;
|
|
436
|
+
|
|
437
|
+
// Add event listeners only once when canvas is created
|
|
438
|
+
win.addEventListener("scroll", this.redraw, { passive: true, capture: true });
|
|
439
|
+
win.addEventListener("resize", this.redraw, { passive: true, capture: true });
|
|
440
|
+
win.addEventListener("animationend", this.redraw, true);
|
|
441
|
+
win.addEventListener("transitionend", this.redraw, true);
|
|
442
|
+
this.observer = this.state.window["ResizeObserver"] ? new ResizeObserver(this.redraw) : null;
|
|
443
|
+
|
|
444
|
+
if (this.observer) { this.observer.observe(doc.body); }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
canvas.width = de.clientWidth;
|
|
448
|
+
canvas.height = de.clientHeight; // TODO: GEMX VERIFY
|
|
449
|
+
canvas.style.top = '0';
|
|
450
|
+
canvas.style.left = '0';
|
|
451
|
+
canvas.getContext(Constant.Context).clearRect(0, 0, canvas.width, canvas.height);
|
|
452
|
+
|
|
453
|
+
this.parentCanvasInfo.canvas = canvas;
|
|
454
|
+
return canvas;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
console.error(`[Heatmap] createPortalCanvas:`, error);
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// private _visibleV2 = (el: HTMLElement, r: DOMRect, height: number): boolean => {
|
|
462
|
+
// let doc: Document | ShadowRoot = this.state.window.document;
|
|
463
|
+
// let visibility = r.height > height ? true : false;
|
|
464
|
+
// if (visibility === false && r.width > 0 && r.height > 0) {
|
|
465
|
+
// while (!visibility && doc) {
|
|
466
|
+
// let shadowElement = null;
|
|
467
|
+
// let elements = doc.elementsFromPoint(r.left + (r.width / 2), r.top + (r.height / 2));
|
|
468
|
+
|
|
469
|
+
// // Check if only dialog and HTML are returned (element is behind dialog)
|
|
470
|
+
// let hasOnlyDialogAndHtml = elements.length === 2 &&
|
|
471
|
+
// elements[0].tagName === 'DIALOG' &&
|
|
472
|
+
// elements[1].tagName === 'HTML';
|
|
473
|
+
|
|
474
|
+
// // If element is behind a dialog, assume it's visible
|
|
475
|
+
// // (we can't check through top-layer, so trust the element exists)
|
|
476
|
+
// if (hasOnlyDialogAndHtml) {
|
|
477
|
+
// visibility = true;
|
|
478
|
+
// break;
|
|
479
|
+
// }
|
|
480
|
+
|
|
481
|
+
// for (let e of elements) {
|
|
482
|
+
// // Check if this is the target element BEFORE skipping dialogs
|
|
483
|
+
// // This handles case where target element IS a dialog
|
|
484
|
+
// if (e === el) {
|
|
485
|
+
// visibility = true;
|
|
486
|
+
// shadowElement = e.shadowRoot && e.shadowRoot != doc ? e.shadowRoot : null;
|
|
487
|
+
// break;
|
|
488
|
+
// }
|
|
489
|
+
|
|
490
|
+
// // Skip dialog elements - treat as transparent for checking elements behind
|
|
491
|
+
// if (e.tagName === 'DIALOG') {
|
|
492
|
+
// continue;
|
|
493
|
+
// }
|
|
494
|
+
|
|
495
|
+
// // Skip canvas and clarity elements
|
|
496
|
+
// if (e.tagName === Constant.Canvas ||
|
|
497
|
+
// (e.id && e.id.indexOf(Constant.ClarityPrefix) === 0)) {
|
|
498
|
+
// continue;
|
|
499
|
+
// }
|
|
500
|
+
|
|
501
|
+
// // This is the first non-ignored element
|
|
502
|
+
// visibility = e === el;
|
|
503
|
+
// shadowElement = e.shadowRoot && e.shadowRoot != doc ? e.shadowRoot : null;
|
|
504
|
+
// break;
|
|
505
|
+
// }
|
|
506
|
+
|
|
507
|
+
// doc = shadowElement;
|
|
508
|
+
// }
|
|
509
|
+
// }
|
|
510
|
+
// return visibility && r.bottom >= 0 && r.top <= height;
|
|
511
|
+
// }
|
|
512
|
+
}
|
package/src/index.ts
ADDED