@probat/react 0.2.0 → 0.2.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.
- package/dist/index.d.mts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +219 -45
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +219 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/context/ProbatContext.tsx +23 -3
- package/src/hoc/itrt-frontend.code-workspace +8 -36
- package/src/hoc/withExperiment.tsx +36 -15
- package/src/hooks/useExperiment.ts +28 -4
- package/src/utils/heatmapTracker.ts +213 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@probat/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "React library for Probat A/B testing and experimentation",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"build": "tsup && npm run postbuild",
|
|
24
24
|
"postbuild": "echo '\"use client\";' | cat - dist/index.mjs > temp && mv temp dist/index.mjs && echo '\"use client\";' | cat - dist/index.js > temp && mv temp dist/index.js",
|
|
25
25
|
"dev": "tsup --watch",
|
|
26
|
-
"typecheck": "tsc --noEmit"
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [
|
|
29
30
|
"probat",
|
|
@@ -102,13 +102,33 @@ export function ProbatProvider({
|
|
|
102
102
|
(typeof window !== "undefined" && (window as any).__PROBAT_REPO) ||
|
|
103
103
|
undefined;
|
|
104
104
|
|
|
105
|
+
// Check for URL overrides (used for Live Heatmap visualization)
|
|
106
|
+
const params = (typeof window !== "undefined") ? new URLSearchParams(window.location.search) : null;
|
|
107
|
+
const isHeatmapMode = params?.get('heatmap') === 'true';
|
|
108
|
+
|
|
109
|
+
let urlProposalId: string | undefined;
|
|
110
|
+
let urlVariantLabel: string | undefined;
|
|
111
|
+
|
|
112
|
+
if (isHeatmapMode && params) {
|
|
113
|
+
urlProposalId = params.get('proposal_id') || undefined;
|
|
114
|
+
urlVariantLabel = params.get('variant_label') || undefined;
|
|
115
|
+
console.log('[PROBAT] Heatmap mode: Overriding variant from URL', { urlProposalId, urlVariantLabel });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Priority Logic:
|
|
119
|
+
// 1. URL params (if in heatmap mode)
|
|
120
|
+
// 2. Explicit props passed to Provider
|
|
121
|
+
// 3. Stored values in localStorage (ONLY if NOT in heatmap mode)
|
|
122
|
+
const finalProposalId = urlProposalId || proposalId || (!isHeatmapMode ? storedProposalId : undefined);
|
|
123
|
+
const finalVariantLabel = urlVariantLabel || variantLabel || (!isHeatmapMode ? storedVariantLabel : undefined);
|
|
124
|
+
|
|
105
125
|
return {
|
|
106
126
|
apiBaseUrl: resolvedApiBaseUrl,
|
|
107
127
|
environment,
|
|
108
128
|
clientKey,
|
|
109
129
|
repoFullName: resolvedRepoFullName,
|
|
110
|
-
proposalId:
|
|
111
|
-
variantLabel:
|
|
130
|
+
proposalId: finalProposalId,
|
|
131
|
+
variantLabel: finalVariantLabel,
|
|
112
132
|
};
|
|
113
133
|
}, [apiBaseUrl, clientKey, explicitEnvironment, explicitRepoFullName, proposalId, variantLabel, storedProposalId, storedVariantLabel]);
|
|
114
134
|
|
|
@@ -140,7 +160,7 @@ export function ProbatProvider({
|
|
|
140
160
|
return () => {
|
|
141
161
|
stopHeatmapTracking();
|
|
142
162
|
};
|
|
143
|
-
}, [contextValue.apiBaseUrl]);
|
|
163
|
+
}, [contextValue.apiBaseUrl, contextValue.proposalId, contextValue.variantLabel]);
|
|
144
164
|
|
|
145
165
|
return (
|
|
146
166
|
<ProbatContext.Provider value={contextValue}>
|
|
@@ -1,38 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"path": "../../../../../../onelab_projects/gushi_test_repo"
|
|
11
|
-
},
|
|
12
|
-
{
|
|
13
|
-
"path": "../../../../../../react-jsx-test-repo"
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
"path": "../../../../../../testforprobat"
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
"path": "../../../../../portfolio"
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
"path": "../../../../../portfolio-nextjs"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"path": "../../../../../react-native-test"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"path": "../../../../../../tbg_test"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"path": "../../../../../../probattestforecho"
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"path": "../../../../../zingexample"
|
|
35
|
-
}
|
|
36
|
-
],
|
|
37
|
-
"settings": {}
|
|
2
|
+
"folders": [
|
|
3
|
+
{
|
|
4
|
+
"path": "../../../.."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"path": "../../../../../itrt-frontend"
|
|
8
|
+
}
|
|
9
|
+
]
|
|
38
10
|
}
|
|
@@ -160,7 +160,24 @@ export function withExperiment<P = any>(
|
|
|
160
160
|
if (useNewAPI && configLoading) return;
|
|
161
161
|
|
|
162
162
|
let alive = true;
|
|
163
|
-
|
|
163
|
+
|
|
164
|
+
// Detect if we are in heatmap mode
|
|
165
|
+
const isHeatmapMode = typeof window !== 'undefined' &&
|
|
166
|
+
new URLSearchParams(window.location.search).get('heatmap') === 'true';
|
|
167
|
+
|
|
168
|
+
// HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
|
|
169
|
+
// (This happens during Live Heatmap visualization)
|
|
170
|
+
if (context.proposalId === proposalId && context.variantLabel) {
|
|
171
|
+
console.log(`[PROBAT HOC] Forced variant from context: ${context.variantLabel}`);
|
|
172
|
+
setChoice({
|
|
173
|
+
experiment_id: `forced_${proposalId}`,
|
|
174
|
+
label: context.variantLabel
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If we are in heatmap mode, bypass the cache to avoid showing stale variants
|
|
180
|
+
const cached = isHeatmapMode ? null : readChoice(proposalId);
|
|
164
181
|
|
|
165
182
|
if (cached) {
|
|
166
183
|
const choiceData = {
|
|
@@ -169,7 +186,7 @@ export function withExperiment<P = any>(
|
|
|
169
186
|
};
|
|
170
187
|
setChoice(choiceData);
|
|
171
188
|
// Set localStorage for heatmap tracking
|
|
172
|
-
if (typeof window !== 'undefined') {
|
|
189
|
+
if (typeof window !== 'undefined' && !isHeatmapMode) {
|
|
173
190
|
try {
|
|
174
191
|
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
175
192
|
window.localStorage.setItem('probat_active_variant_label', cached.label);
|
|
@@ -182,18 +199,22 @@ export function withExperiment<P = any>(
|
|
|
182
199
|
try {
|
|
183
200
|
const { experiment_id, label } = await fetchDecision(apiBaseUrl, proposalId);
|
|
184
201
|
if (!alive) return;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
202
|
+
|
|
203
|
+
// Only write to choice cache and localStorage if NOT in heatmap mode
|
|
204
|
+
if (!isHeatmapMode) {
|
|
205
|
+
writeChoice(proposalId, experiment_id, label);
|
|
206
|
+
if (typeof window !== 'undefined') {
|
|
207
|
+
try {
|
|
208
|
+
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
209
|
+
window.localStorage.setItem('probat_active_variant_label', label);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
|
|
212
|
+
}
|
|
195
213
|
}
|
|
196
214
|
}
|
|
215
|
+
|
|
216
|
+
const choiceData = { experiment_id, label };
|
|
217
|
+
setChoice(choiceData);
|
|
197
218
|
} catch (e) {
|
|
198
219
|
if (!alive) return;
|
|
199
220
|
const choiceData = {
|
|
@@ -201,8 +222,8 @@ export function withExperiment<P = any>(
|
|
|
201
222
|
label: "control",
|
|
202
223
|
};
|
|
203
224
|
setChoice(choiceData);
|
|
204
|
-
|
|
205
|
-
if (typeof window !== 'undefined') {
|
|
225
|
+
|
|
226
|
+
if (typeof window !== 'undefined' && !isHeatmapMode) {
|
|
206
227
|
try {
|
|
207
228
|
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
208
229
|
window.localStorage.setItem('probat_active_variant_label', 'control');
|
|
@@ -215,7 +236,7 @@ export function withExperiment<P = any>(
|
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
return () => { alive = false; };
|
|
218
|
-
}, [proposalId, apiBaseUrl, useNewAPI, configLoading]);
|
|
239
|
+
}, [proposalId, apiBaseUrl, useNewAPI, configLoading, context.proposalId, context.variantLabel]);
|
|
219
240
|
|
|
220
241
|
// Track visit
|
|
221
242
|
useEffect(() => {
|
|
@@ -59,7 +59,8 @@ export function useExperiment(
|
|
|
59
59
|
proposalId: string,
|
|
60
60
|
options?: { autoTrackImpression?: boolean }
|
|
61
61
|
): UseExperimentReturn {
|
|
62
|
-
const
|
|
62
|
+
const context = useProbatContext();
|
|
63
|
+
const { apiBaseUrl } = context;
|
|
63
64
|
const [choice, setChoice] = useState<{
|
|
64
65
|
experiment_id: string;
|
|
65
66
|
label: string;
|
|
@@ -73,7 +74,25 @@ export function useExperiment(
|
|
|
73
74
|
useEffect(() => {
|
|
74
75
|
let alive = true;
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
// Detect if we are in heatmap mode
|
|
78
|
+
const isHeatmapMode = typeof window !== 'undefined' &&
|
|
79
|
+
new URLSearchParams(window.location.search).get('heatmap') === 'true';
|
|
80
|
+
|
|
81
|
+
// HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
|
|
82
|
+
// (This happens during Live Heatmap visualization)
|
|
83
|
+
if (context.proposalId === proposalId && context.variantLabel) {
|
|
84
|
+
console.log(`[PROBAT] Forced variant from context: ${context.variantLabel}`);
|
|
85
|
+
setChoice({
|
|
86
|
+
experiment_id: `forced_${proposalId}`,
|
|
87
|
+
label: context.variantLabel
|
|
88
|
+
});
|
|
89
|
+
setIsLoading(false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If we are in heatmap mode, bypass the cache to avoid showing stale variants
|
|
94
|
+
const cached = isHeatmapMode ? null : readChoice(proposalId);
|
|
95
|
+
|
|
77
96
|
if (cached) {
|
|
78
97
|
setChoice({ experiment_id: cached.experiment_id, label: cached.label });
|
|
79
98
|
setIsLoading(false);
|
|
@@ -86,7 +105,12 @@ export function useExperiment(
|
|
|
86
105
|
proposalId
|
|
87
106
|
);
|
|
88
107
|
if (!alive) return;
|
|
89
|
-
|
|
108
|
+
|
|
109
|
+
// Only write to choice cache if NOT in heatmap mode
|
|
110
|
+
if (!isHeatmapMode) {
|
|
111
|
+
writeChoice(proposalId, experiment_id, label);
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
setChoice({ experiment_id, label });
|
|
91
115
|
setError(null);
|
|
92
116
|
} catch (e) {
|
|
@@ -108,7 +132,7 @@ export function useExperiment(
|
|
|
108
132
|
return () => {
|
|
109
133
|
alive = false;
|
|
110
134
|
};
|
|
111
|
-
}, [proposalId, apiBaseUrl]);
|
|
135
|
+
}, [proposalId, apiBaseUrl, context.proposalId, context.variantLabel]);
|
|
112
136
|
|
|
113
137
|
// Track impression when variant is determined
|
|
114
138
|
useEffect(() => {
|
|
@@ -133,8 +133,12 @@ class HeatmapTracker {
|
|
|
133
133
|
|
|
134
134
|
// Refresh proposal/variant from localStorage at runtime
|
|
135
135
|
const stored = getStoredExperimentInfo();
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
|
|
137
|
+
// If stored info matches current proposal, or we don't have a proposal yet, use stored variant
|
|
138
|
+
const activeProposalId = this.config.proposalId || stored.proposalId;
|
|
139
|
+
const activeVariantLabel = (stored.proposalId === activeProposalId)
|
|
140
|
+
? (stored.variantLabel || this.config.variantLabel)
|
|
141
|
+
: (this.config.variantLabel || stored.variantLabel);
|
|
138
142
|
|
|
139
143
|
// Throttle cursor tracking
|
|
140
144
|
const now = Date.now();
|
|
@@ -145,21 +149,19 @@ class HeatmapTracker {
|
|
|
145
149
|
this.lastCursorTime = now;
|
|
146
150
|
|
|
147
151
|
// Debug: Log first few movements to verify it's working
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
154
|
-
}
|
|
152
|
+
console.log('[PROBAT Heatmap] Cursor movement captured:', {
|
|
153
|
+
x: event.pageX,
|
|
154
|
+
y: event.pageY,
|
|
155
|
+
movementsInBatch: this.movements.length + 1
|
|
156
|
+
});
|
|
155
157
|
|
|
156
158
|
// Get page information
|
|
157
159
|
const pageUrl = window.location.href;
|
|
158
160
|
const siteUrl = window.location.origin;
|
|
159
161
|
|
|
160
|
-
// Get cursor coordinates relative to
|
|
161
|
-
const x = event.
|
|
162
|
-
const y = event.
|
|
162
|
+
// Get cursor coordinates relative to document (including scroll)
|
|
163
|
+
const x = event.pageX;
|
|
164
|
+
const y = event.pageY;
|
|
163
165
|
|
|
164
166
|
// Get viewport dimensions
|
|
165
167
|
const viewportWidth = window.innerWidth;
|
|
@@ -174,8 +176,8 @@ class HeatmapTracker {
|
|
|
174
176
|
viewport_width: viewportWidth,
|
|
175
177
|
viewport_height: viewportHeight,
|
|
176
178
|
session_id: this.sessionId,
|
|
177
|
-
proposal_id:
|
|
178
|
-
variant_label:
|
|
179
|
+
proposal_id: activeProposalId,
|
|
180
|
+
variant_label: activeVariantLabel,
|
|
179
181
|
};
|
|
180
182
|
|
|
181
183
|
// Add to batch
|
|
@@ -261,8 +263,12 @@ class HeatmapTracker {
|
|
|
261
263
|
|
|
262
264
|
// Refresh proposal/variant from localStorage at runtime
|
|
263
265
|
const stored = getStoredExperimentInfo();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
|
|
267
|
+
// Priority: use localStorage variant if it matches the current proposal
|
|
268
|
+
const activeProposalId = this.config.proposalId || stored.proposalId;
|
|
269
|
+
const activeVariantLabel = (stored.proposalId === activeProposalId)
|
|
270
|
+
? (stored.variantLabel || this.config.variantLabel)
|
|
271
|
+
: (this.config.variantLabel || stored.variantLabel);
|
|
266
272
|
|
|
267
273
|
const target = event.target as HTMLElement | null;
|
|
268
274
|
if (!target) return;
|
|
@@ -274,8 +280,9 @@ class HeatmapTracker {
|
|
|
274
280
|
const pageUrl = window.location.href;
|
|
275
281
|
const siteUrl = window.location.origin;
|
|
276
282
|
|
|
277
|
-
|
|
278
|
-
const
|
|
283
|
+
// Get click coordinates relative to document (including scroll)
|
|
284
|
+
const x = event.pageX;
|
|
285
|
+
const y = event.pageY;
|
|
279
286
|
|
|
280
287
|
const viewportWidth = window.innerWidth;
|
|
281
288
|
const viewportHeight = window.innerHeight;
|
|
@@ -293,8 +300,8 @@ class HeatmapTracker {
|
|
|
293
300
|
element_class: elementInfo.class,
|
|
294
301
|
element_id: elementInfo.id,
|
|
295
302
|
session_id: this.sessionId,
|
|
296
|
-
proposal_id:
|
|
297
|
-
variant_label:
|
|
303
|
+
proposal_id: activeProposalId,
|
|
304
|
+
variant_label: activeVariantLabel,
|
|
298
305
|
};
|
|
299
306
|
|
|
300
307
|
this.clicks.push(clickEvent);
|
|
@@ -363,6 +370,19 @@ class HeatmapTracker {
|
|
|
363
370
|
return;
|
|
364
371
|
}
|
|
365
372
|
|
|
373
|
+
// Check for heatmap visualization mode FIRST
|
|
374
|
+
const params = new URLSearchParams(window.location.search);
|
|
375
|
+
if (params.get('heatmap') === 'true') {
|
|
376
|
+
console.log('[PROBAT Heatmap] Heatmap visualization mode detected');
|
|
377
|
+
const pageUrl = params.get('page_url');
|
|
378
|
+
if (pageUrl) {
|
|
379
|
+
this.config.enabled = false;
|
|
380
|
+
this.isInitialized = true;
|
|
381
|
+
this.enableVisualization(pageUrl);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
366
386
|
document.addEventListener('click', this.handleClick, true);
|
|
367
387
|
|
|
368
388
|
// Attach cursor movement listener if enabled
|
|
@@ -374,12 +394,7 @@ class HeatmapTracker {
|
|
|
374
394
|
}
|
|
375
395
|
|
|
376
396
|
this.isInitialized = true;
|
|
377
|
-
console.log('[PROBAT Heatmap] Tracker initialized'
|
|
378
|
-
enabled: this.config.enabled,
|
|
379
|
-
trackCursor: this.config.trackCursor,
|
|
380
|
-
cursorThrottle: this.config.cursorThrottle,
|
|
381
|
-
cursorBatchSize: this.config.cursorBatchSize
|
|
382
|
-
});
|
|
397
|
+
console.log('[PROBAT Heatmap] Tracker initialized');
|
|
383
398
|
|
|
384
399
|
window.addEventListener('beforeunload', () => {
|
|
385
400
|
if (this.clicks.length > 0) {
|
|
@@ -447,6 +462,178 @@ class HeatmapTracker {
|
|
|
447
462
|
|
|
448
463
|
this.isInitialized = false;
|
|
449
464
|
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Enable visualization mode
|
|
468
|
+
*/
|
|
469
|
+
/**
|
|
470
|
+
* Enable visualization mode
|
|
471
|
+
*/
|
|
472
|
+
private async enableVisualization(pageUrl: string): Promise<void> {
|
|
473
|
+
console.log('[PROBAT Heatmap] Enabling visualization mode for:', pageUrl);
|
|
474
|
+
|
|
475
|
+
// Stop tracking just in case
|
|
476
|
+
this.stop();
|
|
477
|
+
this.config.enabled = false;
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
// Fetch heatmap data
|
|
481
|
+
const url = new URL(`${this.config.apiBaseUrl}/api/heatmap/aggregate`);
|
|
482
|
+
url.searchParams.append('site_url', window.location.origin);
|
|
483
|
+
url.searchParams.append('page_url', pageUrl);
|
|
484
|
+
url.searchParams.append('days', '30'); // Default to 30 days
|
|
485
|
+
|
|
486
|
+
// Add variant filtering if present in URL
|
|
487
|
+
const params = new URLSearchParams(window.location.search);
|
|
488
|
+
const proposalId = params.get('proposal_id');
|
|
489
|
+
const variantLabel = params.get('variant_label');
|
|
490
|
+
|
|
491
|
+
if (proposalId) url.searchParams.append('proposal_id', proposalId);
|
|
492
|
+
if (variantLabel) url.searchParams.append('variant_label', variantLabel);
|
|
493
|
+
|
|
494
|
+
const response = await fetch(url.toString());
|
|
495
|
+
if (!response.ok) throw new Error('Failed to fetch heatmap data');
|
|
496
|
+
|
|
497
|
+
const data = await response.json();
|
|
498
|
+
if (data && data.points) {
|
|
499
|
+
console.log(`[PROBAT Heatmap] Found ${data.points.length} points. Rendering...`);
|
|
500
|
+
this.renderHeatmapOverlay(data);
|
|
501
|
+
}
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error('[PROBAT Heatmap] Visualization error:', error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Render heatmap overlay on valid points
|
|
509
|
+
*/
|
|
510
|
+
private renderHeatmapOverlay(data: any): void {
|
|
511
|
+
const points = data.points;
|
|
512
|
+
const trackedWidth = data.viewport_width || 0;
|
|
513
|
+
|
|
514
|
+
// Remove existing heatmap if any
|
|
515
|
+
const existing = document.getElementById('probat-heatmap-overlay');
|
|
516
|
+
if (existing) existing.remove();
|
|
517
|
+
|
|
518
|
+
const canvas = document.createElement('canvas');
|
|
519
|
+
canvas.id = 'probat-heatmap-overlay';
|
|
520
|
+
canvas.style.position = 'absolute';
|
|
521
|
+
canvas.style.top = '0';
|
|
522
|
+
canvas.style.left = '0';
|
|
523
|
+
canvas.style.zIndex = '999999';
|
|
524
|
+
canvas.style.pointerEvents = 'none';
|
|
525
|
+
canvas.style.display = 'block';
|
|
526
|
+
canvas.style.margin = '0';
|
|
527
|
+
canvas.style.padding = '0';
|
|
528
|
+
|
|
529
|
+
// Append to html to ensure it's relative to the absolute document top-left (0,0)
|
|
530
|
+
document.documentElement.appendChild(canvas);
|
|
531
|
+
|
|
532
|
+
const resize = () => {
|
|
533
|
+
const dpr = window.devicePixelRatio || 1;
|
|
534
|
+
const width = document.documentElement.scrollWidth;
|
|
535
|
+
const height = document.documentElement.scrollHeight;
|
|
536
|
+
const currentWidth = window.innerWidth;
|
|
537
|
+
|
|
538
|
+
// Horizontal centering adjustment
|
|
539
|
+
// Formula: current_x = tracked_x + (current_viewport_width - tracked_viewport_width) / 2
|
|
540
|
+
let offsetX = 0;
|
|
541
|
+
if (trackedWidth > 0 && trackedWidth !== currentWidth) {
|
|
542
|
+
offsetX = (currentWidth - trackedWidth) / 2;
|
|
543
|
+
console.log(`[PROBAT Heatmap] Horizontal adjustment: offset=${offsetX}px (Tracked: ${trackedWidth}px, Current: ${currentWidth}px)`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Set display size
|
|
547
|
+
canvas.style.width = width + 'px';
|
|
548
|
+
canvas.style.height = height + 'px';
|
|
549
|
+
|
|
550
|
+
// Set internal resolution scaled by pixel ratio
|
|
551
|
+
canvas.width = width * dpr;
|
|
552
|
+
canvas.height = height * dpr;
|
|
553
|
+
|
|
554
|
+
const ctx = canvas.getContext('2d');
|
|
555
|
+
if (ctx) {
|
|
556
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Clear and set scale in one go
|
|
557
|
+
this.renderPoints(ctx, points, offsetX);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
window.addEventListener('resize', resize);
|
|
562
|
+
// Call multiple times to catch delayed layout shifts
|
|
563
|
+
setTimeout(resize, 300);
|
|
564
|
+
setTimeout(resize, 1000);
|
|
565
|
+
setTimeout(resize, 3000);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Draw points on canvas
|
|
570
|
+
*/
|
|
571
|
+
private renderPoints(ctx: CanvasRenderingContext2D, points: any[], offsetX: number): void {
|
|
572
|
+
points.forEach(point => {
|
|
573
|
+
// Apply horizontal offset for centered layouts
|
|
574
|
+
const x = point.x + offsetX;
|
|
575
|
+
const y = point.y;
|
|
576
|
+
const intensity = point.intensity;
|
|
577
|
+
|
|
578
|
+
// Scale radius based on intensity
|
|
579
|
+
const radius = 20 + (intensity * 12);
|
|
580
|
+
const color = this.getHeatmapColor(intensity);
|
|
581
|
+
|
|
582
|
+
// Use radial gradient for a "heat" look
|
|
583
|
+
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
584
|
+
gradient.addColorStop(0, this.rgbToRgba(color, 0.8));
|
|
585
|
+
gradient.addColorStop(1, this.rgbToRgba(color, 0));
|
|
586
|
+
|
|
587
|
+
ctx.beginPath();
|
|
588
|
+
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
|
589
|
+
ctx.fillStyle = gradient;
|
|
590
|
+
ctx.fill();
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get heatmap color based on intensity
|
|
596
|
+
*/
|
|
597
|
+
private getHeatmapColor(intensity: number): string {
|
|
598
|
+
const clamped = Math.max(0, Math.min(1, intensity));
|
|
599
|
+
|
|
600
|
+
if (clamped < 0.25) {
|
|
601
|
+
const t = clamped / 0.25;
|
|
602
|
+
const r = Math.floor(0 + t * 0);
|
|
603
|
+
const g = Math.floor(0 + t * 255);
|
|
604
|
+
const b = Math.floor(255 + t * (255 - 255));
|
|
605
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
606
|
+
} else if (clamped < 0.5) {
|
|
607
|
+
const t = (clamped - 0.25) / 0.25;
|
|
608
|
+
const r = Math.floor(0 + t * 0);
|
|
609
|
+
const g = 255;
|
|
610
|
+
const b = Math.floor(255 + t * (0 - 255));
|
|
611
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
612
|
+
} else if (clamped < 0.75) {
|
|
613
|
+
const t = (clamped - 0.5) / 0.25;
|
|
614
|
+
const r = Math.floor(0 + t * 255);
|
|
615
|
+
const g = 255;
|
|
616
|
+
const b = 0;
|
|
617
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
618
|
+
} else {
|
|
619
|
+
const t = (clamped - 0.75) / 0.25;
|
|
620
|
+
const r = 255;
|
|
621
|
+
const g = Math.floor(255 + t * (0 - 255));
|
|
622
|
+
const b = 0;
|
|
623
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Convert RGB to RGBA
|
|
629
|
+
*/
|
|
630
|
+
private rgbToRgba(rgb: string, opacity: number): string {
|
|
631
|
+
const match = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
632
|
+
if (match) {
|
|
633
|
+
return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
|
|
634
|
+
}
|
|
635
|
+
return rgb;
|
|
636
|
+
}
|
|
450
637
|
}
|
|
451
638
|
|
|
452
639
|
let trackerInstance: HeatmapTracker | null = null;
|