@gemx-dev/heatmap-react 3.5.34 → 3.5.37

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 (143) hide show
  1. package/dist/esm/components/Layout/ContentMetricBar.d.ts.map +1 -1
  2. package/dist/esm/components/Layout/ContentToolbar.d.ts.map +1 -1
  3. package/dist/esm/components/Layout/ContentTopBar.d.ts.map +1 -1
  4. package/dist/esm/components/Layout/LeftSidebar.d.ts.map +1 -1
  5. package/dist/esm/components/VizDom/VizDomContainer.d.ts.map +1 -1
  6. package/dist/esm/components/VizElement/ElementCallout.d.ts +5 -6
  7. package/dist/esm/components/VizElement/ElementCallout.d.ts.map +1 -1
  8. package/dist/esm/components/VizElement/ElementMissing.d.ts +6 -0
  9. package/dist/esm/components/VizElement/ElementMissing.d.ts.map +1 -0
  10. package/dist/{umd/components/VizElement/HoveredElementOverlay.d.ts → esm/components/VizElement/ElementOverlay.d.ts} +3 -3
  11. package/dist/esm/components/VizElement/ElementOverlay.d.ts.map +1 -0
  12. package/dist/esm/components/VizElement/HeatmapElements.d.ts +2 -1
  13. package/dist/esm/components/VizElement/HeatmapElements.d.ts.map +1 -1
  14. package/dist/esm/configs/iframe.d.ts +1 -1
  15. package/dist/esm/configs/iframe.d.ts.map +1 -1
  16. package/dist/esm/constants/index.d.ts +4 -4
  17. package/dist/esm/constants/index.d.ts.map +1 -1
  18. package/dist/esm/helpers/elm-callout.d.ts +9 -0
  19. package/dist/esm/helpers/elm-callout.d.ts.map +1 -0
  20. package/dist/esm/helpers/elm-getter.d.ts +6 -0
  21. package/dist/esm/helpers/elm-getter.d.ts.map +1 -0
  22. package/dist/esm/helpers/index.d.ts +5 -0
  23. package/dist/esm/helpers/index.d.ts.map +1 -1
  24. package/dist/esm/helpers/viewport-fixer.d.ts +13 -0
  25. package/dist/esm/helpers/viewport-fixer.d.ts.map +1 -0
  26. package/dist/esm/helpers/viewport-replacer.d.ts +25 -0
  27. package/dist/esm/helpers/viewport-replacer.d.ts.map +1 -0
  28. package/dist/esm/helpers/viz-elements.d.ts +1 -2
  29. package/dist/esm/helpers/viz-elements.d.ts.map +1 -1
  30. package/dist/esm/hooks/register/useRegisterControl.d.ts.map +1 -1
  31. package/dist/esm/hooks/vix-elements/index.d.ts +1 -0
  32. package/dist/esm/hooks/vix-elements/index.d.ts.map +1 -1
  33. package/dist/esm/hooks/vix-elements/useClickedElement.d.ts +2 -4
  34. package/dist/esm/hooks/vix-elements/useClickedElement.d.ts.map +1 -1
  35. package/dist/esm/hooks/vix-elements/useElementCalloutVisible.d.ts +7 -0
  36. package/dist/esm/hooks/vix-elements/useElementCalloutVisible.d.ts.map +1 -0
  37. package/dist/esm/hooks/vix-elements/useHoveredElement.d.ts +1 -2
  38. package/dist/esm/hooks/vix-elements/useHoveredElement.d.ts.map +1 -1
  39. package/dist/esm/hooks/viz-render/useHeatmapRender.d.ts.map +1 -1
  40. package/dist/esm/hooks/viz-scale/useContentDimensions.d.ts +1 -1
  41. package/dist/esm/hooks/viz-scale/useContentDimensions.d.ts.map +1 -1
  42. package/dist/esm/hooks/viz-scale/useHeatmapScale.d.ts.map +1 -1
  43. package/dist/esm/hooks/viz-scale/useIframeHeight.d.ts +0 -1
  44. package/dist/esm/hooks/viz-scale/useIframeHeight.d.ts.map +1 -1
  45. package/dist/esm/hooks/viz-scale/useScrollSync.d.ts.map +1 -1
  46. package/dist/esm/index.js +766 -283
  47. package/dist/esm/index.mjs +766 -283
  48. package/dist/esm/stores/comp.d.ts.map +1 -1
  49. package/dist/esm/stores/interaction.d.ts +2 -0
  50. package/dist/esm/stores/interaction.d.ts.map +1 -1
  51. package/dist/esm/stores/viz.d.ts +2 -0
  52. package/dist/esm/stores/viz.d.ts.map +1 -1
  53. package/dist/esm/types/control.d.ts +3 -0
  54. package/dist/esm/types/control.d.ts.map +1 -1
  55. package/dist/esm/types/elm-callout.d.ts +9 -0
  56. package/dist/esm/types/elm-callout.d.ts.map +1 -0
  57. package/dist/esm/types/index.d.ts +2 -0
  58. package/dist/esm/types/index.d.ts.map +1 -1
  59. package/dist/esm/types/viewport-fixer.d.ts +28 -0
  60. package/dist/esm/types/viewport-fixer.d.ts.map +1 -0
  61. package/dist/esm/types/viz-element.d.ts +6 -5
  62. package/dist/esm/types/viz-element.d.ts.map +1 -1
  63. package/dist/esm/ui/BoxStack/BoxStack.d.ts +11 -1
  64. package/dist/esm/ui/BoxStack/BoxStack.d.ts.map +1 -1
  65. package/dist/esm/utils/debounce.d.ts +2 -0
  66. package/dist/esm/utils/debounce.d.ts.map +1 -0
  67. package/dist/style.css +42 -109
  68. package/dist/umd/components/Layout/ContentMetricBar.d.ts.map +1 -1
  69. package/dist/umd/components/Layout/ContentToolbar.d.ts.map +1 -1
  70. package/dist/umd/components/Layout/ContentTopBar.d.ts.map +1 -1
  71. package/dist/umd/components/Layout/LeftSidebar.d.ts.map +1 -1
  72. package/dist/umd/components/VizDom/VizDomContainer.d.ts.map +1 -1
  73. package/dist/umd/components/VizElement/ElementCallout.d.ts +5 -6
  74. package/dist/umd/components/VizElement/ElementCallout.d.ts.map +1 -1
  75. package/dist/umd/components/VizElement/ElementMissing.d.ts +6 -0
  76. package/dist/umd/components/VizElement/ElementMissing.d.ts.map +1 -0
  77. package/dist/{esm/components/VizElement/HoveredElementOverlay.d.ts → umd/components/VizElement/ElementOverlay.d.ts} +3 -3
  78. package/dist/umd/components/VizElement/ElementOverlay.d.ts.map +1 -0
  79. package/dist/umd/components/VizElement/HeatmapElements.d.ts +2 -1
  80. package/dist/umd/components/VizElement/HeatmapElements.d.ts.map +1 -1
  81. package/dist/umd/configs/iframe.d.ts +1 -1
  82. package/dist/umd/configs/iframe.d.ts.map +1 -1
  83. package/dist/umd/constants/index.d.ts +4 -4
  84. package/dist/umd/constants/index.d.ts.map +1 -1
  85. package/dist/umd/helpers/elm-callout.d.ts +9 -0
  86. package/dist/umd/helpers/elm-callout.d.ts.map +1 -0
  87. package/dist/umd/helpers/elm-getter.d.ts +6 -0
  88. package/dist/umd/helpers/elm-getter.d.ts.map +1 -0
  89. package/dist/umd/helpers/index.d.ts +5 -0
  90. package/dist/umd/helpers/index.d.ts.map +1 -1
  91. package/dist/umd/helpers/viewport-fixer.d.ts +13 -0
  92. package/dist/umd/helpers/viewport-fixer.d.ts.map +1 -0
  93. package/dist/umd/helpers/viewport-replacer.d.ts +25 -0
  94. package/dist/umd/helpers/viewport-replacer.d.ts.map +1 -0
  95. package/dist/umd/helpers/viz-elements.d.ts +1 -2
  96. package/dist/umd/helpers/viz-elements.d.ts.map +1 -1
  97. package/dist/umd/hooks/register/useRegisterControl.d.ts.map +1 -1
  98. package/dist/umd/hooks/vix-elements/index.d.ts +1 -0
  99. package/dist/umd/hooks/vix-elements/index.d.ts.map +1 -1
  100. package/dist/umd/hooks/vix-elements/useClickedElement.d.ts +2 -4
  101. package/dist/umd/hooks/vix-elements/useClickedElement.d.ts.map +1 -1
  102. package/dist/umd/hooks/vix-elements/useElementCalloutVisible.d.ts +7 -0
  103. package/dist/umd/hooks/vix-elements/useElementCalloutVisible.d.ts.map +1 -0
  104. package/dist/umd/hooks/vix-elements/useHoveredElement.d.ts +1 -2
  105. package/dist/umd/hooks/vix-elements/useHoveredElement.d.ts.map +1 -1
  106. package/dist/umd/hooks/viz-render/useHeatmapRender.d.ts.map +1 -1
  107. package/dist/umd/hooks/viz-scale/useContentDimensions.d.ts +1 -1
  108. package/dist/umd/hooks/viz-scale/useContentDimensions.d.ts.map +1 -1
  109. package/dist/umd/hooks/viz-scale/useHeatmapScale.d.ts.map +1 -1
  110. package/dist/umd/hooks/viz-scale/useIframeHeight.d.ts +0 -1
  111. package/dist/umd/hooks/viz-scale/useIframeHeight.d.ts.map +1 -1
  112. package/dist/umd/hooks/viz-scale/useScrollSync.d.ts.map +1 -1
  113. package/dist/umd/index.js +2 -2
  114. package/dist/umd/stores/comp.d.ts.map +1 -1
  115. package/dist/umd/stores/interaction.d.ts +2 -0
  116. package/dist/umd/stores/interaction.d.ts.map +1 -1
  117. package/dist/umd/stores/viz.d.ts +2 -0
  118. package/dist/umd/stores/viz.d.ts.map +1 -1
  119. package/dist/umd/types/control.d.ts +3 -0
  120. package/dist/umd/types/control.d.ts.map +1 -1
  121. package/dist/umd/types/elm-callout.d.ts +9 -0
  122. package/dist/umd/types/elm-callout.d.ts.map +1 -0
  123. package/dist/umd/types/index.d.ts +2 -0
  124. package/dist/umd/types/index.d.ts.map +1 -1
  125. package/dist/umd/types/viewport-fixer.d.ts +28 -0
  126. package/dist/umd/types/viewport-fixer.d.ts.map +1 -0
  127. package/dist/umd/types/viz-element.d.ts +6 -5
  128. package/dist/umd/types/viz-element.d.ts.map +1 -1
  129. package/dist/umd/ui/BoxStack/BoxStack.d.ts +11 -1
  130. package/dist/umd/ui/BoxStack/BoxStack.d.ts.map +1 -1
  131. package/dist/umd/utils/debounce.d.ts +2 -0
  132. package/dist/umd/utils/debounce.d.ts.map +1 -0
  133. package/package.json +1 -1
  134. package/dist/esm/components/VizElement/ClickedElementOverlay.d.ts +0 -10
  135. package/dist/esm/components/VizElement/ClickedElementOverlay.d.ts.map +0 -1
  136. package/dist/esm/components/VizElement/HoveredElementOverlay.d.ts.map +0 -1
  137. package/dist/esm/components/VizElement/MissingElementMessage.d.ts +0 -6
  138. package/dist/esm/components/VizElement/MissingElementMessage.d.ts.map +0 -1
  139. package/dist/umd/components/VizElement/ClickedElementOverlay.d.ts +0 -10
  140. package/dist/umd/components/VizElement/ClickedElementOverlay.d.ts.map +0 -1
  141. package/dist/umd/components/VizElement/HoveredElementOverlay.d.ts.map +0 -1
  142. package/dist/umd/components/VizElement/MissingElementMessage.d.ts +0 -6
  143. package/dist/umd/components/VizElement/MissingElementMessage.d.ts.map +0 -1
@@ -1,7 +1,7 @@
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, useCallback, useState, useRef, useMemo, Fragment as Fragment$1 } from 'react';
4
+ import { useEffect, useCallback, useState, useRef, useMemo, forwardRef, Fragment as Fragment$1 } from 'react';
5
5
  import { create } from 'zustand';
6
6
  import { Visualizer } from '@gemx-dev/clarity-visualize';
7
7
  import { createPortal } from 'react-dom';
@@ -72,6 +72,7 @@ const useHeatmapControlStore = create()((set, get) => {
72
72
  Toolbar: null,
73
73
  MetricBar: null,
74
74
  VizLoading: null,
75
+ ElementCallout: null,
75
76
  },
76
77
  registerControl: (key, control) => {
77
78
  set({
@@ -120,11 +121,15 @@ const useHeatmapInteractionStore = create()((set, get) => {
120
121
  setSelectedElement: (selectedElement) => set({ selectedElement }),
121
122
  hoveredElement: null,
122
123
  setHoveredElement: (hoveredElement) => set({ hoveredElement }),
124
+ shouldShowCallout: false,
125
+ setShouldShowCallout: (shouldShowCallout) => set({ shouldShowCallout }),
123
126
  };
124
127
  });
125
128
 
126
129
  const useHeatmapVizStore = create()((set, get) => {
127
130
  return {
131
+ isRenderViz: false,
132
+ setIsRenderViz: (isRenderViz) => set({ isRenderViz }),
128
133
  scale: 1,
129
134
  vizRef: undefined,
130
135
  iframeHeight: 0,
@@ -152,6 +157,7 @@ const useRegisterControl = (control) => {
152
157
  registerControl('Toolbar', control.Toolbar);
153
158
  registerControl('MetricBar', control.MetricBar);
154
159
  registerControl('VizLoading', control.VizLoading);
160
+ registerControl('ElementCallout', control.ElementCallout);
155
161
  };
156
162
 
157
163
  const useRegisterData = (data, dataInfo) => {
@@ -189,47 +195,498 @@ const useRegisterHeatmap = (clickmap) => {
189
195
  }, [clickmap]);
190
196
  };
191
197
 
192
- const isElementInViewport = (elementRect, visualRef, scale) => {
193
- const visualRect = visualRef.current?.getBoundingClientRect();
194
- if (!visualRect)
195
- return false;
196
- // Absolute position of element so visual container (not scaled)
197
- const elementTopRaw = elementRect.top - visualRect.top;
198
- const elementBottomRaw = elementTopRaw + elementRect.height;
199
- // Apply scale because wrapper is scaled
200
- const elementTop = elementTopRaw * scale;
201
- const elementBottom = elementBottomRaw * scale;
202
- const scrollTop = visualRef.current?.scrollTop || 0;
203
- const viewportHeight = visualRect.height;
204
- const viewportTop = scrollTop;
205
- const viewportBottom = scrollTop + viewportHeight;
206
- return elementTop >= viewportTop && elementBottom <= viewportBottom;
198
+ const PADDING = 0;
199
+ const ARROW_SIZE = 8;
200
+ const HORIZONTAL_OFFSET = 0;
201
+ // ============================================================================
202
+ // Viewport & Dimensions
203
+ // ============================================================================
204
+ const getViewportDimensions = () => ({
205
+ width: window.innerWidth,
206
+ height: window.innerHeight,
207
+ });
208
+ const getElementDimensions = (targetElm, calloutElm) => ({
209
+ targetRect: targetElm.getBoundingClientRect(),
210
+ calloutRect: calloutElm.getBoundingClientRect(),
211
+ });
212
+ // ============================================================================
213
+ // Alignment Order
214
+ // ============================================================================
215
+ const getAlignmentOrder = (alignment) => {
216
+ switch (alignment) {
217
+ case 'center':
218
+ return ['center', 'left', 'right'];
219
+ case 'left':
220
+ return ['left', 'center', 'right'];
221
+ case 'right':
222
+ return ['right', 'center', 'left'];
223
+ }
224
+ };
225
+ // ============================================================================
226
+ // Position Calculation
227
+ // ============================================================================
228
+ const calculateLeftPosition = ({ targetRect, calloutRect, hozOffset, align, }) => {
229
+ switch (align) {
230
+ case 'left':
231
+ return targetRect.left + hozOffset;
232
+ case 'right':
233
+ return targetRect.right - calloutRect.width - hozOffset;
234
+ case 'center':
235
+ default:
236
+ return targetRect.left + targetRect.width / 2 - calloutRect.width / 2;
237
+ }
207
238
  };
208
- const calculateElementRank = (selectedElement, sortedElements) => {
209
- if (!sortedElements)
239
+ const calculateVerticalPosition = (targetRect, calloutRect, placement, padding, arrowSize) => {
240
+ return placement === 'top'
241
+ ? targetRect.top - calloutRect.height - padding - arrowSize
242
+ : targetRect.bottom + padding + arrowSize;
243
+ };
244
+ const calculateHorizontalPlacementPosition = (targetRect, calloutRect, placement, padding, arrowSize) => {
245
+ const top = targetRect.top + targetRect.height / 2 - calloutRect.height / 2;
246
+ const left = placement === 'right'
247
+ ? targetRect.right + padding + arrowSize
248
+ : targetRect.left - calloutRect.width - padding - arrowSize;
249
+ return { top, left };
250
+ };
251
+ // ============================================================================
252
+ // Validation
253
+ // ============================================================================
254
+ const isLeftPositionValid = (leftPos, calloutWidth, viewportWidth, padding) => {
255
+ return leftPos >= padding && leftPos + calloutWidth <= viewportWidth - padding;
256
+ };
257
+ const isVerticalPositionValid = (targetRect, calloutRect, placement, viewportHeight, padding, arrowSize) => {
258
+ return placement === 'top'
259
+ ? targetRect.top - calloutRect.height - padding - arrowSize > 0
260
+ : targetRect.bottom + calloutRect.height + padding + arrowSize < viewportHeight;
261
+ };
262
+ const isHorizontalPlacementValid = (targetRect, calloutRect, placement, viewportWidth, padding, arrowSize) => {
263
+ return placement === 'right'
264
+ ? targetRect.right + calloutRect.width + padding + arrowSize < viewportWidth
265
+ : targetRect.left - calloutRect.width - padding - arrowSize > 0;
266
+ };
267
+ // ============================================================================
268
+ // Position Candidates Generation
269
+ // ============================================================================
270
+ const generateVerticalPositionCandidates = (targetRect, calloutRect, viewportHeight, viewportWidth, alignment, hozOffset, padding, arrowSize) => {
271
+ const candidates = [];
272
+ const placements = ['top', 'bottom'];
273
+ placements.forEach((placement) => {
274
+ const verticalPos = calculateVerticalPosition(targetRect, calloutRect, placement, padding, arrowSize);
275
+ const verticalValid = isVerticalPositionValid(targetRect, calloutRect, placement, viewportHeight, padding, arrowSize);
276
+ const alignmentOrder = getAlignmentOrder(alignment);
277
+ alignmentOrder.forEach((align) => {
278
+ const horizontalPos = calculateLeftPosition({
279
+ targetRect,
280
+ calloutRect,
281
+ hozOffset,
282
+ align,
283
+ });
284
+ candidates.push({
285
+ placement,
286
+ top: verticalPos,
287
+ left: horizontalPos,
288
+ horizontalAlign: align,
289
+ valid: verticalValid &&
290
+ isLeftPositionValid(horizontalPos, calloutRect.width, viewportWidth, padding),
291
+ });
292
+ });
293
+ });
294
+ return candidates;
295
+ };
296
+ const generateHorizontalPositionCandidates = (targetRect, calloutRect, viewportWidth, padding, arrowSize) => {
297
+ const placements = ['left', 'right'];
298
+ return placements.map((placement) => {
299
+ const { top, left } = calculateHorizontalPlacementPosition(targetRect, calloutRect, placement, padding, arrowSize);
300
+ return {
301
+ placement,
302
+ top,
303
+ left,
304
+ horizontalAlign: 'center',
305
+ valid: isHorizontalPlacementValid(targetRect, calloutRect, placement, viewportWidth, padding, arrowSize),
306
+ };
307
+ });
308
+ };
309
+ const generateAllPositionCandidates = (rectDimensions, viewport, alignment, hozOffset, padding, arrowSize) => {
310
+ const { targetRect, calloutRect } = rectDimensions;
311
+ const verticalCandidates = generateVerticalPositionCandidates(targetRect, calloutRect, viewport.height, viewport.width, alignment, hozOffset, padding, arrowSize);
312
+ const horizontalCandidates = generateHorizontalPositionCandidates(targetRect, calloutRect, viewport.width, padding, arrowSize);
313
+ return [...verticalCandidates, ...horizontalCandidates];
314
+ };
315
+ // ============================================================================
316
+ // Position Selection
317
+ // ============================================================================
318
+ const selectBestPosition = (candidates) => {
319
+ return candidates.find((p) => p.valid) || candidates[0];
320
+ };
321
+ // ============================================================================
322
+ // Viewport Boundary Adjustment
323
+ // ============================================================================
324
+ const constrainToViewport = (position, calloutRect, viewport, padding) => {
325
+ const left = Math.max(padding, Math.min(position.left, viewport.width - calloutRect.width - padding));
326
+ const top = Math.max(padding, Math.min(position.top, viewport.height - calloutRect.height - padding));
327
+ return { top, left };
328
+ };
329
+ // ============================================================================
330
+ // Main Function
331
+ // ============================================================================
332
+ const calcCalloutPosition = ({ targetElm, calloutElm, setPosition, hozOffset = HORIZONTAL_OFFSET, alignment = 'center', }) => {
333
+ return () => {
334
+ // 1. Get dimensions
335
+ const rectDimensions = getElementDimensions(targetElm, calloutElm);
336
+ const viewport = getViewportDimensions();
337
+ const padding = PADDING;
338
+ const arrowSize = ARROW_SIZE;
339
+ // 2. Generate all position candidates
340
+ const candidates = generateAllPositionCandidates(rectDimensions, viewport, alignment, hozOffset, padding, arrowSize);
341
+ // 3. Select best position
342
+ const bestPosition = selectBestPosition(candidates);
343
+ // 4. Constrain to viewport
344
+ const constrainedPosition = constrainToViewport({ top: bestPosition.top, left: bestPosition.left }, rectDimensions.calloutRect, viewport, padding);
345
+ // 5. Create final position object
346
+ const finalPosition = {
347
+ top: constrainedPosition.top,
348
+ left: constrainedPosition.left,
349
+ placement: bestPosition.placement,
350
+ horizontalAlign: bestPosition.horizontalAlign,
351
+ };
352
+ setPosition(finalPosition);
353
+ };
354
+ };
355
+
356
+ function getElementLayout(element) {
357
+ if (!element?.getBoundingClientRect)
358
+ return null;
359
+ const rect = element.getBoundingClientRect();
360
+ if (rect.width === 0 && rect.height === 0)
361
+ return null;
362
+ return {
363
+ top: rect.top,
364
+ left: rect.left,
365
+ width: rect.width,
366
+ height: rect.height,
367
+ };
368
+ }
369
+ const getElementAtPoint = (doc, x, y) => {
370
+ let el = null;
371
+ if ('caretPositionFromPoint' in doc) {
372
+ el = doc.caretPositionFromPoint(x, y)?.offsetNode ?? null;
373
+ }
374
+ el = el ?? doc.elementFromPoint(x, y);
375
+ let element = el;
376
+ while (element && element.nodeType === Node.TEXT_NODE) {
377
+ element = element.parentElement;
378
+ }
379
+ return element;
380
+ };
381
+ function getElementHash(element) {
382
+ return (element.getAttribute('data-clarity-hash') ||
383
+ element.getAttribute('data-clarity-hashalpha') ||
384
+ element.getAttribute('data-clarity-hashbeta'));
385
+ }
386
+ const getElementRank = (hash, elements) => {
387
+ if (!elements)
210
388
  return 0;
211
- return sortedElements.findIndex((e) => e.hash === selectedElement) + 1;
389
+ return elements.findIndex((e) => e.hash === hash) + 1;
212
390
  };
213
- const buildElementInfo = (selectedElement, info, rect, rank) => {
391
+ const buildElementInfo = (hash, rect, heatmapInfo) => {
392
+ if (!rect || !heatmapInfo)
393
+ return null;
394
+ const info = heatmapInfo.elementMapInfo?.[hash];
395
+ if (!info)
396
+ return null;
397
+ const rank = getElementRank(hash, heatmapInfo.sortedElements);
398
+ const clicks = info.totalclicks ?? 0;
399
+ const selector = info.selector ?? '';
214
400
  const baseInfo = {
215
- hash: selectedElement,
216
- clicks: info.totalclicks ?? 0,
401
+ hash,
402
+ clicks,
217
403
  rank,
218
- selector: info.selector ?? '',
404
+ selector,
219
405
  };
220
- if (rect) {
221
- return { ...baseInfo, ...rect };
222
- }
223
406
  return {
224
407
  ...baseInfo,
225
- left: 0,
226
- top: 0,
227
- width: 0,
228
- height: 0,
408
+ ...rect,
229
409
  };
230
410
  };
231
- const scrollToElementIfNeeded = (visualRef, wrapperRef, rect, scale) => {
232
- if (!visualRef.current || !wrapperRef.current)
411
+
412
+ function calculateRankPosition(rect, widthScale) {
413
+ const top = rect.top <= 18 ? rect.top + 3 : rect.top - 18;
414
+ const left = rect.left <= 18 ? rect.left + 3 : rect.left - 18;
415
+ return {
416
+ transform: `scale(${1.2 * widthScale})`,
417
+ top: Number.isNaN(top) ? undefined : top,
418
+ left: Number.isNaN(left) ? undefined : left,
419
+ };
420
+ }
421
+ function isElementInViewport(elementRect, visualRef, scale) {
422
+ if (!elementRect)
423
+ return false;
424
+ const visualRect = visualRef.current?.getBoundingClientRect();
425
+ if (!visualRect)
426
+ return false;
427
+ // Element position relative to the document (or container's content)
428
+ const elementTop = elementRect.top * scale;
429
+ const elementBottom = (elementRect.top + elementRect.height) * scale;
430
+ // Current scroll position
431
+ const scrollTop = visualRef.current?.scrollTop || 0;
432
+ const viewportHeight = visualRect.height;
433
+ // Visible viewport range in the scrollable content
434
+ const viewportTop = scrollTop;
435
+ const viewportBottom = scrollTop + viewportHeight;
436
+ // Check if element is within the visible viewport
437
+ // Element is visible if it overlaps with the viewport
438
+ return elementBottom > viewportTop && elementTop < viewportBottom;
439
+ }
440
+
441
+ class ViewportUnitsFixer {
442
+ iframe = null;
443
+ config;
444
+ constructor(config) {
445
+ this.config = config;
446
+ this.iframe = config.iframe;
447
+ this.init();
448
+ }
449
+ async init() {
450
+ if (!this.iframe) {
451
+ console.error('[Parent] Required elements not found');
452
+ return;
453
+ }
454
+ // this.injectScriptContent = await generateIframeInjectScript();
455
+ window.addEventListener('message', this.handleMessage.bind(this));
456
+ if (this.iframe.contentDocument?.readyState === 'complete') {
457
+ await this.injectScript();
458
+ }
459
+ else {
460
+ this.iframe.addEventListener('load', () => this.injectScript());
461
+ }
462
+ }
463
+ async injectScript() {
464
+ if (!this.iframe?.contentWindow || !this.iframe.contentDocument)
465
+ return;
466
+ const win = this.iframe.contentWindow;
467
+ const doc = this.iframe.contentDocument;
468
+ win.__viewportConfig = this.config;
469
+ const script = doc.createElement('script');
470
+ script.textContent = `
471
+ (function() {
472
+ 'use strict';
473
+ ${(await Promise.resolve().then(function () { return viewportReplacer; })).default}
474
+ new ViewportUnitsReplacer()
475
+ })();
476
+ `;
477
+ script.type = 'text/javascript';
478
+ script.id = 'viewport-replacer';
479
+ script.onload = () => console.log('[Parent] Viewport replacer module loaded');
480
+ script.onerror = () => console.error('[Parent] Không load được replacer script');
481
+ doc.head.appendChild(script);
482
+ }
483
+ handleMessage(event) {
484
+ const data = event.data;
485
+ if (!data || data.type !== 'IFRAME_HEIGHT_CALCULATED')
486
+ return;
487
+ this.config.onSuccess?.(data);
488
+ }
489
+ recalculate() {
490
+ this.injectScript();
491
+ }
492
+ }
493
+ function initViewportFixer(config) {
494
+ const fixer = new ViewportUnitsFixer(config);
495
+ window.viewportFixer = fixer;
496
+ window.addEventListener('iframe-dimensions-applied', ((e) => {
497
+ const ev = e;
498
+ console.log('Iframe dimensions finalized:', ev.detail);
499
+ }));
500
+ return fixer;
501
+ }
502
+
503
+ class ViewportUnitsReplacer {
504
+ config;
505
+ regex = /([-.\d]+)(vh|svh|lvh|dvh|vw|svw|lvw|dvw)/gi;
506
+ constructor() {
507
+ if (!window.__viewportConfig) {
508
+ throw new Error('[Iframe] Do not have viewport config');
509
+ }
510
+ this.config = window.__viewportConfig;
511
+ console.log('[Iframe] ViewportUnitsReplacer started with config:', this.config);
512
+ this.init();
513
+ }
514
+ px(value) {
515
+ return `${value.toFixed(2)}px`;
516
+ }
517
+ convert(value, unit) {
518
+ const num = parseFloat(value);
519
+ if (isNaN(num))
520
+ return value;
521
+ const map = {
522
+ vh: this.config.targetHeight,
523
+ svh: this.config.targetHeight,
524
+ lvh: this.config.targetHeight,
525
+ dvh: this.config.targetHeight,
526
+ vw: this.config.targetWidth,
527
+ svw: this.config.targetWidth,
528
+ lvw: this.config.targetWidth,
529
+ dvw: this.config.targetWidth,
530
+ };
531
+ return this.px((num / 100) * (map[unit.toLowerCase()] || 0));
532
+ }
533
+ replaceInText(cssText) {
534
+ return cssText.replace(this.regex, (_, value, unit) => this.convert(value, unit));
535
+ }
536
+ processInlineStyles() {
537
+ let count = 0;
538
+ document.querySelectorAll('[style]').forEach((el) => {
539
+ const style = el.getAttribute('style');
540
+ if (style && this.regex.test(style)) {
541
+ el.setAttribute('style', this.replaceInText(style));
542
+ count++;
543
+ }
544
+ });
545
+ console.log(`[Iframe] Replaced ${count} inline style elements`);
546
+ return count;
547
+ }
548
+ processStyleTags() {
549
+ let count = 0;
550
+ document.querySelectorAll('style').forEach((tag) => {
551
+ const css = tag.textContent || '';
552
+ if (this.regex.test(css)) {
553
+ tag.textContent = this.replaceInText(css);
554
+ count++;
555
+ }
556
+ });
557
+ console.log(`[Iframe] Replaced ${count} <style> tags`);
558
+ return count;
559
+ }
560
+ processRule(rule) {
561
+ let count = 0;
562
+ if ('style' in rule && rule.style) {
563
+ const style = rule.style;
564
+ for (let i = 0; i < style.length; i++) {
565
+ const prop = style[i];
566
+ const value = style.getPropertyValue(prop);
567
+ if (value && this.regex.test(value)) {
568
+ style.setProperty(prop, this.replaceInText(value), style.getPropertyPriority(prop));
569
+ count++;
570
+ }
571
+ }
572
+ }
573
+ if ('cssRules' in rule) {
574
+ const rules = rule;
575
+ for (const r of Array.from(rules.cssRules || [])) {
576
+ count += this.processRule(r);
577
+ }
578
+ }
579
+ return count;
580
+ }
581
+ processStylesheets() {
582
+ let total = 0;
583
+ Array.from(document.styleSheets).forEach((sheet) => {
584
+ try {
585
+ // Bỏ qua external CSS (cross-origin)
586
+ if (sheet.href && !sheet.href.startsWith(location.origin)) {
587
+ console.log('[Iframe] Skipping external CSS:', sheet.href);
588
+ return;
589
+ }
590
+ const rules = sheet.cssRules || sheet.rules;
591
+ if (!rules)
592
+ return;
593
+ for (const rule of Array.from(rules)) {
594
+ total += this.processRule(rule);
595
+ }
596
+ }
597
+ catch (e) {
598
+ console.warn('[Iframe] Cannot read stylesheet (CORS?):', e.message);
599
+ }
600
+ });
601
+ console.log(`[Iframe] Replaced ${total} rules in stylesheets`);
602
+ return total;
603
+ }
604
+ async processLinkedStylesheets() {
605
+ const links = document.querySelectorAll('link[rel="stylesheet"]');
606
+ let count = 0;
607
+ for (const link of Array.from(links)) {
608
+ if (!link.href.startsWith(location.origin)) {
609
+ console.log('[Iframe] Skipping external CSS:', link.href);
610
+ continue;
611
+ }
612
+ try {
613
+ const res = await fetch(link.href);
614
+ let css = await res.text();
615
+ if (this.regex.test(css)) {
616
+ css = this.replaceInText(css);
617
+ const style = document.createElement('style');
618
+ style.textContent = css;
619
+ style.dataset.originalHref = link.href;
620
+ link.parentNode?.insertBefore(style, link);
621
+ link.remove();
622
+ count++;
623
+ }
624
+ }
625
+ catch (e) {
626
+ console.warn('[Iframe] Cannot load CSS:', link.href, e);
627
+ }
628
+ }
629
+ console.log(`[Iframe] Replaced ${count} linked CSS files`);
630
+ return count;
631
+ }
632
+ getFinalHeight() {
633
+ // Trigger reflow
634
+ void document.body.offsetHeight;
635
+ return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight, document.documentElement.clientHeight);
636
+ }
637
+ notifyParent(height) {
638
+ window.parent.postMessage({
639
+ type: 'IFRAME_HEIGHT_CALCULATED',
640
+ height,
641
+ width: document.body.scrollWidth,
642
+ }, '*');
643
+ console.log('[Iframe] Sent height to parent:', height);
644
+ }
645
+ async waitForResources() {
646
+ if ('fonts' in document) {
647
+ await document.fonts.ready;
648
+ }
649
+ const images = Array.from(document.images).filter((img) => !img.complete);
650
+ if (images.length > 0) {
651
+ await Promise.all(images.map((img) => new Promise((resolve) => {
652
+ img.onload = img.onerror = resolve;
653
+ })));
654
+ }
655
+ }
656
+ async run() {
657
+ try {
658
+ this.processInlineStyles();
659
+ this.processStyleTags();
660
+ this.processStylesheets();
661
+ await this.processLinkedStylesheets();
662
+ // await this.waitForResources();
663
+ requestAnimationFrame(() => {
664
+ const height = this.getFinalHeight();
665
+ this.notifyParent(height);
666
+ });
667
+ }
668
+ catch (err) {
669
+ console.error('[Iframe] Critical error:', err);
670
+ this.notifyParent(document.body.scrollHeight || 1000);
671
+ }
672
+ }
673
+ init() {
674
+ if (document.readyState === 'loading') {
675
+ document.addEventListener('DOMContentLoaded', () => this.run());
676
+ }
677
+ else {
678
+ this.run();
679
+ }
680
+ }
681
+ }
682
+
683
+ var viewportReplacer = /*#__PURE__*/Object.freeze({
684
+ __proto__: null,
685
+ default: ViewportUnitsReplacer
686
+ });
687
+
688
+ const scrollToElementIfNeeded = (visualRef, rect, scale) => {
689
+ if (!visualRef.current)
233
690
  return;
234
691
  const visualRect = visualRef.current.getBoundingClientRect();
235
692
  if (isElementInViewport(rect, visualRef, scale)) {
@@ -245,13 +702,14 @@ const scrollToElementIfNeeded = (visualRef, wrapperRef, rect, scale) => {
245
702
  behavior: 'smooth',
246
703
  });
247
704
  };
248
- const useClickedElement = ({ visualRef, wrapperRef, getRect }) => {
705
+ const useClickedElement = ({ visualRef, getRect }) => {
249
706
  const heatmapInfo = useHeatmapDataStore((state) => state.dataInfo);
250
707
  const selectedElement = useHeatmapInteractionStore((state) => state.selectedElement);
708
+ const shouldShowCallout = useHeatmapInteractionStore((state) => state.shouldShowCallout);
709
+ const setShouldShowCallout = useHeatmapInteractionStore((state) => state.setShouldShowCallout);
251
710
  const scale = useHeatmapVizStore((state) => state.scale);
252
711
  const [clickedElement, setClickedElement] = useState(null);
253
712
  const [showMissingElement, setShowMissingElement] = useState(false);
254
- const [shouldShowCallout, setShouldShowCallout] = useState(false);
255
713
  const reset = () => {
256
714
  setClickedElement(null);
257
715
  setShowMissingElement(false);
@@ -267,26 +725,58 @@ const useClickedElement = ({ visualRef, wrapperRef, getRect }) => {
267
725
  setClickedElement(null);
268
726
  return;
269
727
  }
270
- const rect = getRect({ hash: selectedElement, selector: info.selector });
271
- const rank = calculateElementRank(selectedElement, heatmapInfo.sortedElements);
272
- const elementInfo = buildElementInfo(selectedElement, info, rect, rank);
273
- if (rect) {
274
- setShowMissingElement(false);
275
- scrollToElementIfNeeded(visualRef, wrapperRef, rect, scale);
276
- setShouldShowCallout(true);
277
- requestAnimationFrame(() => {
278
- setClickedElement(elementInfo);
279
- });
280
- }
281
- else {
728
+ const hash = selectedElement;
729
+ const selector = info.selector;
730
+ const rect = getRect({ hash: selectedElement, selector });
731
+ const elementInfo = buildElementInfo(hash, rect, heatmapInfo);
732
+ if (!rect) {
282
733
  setClickedElement(elementInfo);
283
734
  setShowMissingElement(true);
284
735
  setShouldShowCallout(false);
736
+ return;
285
737
  }
286
- }, [selectedElement, heatmapInfo, getRect, visualRef, wrapperRef, scale]);
738
+ setShowMissingElement(false);
739
+ scrollToElementIfNeeded(visualRef, rect, scale);
740
+ setShouldShowCallout(true);
741
+ requestAnimationFrame(() => {
742
+ setClickedElement(elementInfo);
743
+ });
744
+ }, [selectedElement, heatmapInfo, getRect, visualRef, scale]);
287
745
  return { clickedElement, showMissingElement, shouldShowCallout, setShouldShowCallout };
288
746
  };
289
747
 
748
+ const useElementCalloutVisible = ({ visualRef, getRect }) => {
749
+ const scale = useHeatmapVizStore((state) => state.scale);
750
+ const selectedElement = useHeatmapInteractionStore((state) => state.selectedElement);
751
+ const heatmapInfo = useHeatmapDataStore((state) => state.dataInfo);
752
+ const setShouldShowCallout = useHeatmapInteractionStore((state) => state.setShouldShowCallout);
753
+ useEffect(() => {
754
+ if (!selectedElement)
755
+ return;
756
+ const elementIsInViewportFn = () => {
757
+ const elementInfo = heatmapInfo?.elementMapInfo?.[selectedElement];
758
+ if (!elementInfo)
759
+ return;
760
+ const rect = getRect({ hash: selectedElement, selector: elementInfo.selector });
761
+ const isInViewport = isElementInViewport(rect, visualRef, scale);
762
+ setShouldShowCallout(isInViewport);
763
+ };
764
+ elementIsInViewportFn();
765
+ const handleUpdate = () => {
766
+ requestAnimationFrame(elementIsInViewportFn);
767
+ };
768
+ window.addEventListener('scroll', handleUpdate, true);
769
+ window.addEventListener('resize', handleUpdate);
770
+ visualRef?.current?.addEventListener('scroll', handleUpdate);
771
+ return () => {
772
+ window.removeEventListener('scroll', handleUpdate, true);
773
+ window.removeEventListener('resize', handleUpdate);
774
+ visualRef?.current?.removeEventListener('scroll', handleUpdate);
775
+ };
776
+ }, [selectedElement, visualRef, getRect, scale, heatmapInfo, setShouldShowCallout]);
777
+ return {};
778
+ };
779
+
290
780
  const useHeatmapEffects = ({ isVisible, isElementSidebarOpen, setShouldShowCallout, resetAll, }) => {
291
781
  const selectedElement = useHeatmapInteractionStore((state) => state.selectedElement);
292
782
  // Reset khi ẩn
@@ -305,32 +795,6 @@ const useHeatmapEffects = ({ isVisible, isElementSidebarOpen, setShouldShowCallo
305
795
  }, [isElementSidebarOpen, selectedElement, setShouldShowCallout]);
306
796
  };
307
797
 
308
- function getElementLayout(element) {
309
- if (!element?.getBoundingClientRect)
310
- return null;
311
- const rect = element.getBoundingClientRect();
312
- if (rect.width === 0 && rect.height === 0)
313
- return null;
314
- return {
315
- top: rect.top,
316
- left: rect.left,
317
- width: rect.width,
318
- height: rect.height,
319
- };
320
- }
321
- function formatPercentage(value, decimals = 2) {
322
- return value.toFixed(decimals);
323
- }
324
- function calculateRankPosition(rect, widthScale) {
325
- const top = rect.top <= 18 ? rect.top + 3 : rect.top - 18;
326
- const left = rect.left <= 18 ? rect.left + 3 : rect.left - 18;
327
- return {
328
- transform: `scale(${1.2 * widthScale})`,
329
- top: Number.isNaN(top) ? undefined : top,
330
- left: Number.isNaN(left) ? undefined : left,
331
- };
332
- }
333
-
334
798
  const useHeatmapElementPosition = ({ iframeRef, wrapperRef, visualizer }) => {
335
799
  const widthScale = useHeatmapVizStore((state) => state.scale);
336
800
  const iframeHeight = useHeatmapVizStore((state) => state.iframeHeight);
@@ -380,70 +844,63 @@ const debounce = (fn, delay) => {
380
844
  timeout = setTimeout(() => fn(...args), delay);
381
845
  };
382
846
  };
847
+
383
848
  const useHoveredElement = ({ iframeRef, getRect }) => {
384
849
  const hoveredElement = useHeatmapInteractionStore((state) => state.hoveredElement);
385
850
  const setHoveredElement = useHeatmapInteractionStore((state) => state.setHoveredElement);
386
851
  const onSelect = useHeatmapInteractionStore((state) => state.setSelectedElement);
387
852
  const widthScale = useHeatmapVizStore((state) => state.scale);
388
853
  const heatmapInfo = useHeatmapDataStore((state) => state.dataInfo);
389
- const handleMouseMove = useCallback(debounce((event) => {
390
- if (!iframeRef.current?.contentDocument || !heatmapInfo?.elementMapInfo) {
391
- setHoveredElement(null);
854
+ const reset = useCallback(() => {
855
+ setHoveredElement(null);
856
+ }, [setHoveredElement]);
857
+ const handleMouseLeave = useCallback(() => {
858
+ reset();
859
+ }, [reset]);
860
+ const getHashFromEvent = useCallback((event) => {
861
+ if (!heatmapInfo || !isIframeReady(iframeRef, heatmapInfo)) {
862
+ reset();
392
863
  return;
393
864
  }
394
865
  const iframe = iframeRef.current;
395
- const iframeRect = iframe.getBoundingClientRect();
396
- let x = event.clientX - iframeRect.left;
397
- let y = event.clientY - iframeRect.top;
398
- if (widthScale !== 1) {
399
- x /= widthScale;
400
- y /= widthScale;
401
- }
402
866
  const doc = iframe.contentDocument;
403
- if (!doc) {
404
- setHoveredElement(null);
867
+ const iframeRect = iframe.getBoundingClientRect();
868
+ const { x, y } = convertViewportToIframeCoords(event.clientX, event.clientY, iframeRect, widthScale);
869
+ const targetElement = findTargetElement(doc, x, y);
870
+ if (!targetElement || !isValidElement(targetElement, heatmapInfo)) {
871
+ reset();
405
872
  return;
406
873
  }
407
- let targetElement = getElementAtPoint(doc, x, y) || null;
408
- if (!targetElement) {
409
- targetElement = doc.elementFromPoint(x, y);
410
- }
411
- if (!targetElement) {
412
- setHoveredElement(null);
874
+ const hash = getElementHash(targetElement);
875
+ if (!!hash)
876
+ return hash;
877
+ reset();
878
+ return;
879
+ }, [heatmapInfo, iframeRef, getRect, widthScale, reset]);
880
+ const handleMouseMove = useCallback(debounce((event) => {
881
+ if (!heatmapInfo) {
882
+ reset();
413
883
  return;
414
884
  }
415
- const hash = targetElement.getAttribute('data-clarity-hash') ||
416
- targetElement.getAttribute('data-clarity-hashalpha') ||
417
- targetElement.getAttribute('data-clarity-hashbeta');
418
- if (!hash || !heatmapInfo.elementMapInfo[hash]) {
419
- setHoveredElement(null);
885
+ const hash = getHashFromEvent(event);
886
+ if (!hash) {
887
+ reset();
420
888
  return;
421
889
  }
422
- const info = heatmapInfo.elementMapInfo[hash];
423
- const position = getRect({ hash, selector: info.selector });
424
- if (position && heatmapInfo.sortedElements) {
425
- const rank = heatmapInfo.sortedElements.findIndex((e) => e.hash === hash) + 1;
426
- setHoveredElement({
427
- ...position,
428
- hash,
429
- clicks: info.totalclicks ?? 0,
430
- rank,
431
- selector: info.selector ?? '',
432
- });
433
- }
434
- else {
435
- setHoveredElement(null);
890
+ const selector = heatmapInfo?.elementMapInfo?.[hash];
891
+ const rect = getRect({ hash, selector });
892
+ const elementInfo = buildElementInfo(hash, rect, heatmapInfo);
893
+ if (!elementInfo) {
894
+ reset();
895
+ return;
436
896
  }
437
- }, 16), // ~60fps
438
- [iframeRef, heatmapInfo, getRect]);
439
- const handleMouseLeave = useCallback(() => {
440
- setHoveredElement(null);
441
- }, []);
897
+ setHoveredElement(elementInfo);
898
+ }, 16), [heatmapInfo, getRect, reset, getHashFromEvent]);
442
899
  const handleClick = useCallback(() => {
443
- if (hoveredElement?.hash && onSelect) {
444
- onSelect(hoveredElement.hash);
445
- }
446
- }, [hoveredElement, onSelect]);
900
+ if (!hoveredElement?.hash)
901
+ return;
902
+ onSelect(hoveredElement.hash);
903
+ }, [hoveredElement?.hash]);
447
904
  return {
448
905
  hoveredElement,
449
906
  handleMouseMove,
@@ -451,41 +908,54 @@ const useHoveredElement = ({ iframeRef, getRect }) => {
451
908
  handleClick,
452
909
  };
453
910
  };
454
- const getElementAtPoint = (doc, x, y) => {
455
- let el = null;
456
- if ('caretPositionFromPoint' in doc) {
457
- el = doc.caretPositionFromPoint(x, y)?.offsetNode ?? null;
911
+ const convertViewportToIframeCoords = (clientX, clientY, iframeRect, scale) => {
912
+ let x = clientX - iframeRect.left;
913
+ let y = clientY - iframeRect.top;
914
+ if (scale !== 1) {
915
+ x /= scale;
916
+ y /= scale;
458
917
  }
459
- el = el ?? doc.elementFromPoint(x, y);
460
- let element = el;
461
- while (element && element.nodeType === Node.TEXT_NODE) {
462
- element = element.parentElement;
918
+ return { x, y };
919
+ };
920
+ const findTargetElement = (doc, x, y) => {
921
+ let targetElement = getElementAtPoint(doc, x, y);
922
+ if (!targetElement) {
923
+ targetElement = doc.elementFromPoint(x, y);
463
924
  }
464
- return element;
925
+ return targetElement;
926
+ };
927
+ const isIframeReady = (iframeRef, heatmapInfo) => {
928
+ return !!(iframeRef.current?.contentDocument && heatmapInfo?.elementMapInfo);
929
+ };
930
+ const isValidElement = (element, heatmapInfo) => {
931
+ if (!element)
932
+ return false;
933
+ const hash = getElementHash(element);
934
+ if (!hash)
935
+ return false;
936
+ return !!heatmapInfo?.elementMapInfo?.[hash];
465
937
  };
466
938
 
467
939
  const useHeatmapRender = () => {
468
940
  const data = useHeatmapDataStore((state) => state.data);
469
941
  const config = useHeatmapDataStore((state) => state.config);
470
942
  const setVizRef = useHeatmapVizStore((state) => state.setVizRef);
943
+ const setIsRenderViz = useHeatmapVizStore((state) => state.setIsRenderViz);
944
+ const setIframeHeight = useHeatmapVizStore((state) => state.setIframeHeight);
471
945
  const iframeRef = useRef(null);
472
946
  const renderHeatmap = useCallback(async (payloads) => {
473
947
  if (!payloads || payloads.length === 0)
474
948
  return;
949
+ setIsRenderViz(false);
475
950
  const visualizer = new Visualizer();
476
951
  const iframe = iframeRef.current;
477
952
  if (!iframe?.contentWindow)
478
953
  return;
479
954
  await visualizer.html(payloads, iframe.contentWindow);
480
- // iframe.contentDocument?.body.querySelectorAll('body>*').forEach((element) => {
481
- // console.log(`🚀 🐥 ~ useHeatmapRender ~ element:`, element);
482
- // const isClosedEcomsendWidget = element.closest('#ecomsend-widget');
483
- // const isEcomsendWidget = element.querySelector('#ecomsend-widget');
484
- // const isRemove = !(isClosedEcomsendWidget || isEcomsendWidget);
485
- // if (isRemove) {
486
- // element.remove();
487
- // }
488
- // });
955
+ reset(iframe, payloads, (height) => {
956
+ height && setIframeHeight(height);
957
+ setIsRenderViz(true);
958
+ });
489
959
  setVizRef(visualizer);
490
960
  }, []);
491
961
  useEffect(() => {
@@ -500,6 +970,50 @@ const useHeatmapRender = () => {
500
970
  iframeRef,
501
971
  };
502
972
  };
973
+ function sort(a, b) {
974
+ return a.time - b.time;
975
+ }
976
+ function findLastSizeOfDom(data) {
977
+ const firstDoc = data.find((item) => item.envelope.sequence === 1)?.doc;
978
+ const docSorted = firstDoc?.sort(sort);
979
+ const firstEvent = docSorted?.[0];
980
+ const docSize = {
981
+ width: firstEvent?.data.width,
982
+ height: firstEvent?.data.height,
983
+ };
984
+ const newData = JSON.parse(JSON.stringify(data));
985
+ const reversedData = newData.reverse();
986
+ const lastResizeEvent = reversedData.find((item) => !!item.resize);
987
+ const firstEventResize = lastResizeEvent?.resize?.[0];
988
+ const resize = {
989
+ width: firstEventResize?.data.width,
990
+ height: firstEventResize?.data.height,
991
+ };
992
+ return {
993
+ doc: docSize,
994
+ resize: resize,
995
+ size: {
996
+ width: resize.width ?? docSize.width,
997
+ height: resize.height ?? docSize.height,
998
+ },
999
+ };
1000
+ }
1001
+ function reset(iframe, payloads, onSuccess) {
1002
+ const { size } = findLastSizeOfDom(payloads);
1003
+ const docWidth = size.width ?? 0;
1004
+ const docHeight = size.height ?? 0;
1005
+ const viewportFixer = initViewportFixer({
1006
+ targetWidth: docWidth,
1007
+ targetHeight: docHeight,
1008
+ iframe: iframe,
1009
+ onSuccess: (data) => {
1010
+ onSuccess(data.height);
1011
+ iframe.height = `${data.height}px`;
1012
+ },
1013
+ });
1014
+ viewportFixer.recalculate();
1015
+ return iframe;
1016
+ }
503
1017
 
504
1018
  function isMobileDevice(userAgent) {
505
1019
  if (!userAgent)
@@ -671,24 +1185,24 @@ const useContainerDimensions = (props) => {
671
1185
  return { containerWidth, containerHeight };
672
1186
  };
673
1187
 
674
- const useContentDimensions = (props) => {
675
- const contentWidth = useHeatmapDataStore((state) => state.config?.width ?? 0);
676
- const { iframeRef } = props;
1188
+ const useContentDimensions = ({ iframeRef, }) => {
1189
+ const config = useHeatmapDataStore((state) => state.config);
1190
+ const contentWidth = config?.width ?? 0;
677
1191
  useEffect(() => {
678
1192
  if (!contentWidth)
679
1193
  return;
680
1194
  if (!iframeRef.current)
681
1195
  return;
682
- iframeRef.current.width = `${contentWidth}px`;
1196
+ // iframeRef.current.width = `${contentWidth}px`;
683
1197
  }, [contentWidth, iframeRef]);
684
1198
  return { contentWidth };
685
1199
  };
686
1200
 
687
- // Hook 3: Iframe Height Observer
688
1201
  const useIframeHeight = (props) => {
689
- const { iframeRef, contentWidth } = props;
1202
+ const { iframeRef } = props;
690
1203
  const iframeHeight = useHeatmapVizStore((state) => state.iframeHeight);
691
1204
  const setIframeHeight = useHeatmapVizStore((state) => state.setIframeHeight);
1205
+ const isRenderViz = useHeatmapVizStore((state) => state.isRenderViz);
692
1206
  const resizeObserverRef = useRef(null);
693
1207
  const mutationObserverRef = useRef(null);
694
1208
  const updateIframeHeight = useCallback(() => {
@@ -710,17 +1224,9 @@ const useIframeHeight = (props) => {
710
1224
  console.warn('Cannot measure iframe content:', error);
711
1225
  }
712
1226
  }, [iframeRef, setIframeHeight]);
713
- useEffect(() => {
714
- if (contentWidth > 0) {
715
- const timeoutId = setTimeout(() => {
716
- updateIframeHeight();
717
- }, 100);
718
- return () => clearTimeout(timeoutId);
719
- }
720
- }, [contentWidth]);
721
1227
  useEffect(() => {
722
1228
  const iframe = iframeRef.current;
723
- if (!iframe)
1229
+ if (!iframe || !isRenderViz)
724
1230
  return;
725
1231
  const setupObservers = () => {
726
1232
  try {
@@ -772,7 +1278,7 @@ const useIframeHeight = (props) => {
772
1278
  }
773
1279
  iframe.removeEventListener('load', setupObservers);
774
1280
  };
775
- }, [iframeRef, updateIframeHeight]);
1281
+ }, [iframeRef, isRenderViz, updateIframeHeight]);
776
1282
  return { iframeHeight };
777
1283
  };
778
1284
 
@@ -802,7 +1308,6 @@ const useScrollSync = ({ iframeRef }) => {
802
1308
  if (iframeWindow && iframeDocument) {
803
1309
  const iframeScrollTop = scrollTop / widthScale;
804
1310
  iframe.style.top = `${iframeScrollTop}px`;
805
- // iframeWindow.scrollTo({ top: iframeScrollTop, behavior: 'smooth' });
806
1311
  }
807
1312
  }
808
1313
  catch (error) {
@@ -813,13 +1318,14 @@ const useScrollSync = ({ iframeRef }) => {
813
1318
  };
814
1319
 
815
1320
  const useHeatmapScale = (props) => {
1321
+ // const iframeHeight = useHeatmapVizStore((state) => state.iframeHeight);
816
1322
  const { wrapperRef, iframeRef, visualRef } = props;
817
1323
  // 1. Observe container dimensions
818
1324
  const { containerWidth, containerHeight } = useContainerDimensions({ wrapperRef });
819
1325
  // 2. Get content dimensions from config
820
1326
  const { contentWidth } = useContentDimensions({ iframeRef });
821
1327
  // 3. Observe iframe height (now reacts to width changes)
822
- const { iframeHeight } = useIframeHeight({ iframeRef, contentWidth });
1328
+ const { iframeHeight } = useIframeHeight({ iframeRef });
823
1329
  // 4. Calculate scale
824
1330
  const { scale } = useScaleCalculation({ containerWidth, contentWidth });
825
1331
  // 5. Setup scroll sync
@@ -834,7 +1340,7 @@ const useHeatmapScale = (props) => {
834
1340
  };
835
1341
  };
836
1342
 
837
- const BoxStack = ({ children, ...props }) => {
1343
+ const BoxStack = forwardRef(({ children, ...props }, ref) => {
838
1344
  const id = props.id;
839
1345
  const flexDirection = props.flexDirection;
840
1346
  const overflow = props.overflow || 'hidden';
@@ -845,6 +1351,9 @@ const BoxStack = ({ children, ...props }) => {
845
1351
  const style = props.style || {};
846
1352
  const gap = props.gap || 0;
847
1353
  const height = props.height || 'auto';
1354
+ const isZIndexDefined = typeof props.zIndex !== undefined;
1355
+ const zIndex = props.zIndex;
1356
+ const backgroundColor = props.backgroundColor || 'transparent';
848
1357
  const styleGap = useMemo(() => {
849
1358
  switch (flexDirection) {
850
1359
  case 'row':
@@ -866,15 +1375,17 @@ const BoxStack = ({ children, ...props }) => {
866
1375
  justifyContent,
867
1376
  alignItems,
868
1377
  height,
1378
+ backgroundColor,
1379
+ ...(isZIndexDefined ? { zIndex } : {}),
869
1380
  ...styleGap,
870
1381
  ...style,
871
1382
  };
872
- return (jsx("div", { id: id, style: styleProps, children: children }));
873
- };
1383
+ return (jsx("div", { id: id, style: styleProps, ref: ref, children: children }));
1384
+ });
874
1385
 
875
1386
  const ContentTopBar = () => {
876
1387
  const controls = useHeatmapControlStore((state) => state.controls);
877
- return (jsx(BoxStack, { id: "gx-hm-content-header", flexDirection: "row", alignItems: "center", overflow: "auto", style: {
1388
+ return (jsx(BoxStack, { id: "gx-hm-content-header", flexDirection: "row", alignItems: "center", overflow: "auto", zIndex: 1, backgroundColor: "white", style: {
878
1389
  borderBottom: `${HEATMAP_CONFIG.borderWidth}px solid ${HEATMAP_CONFIG.borderColor}`,
879
1390
  }, children: controls.TopBar ?? null }));
880
1391
  };
@@ -912,110 +1423,14 @@ const useHeatmapVizCanvas = ({ type }) => {
912
1423
  return heatmapRender?.();
913
1424
  };
914
1425
 
915
- const CLICKED_ELEMENT_ID = 'clickedElement';
916
- const SECONDARY_CLICKED_ELEMENT_ID = 'secondaryClickedElementID';
917
- const HOVERED_ELEMENT_ID = 'hoveredElement';
918
- const SECONDARY_HOVERED_ELEMENT_ID = 'secondaryhoveredElementID';
919
-
920
- const ElementCallout = (props) => {
921
- const { element, target, isSecondary, language, wrapperRef } = props;
922
- const heatmapInfo = useHeatmapDataStore((state) => state.dataInfo);
923
- const calloutRef = useRef(null);
924
- const [position, setPosition] = useState({
925
- top: 0,
926
- left: 0,
927
- placement: 'top',
928
- });
929
- const totalClicks = heatmapInfo?.totalClicks ?? 1;
930
- const percentage = formatPercentage(((element.clicks ?? 0) / totalClicks) * 100, 2);
931
- useEffect(() => {
932
- const targetElement = document.querySelector(target);
933
- const calloutElement = calloutRef.current;
934
- if (!targetElement || !calloutElement)
935
- return;
936
- const calculatePosition = () => {
937
- const targetRect = targetElement.getBoundingClientRect();
938
- const calloutRect = calloutElement.getBoundingClientRect();
939
- const viewportWidth = window.innerWidth;
940
- const viewportHeight = window.innerHeight;
941
- const padding = 12; // Space between target and callout
942
- const arrowSize = 8;
943
- let top = 0;
944
- let left = 0;
945
- let placement = 'top';
946
- const positions = [
947
- {
948
- placement: 'top',
949
- top: targetRect.top - calloutRect.height - padding - arrowSize,
950
- left: targetRect.left + targetRect.width / 2 - calloutRect.width / 2,
951
- valid: targetRect.top - calloutRect.height - padding - arrowSize > 0,
952
- },
953
- {
954
- placement: 'bottom',
955
- top: targetRect.bottom + padding + arrowSize,
956
- left: targetRect.left + targetRect.width / 2 - calloutRect.width / 2,
957
- valid: targetRect.bottom + calloutRect.height + padding + arrowSize < viewportHeight,
958
- },
959
- {
960
- placement: 'right',
961
- top: targetRect.top + targetRect.height / 2 - calloutRect.height / 2,
962
- left: targetRect.right + padding + arrowSize,
963
- valid: targetRect.right + calloutRect.width + padding + arrowSize < viewportWidth,
964
- },
965
- {
966
- placement: 'left',
967
- top: targetRect.top + targetRect.height / 2 - calloutRect.height / 2,
968
- left: targetRect.left - calloutRect.width - padding - arrowSize,
969
- valid: targetRect.left - calloutRect.width - padding - arrowSize > 0,
970
- },
971
- ];
972
- // Find first valid position
973
- const validPosition = positions.find((p) => p.valid) || positions[0];
974
- top = validPosition.top;
975
- left = validPosition.left;
976
- placement = validPosition.placement;
977
- // Keep within viewport bounds
978
- left = Math.max(padding, Math.min(left, viewportWidth - calloutRect.width - padding));
979
- top = Math.max(padding, Math.min(top, viewportHeight - calloutRect.height - padding));
980
- setPosition({ top, left, placement });
981
- };
982
- calculatePosition();
983
- const handleUpdate = () => {
984
- requestAnimationFrame(calculatePosition);
985
- };
986
- window.addEventListener('scroll', handleUpdate, true);
987
- window.addEventListener('resize', handleUpdate);
988
- wrapperRef?.current?.addEventListener('scroll', handleUpdate);
989
- return () => {
990
- window.removeEventListener('scroll', handleUpdate, true);
991
- window.removeEventListener('resize', handleUpdate);
992
- wrapperRef?.current?.removeEventListener('scroll', handleUpdate);
993
- };
994
- }, [target, wrapperRef]);
995
- const calloutContent = (jsxs("div", { ref: calloutRef, className: `clarity-callout clarity-callout--${position.placement} ${isSecondary ? 'clarity-callout--secondary' : ''}`, style: {
996
- position: 'fixed',
997
- top: position.top,
998
- left: position.left,
999
- zIndex: 2147483647,
1000
- }, "aria-live": "assertive", role: "tooltip", children: [jsx("div", { className: "clarity-callout__arrow" }), jsx("div", { className: "clarity-callout__content", children: 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, "%)"] })] })] }) })] }));
1001
- return createPortal(calloutContent, document.getElementById('gx-hm-viz-container'));
1002
- };
1426
+ const CLICKED_ELEMENT_ID = 'gx-hm-clicked-element';
1427
+ const SECONDARY_CLICKED_ELEMENT_ID = 'gx-hm-secondary-clicked-element';
1428
+ const HOVERED_ELEMENT_ID = 'gx-hm-hovered-element';
1429
+ const SECONDARY_HOVERED_ELEMENT_ID = 'gx-hm-secondary-hovered-element';
1003
1430
 
1004
1431
  const RankBadge = ({ index, elementRect, widthScale, clickOnElement, }) => {
1005
1432
  const style = calculateRankPosition(elementRect, widthScale);
1006
- return (jsx("div", { className: "rankBadge", style: style, onClick: clickOnElement, "data-testid": "elementRank", children: index }));
1007
- };
1008
-
1009
- const ClickedElementOverlay = ({ element, shouldShowCallout, isSecondary, targetId, }) => {
1010
- const widthScale = useHeatmapVizStore((state) => state.scale);
1011
- if (!element || (element.width === 0 && element.height === 0))
1012
- return null;
1013
- return (jsxs(Fragment$1, { children: [jsx("div", { className: "heatmapElement", id: targetId, style: {
1014
- top: element.top,
1015
- left: element.left,
1016
- width: element.width,
1017
- height: element.height,
1018
- } }), jsx(RankBadge, { index: element.rank, elementRect: element, widthScale: widthScale }), shouldShowCallout && (jsx(ElementCallout, { element: element, target: `#${targetId}`, isSecondary: isSecondary }))] }));
1433
+ return (jsx("div", { className: "gx-hm-rank-badge", style: style, onClick: clickOnElement, children: index }));
1019
1434
  };
1020
1435
 
1021
1436
  const NUMBER_OF_TOP_ELEMENTS = 10;
@@ -1033,27 +1448,57 @@ const DefaultRankBadges = ({ getRect, hidden }) => {
1033
1448
  }) }));
1034
1449
  };
1035
1450
 
1036
- const HoveredElementOverlay = ({ element, onClick, isSecondary, targetId, }) => {
1037
- if (!element)
1038
- return null;
1039
- // Iframe has border, so we need to add it to the top position
1040
- const top = element.top + HEATMAP_CONFIG['borderWidthIframe'];
1041
- const left = element.left + HEATMAP_CONFIG['borderWidthIframe'];
1042
- const width = element.width;
1043
- const height = element.height;
1044
- return (jsxs(Fragment$1, { children: [jsx("div", { onClick: onClick, className: "heatmapElement hovered", id: targetId, style: {
1045
- top,
1046
- left,
1047
- width,
1048
- height,
1049
- cursor: 'pointer',
1050
- } }), jsx(RankBadge, { index: element.rank, elementRect: element, widthScale: 1, clickOnElement: onClick })] }));
1451
+ const DEFAULT_POSITION = {
1452
+ top: 0,
1453
+ left: 0,
1454
+ placement: 'top',
1455
+ horizontalAlign: 'center',
1456
+ };
1457
+ const ElementCallout = (props) => {
1458
+ const CompElementCallout = useHeatmapControlStore((state) => state.controls.ElementCallout);
1459
+ const { element, target, visualRef, hozOffset, alignment = 'left' } = props;
1460
+ const calloutRef = useRef(null);
1461
+ const [position, setPosition] = useState(DEFAULT_POSITION);
1462
+ useEffect(() => {
1463
+ const targetElm = document.querySelector(target);
1464
+ const calloutElm = calloutRef.current;
1465
+ if (!targetElm || !calloutElm)
1466
+ return;
1467
+ const positionFn = calcCalloutPosition({
1468
+ targetElm,
1469
+ calloutElm,
1470
+ setPosition,
1471
+ hozOffset,
1472
+ alignment,
1473
+ });
1474
+ positionFn();
1475
+ const handleUpdate = () => {
1476
+ requestAnimationFrame(positionFn);
1477
+ };
1478
+ window.addEventListener('scroll', handleUpdate, true);
1479
+ window.addEventListener('resize', handleUpdate);
1480
+ visualRef?.current?.addEventListener('scroll', handleUpdate);
1481
+ return () => {
1482
+ window.removeEventListener('scroll', handleUpdate, true);
1483
+ window.removeEventListener('resize', handleUpdate);
1484
+ visualRef?.current?.removeEventListener('scroll', handleUpdate);
1485
+ };
1486
+ }, [target, visualRef, hozOffset, alignment]);
1487
+ const calloutContent = (jsx("div", { ref: calloutRef, className: `clarity-callout clarity-callout--${position.placement} clarity-callout--align-${position.horizontalAlign}`, style: {
1488
+ position: 'fixed',
1489
+ top: position.top,
1490
+ left: position.left,
1491
+ zIndex: 2147483647,
1492
+ }, "aria-live": "assertive", role: "tooltip", children: CompElementCallout && jsx(CompElementCallout, { elementHash: element.hash }) }));
1493
+ return createPortal(calloutContent, document.getElementById('gx-hm-viz-container'));
1051
1494
  };
1052
1495
 
1053
- const MissingElementMessage = () => {
1496
+ const ElementMissing = ({ show = true }) => {
1497
+ if (!show)
1498
+ return null;
1054
1499
  const widthScale = useHeatmapVizStore((state) => state.scale);
1055
1500
  return (jsx("div", { className: "missingElement", style: {
1056
- position: 'absolute',
1501
+ position: 'fixed',
1057
1502
  top: '50%',
1058
1503
  left: '50%',
1059
1504
  transform: `translate(-50%, -50%) scale(${1 / widthScale})`,
@@ -1069,6 +1514,39 @@ const MissingElementMessage = () => {
1069
1514
  }, "aria-live": "assertive", children: "Element not visible on current screen" }));
1070
1515
  };
1071
1516
 
1517
+ const TARGET_ID_BY_TYPE = {
1518
+ hovered: {
1519
+ default: HOVERED_ELEMENT_ID,
1520
+ secondary: SECONDARY_HOVERED_ELEMENT_ID,
1521
+ },
1522
+ clicked: {
1523
+ default: CLICKED_ELEMENT_ID,
1524
+ secondary: SECONDARY_CLICKED_ELEMENT_ID,
1525
+ },
1526
+ };
1527
+ const ElementOverlay = ({ type, element, onClick, isSecondary }) => {
1528
+ const widthScale = useHeatmapVizStore((state) => state.scale);
1529
+ if (!element || (element.width === 0 && element.height === 0))
1530
+ return null;
1531
+ // Iframe has border, so we need to add it to the top position
1532
+ const top = element.top + HEATMAP_CONFIG['borderWidthIframe'];
1533
+ const left = element.left + HEATMAP_CONFIG['borderWidthIframe'];
1534
+ const width = element.width;
1535
+ const height = element.height;
1536
+ const targetId = TARGET_ID_BY_TYPE[type][isSecondary ? 'secondary' : 'default'];
1537
+ return (jsxs(Fragment$1, { children: [jsx("div", { onClick: onClick, className: "heatmapElement", id: targetId, style: {
1538
+ top,
1539
+ left,
1540
+ width,
1541
+ height,
1542
+ cursor: 'pointer',
1543
+ } }), jsx(RankBadge, { index: element.rank, elementRect: element, widthScale: type === 'hovered' ? 1 : widthScale, clickOnElement: onClick })] }));
1544
+ };
1545
+
1546
+ const ELEMENT_CALLOUT = {
1547
+ hozOffset: -8,
1548
+ alignment: 'left',
1549
+ };
1072
1550
  const HeatmapElements = (props) => {
1073
1551
  const height = useHeatmapVizStore((state) => state.iframeHeight);
1074
1552
  const { iframeRef, wrapperRef, visualRef, visualizer, iframeDimensions, isElementSidebarOpen, isVisible = true, areDefaultRanksHidden, isSecondary, ...rest } = props;
@@ -1078,15 +1556,17 @@ const HeatmapElements = (props) => {
1078
1556
  visualizer,
1079
1557
  });
1080
1558
  const { clickedElement, showMissingElement, shouldShowCallout, setShouldShowCallout } = useClickedElement({
1081
- iframeRef,
1082
1559
  visualRef,
1083
- wrapperRef,
1084
1560
  getRect,
1085
1561
  });
1086
1562
  const { hoveredElement, handleMouseMove, handleMouseLeave, handleClick } = useHoveredElement({
1087
1563
  iframeRef,
1088
1564
  getRect,
1089
1565
  });
1566
+ useElementCalloutVisible({
1567
+ visualRef,
1568
+ getRect,
1569
+ });
1090
1570
  const resetAll = () => {
1091
1571
  // setShouldShowCallout(false);
1092
1572
  };
@@ -1098,7 +1578,7 @@ const HeatmapElements = (props) => {
1098
1578
  });
1099
1579
  if (!isVisible)
1100
1580
  return null;
1101
- return (jsxs("div", { onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, className: "heatmapElements gx-hm-elements", style: { ...iframeDimensions, height }, children: [jsx(DefaultRankBadges, { getRect: getRect, hidden: areDefaultRanksHidden }), jsx(ClickedElementOverlay, { element: clickedElement, shouldShowCallout: shouldShowCallout, isSecondary: isSecondary, targetId: isSecondary ? SECONDARY_CLICKED_ELEMENT_ID : CLICKED_ELEMENT_ID, ...rest }), showMissingElement && jsx(MissingElementMessage, {}), jsx(HoveredElementOverlay, { element: hoveredElement, onClick: handleClick, isSecondary: isSecondary, targetId: isSecondary ? SECONDARY_HOVERED_ELEMENT_ID : HOVERED_ELEMENT_ID }), hoveredElement?.hash !== clickedElement?.hash && hoveredElement && (jsx(ElementCallout, { element: hoveredElement, target: `#${props.isSecondary ? SECONDARY_HOVERED_ELEMENT_ID : HOVERED_ELEMENT_ID}`, isSecondary: props.isSecondary, wrapperRef: props.wrapperRef }))] }));
1581
+ return (jsxs("div", { onMouseMove: handleMouseMove, onMouseLeave: handleMouseLeave, className: "gx-hm-elements", style: { ...iframeDimensions, height }, children: [jsx(ElementMissing, { show: showMissingElement }), jsx(DefaultRankBadges, { getRect: getRect, hidden: areDefaultRanksHidden }), jsx(ElementOverlay, { type: "clicked", element: clickedElement, isSecondary: isSecondary }), jsx(ElementOverlay, { type: "hovered", element: hoveredElement, isSecondary: isSecondary, onClick: handleClick }), hoveredElement?.hash !== clickedElement?.hash && hoveredElement && (jsx(ElementCallout, { element: hoveredElement, target: `#${HOVERED_ELEMENT_ID}`, visualRef: visualRef, ...ELEMENT_CALLOUT })), shouldShowCallout && clickedElement && (jsx(ElementCallout, { element: clickedElement, target: `#${CLICKED_ELEMENT_ID}`, visualRef: visualRef, ...ELEMENT_CALLOUT }))] }));
1102
1582
  };
1103
1583
 
1104
1584
  const VizElements = ({ iframeRef, visualRef, wrapperRef }) => {
@@ -1218,16 +1698,17 @@ const VizDomRenderer = ({ mode = 'heatmap' }) => {
1218
1698
  const VizDomContainer = () => {
1219
1699
  const controls = useHeatmapControlStore((state) => state.controls);
1220
1700
  const isRendering = useHeatmapDataStore((state) => state.isRendering);
1701
+ const iframeHeight = useHeatmapVizStore((state) => state.iframeHeight);
1221
1702
  if (isRendering)
1222
1703
  return controls.VizLoading ?? null;
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: {
1704
+ return (jsx(BoxStack, { id: "gx-hm-viz-container", flexDirection: "column", flex: "1 1 auto", overflow: "auto", zIndex: 1, children: jsxs(BoxStack, { id: "gx-hm-content", flexDirection: "column", flex: "1 1 auto", overflow: "hidden", style: {
1224
1705
  minWidth: '394px',
1225
- }, children: jsx(VizDomRenderer, {}) }) }));
1706
+ }, children: [jsx(VizDomRenderer, {}), iframeHeight === 0 && (jsxs("div", { className: "gx-hm-loading", children: [jsx("div", { className: "gx-hm-loading--spinner" }), jsx("p", { className: "gx-hm-loading--text", children: "Loading visualization..." })] }))] }) }));
1226
1707
  };
1227
1708
 
1228
1709
  const ContentMetricBar = () => {
1229
1710
  const controls = useHeatmapControlStore((state) => state.controls);
1230
- return (jsx(BoxStack, { id: "gx-hm-content-header", flexDirection: "row", alignItems: "center", overflow: "auto", style: {
1711
+ return (jsx(BoxStack, { id: "gx-hm-content-metric-bar", flexDirection: "row", alignItems: "center", overflow: "auto", zIndex: 1, backgroundColor: "white", style: {
1231
1712
  borderBottom: `${HEATMAP_CONFIG.borderWidth}px solid ${HEATMAP_CONFIG.borderColor}`,
1232
1713
  }, children: controls.MetricBar ?? null }));
1233
1714
  };
@@ -1235,6 +1716,7 @@ const ContentMetricBar = () => {
1235
1716
  const ContentToolbar = () => {
1236
1717
  const controls = useHeatmapControlStore((state) => state.controls);
1237
1718
  return (jsx("div", { id: "gx-hm-content-toolbar", style: {
1719
+ zIndex: 2,
1238
1720
  position: 'absolute',
1239
1721
  bottom: 0,
1240
1722
  left: '8px',
@@ -1255,6 +1737,7 @@ const LeftSidebar = () => {
1255
1737
  return (jsx("div", { className: "gx-hm-sidebar", style: {
1256
1738
  height: '100%',
1257
1739
  display: 'flex',
1740
+ zIndex: 1,
1258
1741
  ...(isHideSidebar
1259
1742
  ? {
1260
1743
  width: '0',