@gemx-dev/heatmap-react 3.5.13 → 3.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/esm/components/Layout/HeatmapLayout.d.ts +4 -0
  2. package/dist/esm/components/Layout/HeatmapLayout.d.ts.map +1 -1
  3. package/dist/esm/components/Layout/WrapperLayout.d.ts +1 -0
  4. package/dist/esm/components/Layout/WrapperLayout.d.ts.map +1 -1
  5. package/dist/esm/components/VizDom/VizDomRenderer.d.ts.map +1 -1
  6. package/dist/esm/components/VizElement/ClickedElementOverlay.d.ts +17 -0
  7. package/dist/esm/components/VizElement/ClickedElementOverlay.d.ts.map +1 -0
  8. package/dist/esm/components/VizElement/DefaultRankBadges.d.ts +11 -0
  9. package/dist/esm/components/VizElement/DefaultRankBadges.d.ts.map +1 -0
  10. package/dist/esm/components/VizElement/ElementCallout.d.ts +17 -0
  11. package/dist/esm/components/VizElement/ElementCallout.d.ts.map +1 -0
  12. package/dist/esm/components/VizElement/HeatmapElements.d.ts +23 -0
  13. package/dist/esm/components/VizElement/HeatmapElements.d.ts.map +1 -0
  14. package/dist/esm/components/VizElement/HoveredElementOverlay.d.ts +12 -0
  15. package/dist/esm/components/VizElement/HoveredElementOverlay.d.ts.map +1 -0
  16. package/dist/esm/components/VizElement/MissingElementMessage.d.ts +7 -0
  17. package/dist/esm/components/VizElement/MissingElementMessage.d.ts.map +1 -0
  18. package/dist/esm/components/VizElement/RankBadge.d.ts +10 -0
  19. package/dist/esm/components/VizElement/RankBadge.d.ts.map +1 -0
  20. package/dist/esm/components/VizElement/VizElements.d.ts +3 -0
  21. package/dist/esm/components/VizElement/VizElements.d.ts.map +1 -1
  22. package/dist/esm/components/VizElement/temp/ClarityVisualizer.d.ts +150 -0
  23. package/dist/esm/components/VizElement/temp/ClarityVisualizer.d.ts.map +1 -0
  24. package/dist/esm/components/VizElement/{VizElementRank.d.ts → temp/VizElementRank.d.ts} +2 -3
  25. package/dist/esm/components/VizElement/temp/VizElementRank.d.ts.map +1 -0
  26. package/dist/esm/constants/index.d.ts +5 -0
  27. package/dist/esm/constants/index.d.ts.map +1 -0
  28. package/dist/esm/helpers/viz-elements.d.ts +10 -0
  29. package/dist/esm/helpers/viz-elements.d.ts.map +1 -0
  30. package/dist/esm/hooks/index.d.ts +1 -0
  31. package/dist/esm/hooks/index.d.ts.map +1 -1
  32. package/dist/esm/hooks/vix-elements/index.d.ts +5 -0
  33. package/dist/esm/hooks/vix-elements/index.d.ts.map +1 -0
  34. package/dist/esm/hooks/vix-elements/useClickedElement.d.ts +14 -0
  35. package/dist/esm/hooks/vix-elements/useClickedElement.d.ts.map +1 -0
  36. package/dist/esm/hooks/vix-elements/useHeatmapEffects.d.ts +8 -0
  37. package/dist/esm/hooks/vix-elements/useHeatmapEffects.d.ts.map +1 -0
  38. package/dist/esm/hooks/vix-elements/useHeatmapElementPosition.d.ts +13 -0
  39. package/dist/esm/hooks/vix-elements/useHeatmapElementPosition.d.ts.map +1 -0
  40. package/dist/esm/hooks/vix-elements/useHoveredElement.d.ts +17 -0
  41. package/dist/esm/hooks/vix-elements/useHoveredElement.d.ts.map +1 -0
  42. package/dist/esm/hooks/viz-render/useHeatmapRender.d.ts +2 -0
  43. package/dist/esm/hooks/viz-render/useHeatmapRender.d.ts.map +1 -1
  44. package/dist/esm/hooks/viz-render/useHeatmapVizRender.d.ts +2 -0
  45. package/dist/esm/hooks/viz-render/useHeatmapVizRender.d.ts.map +1 -1
  46. package/dist/esm/hooks/viz-render/useReplayRender.d.ts +2 -0
  47. package/dist/esm/hooks/viz-render/useReplayRender.d.ts.map +1 -1
  48. package/dist/esm/index.js +659 -24
  49. package/dist/esm/index.mjs +659 -24
  50. package/dist/esm/stores/data.d.ts +3 -1
  51. package/dist/esm/stores/data.d.ts.map +1 -1
  52. package/dist/style.css +20 -0
  53. package/dist/umd/components/Layout/HeatmapLayout.d.ts +4 -0
  54. package/dist/umd/components/Layout/HeatmapLayout.d.ts.map +1 -1
  55. package/dist/umd/components/Layout/WrapperLayout.d.ts +1 -0
  56. package/dist/umd/components/Layout/WrapperLayout.d.ts.map +1 -1
  57. package/dist/umd/components/VizDom/VizDomRenderer.d.ts.map +1 -1
  58. package/dist/umd/components/VizElement/ClickedElementOverlay.d.ts +17 -0
  59. package/dist/umd/components/VizElement/ClickedElementOverlay.d.ts.map +1 -0
  60. package/dist/umd/components/VizElement/DefaultRankBadges.d.ts +11 -0
  61. package/dist/umd/components/VizElement/DefaultRankBadges.d.ts.map +1 -0
  62. package/dist/umd/components/VizElement/ElementCallout.d.ts +17 -0
  63. package/dist/umd/components/VizElement/ElementCallout.d.ts.map +1 -0
  64. package/dist/umd/components/VizElement/HeatmapElements.d.ts +23 -0
  65. package/dist/umd/components/VizElement/HeatmapElements.d.ts.map +1 -0
  66. package/dist/umd/components/VizElement/HoveredElementOverlay.d.ts +12 -0
  67. package/dist/umd/components/VizElement/HoveredElementOverlay.d.ts.map +1 -0
  68. package/dist/umd/components/VizElement/MissingElementMessage.d.ts +7 -0
  69. package/dist/umd/components/VizElement/MissingElementMessage.d.ts.map +1 -0
  70. package/dist/umd/components/VizElement/RankBadge.d.ts +10 -0
  71. package/dist/umd/components/VizElement/RankBadge.d.ts.map +1 -0
  72. package/dist/umd/components/VizElement/VizElements.d.ts +3 -0
  73. package/dist/umd/components/VizElement/VizElements.d.ts.map +1 -1
  74. package/dist/umd/components/VizElement/temp/ClarityVisualizer.d.ts +150 -0
  75. package/dist/umd/components/VizElement/temp/ClarityVisualizer.d.ts.map +1 -0
  76. package/dist/umd/components/VizElement/{VizElementRank.d.ts → temp/VizElementRank.d.ts} +2 -3
  77. package/dist/umd/components/VizElement/temp/VizElementRank.d.ts.map +1 -0
  78. package/dist/umd/constants/index.d.ts +5 -0
  79. package/dist/umd/constants/index.d.ts.map +1 -0
  80. package/dist/umd/helpers/viz-elements.d.ts +10 -0
  81. package/dist/umd/helpers/viz-elements.d.ts.map +1 -0
  82. package/dist/umd/hooks/index.d.ts +1 -0
  83. package/dist/umd/hooks/index.d.ts.map +1 -1
  84. package/dist/umd/hooks/vix-elements/index.d.ts +5 -0
  85. package/dist/umd/hooks/vix-elements/index.d.ts.map +1 -0
  86. package/dist/umd/hooks/vix-elements/useClickedElement.d.ts +14 -0
  87. package/dist/umd/hooks/vix-elements/useClickedElement.d.ts.map +1 -0
  88. package/dist/umd/hooks/vix-elements/useHeatmapEffects.d.ts +8 -0
  89. package/dist/umd/hooks/vix-elements/useHeatmapEffects.d.ts.map +1 -0
  90. package/dist/umd/hooks/vix-elements/useHeatmapElementPosition.d.ts +13 -0
  91. package/dist/umd/hooks/vix-elements/useHeatmapElementPosition.d.ts.map +1 -0
  92. package/dist/umd/hooks/vix-elements/useHoveredElement.d.ts +17 -0
  93. package/dist/umd/hooks/vix-elements/useHoveredElement.d.ts.map +1 -0
  94. package/dist/umd/hooks/viz-render/useHeatmapRender.d.ts +2 -0
  95. package/dist/umd/hooks/viz-render/useHeatmapRender.d.ts.map +1 -1
  96. package/dist/umd/hooks/viz-render/useHeatmapVizRender.d.ts +2 -0
  97. package/dist/umd/hooks/viz-render/useHeatmapVizRender.d.ts.map +1 -1
  98. package/dist/umd/hooks/viz-render/useReplayRender.d.ts +2 -0
  99. package/dist/umd/hooks/viz-render/useReplayRender.d.ts.map +1 -1
  100. package/dist/umd/index.js +2 -2
  101. package/dist/umd/stores/data.d.ts +3 -1
  102. package/dist/umd/stores/data.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/dist/esm/components/VizElement/VizElementRank.d.ts.map +0 -1
  105. package/dist/umd/components/VizElement/VizElementRank.d.ts.map +0 -1
@@ -1,9 +1,10 @@
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, useRef, useCallback, useState } from 'react';
5
- import { Visualizer } from '@gemx-dev/clarity-visualize';
4
+ import { useEffect, useMemo, useState, useCallback, useRef, Fragment as Fragment$1 } from 'react';
6
5
  import { create } from 'zustand';
6
+ import { Visualizer } from '@gemx-dev/clarity-visualize';
7
+ import { createPortal } from 'react-dom';
7
8
 
8
9
  const initialNodes = { id: '1', position: { x: 0, y: 0 }, data: { label: '1' } };
9
10
  const GraphView = ({ children, width, height }) => {
@@ -37,6 +38,21 @@ const GraphView = ({ children, width, height }) => {
37
38
  return (jsxs(ReactFlow, { nodes: nodes, nodeTypes: nodeTypes, onNodesChange: onNodesChange, debug: true, minZoom: 0.5, maxZoom: 2, fitView: true, children: [jsx(Controls, {}), jsx(Background, {})] }));
38
39
  };
39
40
 
41
+ const useHeatmapDataStore = create()((set, get) => ({
42
+ data: undefined,
43
+ clickmap: undefined,
44
+ config: undefined,
45
+ iframeHeight: 0,
46
+ state: {
47
+ hideSidebar: false,
48
+ },
49
+ setData: (data) => set({ data }),
50
+ setClickmap: (clickmap) => set({ clickmap }),
51
+ setState: (state) => set({ state: { ...get().state, ...state } }),
52
+ setConfig: (config) => set({ config: { ...get().config, ...config } }),
53
+ setIframeHeight: (iframeHeight) => set({ iframeHeight }),
54
+ }));
55
+
40
56
  const BoxStack = ({ children, ...props }) => {
41
57
  const id = props.id;
42
58
  const flexDirection = props.flexDirection;
@@ -96,6 +112,231 @@ const HEATMAP_STYLE = {
96
112
  },
97
113
  };
98
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
+
99
340
  const recreateIframe = (iframeRef, config) => {
100
341
  const container = iframeRef.current?.parentElement;
101
342
  if (!container)
@@ -120,19 +361,6 @@ const recreateIframe = (iframeRef, config) => {
120
361
  return newIframe;
121
362
  };
122
363
 
123
- const useHeatmapDataStore = create()((set, get) => ({
124
- data: undefined,
125
- config: undefined,
126
- iframeHeight: 0,
127
- state: {
128
- hideSidebar: false,
129
- },
130
- setData: (data) => set({ data }),
131
- setState: (state) => set({ state: { ...get().state, ...state } }),
132
- setConfig: (config) => set({ config: { ...get().config, ...config } }),
133
- setIframeHeight: (iframeHeight) => set({ iframeHeight }),
134
- }));
135
-
136
364
  function isMobileDevice(userAgent) {
137
365
  if (!userAgent)
138
366
  return false;
@@ -143,6 +371,7 @@ const useHeatmapRender = () => {
143
371
  const data = useHeatmapDataStore((state) => state.data);
144
372
  const config = useHeatmapDataStore((state) => state.config);
145
373
  const setConfig = useHeatmapDataStore((state) => state.setConfig);
374
+ const clickmap = useHeatmapDataStore((state) => state.clickmap);
146
375
  const visualizerRef = useRef(null);
147
376
  const iframeRef = useRef(null);
148
377
  const initializeVisualizer = useCallback((envelope, userAgent) => {
@@ -161,13 +390,14 @@ const useHeatmapRender = () => {
161
390
  locale: 'en-us',
162
391
  });
163
392
  return visualizer;
164
- }, [setConfig]);
393
+ }, []);
165
394
  // Process and render heatmap HTML
166
395
  const renderHeatmap = useCallback(async (payloads) => {
167
396
  if (!payloads || payloads.length === 0)
168
397
  return;
169
- let visualizer = visualizerRef.current || new Visualizer();
398
+ let visualizer = new Visualizer();
170
399
  const iframe = recreateIframe(iframeRef, config);
400
+ // const merged = visualizer.merge(payloads);
171
401
  // setIframeHeight(Number(iframeRef.current?.height || 0));
172
402
  // for (const decoded of payloads) {
173
403
  // // Initialize on first sequence
@@ -185,6 +415,7 @@ const useHeatmapRender = () => {
185
415
  // Render static HTML
186
416
  if (visualizer && iframe?.contentWindow) {
187
417
  await visualizer.html(payloads, iframe.contentWindow);
418
+ visualizerRef.current = visualizer;
188
419
  }
189
420
  }, [initializeVisualizer]);
190
421
  useEffect(() => {
@@ -195,8 +426,15 @@ const useHeatmapRender = () => {
195
426
  visualizerRef.current = null;
196
427
  };
197
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]);
198
435
  return {
199
436
  iframeRef,
437
+ clarityVisualizer: visualizerRef.current,
200
438
  };
201
439
  };
202
440
 
@@ -318,6 +556,7 @@ const useReplayRender = () => {
318
556
  return {
319
557
  iframeRef,
320
558
  isPlaying: isPlayingRef.current,
559
+ clarityVisualizer: visualizerRef.current,
321
560
  play,
322
561
  pause,
323
562
  };
@@ -530,8 +769,386 @@ const useHeatmapScale = (props) => {
530
769
  };
531
770
  };
532
771
 
533
- const VizElements = ({ width, height }) => {
534
- return (jsx("div", { className: "gx-hm-elements", style: { width, height }, children: jsx("div", { className: "gx-hm-element" }) }));
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
+ } }));
535
1152
  };
536
1153
 
537
1154
  const ReplayControls = () => {
@@ -597,7 +1214,7 @@ const VizDomRenderer = ({ mode = 'heatmap' }) => {
597
1214
  height: iframeHeight,
598
1215
  transform: `scale(${scale})`,
599
1216
  transformOrigin: 'top center',
600
- }, children: [jsx(VizElements, { width: contentWidth, height: iframeHeight }), jsx("iframe", {
1217
+ }, children: [jsx(VizElements, { width: contentWidth, height: iframeHeight, widthScale: scale, iframeRef: iframeRef, wrapperRef: wrapperRef }), jsx("iframe", {
601
1218
  // key={iframeKey}
602
1219
  ref: iframeRef, ...HEATMAP_IFRAME, width: contentWidth, height: iframeHeight, scrolling: "no" })] }) }), mode === 'replay' && jsx(ReplayControls, {})] }));
603
1220
  };
@@ -636,15 +1253,33 @@ const WrapperPreview = ({ children }) => {
636
1253
  return (jsxs("div", { className: "gx-hm-container", style: { display: 'flex', overflowY: 'hidden', flex: '1', position: 'relative' }, children: [jsx(LeftSidebar, { children: children }), jsx(VizDomContainer, {})] }));
637
1254
  };
638
1255
 
639
- const WrapperLayout = ({ header, sidebar }) => {
640
- return (jsxs(BoxStack, { id: "gx-hm-layout", flexDirection: "column", flex: "1", children: [jsx(ContentHeader, { children: header }), jsx(WrapperPreview, { children: sidebar })] }));
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 })] }));
641
1258
  };
642
1259
 
643
- const HeatmapLayout = ({ header, sidebar }) => {
1260
+ const HeatmapLayout = ({ data, clickmap, header, toolbar, sidebar, }) => {
1261
+ const setData = useHeatmapDataStore((state) => state.setData);
1262
+ const setClickmap = useHeatmapDataStore((state) => state.setClickmap);
1263
+ const handleSetClickmap = useCallback((clickmap) => {
1264
+ if (!clickmap)
1265
+ return;
1266
+ setClickmap(clickmap);
1267
+ }, [clickmap]);
1268
+ const handleSetData = useCallback((data) => {
1269
+ if (!data)
1270
+ return;
1271
+ setData(data);
1272
+ }, [data]);
1273
+ useEffect(() => {
1274
+ handleSetData(data);
1275
+ }, [data]);
1276
+ useEffect(() => {
1277
+ handleSetClickmap(clickmap);
1278
+ }, [clickmap]);
644
1279
  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: {
645
1280
  minHeight: '100%',
646
1281
  display: 'flex',
647
- }, children: jsx(WrapperLayout, { header: header, sidebar: sidebar }) }) }) }));
1282
+ }, children: jsx(WrapperLayout, { header: header, toolbar: toolbar, sidebar: sidebar }) }) }) }));
648
1283
  };
649
1284
 
650
1285
  var PanelContent;