@gemx-dev/heatmap-react 3.5.26 → 3.5.28
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/esm/components/Layout/HeatmapLayout.d.ts.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +19 -1184
- package/dist/esm/index.mjs +19 -1184
- package/dist/esm/stores/data.d.ts +2 -1
- package/dist/esm/stores/data.d.ts.map +1 -1
- package/dist/esm/types/clarity.d.ts +36 -0
- package/dist/esm/types/clarity.d.ts.map +1 -0
- package/dist/esm/types/heatmap.d.ts +5 -0
- package/dist/esm/types/heatmap.d.ts.map +1 -0
- package/dist/esm/types/index.d.ts +4 -0
- package/dist/esm/types/index.d.ts.map +1 -0
- package/dist/esm/types/viz-element.d.ts +34 -0
- package/dist/esm/types/viz-element.d.ts.map +1 -0
- package/dist/umd/components/Layout/HeatmapLayout.d.ts.map +1 -1
- package/dist/umd/index.d.ts +1 -1
- package/dist/umd/index.d.ts.map +1 -1
- package/dist/umd/index.js +2 -2
- package/dist/umd/stores/data.d.ts +2 -1
- package/dist/umd/stores/data.d.ts.map +1 -1
- package/dist/umd/types/clarity.d.ts +36 -0
- package/dist/umd/types/clarity.d.ts.map +1 -0
- package/dist/umd/types/heatmap.d.ts +5 -0
- package/dist/umd/types/heatmap.d.ts.map +1 -0
- package/dist/umd/types/index.d.ts +4 -0
- package/dist/umd/types/index.d.ts.map +1 -0
- package/dist/umd/types/viz-element.d.ts +34 -0
- package/dist/umd/types/viz-element.d.ts.map +1 -0
- package/package.json +5 -4
package/dist/esm/index.mjs
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
3
3
|
import { useNodesState, ReactFlow, Controls, Background } from '@xyflow/react';
|
|
4
|
-
import { useEffect, useMemo,
|
|
4
|
+
import { useEffect, useMemo, useCallback } from 'react';
|
|
5
5
|
import { create } from 'zustand';
|
|
6
|
-
import { Visualizer } from '@gemx-dev/clarity-visualize';
|
|
7
|
-
import { createPortal } from 'react-dom';
|
|
8
6
|
|
|
9
7
|
const initialNodes = { id: '1', position: { x: 0, y: 0 }, data: { label: '1' } };
|
|
10
8
|
const GraphView = ({ children, width, height }) => {
|
|
@@ -38,20 +36,23 @@ const GraphView = ({ children, width, height }) => {
|
|
|
38
36
|
return (jsxs(ReactFlow, { nodes: nodes, nodeTypes: nodeTypes, onNodesChange: onNodesChange, debug: true, minZoom: 0.5, maxZoom: 2, fitView: true, children: [jsx(Controls, {}), jsx(Background, {})] }));
|
|
39
37
|
};
|
|
40
38
|
|
|
41
|
-
const useHeatmapDataStore = create()((set, get) =>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
})
|
|
39
|
+
const useHeatmapDataStore = create()((set, get) => {
|
|
40
|
+
return {
|
|
41
|
+
data: undefined,
|
|
42
|
+
clickmap: undefined,
|
|
43
|
+
config: undefined,
|
|
44
|
+
iframeHeight: 0,
|
|
45
|
+
state: {
|
|
46
|
+
hideSidebar: false,
|
|
47
|
+
},
|
|
48
|
+
setData: (data) => set({ data }),
|
|
49
|
+
setClickmap: (clickmap) => set({ clickmap }),
|
|
50
|
+
setState: (state) => set({ state: { ...get().state, ...state } }),
|
|
51
|
+
setConfig: (value) => set({ config: { ...get().config, ...value } }),
|
|
52
|
+
setConfigBy: (key, value) => set({ config: { ...get().config, [key]: value } }),
|
|
53
|
+
setIframeHeight: (iframeHeight) => set({ iframeHeight }),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
55
56
|
|
|
56
57
|
const BoxStack = ({ children, ...props }) => {
|
|
57
58
|
const id = props.id;
|
|
@@ -91,1172 +92,6 @@ const BoxStack = ({ children, ...props }) => {
|
|
|
91
92
|
return (jsx("div", { id: id, style: styleProps, children: children }));
|
|
92
93
|
};
|
|
93
94
|
|
|
94
|
-
const ContentHeader = ({ children }) => {
|
|
95
|
-
return (jsx(BoxStack, { id: "gx-hm-content-header", flexDirection: "row", alignItems: "center", overflow: "auto", children: children }));
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const HEATMAP_IFRAME = {
|
|
99
|
-
id: 'clarity-iframe',
|
|
100
|
-
title: 'Clarity Session Replay',
|
|
101
|
-
sandbox: 'allow-same-origin allow-scripts',
|
|
102
|
-
scrolling: 'no',
|
|
103
|
-
height: '100%',
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const HEATMAP_CONFIG = {
|
|
107
|
-
paddingBlock: 0,
|
|
108
|
-
};
|
|
109
|
-
const HEATMAP_STYLE = {
|
|
110
|
-
wrapper: {
|
|
111
|
-
padding: `${HEATMAP_CONFIG.paddingBlock}px 0`,
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const useClickedElement = ({ selectedElement, heatmapInfo, getRect }) => {
|
|
116
|
-
const [clickedElement, setClickedElement] = useState(null);
|
|
117
|
-
const [showMissingElement, setShowMissingElement] = useState(false);
|
|
118
|
-
const [shouldShowCallout, setShouldShowCallout] = useState(false);
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
if (!selectedElement || !heatmapInfo?.elementMapInfo) {
|
|
121
|
-
setClickedElement(null);
|
|
122
|
-
setShowMissingElement(false);
|
|
123
|
-
setShouldShowCallout(false);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const info = heatmapInfo.elementMapInfo[selectedElement];
|
|
127
|
-
if (!info) {
|
|
128
|
-
setClickedElement(null);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const rect = getRect({ hash: selectedElement, selector: info.selector });
|
|
132
|
-
if (rect && heatmapInfo.sortedElements) {
|
|
133
|
-
const rank = heatmapInfo.sortedElements.findIndex((e) => e.hash === selectedElement) + 1;
|
|
134
|
-
setClickedElement({
|
|
135
|
-
...rect,
|
|
136
|
-
hash: selectedElement,
|
|
137
|
-
clicks: info.totalclicks ?? 0,
|
|
138
|
-
rank,
|
|
139
|
-
selector: info.selector ?? '',
|
|
140
|
-
});
|
|
141
|
-
setShowMissingElement(false);
|
|
142
|
-
setShouldShowCallout(true);
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
const rank = (heatmapInfo.sortedElements?.findIndex((e) => e.hash === selectedElement) ?? -1) + 1;
|
|
146
|
-
setClickedElement({
|
|
147
|
-
hash: selectedElement,
|
|
148
|
-
clicks: info.totalclicks ?? 0,
|
|
149
|
-
rank,
|
|
150
|
-
selector: info.selector ?? '',
|
|
151
|
-
left: 0,
|
|
152
|
-
top: 0,
|
|
153
|
-
width: 0,
|
|
154
|
-
height: 0,
|
|
155
|
-
});
|
|
156
|
-
setShowMissingElement(true);
|
|
157
|
-
setShouldShowCallout(false);
|
|
158
|
-
}
|
|
159
|
-
}, [selectedElement, heatmapInfo, getRect]);
|
|
160
|
-
return { clickedElement, showMissingElement, shouldShowCallout, setShouldShowCallout };
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const useHeatmapEffects = ({ isVisible, isElementSidebarOpen, selectedElement, setShouldShowCallout, resetAll, }) => {
|
|
164
|
-
// Reset khi ẩn
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
if (!isVisible)
|
|
167
|
-
resetAll();
|
|
168
|
-
}, [isVisible, resetAll]);
|
|
169
|
-
// Ẩn callout khi sidebar mở
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
if (isElementSidebarOpen && selectedElement) {
|
|
172
|
-
setShouldShowCallout(false);
|
|
173
|
-
}
|
|
174
|
-
else if (!isElementSidebarOpen && selectedElement) {
|
|
175
|
-
setShouldShowCallout(true);
|
|
176
|
-
}
|
|
177
|
-
}, [isElementSidebarOpen, selectedElement, setShouldShowCallout]);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
function getElementLayout(element) {
|
|
181
|
-
if (!element?.getBoundingClientRect)
|
|
182
|
-
return null;
|
|
183
|
-
const rect = element.getBoundingClientRect();
|
|
184
|
-
if (rect.width === 0 && rect.height === 0)
|
|
185
|
-
return null;
|
|
186
|
-
return {
|
|
187
|
-
top: rect.top,
|
|
188
|
-
left: rect.left,
|
|
189
|
-
width: rect.width,
|
|
190
|
-
height: rect.height,
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
function formatPercentage(value, decimals = 2) {
|
|
194
|
-
return value.toFixed(decimals);
|
|
195
|
-
}
|
|
196
|
-
function getSimpleSelector(selector) {
|
|
197
|
-
const parts = selector.split(' > ');
|
|
198
|
-
return parts[parts.length - 1] || selector;
|
|
199
|
-
}
|
|
200
|
-
function calculateRankPosition(rect, widthScale) {
|
|
201
|
-
const top = rect.top <= 18 ? rect.top + 3 : rect.top - 18;
|
|
202
|
-
const left = rect.left <= 18 ? rect.left + 3 : rect.left - 18;
|
|
203
|
-
return {
|
|
204
|
-
transform: `scale(${1.2 * widthScale})`,
|
|
205
|
-
top: Number.isNaN(top) ? undefined : top,
|
|
206
|
-
left: Number.isNaN(left) ? undefined : left,
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const useHeatmapElementPosition = ({ iframeRef, parentRef, visualizer, heatmapWidth, iframeHeight, widthScale, projectId, }) => {
|
|
211
|
-
return useCallback((element) => {
|
|
212
|
-
const hash = element?.hash;
|
|
213
|
-
if (!iframeRef.current?.contentDocument || !hash || !visualizer)
|
|
214
|
-
return null;
|
|
215
|
-
let domElement = null;
|
|
216
|
-
try {
|
|
217
|
-
domElement = visualizer.get(hash);
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
console.error('Visualizer error:', { projectId, hash, error });
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
if (!domElement)
|
|
224
|
-
return null;
|
|
225
|
-
const layout = getElementLayout(domElement);
|
|
226
|
-
if (!layout)
|
|
227
|
-
return null;
|
|
228
|
-
const parentEl = parentRef.current;
|
|
229
|
-
if (!parentEl)
|
|
230
|
-
return null;
|
|
231
|
-
const scrollOffset = parentEl.scrollTop / widthScale;
|
|
232
|
-
const adjustedTop = layout.top + scrollOffset;
|
|
233
|
-
const outOfBounds = adjustedTop < 0 ||
|
|
234
|
-
adjustedTop > (iframeHeight || Infinity) ||
|
|
235
|
-
layout.left < 0 ||
|
|
236
|
-
(typeof heatmapWidth === 'number' && layout.left > heatmapWidth);
|
|
237
|
-
if (outOfBounds)
|
|
238
|
-
return null;
|
|
239
|
-
return {
|
|
240
|
-
left: layout.left,
|
|
241
|
-
top: adjustedTop,
|
|
242
|
-
width: Math.min(layout.width, heatmapWidth || layout.width),
|
|
243
|
-
height: layout.height,
|
|
244
|
-
};
|
|
245
|
-
}, [iframeRef, parentRef, visualizer, heatmapWidth, iframeHeight, widthScale, projectId]);
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
const debounce = (fn, delay) => {
|
|
249
|
-
let timeout;
|
|
250
|
-
return (...args) => {
|
|
251
|
-
clearTimeout(timeout);
|
|
252
|
-
timeout = setTimeout(() => fn(...args), delay);
|
|
253
|
-
};
|
|
254
|
-
};
|
|
255
|
-
const useHoveredElement = ({ iframeRef, heatmapInfo, widthScale, getRect, onSelect, }) => {
|
|
256
|
-
const [hoveredElement, setHoveredElement] = useState(null);
|
|
257
|
-
const handleMouseMove = useCallback(debounce((event) => {
|
|
258
|
-
if (!iframeRef.current?.contentDocument || !heatmapInfo?.elementMapInfo) {
|
|
259
|
-
setHoveredElement(null);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const iframe = iframeRef.current;
|
|
263
|
-
const iframeRect = iframe.getBoundingClientRect();
|
|
264
|
-
let x = event.clientX - iframeRect.left;
|
|
265
|
-
console.log(`🚀 🐥 ~ useHoveredElement ~ iframeRect.left:`, iframeRect.left);
|
|
266
|
-
console.log(`🚀 🐥 ~ useHoveredElement ~ event.clientX:`, event.clientX);
|
|
267
|
-
let y = event.clientY - iframeRect.top;
|
|
268
|
-
if (widthScale !== 1) {
|
|
269
|
-
x /= widthScale;
|
|
270
|
-
y /= widthScale;
|
|
271
|
-
}
|
|
272
|
-
const doc = iframe.contentDocument;
|
|
273
|
-
if (!doc) {
|
|
274
|
-
setHoveredElement(null);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
let targetElement = null;
|
|
278
|
-
// Best: dùng caretPositionFromPoint nếu có (Chrome, Safari)
|
|
279
|
-
targetElement = getElementAtPoint(doc, x, y);
|
|
280
|
-
if (!targetElement) {
|
|
281
|
-
targetElement = doc.elementFromPoint(x, y);
|
|
282
|
-
}
|
|
283
|
-
if (!targetElement) {
|
|
284
|
-
setHoveredElement(null);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
// Lấy hash từ nhiều attribute khả dĩ
|
|
288
|
-
const hash = targetElement.getAttribute('data-clarity-hash') ||
|
|
289
|
-
targetElement.getAttribute('data-clarity-hashalpha') ||
|
|
290
|
-
targetElement.getAttribute('data-clarity-hashbeta');
|
|
291
|
-
if (!hash || !heatmapInfo.elementMapInfo[hash]) {
|
|
292
|
-
setHoveredElement(null);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
const info = heatmapInfo.elementMapInfo[hash];
|
|
296
|
-
const position = getRect({ hash, selector: info.selector });
|
|
297
|
-
if (position && heatmapInfo.sortedElements) {
|
|
298
|
-
const rank = heatmapInfo.sortedElements.findIndex((e) => e.hash === hash) + 1;
|
|
299
|
-
setHoveredElement({
|
|
300
|
-
...position,
|
|
301
|
-
hash,
|
|
302
|
-
clicks: info.totalclicks ?? 0,
|
|
303
|
-
rank,
|
|
304
|
-
selector: info.selector ?? '',
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
setHoveredElement(null);
|
|
309
|
-
}
|
|
310
|
-
}, 16), // ~60fps
|
|
311
|
-
[iframeRef, heatmapInfo, getRect]);
|
|
312
|
-
const handleMouseLeave = useCallback(() => {
|
|
313
|
-
setHoveredElement(null);
|
|
314
|
-
}, []);
|
|
315
|
-
const handleClick = useCallback(() => {
|
|
316
|
-
if (hoveredElement?.hash && onSelect) {
|
|
317
|
-
onSelect(hoveredElement.hash);
|
|
318
|
-
}
|
|
319
|
-
}, [hoveredElement, onSelect]);
|
|
320
|
-
return {
|
|
321
|
-
hoveredElement,
|
|
322
|
-
handleMouseMove,
|
|
323
|
-
handleMouseLeave,
|
|
324
|
-
handleClick,
|
|
325
|
-
};
|
|
326
|
-
};
|
|
327
|
-
const getElementAtPoint = (doc, x, y) => {
|
|
328
|
-
let el = null;
|
|
329
|
-
if ('caretPositionFromPoint' in doc) {
|
|
330
|
-
el = doc.caretPositionFromPoint(x, y)?.offsetNode ?? null;
|
|
331
|
-
}
|
|
332
|
-
el = el ?? doc.elementFromPoint(x, y);
|
|
333
|
-
let element = el;
|
|
334
|
-
while (element && element.nodeType === Node.TEXT_NODE) {
|
|
335
|
-
element = element.parentElement;
|
|
336
|
-
}
|
|
337
|
-
return element;
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
const recreateIframe = (iframeRef, config) => {
|
|
341
|
-
const container = iframeRef.current?.parentElement;
|
|
342
|
-
if (!container)
|
|
343
|
-
return;
|
|
344
|
-
const oldIframe = iframeRef.current;
|
|
345
|
-
if (!oldIframe?.contentDocument?.body.innerHTML) {
|
|
346
|
-
return oldIframe;
|
|
347
|
-
}
|
|
348
|
-
if (oldIframe && oldIframe.parentElement) {
|
|
349
|
-
oldIframe.parentElement.removeChild(oldIframe);
|
|
350
|
-
}
|
|
351
|
-
const newIframe = document.createElement('iframe');
|
|
352
|
-
newIframe.id = HEATMAP_IFRAME.id;
|
|
353
|
-
newIframe.title = HEATMAP_IFRAME.title;
|
|
354
|
-
newIframe.sandbox = HEATMAP_IFRAME.sandbox;
|
|
355
|
-
newIframe.scrolling = HEATMAP_IFRAME.scrolling;
|
|
356
|
-
newIframe.width = config?.width ? `${config.width}px` : '100%';
|
|
357
|
-
// Append to container
|
|
358
|
-
container.appendChild(newIframe);
|
|
359
|
-
// Update ref
|
|
360
|
-
iframeRef.current = newIframe;
|
|
361
|
-
return newIframe;
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
function isMobileDevice(userAgent) {
|
|
365
|
-
if (!userAgent)
|
|
366
|
-
return false;
|
|
367
|
-
return /android|webos|iphone|ipad|ipod|blackberry|windows phone|opera mini|iemobile|mobile|silk|fennec|bada|tizen|symbian|nokia|palmsource|meego|sailfish|kindle|playbook|bb10|rim/i.test(userAgent);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const useHeatmapRender = () => {
|
|
371
|
-
const data = useHeatmapDataStore((state) => state.data);
|
|
372
|
-
const config = useHeatmapDataStore((state) => state.config);
|
|
373
|
-
const setConfig = useHeatmapDataStore((state) => state.setConfig);
|
|
374
|
-
const clickmap = useHeatmapDataStore((state) => state.clickmap);
|
|
375
|
-
const visualizerRef = useRef(null);
|
|
376
|
-
const iframeRef = useRef(null);
|
|
377
|
-
const initializeVisualizer = useCallback((envelope, userAgent) => {
|
|
378
|
-
const iframe = iframeRef.current;
|
|
379
|
-
if (!iframe?.contentWindow)
|
|
380
|
-
return null;
|
|
381
|
-
const visualizer = new Visualizer();
|
|
382
|
-
const mobile = isMobileDevice(userAgent);
|
|
383
|
-
visualizer.setup(iframe.contentWindow, {
|
|
384
|
-
version: envelope.version,
|
|
385
|
-
onresize: (width) => {
|
|
386
|
-
setConfig({ width });
|
|
387
|
-
},
|
|
388
|
-
mobile,
|
|
389
|
-
vNext: true,
|
|
390
|
-
locale: 'en-us',
|
|
391
|
-
});
|
|
392
|
-
return visualizer;
|
|
393
|
-
}, []);
|
|
394
|
-
// Process and render heatmap HTML
|
|
395
|
-
const renderHeatmap = useCallback(async (payloads) => {
|
|
396
|
-
if (!payloads || payloads.length === 0)
|
|
397
|
-
return;
|
|
398
|
-
let visualizer = new Visualizer();
|
|
399
|
-
const iframe = recreateIframe(iframeRef, config);
|
|
400
|
-
// const merged = visualizer.merge(payloads);
|
|
401
|
-
// setIframeHeight(Number(iframeRef.current?.height || 0));
|
|
402
|
-
// for (const decoded of payloads) {
|
|
403
|
-
// // Initialize on first sequence
|
|
404
|
-
// if (decoded.envelope.sequence === 1) {
|
|
405
|
-
// const userAgent = (decoded.dimension?.[0]?.data[0]?.[0] as string) || '';
|
|
406
|
-
// visualizer = initializeVisualizer(decoded.envelope as any, userAgent);
|
|
407
|
-
// if (!visualizer) return;
|
|
408
|
-
// visualizerRef.current = visualizer;
|
|
409
|
-
// }
|
|
410
|
-
// if (!visualizer) continue;
|
|
411
|
-
// // Merge and process DOM
|
|
412
|
-
// const merged = visualizer.merge([decoded]);
|
|
413
|
-
// visualizer.dom(merged.dom);
|
|
414
|
-
// }
|
|
415
|
-
// Render static HTML
|
|
416
|
-
if (visualizer && iframe?.contentWindow) {
|
|
417
|
-
await visualizer.html(payloads, iframe.contentWindow);
|
|
418
|
-
visualizerRef.current = visualizer;
|
|
419
|
-
}
|
|
420
|
-
}, [initializeVisualizer]);
|
|
421
|
-
useEffect(() => {
|
|
422
|
-
if (!data || data.length === 0)
|
|
423
|
-
return;
|
|
424
|
-
renderHeatmap(data);
|
|
425
|
-
return () => {
|
|
426
|
-
visualizerRef.current = null;
|
|
427
|
-
};
|
|
428
|
-
}, [config, data, renderHeatmap]);
|
|
429
|
-
useEffect(() => {
|
|
430
|
-
if (!visualizerRef.current || !clickmap || clickmap.length === 0)
|
|
431
|
-
return;
|
|
432
|
-
visualizerRef.current.clearmap();
|
|
433
|
-
visualizerRef.current?.clickmap(clickmap);
|
|
434
|
-
}, [clickmap]);
|
|
435
|
-
return {
|
|
436
|
-
iframeRef,
|
|
437
|
-
clarityVisualizer: visualizerRef.current,
|
|
438
|
-
};
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
function sortEvents(a, b) {
|
|
442
|
-
return a.time - b.time;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const useReplayRender = () => {
|
|
446
|
-
const data = useHeatmapDataStore((state) => state.data);
|
|
447
|
-
const setConfig = useHeatmapDataStore((state) => state.setConfig);
|
|
448
|
-
const visualizerRef = useRef(null);
|
|
449
|
-
const iframeRef = useRef(null);
|
|
450
|
-
const eventsRef = useRef([]);
|
|
451
|
-
const animationFrameRef = useRef(null);
|
|
452
|
-
const isPlayingRef = useRef(false);
|
|
453
|
-
// Initialize visualizer for replay
|
|
454
|
-
const initializeVisualizer = useCallback((envelope, userAgent) => {
|
|
455
|
-
const iframe = iframeRef.current;
|
|
456
|
-
if (!iframe?.contentWindow)
|
|
457
|
-
return null;
|
|
458
|
-
// Clear previous events
|
|
459
|
-
eventsRef.current = [];
|
|
460
|
-
const visualizer = new Visualizer();
|
|
461
|
-
const mobile = isMobileDevice(userAgent);
|
|
462
|
-
visualizer.setup(iframe.contentWindow, {
|
|
463
|
-
version: envelope.version,
|
|
464
|
-
onresize: (width) => {
|
|
465
|
-
setConfig({ width });
|
|
466
|
-
},
|
|
467
|
-
mobile,
|
|
468
|
-
vNext: true,
|
|
469
|
-
locale: 'en-us',
|
|
470
|
-
});
|
|
471
|
-
return visualizer;
|
|
472
|
-
}, [setConfig]);
|
|
473
|
-
// Animation loop for replay
|
|
474
|
-
const replayLoop = useCallback(() => {
|
|
475
|
-
if (!isPlayingRef.current)
|
|
476
|
-
return;
|
|
477
|
-
const events = eventsRef.current;
|
|
478
|
-
const visualizer = visualizerRef.current;
|
|
479
|
-
if (!visualizer || events.length === 0) {
|
|
480
|
-
animationFrameRef.current = requestAnimationFrame(replayLoop);
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
const event = events[0];
|
|
484
|
-
const end = event.time + 16; // 60FPS => 16ms per frame
|
|
485
|
-
let index = 0;
|
|
486
|
-
// Get events within current frame
|
|
487
|
-
while (events[index] && events[index].time < end) {
|
|
488
|
-
index++;
|
|
489
|
-
}
|
|
490
|
-
// Render events for this frame
|
|
491
|
-
if (index > 0) {
|
|
492
|
-
visualizer.render(events.splice(0, index));
|
|
493
|
-
}
|
|
494
|
-
animationFrameRef.current = requestAnimationFrame(replayLoop);
|
|
495
|
-
}, []);
|
|
496
|
-
// Start replay
|
|
497
|
-
const startReplay = useCallback(async (payloads) => {
|
|
498
|
-
if (!payloads || payloads.length === 0)
|
|
499
|
-
return;
|
|
500
|
-
let visualizer = visualizerRef.current;
|
|
501
|
-
for (const decoded of payloads) {
|
|
502
|
-
// Initialize on first sequence
|
|
503
|
-
if (decoded.envelope.sequence === 1) {
|
|
504
|
-
const userAgent = decoded.dimension?.[0]?.data[0]?.[0] || '';
|
|
505
|
-
visualizer = initializeVisualizer(decoded.envelope, userAgent);
|
|
506
|
-
if (!visualizer)
|
|
507
|
-
return;
|
|
508
|
-
visualizerRef.current = visualizer;
|
|
509
|
-
}
|
|
510
|
-
if (!visualizer)
|
|
511
|
-
continue;
|
|
512
|
-
// Merge events and DOM
|
|
513
|
-
const merged = visualizer.merge([decoded]);
|
|
514
|
-
eventsRef.current = eventsRef.current.concat(merged.events).sort(sortEvents);
|
|
515
|
-
visualizer.dom(merged.dom);
|
|
516
|
-
}
|
|
517
|
-
// Render HTML
|
|
518
|
-
if (visualizer && iframeRef.current?.contentWindow) {
|
|
519
|
-
await visualizer.html(payloads, iframeRef.current.contentWindow);
|
|
520
|
-
}
|
|
521
|
-
// Auto-start replay
|
|
522
|
-
isPlayingRef.current = true;
|
|
523
|
-
animationFrameRef.current = requestAnimationFrame(replayLoop);
|
|
524
|
-
}, [initializeVisualizer, replayLoop]);
|
|
525
|
-
// Play control
|
|
526
|
-
const play = useCallback(() => {
|
|
527
|
-
if (!isPlayingRef.current) {
|
|
528
|
-
isPlayingRef.current = true;
|
|
529
|
-
animationFrameRef.current = requestAnimationFrame(replayLoop);
|
|
530
|
-
}
|
|
531
|
-
}, [replayLoop]);
|
|
532
|
-
// Pause control
|
|
533
|
-
const pause = useCallback(() => {
|
|
534
|
-
isPlayingRef.current = false;
|
|
535
|
-
if (animationFrameRef.current) {
|
|
536
|
-
cancelAnimationFrame(animationFrameRef.current);
|
|
537
|
-
animationFrameRef.current = null;
|
|
538
|
-
}
|
|
539
|
-
}, []);
|
|
540
|
-
// Main effect: Start replay when data changes
|
|
541
|
-
useEffect(() => {
|
|
542
|
-
if (!data || data.length === 0)
|
|
543
|
-
return;
|
|
544
|
-
startReplay(data);
|
|
545
|
-
// Cleanup
|
|
546
|
-
return () => {
|
|
547
|
-
isPlayingRef.current = false;
|
|
548
|
-
if (animationFrameRef.current) {
|
|
549
|
-
cancelAnimationFrame(animationFrameRef.current);
|
|
550
|
-
animationFrameRef.current = null;
|
|
551
|
-
}
|
|
552
|
-
eventsRef.current = [];
|
|
553
|
-
visualizerRef.current = null;
|
|
554
|
-
};
|
|
555
|
-
}, [data, startReplay]);
|
|
556
|
-
return {
|
|
557
|
-
iframeRef,
|
|
558
|
-
isPlaying: isPlayingRef.current,
|
|
559
|
-
clarityVisualizer: visualizerRef.current,
|
|
560
|
-
play,
|
|
561
|
-
pause,
|
|
562
|
-
};
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
const useHeatmapVizRender = (mode) => {
|
|
566
|
-
const heatmapResult = useMemo(() => {
|
|
567
|
-
switch (mode) {
|
|
568
|
-
case 'heatmap':
|
|
569
|
-
return useHeatmapRender;
|
|
570
|
-
case 'replay':
|
|
571
|
-
return useReplayRender;
|
|
572
|
-
}
|
|
573
|
-
}, [mode]);
|
|
574
|
-
return heatmapResult();
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
const useContainerDimensions = (props) => {
|
|
578
|
-
const { wrapperRef } = props;
|
|
579
|
-
const [containerWidth, setContainerWidth] = useState(0);
|
|
580
|
-
const [containerHeight, setContainerHeight] = useState(0);
|
|
581
|
-
const resizeObserverRef = useRef(null);
|
|
582
|
-
const updateDimensions = useCallback(() => {
|
|
583
|
-
const scrollContainer = wrapperRef.current?.parentElement?.parentElement;
|
|
584
|
-
if (scrollContainer) {
|
|
585
|
-
setContainerWidth(scrollContainer.clientWidth);
|
|
586
|
-
setContainerHeight(scrollContainer.clientHeight);
|
|
587
|
-
}
|
|
588
|
-
}, [wrapperRef]);
|
|
589
|
-
useEffect(() => {
|
|
590
|
-
const scrollContainer = wrapperRef.current?.parentElement?.parentElement;
|
|
591
|
-
if (!scrollContainer || typeof window.ResizeObserver === 'undefined') {
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
resizeObserverRef.current = new ResizeObserver(updateDimensions);
|
|
595
|
-
resizeObserverRef.current.observe(scrollContainer);
|
|
596
|
-
updateDimensions();
|
|
597
|
-
return () => {
|
|
598
|
-
if (resizeObserverRef.current && scrollContainer) {
|
|
599
|
-
resizeObserverRef.current.unobserve(scrollContainer);
|
|
600
|
-
}
|
|
601
|
-
};
|
|
602
|
-
}, [wrapperRef, updateDimensions]);
|
|
603
|
-
return { containerWidth, containerHeight };
|
|
604
|
-
};
|
|
605
|
-
|
|
606
|
-
const useContentDimensions = (props) => {
|
|
607
|
-
const { iframeRef, config } = props;
|
|
608
|
-
const [contentWidth, setContentWidth] = useState(0);
|
|
609
|
-
useEffect(() => {
|
|
610
|
-
if (config?.width) {
|
|
611
|
-
if (iframeRef.current) {
|
|
612
|
-
iframeRef.current.width = `${config.width}px`;
|
|
613
|
-
}
|
|
614
|
-
setContentWidth(config.width);
|
|
615
|
-
}
|
|
616
|
-
}, [config?.width, iframeRef]);
|
|
617
|
-
return { contentWidth };
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
// Hook 3: Iframe Height Observer
|
|
621
|
-
const useIframeHeight = (props) => {
|
|
622
|
-
const { iframeRef, contentWidth } = props;
|
|
623
|
-
const iframeHeight = useHeatmapDataStore((state) => state.iframeHeight);
|
|
624
|
-
const setIframeHeight = useHeatmapDataStore((state) => state.setIframeHeight);
|
|
625
|
-
const resizeObserverRef = useRef(null);
|
|
626
|
-
const mutationObserverRef = useRef(null);
|
|
627
|
-
const updateIframeHeight = useCallback(() => {
|
|
628
|
-
const iframe = iframeRef.current;
|
|
629
|
-
if (!iframe)
|
|
630
|
-
return;
|
|
631
|
-
try {
|
|
632
|
-
const iframeDocument = iframe.contentDocument;
|
|
633
|
-
const iframeBody = iframeDocument?.body;
|
|
634
|
-
if (!iframeBody)
|
|
635
|
-
return;
|
|
636
|
-
const bodyHeight = Math.max(iframeBody.scrollHeight, iframeBody.offsetHeight, iframeBody.clientHeight);
|
|
637
|
-
if (bodyHeight > 0) {
|
|
638
|
-
iframe.height = `${bodyHeight}px`;
|
|
639
|
-
setIframeHeight(bodyHeight);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
catch (error) {
|
|
643
|
-
console.warn('Cannot measure iframe content:', error);
|
|
644
|
-
}
|
|
645
|
-
}, [iframeRef, setIframeHeight]);
|
|
646
|
-
// Trigger height update when content width changes
|
|
647
|
-
useEffect(() => {
|
|
648
|
-
if (contentWidth > 0) {
|
|
649
|
-
// Delay to allow iframe content to reflow after width change
|
|
650
|
-
const timeoutId = setTimeout(() => {
|
|
651
|
-
updateIframeHeight();
|
|
652
|
-
}, 100);
|
|
653
|
-
return () => clearTimeout(timeoutId);
|
|
654
|
-
}
|
|
655
|
-
}, [contentWidth, updateIframeHeight]);
|
|
656
|
-
useEffect(() => {
|
|
657
|
-
const iframe = iframeRef.current;
|
|
658
|
-
if (!iframe)
|
|
659
|
-
return;
|
|
660
|
-
const setupObservers = () => {
|
|
661
|
-
try {
|
|
662
|
-
const iframeDocument = iframe.contentDocument;
|
|
663
|
-
const iframeBody = iframeDocument?.body;
|
|
664
|
-
if (!iframeBody)
|
|
665
|
-
return;
|
|
666
|
-
// Cleanup existing observers
|
|
667
|
-
if (resizeObserverRef.current) {
|
|
668
|
-
resizeObserverRef.current.disconnect();
|
|
669
|
-
}
|
|
670
|
-
if (mutationObserverRef.current) {
|
|
671
|
-
mutationObserverRef.current.disconnect();
|
|
672
|
-
}
|
|
673
|
-
// ResizeObserver for size changes
|
|
674
|
-
if (typeof window.ResizeObserver !== 'undefined') {
|
|
675
|
-
resizeObserverRef.current = new ResizeObserver(updateIframeHeight);
|
|
676
|
-
resizeObserverRef.current.observe(iframeBody);
|
|
677
|
-
}
|
|
678
|
-
// MutationObserver for DOM changes
|
|
679
|
-
if (typeof window.MutationObserver !== 'undefined') {
|
|
680
|
-
mutationObserverRef.current = new MutationObserver(updateIframeHeight);
|
|
681
|
-
mutationObserverRef.current.observe(iframeBody, {
|
|
682
|
-
childList: true,
|
|
683
|
-
subtree: true,
|
|
684
|
-
attributes: true,
|
|
685
|
-
characterData: true,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
// Initial measurement
|
|
689
|
-
updateIframeHeight();
|
|
690
|
-
}
|
|
691
|
-
catch (error) {
|
|
692
|
-
console.warn('Cannot access iframe content:', error);
|
|
693
|
-
}
|
|
694
|
-
};
|
|
695
|
-
if (iframe.contentDocument?.readyState === 'complete') {
|
|
696
|
-
setupObservers();
|
|
697
|
-
}
|
|
698
|
-
else {
|
|
699
|
-
iframe.addEventListener('load', setupObservers, { once: true });
|
|
700
|
-
}
|
|
701
|
-
return () => {
|
|
702
|
-
if (resizeObserverRef.current) {
|
|
703
|
-
resizeObserverRef.current.disconnect();
|
|
704
|
-
}
|
|
705
|
-
if (mutationObserverRef.current) {
|
|
706
|
-
mutationObserverRef.current.disconnect();
|
|
707
|
-
}
|
|
708
|
-
iframe.removeEventListener('load', setupObservers);
|
|
709
|
-
};
|
|
710
|
-
}, [iframeRef, updateIframeHeight]);
|
|
711
|
-
return { iframeHeight };
|
|
712
|
-
};
|
|
713
|
-
|
|
714
|
-
const useScaleCalculation = (props) => {
|
|
715
|
-
const { containerWidth, contentWidth } = props;
|
|
716
|
-
const [scale, setScale] = useState(1);
|
|
717
|
-
useEffect(() => {
|
|
718
|
-
if (containerWidth > 0 && contentWidth > 0) {
|
|
719
|
-
const availableWidth = containerWidth - HEATMAP_CONFIG['paddingBlock'] * 2;
|
|
720
|
-
const calculatedScale = Math.min(availableWidth / contentWidth, 1);
|
|
721
|
-
setScale(calculatedScale);
|
|
722
|
-
}
|
|
723
|
-
}, [containerWidth, contentWidth]);
|
|
724
|
-
return { scale };
|
|
725
|
-
};
|
|
726
|
-
|
|
727
|
-
const useScrollSync = (props) => {
|
|
728
|
-
const { iframeRef, scale } = props;
|
|
729
|
-
const handleScroll = useCallback((scrollTop) => {
|
|
730
|
-
const iframe = iframeRef.current;
|
|
731
|
-
if (!iframe || scale <= 0)
|
|
732
|
-
return;
|
|
733
|
-
try {
|
|
734
|
-
const iframeWindow = iframe.contentWindow;
|
|
735
|
-
const iframeDocument = iframe.contentDocument;
|
|
736
|
-
if (iframeWindow && iframeDocument) {
|
|
737
|
-
const iframeScrollTop = scrollTop / scale;
|
|
738
|
-
iframe.style.top = `${iframeScrollTop}px`;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
catch (error) {
|
|
742
|
-
console.warn('Cannot sync scroll to iframe:', error);
|
|
743
|
-
}
|
|
744
|
-
}, [iframeRef, scale]);
|
|
745
|
-
return { handleScroll };
|
|
746
|
-
};
|
|
747
|
-
|
|
748
|
-
const useHeatmapScale = (props) => {
|
|
749
|
-
const { wrapperRef, iframeRef, config } = props;
|
|
750
|
-
// 1. Observe container dimensions
|
|
751
|
-
const { containerWidth, containerHeight } = useContainerDimensions({ wrapperRef });
|
|
752
|
-
// 2. Get content dimensions from config
|
|
753
|
-
const { contentWidth } = useContentDimensions({ iframeRef, config });
|
|
754
|
-
// 3. Observe iframe height (now reacts to width changes)
|
|
755
|
-
const { iframeHeight } = useIframeHeight({ iframeRef, contentWidth });
|
|
756
|
-
// 4. Calculate scale
|
|
757
|
-
const { scale } = useScaleCalculation({ containerWidth, contentWidth });
|
|
758
|
-
// 5. Setup scroll sync
|
|
759
|
-
const { handleScroll } = useScrollSync({ iframeRef, scale });
|
|
760
|
-
return {
|
|
761
|
-
containerWidth,
|
|
762
|
-
containerHeight,
|
|
763
|
-
contentWidth,
|
|
764
|
-
iframeHeight,
|
|
765
|
-
scale,
|
|
766
|
-
scaledWidth: contentWidth * scale,
|
|
767
|
-
scaledHeight: iframeHeight * scale,
|
|
768
|
-
handleScroll,
|
|
769
|
-
};
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
const CLICKED_ELEMENT_ID = 'clickedElement';
|
|
773
|
-
const SECONDARY_CLICKED_ELEMENT_ID = 'secondaryClickedElementID';
|
|
774
|
-
const HOVERED_ELEMENT_ID = 'hoveredElement';
|
|
775
|
-
const SECONDARY_HOVERED_ELEMENT_ID = 'secondaryhoveredElementID';
|
|
776
|
-
|
|
777
|
-
const ElementCallout = ({ element, target, totalClicks, isSecondary, isRecordingView, isCompareMode, deviceType, heatmapType, language, widthScale, parentRef, }) => {
|
|
778
|
-
const calloutRef = useRef(null);
|
|
779
|
-
const [position, setPosition] = useState({
|
|
780
|
-
top: 0,
|
|
781
|
-
left: 0,
|
|
782
|
-
placement: 'top',
|
|
783
|
-
});
|
|
784
|
-
const percentage = formatPercentage(((element.clicks ?? 0) / totalClicks) * 100, 2);
|
|
785
|
-
// Calculate callout position
|
|
786
|
-
useEffect(() => {
|
|
787
|
-
const targetElement = document.querySelector(target);
|
|
788
|
-
const calloutElement = calloutRef.current;
|
|
789
|
-
if (!targetElement || !calloutElement)
|
|
790
|
-
return;
|
|
791
|
-
const calculatePosition = () => {
|
|
792
|
-
const targetRect = targetElement.getBoundingClientRect();
|
|
793
|
-
const calloutRect = calloutElement.getBoundingClientRect();
|
|
794
|
-
const viewportWidth = window.innerWidth;
|
|
795
|
-
const viewportHeight = window.innerHeight;
|
|
796
|
-
const padding = 12; // Space between target and callout
|
|
797
|
-
const arrowSize = 8;
|
|
798
|
-
let top = 0;
|
|
799
|
-
let left = 0;
|
|
800
|
-
let placement = 'top';
|
|
801
|
-
// Try positions in order: top, bottom, right, left
|
|
802
|
-
const positions = [
|
|
803
|
-
// Top
|
|
804
|
-
{
|
|
805
|
-
placement: 'top',
|
|
806
|
-
top: targetRect.top - calloutRect.height - padding - arrowSize,
|
|
807
|
-
left: targetRect.left + targetRect.width / 2 - calloutRect.width / 2,
|
|
808
|
-
valid: targetRect.top - calloutRect.height - padding - arrowSize > 0,
|
|
809
|
-
},
|
|
810
|
-
// Bottom
|
|
811
|
-
{
|
|
812
|
-
placement: 'bottom',
|
|
813
|
-
top: targetRect.bottom + padding + arrowSize,
|
|
814
|
-
left: targetRect.left + targetRect.width / 2 - calloutRect.width / 2,
|
|
815
|
-
valid: targetRect.bottom + calloutRect.height + padding + arrowSize < viewportHeight,
|
|
816
|
-
},
|
|
817
|
-
// Right
|
|
818
|
-
{
|
|
819
|
-
placement: 'right',
|
|
820
|
-
top: targetRect.top + targetRect.height / 2 - calloutRect.height / 2,
|
|
821
|
-
left: targetRect.right + padding + arrowSize,
|
|
822
|
-
valid: targetRect.right + calloutRect.width + padding + arrowSize < viewportWidth,
|
|
823
|
-
},
|
|
824
|
-
// Left
|
|
825
|
-
{
|
|
826
|
-
placement: 'left',
|
|
827
|
-
top: targetRect.top + targetRect.height / 2 - calloutRect.height / 2,
|
|
828
|
-
left: targetRect.left - calloutRect.width - padding - arrowSize,
|
|
829
|
-
valid: targetRect.left - calloutRect.width - padding - arrowSize > 0,
|
|
830
|
-
},
|
|
831
|
-
];
|
|
832
|
-
// Find first valid position
|
|
833
|
-
const validPosition = positions.find((p) => p.valid) || positions[0];
|
|
834
|
-
top = validPosition.top;
|
|
835
|
-
left = validPosition.left;
|
|
836
|
-
placement = validPosition.placement;
|
|
837
|
-
// Keep within viewport bounds
|
|
838
|
-
left = Math.max(padding, Math.min(left, viewportWidth - calloutRect.width - padding));
|
|
839
|
-
top = Math.max(padding, Math.min(top, viewportHeight - calloutRect.height - padding));
|
|
840
|
-
setPosition({ top, left, placement });
|
|
841
|
-
};
|
|
842
|
-
// Initial calculation
|
|
843
|
-
calculatePosition();
|
|
844
|
-
// Recalculate on scroll/resize
|
|
845
|
-
const handleUpdate = () => {
|
|
846
|
-
requestAnimationFrame(calculatePosition);
|
|
847
|
-
};
|
|
848
|
-
window.addEventListener('scroll', handleUpdate, true);
|
|
849
|
-
window.addEventListener('resize', handleUpdate);
|
|
850
|
-
parentRef?.current?.addEventListener('scroll', handleUpdate);
|
|
851
|
-
return () => {
|
|
852
|
-
window.removeEventListener('scroll', handleUpdate, true);
|
|
853
|
-
window.removeEventListener('resize', handleUpdate);
|
|
854
|
-
parentRef?.current?.removeEventListener('scroll', handleUpdate);
|
|
855
|
-
};
|
|
856
|
-
}, [target, parentRef]);
|
|
857
|
-
const calloutContent = (jsxs("div", { ref: calloutRef, className: `clarity-callout clarity-callout--${position.placement} ${isSecondary ? 'clarity-callout--secondary' : ''}`, style: {
|
|
858
|
-
position: 'fixed',
|
|
859
|
-
top: position.top,
|
|
860
|
-
left: position.left,
|
|
861
|
-
zIndex: 2147483647,
|
|
862
|
-
}, "aria-live": "assertive", role: "tooltip", children: [jsx("div", { className: "clarity-callout__arrow" }), jsxs("div", { className: "clarity-callout__content", children: [jsx("div", { className: "clarity-callout__rank", children: element.rank }), jsxs("div", { className: "clarity-callout__stats", children: [jsx("div", { className: "clarity-callout__label", children: "Clicks" }), jsxs("div", { className: "clarity-callout__value", children: [jsx("span", { className: "clarity-callout__count", children: element.clicks?.toLocaleString(language) }), jsxs("span", { className: "clarity-callout__percentage", children: ["(", percentage, "%)"] })] })] }), !isRecordingView && !isCompareMode && (jsxs("button", { className: "clarity-callout__button", "data-clarity-id": "viewRecordingClickedFromHeatmapIframe", "data-element-hash": element.hash, "data-selector": getSimpleSelector(element.selector), "data-device-type": deviceType, "data-heatmap-type": heatmapType, children: [jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", children: jsx("path", { d: "M5 3L11 8L5 13V3Z", fill: "currentColor" }) }), "View Recording"] }))] }), jsx("style", { children: `
|
|
863
|
-
.clarity-callout {
|
|
864
|
-
background: white;
|
|
865
|
-
border-radius: 8px;
|
|
866
|
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
867
|
-
padding: 16px;
|
|
868
|
-
min-width: 200px;
|
|
869
|
-
max-width: 280px;
|
|
870
|
-
animation: clarity-callout-fade-in 0.2s ease-out;
|
|
871
|
-
pointer-events: auto;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
@keyframes clarity-callout-fade-in {
|
|
875
|
-
from {
|
|
876
|
-
opacity: 0;
|
|
877
|
-
transform: scale(0.95);
|
|
878
|
-
}
|
|
879
|
-
to {
|
|
880
|
-
opacity: 1;
|
|
881
|
-
transform: scale(1);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
.clarity-callout__arrow {
|
|
886
|
-
position: absolute;
|
|
887
|
-
width: 16px;
|
|
888
|
-
height: 16px;
|
|
889
|
-
background: white;
|
|
890
|
-
transform: rotate(45deg);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
/* Arrow positions */
|
|
894
|
-
.clarity-callout--top .clarity-callout__arrow {
|
|
895
|
-
bottom: -8px;
|
|
896
|
-
left: 50%;
|
|
897
|
-
margin-left: -8px;
|
|
898
|
-
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
.clarity-callout--bottom .clarity-callout__arrow {
|
|
902
|
-
top: -8px;
|
|
903
|
-
left: 50%;
|
|
904
|
-
margin-left: -8px;
|
|
905
|
-
box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.1);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
.clarity-callout--left .clarity-callout__arrow {
|
|
909
|
-
right: -8px;
|
|
910
|
-
top: 50%;
|
|
911
|
-
margin-top: -8px;
|
|
912
|
-
box-shadow: 2px -2px 4px rgba(0, 0, 0, 0.1);
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
.clarity-callout--right .clarity-callout__arrow {
|
|
916
|
-
left: -8px;
|
|
917
|
-
top: 50%;
|
|
918
|
-
margin-top: -8px;
|
|
919
|
-
box-shadow: -2px 2px 4px rgba(0, 0, 0, 0.1);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
.clarity-callout__content {
|
|
923
|
-
position: relative;
|
|
924
|
-
z-index: 1;
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
.clarity-callout__rank {
|
|
928
|
-
display: inline-flex;
|
|
929
|
-
align-items: center;
|
|
930
|
-
justify-content: center;
|
|
931
|
-
width: 32px;
|
|
932
|
-
height: 32px;
|
|
933
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
934
|
-
color: white;
|
|
935
|
-
border-radius: 8px;
|
|
936
|
-
font-weight: 600;
|
|
937
|
-
font-size: 16px;
|
|
938
|
-
margin-bottom: 12px;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
.clarity-callout--secondary .clarity-callout__rank {
|
|
942
|
-
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
.clarity-callout__stats {
|
|
946
|
-
margin-bottom: 12px;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
.clarity-callout__label {
|
|
950
|
-
font-size: 12px;
|
|
951
|
-
color: #6b7280;
|
|
952
|
-
margin-bottom: 4px;
|
|
953
|
-
font-weight: 500;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
.clarity-callout__value {
|
|
957
|
-
display: flex;
|
|
958
|
-
align-items: baseline;
|
|
959
|
-
gap: 6px;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
.clarity-callout__count {
|
|
963
|
-
font-size: 20px;
|
|
964
|
-
font-weight: 700;
|
|
965
|
-
color: #111827;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
.clarity-callout__percentage {
|
|
969
|
-
font-size: 14px;
|
|
970
|
-
color: #6b7280;
|
|
971
|
-
font-weight: 500;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
.clarity-callout__button {
|
|
975
|
-
display: flex;
|
|
976
|
-
align-items: center;
|
|
977
|
-
justify-content: center;
|
|
978
|
-
gap: 6px;
|
|
979
|
-
width: 100%;
|
|
980
|
-
padding: 8px 12px;
|
|
981
|
-
background: #667eea;
|
|
982
|
-
color: white;
|
|
983
|
-
border: none;
|
|
984
|
-
border-radius: 6px;
|
|
985
|
-
font-size: 13px;
|
|
986
|
-
font-weight: 600;
|
|
987
|
-
cursor: pointer;
|
|
988
|
-
transition: all 0.2s;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
.clarity-callout__button:hover {
|
|
992
|
-
background: #5568d3;
|
|
993
|
-
transform: translateY(-1px);
|
|
994
|
-
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
.clarity-callout__button:active {
|
|
998
|
-
transform: translateY(0);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
.clarity-callout__button svg {
|
|
1002
|
-
width: 14px;
|
|
1003
|
-
height: 14px;
|
|
1004
|
-
}
|
|
1005
|
-
` })] }));
|
|
1006
|
-
// Render to body using Portal
|
|
1007
|
-
return createPortal(calloutContent, document.body);
|
|
1008
|
-
};
|
|
1009
|
-
|
|
1010
|
-
const RankBadge = ({ index, elementRect, widthScale, clickOnElement, }) => {
|
|
1011
|
-
const style = calculateRankPosition(elementRect, widthScale);
|
|
1012
|
-
return (jsx("div", { className: "rankBadge", style: style, onClick: clickOnElement, "data-testid": "elementRank", children: index }));
|
|
1013
|
-
};
|
|
1014
|
-
|
|
1015
|
-
const ClickedElementOverlay = ({ element, shouldShowCallout, isSecondary, targetId, totalClicks = 1, isRecordingView, isCompareMode, deviceType, heatmapType, widthScale, }) => {
|
|
1016
|
-
if (!element || (element.width === 0 && element.height === 0))
|
|
1017
|
-
return null;
|
|
1018
|
-
return (jsxs(Fragment$1, { children: [jsx("div", { className: "heatmapElement", id: targetId, style: {
|
|
1019
|
-
top: element.top,
|
|
1020
|
-
left: element.left,
|
|
1021
|
-
width: element.width,
|
|
1022
|
-
height: element.height,
|
|
1023
|
-
} }), jsx(RankBadge, { index: element.rank, elementRect: element, widthScale: widthScale }), shouldShowCallout && (jsx(ElementCallout, { element: element, target: `#${targetId}`, totalClicks: totalClicks, isSecondary: isSecondary, isRecordingView: isRecordingView, isCompareMode: isCompareMode, deviceType: deviceType, heatmapType: heatmapType, widthScale: widthScale }))] }));
|
|
1024
|
-
};
|
|
1025
|
-
|
|
1026
|
-
const DefaultRankBadges = ({ elements, getRect, widthScale, hidden }) => {
|
|
1027
|
-
if (hidden || elements.length === 0)
|
|
1028
|
-
return null;
|
|
1029
|
-
return (jsx(Fragment, { children: elements.map((element, index) => {
|
|
1030
|
-
const rect = getRect(element);
|
|
1031
|
-
if (!rect)
|
|
1032
|
-
return null;
|
|
1033
|
-
return (jsx(RankBadge, { index: index + 1, elementRect: rect, widthScale: widthScale }, element.hash));
|
|
1034
|
-
}) }));
|
|
1035
|
-
};
|
|
1036
|
-
|
|
1037
|
-
const HoveredElementOverlay = ({ element, onClick, isSecondary, targetId, totalClicks = 1, }) => {
|
|
1038
|
-
if (!element)
|
|
1039
|
-
return null;
|
|
1040
|
-
return (jsxs(Fragment$1, { children: [jsx("div", { onClick: onClick, className: "heatmapElement hovered", id: targetId, style: {
|
|
1041
|
-
top: element.top,
|
|
1042
|
-
left: element.left,
|
|
1043
|
-
width: element.width,
|
|
1044
|
-
height: element.height,
|
|
1045
|
-
cursor: 'pointer',
|
|
1046
|
-
} }), jsx(RankBadge, { index: element.rank, elementRect: element, widthScale: 1, clickOnElement: onClick })] }));
|
|
1047
|
-
};
|
|
1048
|
-
|
|
1049
|
-
const MissingElementMessage = ({ widthScale }) => {
|
|
1050
|
-
return (jsx("div", { className: "missingElement", style: {
|
|
1051
|
-
position: 'absolute',
|
|
1052
|
-
top: '50%',
|
|
1053
|
-
left: '50%',
|
|
1054
|
-
transform: `translate(-50%, -50%) scale(${1 / widthScale})`,
|
|
1055
|
-
background: 'rgba(0, 0, 0, 0.8)',
|
|
1056
|
-
color: 'white',
|
|
1057
|
-
padding: '12px 20px',
|
|
1058
|
-
borderRadius: '8px',
|
|
1059
|
-
fontSize: '14px',
|
|
1060
|
-
fontWeight: '500',
|
|
1061
|
-
zIndex: 9999,
|
|
1062
|
-
pointerEvents: 'none',
|
|
1063
|
-
whiteSpace: 'nowrap',
|
|
1064
|
-
}, "aria-live": "assertive", children: "Element not visible on current screen" }));
|
|
1065
|
-
};
|
|
1066
|
-
|
|
1067
|
-
const HeatmapElements = (props) => {
|
|
1068
|
-
const { iframeRef, parentRef, visualizer, heatmapInfo, widthScale, iframeHeight, iframeDimensions, selectedElement, isElementSidebarOpen, isVisible = true, selectElement, areDefaultRanksHidden, isSecondary, ...rest } = props;
|
|
1069
|
-
const getRect = useHeatmapElementPosition({
|
|
1070
|
-
iframeRef,
|
|
1071
|
-
parentRef,
|
|
1072
|
-
visualizer,
|
|
1073
|
-
heatmapWidth: heatmapInfo?.width,
|
|
1074
|
-
iframeHeight,
|
|
1075
|
-
widthScale,
|
|
1076
|
-
projectId: props.projectId,
|
|
1077
|
-
});
|
|
1078
|
-
const { clickedElement, showMissingElement, shouldShowCallout, setShouldShowCallout } = useClickedElement({
|
|
1079
|
-
selectedElement,
|
|
1080
|
-
heatmapInfo,
|
|
1081
|
-
getRect,
|
|
1082
|
-
});
|
|
1083
|
-
const { hoveredElement, handleMouseMove, handleMouseLeave, handleClick } = useHoveredElement({
|
|
1084
|
-
iframeRef,
|
|
1085
|
-
heatmapInfo,
|
|
1086
|
-
getRect,
|
|
1087
|
-
onSelect: selectElement,
|
|
1088
|
-
widthScale,
|
|
1089
|
-
});
|
|
1090
|
-
const resetAll = () => {
|
|
1091
|
-
// nếu cần reset thêm state ở đây
|
|
1092
|
-
// setShouldShowCallout(false);
|
|
1093
|
-
};
|
|
1094
|
-
useHeatmapEffects({
|
|
1095
|
-
isVisible,
|
|
1096
|
-
isElementSidebarOpen,
|
|
1097
|
-
selectedElement,
|
|
1098
|
-
setShouldShowCallout,
|
|
1099
|
-
resetAll,
|
|
1100
|
-
});
|
|
1101
|
-
if (!isVisible)
|
|
1102
|
-
return null;
|
|
1103
|
-
const top10 = heatmapInfo?.sortedElements?.slice(0, 10) ?? [];
|
|
1104
|
-
return (jsxs("div", { onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, className: "heatmapElements gx-hm-elements", style: iframeDimensions, children: [jsx(DefaultRankBadges, { elements: top10, getRect: getRect, widthScale: widthScale, hidden: areDefaultRanksHidden }), jsx(ClickedElementOverlay, { widthScale: widthScale, element: clickedElement, shouldShowCallout: shouldShowCallout, isSecondary: isSecondary, targetId: isSecondary ? SECONDARY_CLICKED_ELEMENT_ID : CLICKED_ELEMENT_ID, ...rest }), showMissingElement && jsx(MissingElementMessage, { widthScale: widthScale }), jsx(HoveredElementOverlay, { element: hoveredElement, onClick: handleClick, isSecondary: isSecondary, targetId: isSecondary ? SECONDARY_HOVERED_ELEMENT_ID : HOVERED_ELEMENT_ID, totalClicks: heatmapInfo?.totalClicks ?? 1 }), hoveredElement !== clickedElement && hoveredElement && (jsx(ElementCallout, { element: hoveredElement, target: `#${props.isSecondary ? SECONDARY_HOVERED_ELEMENT_ID : HOVERED_ELEMENT_ID}`, totalClicks: props.heatmapInfo?.totalClicks ?? 1, isSecondary: props.isSecondary, parentRef: props.parentRef }))] }));
|
|
1105
|
-
};
|
|
1106
|
-
|
|
1107
|
-
const VizElements = ({ width, height, iframeRef, wrapperRef, widthScale, }) => {
|
|
1108
|
-
useHeatmapDataStore((state) => state.data);
|
|
1109
|
-
const heatmapInfo = {
|
|
1110
|
-
sortedElements: [
|
|
1111
|
-
{
|
|
1112
|
-
hash: '9ebwu6a3',
|
|
1113
|
-
selector: 'Join our email list',
|
|
1114
|
-
},
|
|
1115
|
-
{
|
|
1116
|
-
hash: '350hde5d4',
|
|
1117
|
-
selector: 'Products',
|
|
1118
|
-
},
|
|
1119
|
-
],
|
|
1120
|
-
elementMapInfo: {
|
|
1121
|
-
'9ebwu6a3': {
|
|
1122
|
-
totalclicks: 4,
|
|
1123
|
-
hash: '9ebwu6a3',
|
|
1124
|
-
},
|
|
1125
|
-
'350hde5d4': {
|
|
1126
|
-
totalclicks: 4,
|
|
1127
|
-
hash: '350hde5d4',
|
|
1128
|
-
},
|
|
1129
|
-
},
|
|
1130
|
-
totalClicks: 8,
|
|
1131
|
-
};
|
|
1132
|
-
const visualizer = {
|
|
1133
|
-
get: (hash) => {
|
|
1134
|
-
const doc = iframeRef.current?.contentDocument;
|
|
1135
|
-
if (!doc)
|
|
1136
|
-
return null;
|
|
1137
|
-
// Find element by hash attribute
|
|
1138
|
-
return doc.querySelector(`[data-clarity-hashalpha="${hash}"]`);
|
|
1139
|
-
},
|
|
1140
|
-
};
|
|
1141
|
-
const [selectedElement, setSelectedElement] = useState(null);
|
|
1142
|
-
if (!iframeRef.current)
|
|
1143
|
-
return null;
|
|
1144
|
-
return (jsx(HeatmapElements, { visualizer: visualizer, iframeRef: iframeRef, parentRef: wrapperRef, iframeHeight: window.innerHeight, widthScale: widthScale, heatmapInfo: heatmapInfo, selectedElement: selectedElement, selectElement: setSelectedElement, isVisible: true, iframeDimensions: {
|
|
1145
|
-
width,
|
|
1146
|
-
height,
|
|
1147
|
-
position: 'absolute',
|
|
1148
|
-
top: 0,
|
|
1149
|
-
left: 0,
|
|
1150
|
-
// pointerEvents: 'none',
|
|
1151
|
-
} }));
|
|
1152
|
-
};
|
|
1153
|
-
|
|
1154
|
-
const ReplayControls = () => {
|
|
1155
|
-
const replayResult = useReplayRender();
|
|
1156
|
-
return (jsxs("div", { style: {
|
|
1157
|
-
position: 'absolute',
|
|
1158
|
-
bottom: 20,
|
|
1159
|
-
left: '50%',
|
|
1160
|
-
transform: 'translateX(-50%)',
|
|
1161
|
-
display: 'flex',
|
|
1162
|
-
gap: 10,
|
|
1163
|
-
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
1164
|
-
padding: '10px 20px',
|
|
1165
|
-
borderRadius: 8,
|
|
1166
|
-
}, children: [jsx("button", { onClick: replayResult.play, style: {
|
|
1167
|
-
padding: '8px 16px',
|
|
1168
|
-
backgroundColor: '#4CAF50',
|
|
1169
|
-
color: 'white',
|
|
1170
|
-
border: 'none',
|
|
1171
|
-
borderRadius: 4,
|
|
1172
|
-
cursor: 'pointer',
|
|
1173
|
-
}, children: "Play" }), jsx("button", { onClick: replayResult.pause, style: {
|
|
1174
|
-
padding: '8px 16px',
|
|
1175
|
-
backgroundColor: '#f44336',
|
|
1176
|
-
color: 'white',
|
|
1177
|
-
border: 'none',
|
|
1178
|
-
borderRadius: 4,
|
|
1179
|
-
cursor: 'pointer',
|
|
1180
|
-
}, children: "Pause" })] }));
|
|
1181
|
-
};
|
|
1182
|
-
|
|
1183
|
-
const VizDomRenderer = ({ mode = 'heatmap' }) => {
|
|
1184
|
-
const config = useHeatmapDataStore((state) => state.config);
|
|
1185
|
-
const wrapperRef = useRef(null);
|
|
1186
|
-
const visualRef = useRef(null);
|
|
1187
|
-
const { iframeRef } = useHeatmapVizRender(mode);
|
|
1188
|
-
const { contentWidth, iframeHeight, scale, scaledHeight, handleScroll } = useHeatmapScale({
|
|
1189
|
-
wrapperRef,
|
|
1190
|
-
iframeRef,
|
|
1191
|
-
config,
|
|
1192
|
-
});
|
|
1193
|
-
const onScroll = (e) => {
|
|
1194
|
-
const scrollTop = e.currentTarget.scrollTop;
|
|
1195
|
-
handleScroll(scrollTop);
|
|
1196
|
-
};
|
|
1197
|
-
return (jsxs("div", { ref: visualRef, className: "gx-hm-visual", onScroll: onScroll, style: {
|
|
1198
|
-
overflow: 'hidden auto',
|
|
1199
|
-
display: 'flex',
|
|
1200
|
-
position: 'relative',
|
|
1201
|
-
justifyContent: 'center',
|
|
1202
|
-
flex: 1,
|
|
1203
|
-
backgroundColor: '#fff',
|
|
1204
|
-
// borderRadius: '8px',
|
|
1205
|
-
}, children: [jsx("div", { className: "gx-hm-visual-unscaled", style: {
|
|
1206
|
-
width: '100%',
|
|
1207
|
-
display: 'flex',
|
|
1208
|
-
justifyContent: 'center',
|
|
1209
|
-
alignItems: 'flex-start',
|
|
1210
|
-
height: scaledHeight > 0 ? `${scaledHeight + HEATMAP_CONFIG['paddingBlock']}px` : 'auto',
|
|
1211
|
-
padding: HEATMAP_STYLE['wrapper']['padding'],
|
|
1212
|
-
}, children: jsxs("div", { className: "gx-hm-wrapper", ref: wrapperRef, style: {
|
|
1213
|
-
width: contentWidth,
|
|
1214
|
-
height: iframeHeight,
|
|
1215
|
-
transform: `scale(${scale})`,
|
|
1216
|
-
transformOrigin: 'top center',
|
|
1217
|
-
}, children: [jsx(VizElements, { width: contentWidth, height: iframeHeight, widthScale: scale, iframeRef: iframeRef, wrapperRef: wrapperRef }), jsx("iframe", {
|
|
1218
|
-
// key={iframeKey}
|
|
1219
|
-
ref: iframeRef, ...HEATMAP_IFRAME, width: contentWidth, height: iframeHeight, scrolling: "no" })] }) }), mode === 'replay' && jsx(ReplayControls, {})] }));
|
|
1220
|
-
};
|
|
1221
|
-
|
|
1222
|
-
const VizDomContainer = () => {
|
|
1223
|
-
return (jsx(BoxStack, { id: "gx-hm-viz-container", flexDirection: "column", flex: "1 1 auto", overflow: "auto", children: jsx(BoxStack, { id: "gx-hm-content", flexDirection: "column", flex: "1 1 auto", overflow: "hidden", style: {
|
|
1224
|
-
// margin: '0px 16px 0px 12px',
|
|
1225
|
-
// borderRadius: '8px 8px 0 0',
|
|
1226
|
-
// borderRadius: '8px',
|
|
1227
|
-
minWidth: '394px',
|
|
1228
|
-
padding: '12px',
|
|
1229
|
-
background: '#f1f1f1',
|
|
1230
|
-
}, children: jsx(VizDomRenderer, {}) }) }));
|
|
1231
|
-
};
|
|
1232
|
-
|
|
1233
|
-
const SIDEBAR_WIDTH = 280;
|
|
1234
|
-
const LeftSidebar = ({ children }) => {
|
|
1235
|
-
const isHideSidebar = useHeatmapDataStore((state) => state.state.hideSidebar);
|
|
1236
|
-
if (isHideSidebar) {
|
|
1237
|
-
return null;
|
|
1238
|
-
}
|
|
1239
|
-
return (jsx("div", { className: "gx-hm-sidebar", style: {
|
|
1240
|
-
height: '100%',
|
|
1241
|
-
display: 'flex',
|
|
1242
|
-
...(isHideSidebar
|
|
1243
|
-
? {
|
|
1244
|
-
width: '0',
|
|
1245
|
-
transform: 'translateX(-100%)',
|
|
1246
|
-
visibility: 'hidden',
|
|
1247
|
-
}
|
|
1248
|
-
: { width: `${SIDEBAR_WIDTH}px` }),
|
|
1249
|
-
}, children: jsx("div", { className: "gx-hm-sidebar-wrapper", style: { height: '100%', width: `${SIDEBAR_WIDTH}px` }, children: children }) }));
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
const WrapperPreview = ({ children }) => {
|
|
1253
|
-
return (jsxs("div", { className: "gx-hm-container", style: { display: 'flex', overflowY: 'hidden', flex: '1', position: 'relative' }, children: [jsx(LeftSidebar, { children: children }), jsx(VizDomContainer, {})] }));
|
|
1254
|
-
};
|
|
1255
|
-
|
|
1256
|
-
const WrapperLayout = ({ header, toolbar, sidebar }) => {
|
|
1257
|
-
return (jsxs(BoxStack, { id: "gx-hm-layout", flexDirection: "column", flex: "1", children: [jsx(ContentHeader, { children: header }), jsx(ContentHeader, { children: toolbar }), jsx(WrapperPreview, { children: sidebar })] }));
|
|
1258
|
-
};
|
|
1259
|
-
|
|
1260
95
|
const HeatmapLayout = ({ data, clickmap, header, toolbar, sidebar, }) => {
|
|
1261
96
|
const setData = useHeatmapDataStore((state) => state.setData);
|
|
1262
97
|
const setClickmap = useHeatmapDataStore((state) => state.setClickmap);
|
|
@@ -1279,7 +114,7 @@ const HeatmapLayout = ({ data, clickmap, header, toolbar, sidebar, }) => {
|
|
|
1279
114
|
return (jsx(BoxStack, { id: "gx-hm-project", flexDirection: "column", flex: "1", height: "100%", children: jsx(BoxStack, { id: "gx-hm-project-content", flexDirection: "column", flex: "1", children: jsx("div", { style: {
|
|
1280
115
|
minHeight: '100%',
|
|
1281
116
|
display: 'flex',
|
|
1282
|
-
}
|
|
117
|
+
} }) }) }));
|
|
1283
118
|
};
|
|
1284
119
|
|
|
1285
120
|
var PanelContent;
|