@gemx-dev/heatmap-react 3.5.15 → 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 (93) hide show
  1. package/dist/esm/components/VizDom/VizDomRenderer.d.ts.map +1 -1
  2. package/dist/esm/components/VizElement/ClickedElementOverlay.d.ts +17 -0
  3. package/dist/esm/components/VizElement/ClickedElementOverlay.d.ts.map +1 -0
  4. package/dist/esm/components/VizElement/DefaultRankBadges.d.ts +11 -0
  5. package/dist/esm/components/VizElement/DefaultRankBadges.d.ts.map +1 -0
  6. package/dist/esm/components/VizElement/ElementCallout.d.ts +17 -0
  7. package/dist/esm/components/VizElement/ElementCallout.d.ts.map +1 -0
  8. package/dist/esm/components/VizElement/HeatmapElements.d.ts +23 -0
  9. package/dist/esm/components/VizElement/HeatmapElements.d.ts.map +1 -0
  10. package/dist/esm/components/VizElement/HoveredElementOverlay.d.ts +12 -0
  11. package/dist/esm/components/VizElement/HoveredElementOverlay.d.ts.map +1 -0
  12. package/dist/esm/components/VizElement/MissingElementMessage.d.ts +7 -0
  13. package/dist/esm/components/VizElement/MissingElementMessage.d.ts.map +1 -0
  14. package/dist/esm/components/VizElement/RankBadge.d.ts +10 -0
  15. package/dist/esm/components/VizElement/RankBadge.d.ts.map +1 -0
  16. package/dist/esm/components/VizElement/VizElements.d.ts +3 -0
  17. package/dist/esm/components/VizElement/VizElements.d.ts.map +1 -1
  18. package/dist/esm/components/VizElement/temp/ClarityVisualizer.d.ts +150 -0
  19. package/dist/esm/components/VizElement/temp/ClarityVisualizer.d.ts.map +1 -0
  20. package/dist/esm/components/VizElement/{VizElementRank.d.ts → temp/VizElementRank.d.ts} +2 -3
  21. package/dist/esm/components/VizElement/temp/VizElementRank.d.ts.map +1 -0
  22. package/dist/esm/constants/index.d.ts +5 -0
  23. package/dist/esm/constants/index.d.ts.map +1 -0
  24. package/dist/esm/helpers/viz-elements.d.ts +10 -0
  25. package/dist/esm/helpers/viz-elements.d.ts.map +1 -0
  26. package/dist/esm/hooks/index.d.ts +1 -0
  27. package/dist/esm/hooks/index.d.ts.map +1 -1
  28. package/dist/esm/hooks/vix-elements/index.d.ts +5 -0
  29. package/dist/esm/hooks/vix-elements/index.d.ts.map +1 -0
  30. package/dist/esm/hooks/vix-elements/useClickedElement.d.ts +14 -0
  31. package/dist/esm/hooks/vix-elements/useClickedElement.d.ts.map +1 -0
  32. package/dist/esm/hooks/vix-elements/useHeatmapEffects.d.ts +8 -0
  33. package/dist/esm/hooks/vix-elements/useHeatmapEffects.d.ts.map +1 -0
  34. package/dist/esm/hooks/vix-elements/useHeatmapElementPosition.d.ts +13 -0
  35. package/dist/esm/hooks/vix-elements/useHeatmapElementPosition.d.ts.map +1 -0
  36. package/dist/esm/hooks/vix-elements/useHoveredElement.d.ts +17 -0
  37. package/dist/esm/hooks/vix-elements/useHoveredElement.d.ts.map +1 -0
  38. package/dist/esm/hooks/viz-render/useHeatmapRender.d.ts +2 -0
  39. package/dist/esm/hooks/viz-render/useHeatmapRender.d.ts.map +1 -1
  40. package/dist/esm/hooks/viz-render/useHeatmapVizRender.d.ts +2 -0
  41. package/dist/esm/hooks/viz-render/useHeatmapVizRender.d.ts.map +1 -1
  42. package/dist/esm/hooks/viz-render/useReplayRender.d.ts +2 -0
  43. package/dist/esm/hooks/viz-render/useReplayRender.d.ts.map +1 -1
  44. package/dist/esm/index.js +612 -5
  45. package/dist/esm/index.mjs +612 -5
  46. package/dist/style.css +20 -0
  47. package/dist/umd/components/VizDom/VizDomRenderer.d.ts.map +1 -1
  48. package/dist/umd/components/VizElement/ClickedElementOverlay.d.ts +17 -0
  49. package/dist/umd/components/VizElement/ClickedElementOverlay.d.ts.map +1 -0
  50. package/dist/umd/components/VizElement/DefaultRankBadges.d.ts +11 -0
  51. package/dist/umd/components/VizElement/DefaultRankBadges.d.ts.map +1 -0
  52. package/dist/umd/components/VizElement/ElementCallout.d.ts +17 -0
  53. package/dist/umd/components/VizElement/ElementCallout.d.ts.map +1 -0
  54. package/dist/umd/components/VizElement/HeatmapElements.d.ts +23 -0
  55. package/dist/umd/components/VizElement/HeatmapElements.d.ts.map +1 -0
  56. package/dist/umd/components/VizElement/HoveredElementOverlay.d.ts +12 -0
  57. package/dist/umd/components/VizElement/HoveredElementOverlay.d.ts.map +1 -0
  58. package/dist/umd/components/VizElement/MissingElementMessage.d.ts +7 -0
  59. package/dist/umd/components/VizElement/MissingElementMessage.d.ts.map +1 -0
  60. package/dist/umd/components/VizElement/RankBadge.d.ts +10 -0
  61. package/dist/umd/components/VizElement/RankBadge.d.ts.map +1 -0
  62. package/dist/umd/components/VizElement/VizElements.d.ts +3 -0
  63. package/dist/umd/components/VizElement/VizElements.d.ts.map +1 -1
  64. package/dist/umd/components/VizElement/temp/ClarityVisualizer.d.ts +150 -0
  65. package/dist/umd/components/VizElement/temp/ClarityVisualizer.d.ts.map +1 -0
  66. package/dist/umd/components/VizElement/{VizElementRank.d.ts → temp/VizElementRank.d.ts} +2 -3
  67. package/dist/umd/components/VizElement/temp/VizElementRank.d.ts.map +1 -0
  68. package/dist/umd/constants/index.d.ts +5 -0
  69. package/dist/umd/constants/index.d.ts.map +1 -0
  70. package/dist/umd/helpers/viz-elements.d.ts +10 -0
  71. package/dist/umd/helpers/viz-elements.d.ts.map +1 -0
  72. package/dist/umd/hooks/index.d.ts +1 -0
  73. package/dist/umd/hooks/index.d.ts.map +1 -1
  74. package/dist/umd/hooks/vix-elements/index.d.ts +5 -0
  75. package/dist/umd/hooks/vix-elements/index.d.ts.map +1 -0
  76. package/dist/umd/hooks/vix-elements/useClickedElement.d.ts +14 -0
  77. package/dist/umd/hooks/vix-elements/useClickedElement.d.ts.map +1 -0
  78. package/dist/umd/hooks/vix-elements/useHeatmapEffects.d.ts +8 -0
  79. package/dist/umd/hooks/vix-elements/useHeatmapEffects.d.ts.map +1 -0
  80. package/dist/umd/hooks/vix-elements/useHeatmapElementPosition.d.ts +13 -0
  81. package/dist/umd/hooks/vix-elements/useHeatmapElementPosition.d.ts.map +1 -0
  82. package/dist/umd/hooks/vix-elements/useHoveredElement.d.ts +17 -0
  83. package/dist/umd/hooks/vix-elements/useHoveredElement.d.ts.map +1 -0
  84. package/dist/umd/hooks/viz-render/useHeatmapRender.d.ts +2 -0
  85. package/dist/umd/hooks/viz-render/useHeatmapRender.d.ts.map +1 -1
  86. package/dist/umd/hooks/viz-render/useHeatmapVizRender.d.ts +2 -0
  87. package/dist/umd/hooks/viz-render/useHeatmapVizRender.d.ts.map +1 -1
  88. package/dist/umd/hooks/viz-render/useReplayRender.d.ts +2 -0
  89. package/dist/umd/hooks/viz-render/useReplayRender.d.ts.map +1 -1
  90. package/dist/umd/index.js +2 -2
  91. package/package.json +1 -1
  92. package/dist/esm/components/VizElement/VizElementRank.d.ts.map +0 -1
  93. 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';
4
+ import { useEffect, useMemo, useState, useCallback, useRef, Fragment as Fragment$1 } from 'react';
5
5
  import { create } from 'zustand';
6
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 }) => {
@@ -111,6 +112,231 @@ const HEATMAP_STYLE = {
111
112
  },
112
113
  };
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
+
114
340
  const recreateIframe = (iframeRef, config) => {
115
341
  const container = iframeRef.current?.parentElement;
116
342
  if (!container)
@@ -164,13 +390,14 @@ const useHeatmapRender = () => {
164
390
  locale: 'en-us',
165
391
  });
166
392
  return visualizer;
167
- }, [setConfig]);
393
+ }, []);
168
394
  // Process and render heatmap HTML
169
395
  const renderHeatmap = useCallback(async (payloads) => {
170
396
  if (!payloads || payloads.length === 0)
171
397
  return;
172
398
  let visualizer = new Visualizer();
173
399
  const iframe = recreateIframe(iframeRef, config);
400
+ // const merged = visualizer.merge(payloads);
174
401
  // setIframeHeight(Number(iframeRef.current?.height || 0));
175
402
  // for (const decoded of payloads) {
176
403
  // // Initialize on first sequence
@@ -207,6 +434,7 @@ const useHeatmapRender = () => {
207
434
  }, [clickmap]);
208
435
  return {
209
436
  iframeRef,
437
+ clarityVisualizer: visualizerRef.current,
210
438
  };
211
439
  };
212
440
 
@@ -328,6 +556,7 @@ const useReplayRender = () => {
328
556
  return {
329
557
  iframeRef,
330
558
  isPlaying: isPlayingRef.current,
559
+ clarityVisualizer: visualizerRef.current,
331
560
  play,
332
561
  pause,
333
562
  };
@@ -540,8 +769,386 @@ const useHeatmapScale = (props) => {
540
769
  };
541
770
  };
542
771
 
543
- const VizElements = ({ width, height }) => {
544
- 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
+ } }));
545
1152
  };
546
1153
 
547
1154
  const ReplayControls = () => {
@@ -607,7 +1214,7 @@ const VizDomRenderer = ({ mode = 'heatmap' }) => {
607
1214
  height: iframeHeight,
608
1215
  transform: `scale(${scale})`,
609
1216
  transformOrigin: 'top center',
610
- }, 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", {
611
1218
  // key={iframeKey}
612
1219
  ref: iframeRef, ...HEATMAP_IFRAME, width: contentWidth, height: iframeHeight, scrolling: "no" })] }) }), mode === 'replay' && jsx(ReplayControls, {})] }));
613
1220
  };
package/dist/style.css CHANGED
@@ -8,3 +8,23 @@
8
8
  position: absolute;
9
9
  z-index: 2;
10
10
  }
11
+
12
+ .rankBadge {
13
+ position: absolute;
14
+ width: 36px;
15
+ height: 36px;
16
+ background: #0078D4;
17
+ color: white;
18
+ border: 1px solid #FFFFFF;
19
+ border-radius: 32px;
20
+ text-align: center;
21
+ line-height: 32px;
22
+ filter: drop-shadow(0px 1.2px 3.6px rgba(0, 0, 0, 0.1));
23
+ cursor: default;
24
+ }
25
+
26
+ .heatmapElement {
27
+ position: absolute;
28
+ border: 1px solid white;
29
+ outline: 1px solid #0078D4;
30
+ }