@gemx-dev/heatmap-react 3.5.92-dev.39 → 3.5.92-dev.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/helpers/viewport/element.d.ts +7 -1
- package/dist/esm/helpers/viewport/element.d.ts.map +1 -1
- package/dist/esm/helpers/viz-elm-callout/dimensions.d.ts +1 -0
- package/dist/esm/helpers/viz-elm-callout/dimensions.d.ts.map +1 -1
- package/dist/esm/helpers/viz-elm-callout/position-selector.d.ts.map +1 -1
- package/dist/esm/helpers/viz-elm-callout/viz-elm.d.ts.map +1 -1
- package/dist/esm/hooks/viz-elm/useClickedElement.d.ts.map +1 -1
- package/dist/esm/index.js +1338 -1264
- package/dist/esm/index.mjs +1338 -1264
- package/dist/umd/helpers/viewport/element.d.ts +7 -1
- package/dist/umd/helpers/viewport/element.d.ts.map +1 -1
- package/dist/umd/helpers/viz-elm-callout/dimensions.d.ts +1 -0
- package/dist/umd/helpers/viz-elm-callout/dimensions.d.ts.map +1 -1
- package/dist/umd/helpers/viz-elm-callout/position-selector.d.ts.map +1 -1
- package/dist/umd/helpers/viz-elm-callout/viz-elm.d.ts.map +1 -1
- package/dist/umd/hooks/viz-elm/useClickedElement.d.ts.map +1 -1
- package/dist/umd/index.js +1 -1
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1758,1376 +1758,1444 @@ const HOVERED_ELEMENT_ID_BASE = 'gx-hm-hovered-element';
|
|
|
1758
1758
|
const SECONDARY_HOVERED_ELEMENT_ID_BASE = 'gx-hm-secondary-hovered-element';
|
|
1759
1759
|
const DEFAULT_POSITION_MODE = 'absolute';
|
|
1760
1760
|
|
|
1761
|
-
function
|
|
1762
|
-
if (!
|
|
1763
|
-
return
|
|
1764
|
-
const
|
|
1765
|
-
if (
|
|
1766
|
-
return
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
// Visible viewport range in the scrollable content
|
|
1774
|
-
const viewportTop = scrollTop;
|
|
1775
|
-
const viewportBottom = scrollTop + viewportHeight;
|
|
1776
|
-
// Check if element is within the visible viewport
|
|
1777
|
-
// Element is visible if it overlaps with the viewport
|
|
1778
|
-
return elementBottom > viewportTop && elementTop < viewportBottom;
|
|
1779
|
-
}
|
|
1780
|
-
function isElementRectInViewport(params) {
|
|
1781
|
-
const { candidate, options } = params;
|
|
1782
|
-
const { rectDimensions, visualViewport: visualRect, widthScale: scale, containerRect } = options;
|
|
1783
|
-
if (!visualRect)
|
|
1784
|
-
return false;
|
|
1785
|
-
const { calloutRect, targetAbsoluteRect } = rectDimensions;
|
|
1786
|
-
const { placement, horizontalAlign } = candidate;
|
|
1787
|
-
// candidate position (relative to element) scaled + element absolute position in iframe
|
|
1788
|
-
const offsetTop = (targetAbsoluteRect?.top ?? 0) * scale;
|
|
1789
|
-
const offsetLeft = (targetAbsoluteRect?.left ?? 0) * scale;
|
|
1790
|
-
// Base position in scroll space
|
|
1791
|
-
const baseTop = candidate.top * scale + offsetTop;
|
|
1792
|
-
const baseLeft = candidate.left * scale + offsetLeft;
|
|
1793
|
-
const { width: calloutWidth, height: calloutHeight } = calloutRect;
|
|
1794
|
-
const transformOffsetY = placement === 'top' ? calloutHeight * (scale - 1) : 0;
|
|
1795
|
-
const transformOffsetX = horizontalAlign !== 'left' ? calloutWidth * (scale - 1) : 0;
|
|
1796
|
-
const visualTop = baseTop + transformOffsetY + CALLOUT_ARROW_SIZE;
|
|
1797
|
-
const visualBottom = visualTop + calloutHeight;
|
|
1798
|
-
const visualLeft = baseLeft + transformOffsetX;
|
|
1799
|
-
const visualRight = visualLeft + calloutWidth;
|
|
1800
|
-
// Viewport bounds
|
|
1801
|
-
const scrollTop = visualRect?.scrollTop || 0;
|
|
1802
|
-
const scrollLeft = visualRect?.scrollLeft || 0;
|
|
1803
|
-
const viewportTop = scrollTop;
|
|
1804
|
-
const viewportBottom = scrollTop + visualRect.height;
|
|
1805
|
-
const viewportLeft = scrollLeft;
|
|
1806
|
-
const viewportRight = scrollLeft + (containerRect?.width ?? visualRect.width);
|
|
1807
|
-
// Check if element is fully within the visible viewport
|
|
1808
|
-
const isInVertical = visualTop > viewportTop && visualBottom < viewportBottom;
|
|
1809
|
-
const isInHorizontal = visualLeft > viewportLeft && visualRight < viewportRight;
|
|
1810
|
-
return isInVertical && isInHorizontal;
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
function isMobileDevice(userAgent) {
|
|
1814
|
-
if (!userAgent)
|
|
1815
|
-
return false;
|
|
1816
|
-
return /android|webos|iphone|ipad|ipod|blackberry|windows phone|opera mini|iemobile|mobile|silk|fennec|bada|tizen|symbian|nokia|palmsource|meego|sailfish|kindle|playbook|bb10|rim/i.test(userAgent);
|
|
1761
|
+
function getElementLayout(element) {
|
|
1762
|
+
if (!element?.getBoundingClientRect)
|
|
1763
|
+
return null;
|
|
1764
|
+
const rect = element.getBoundingClientRect();
|
|
1765
|
+
if (rect.width === 0 && rect.height === 0)
|
|
1766
|
+
return null;
|
|
1767
|
+
return {
|
|
1768
|
+
top: rect.top,
|
|
1769
|
+
left: rect.left,
|
|
1770
|
+
width: rect.width,
|
|
1771
|
+
height: rect.height,
|
|
1772
|
+
};
|
|
1817
1773
|
}
|
|
1774
|
+
const getElementRank = (hash, elements) => {
|
|
1775
|
+
if (!elements)
|
|
1776
|
+
return 0;
|
|
1777
|
+
return elements.findIndex((e) => e.hash === hash) + 1;
|
|
1778
|
+
};
|
|
1779
|
+
const buildElementInfo = (hash, rect, heatmapInfo) => {
|
|
1780
|
+
if (!rect || !heatmapInfo)
|
|
1781
|
+
return null;
|
|
1782
|
+
const info = heatmapInfo.clickMapMetrics?.[hash];
|
|
1783
|
+
if (!info)
|
|
1784
|
+
return null;
|
|
1785
|
+
const rank = getElementRank(hash, heatmapInfo.sortedElements);
|
|
1786
|
+
const clicks = info.totalClicked.value ?? 0;
|
|
1787
|
+
const selector = info.selector ?? '';
|
|
1788
|
+
const baseInfo = {
|
|
1789
|
+
hash,
|
|
1790
|
+
clicks,
|
|
1791
|
+
rank,
|
|
1792
|
+
selector,
|
|
1793
|
+
};
|
|
1794
|
+
return {
|
|
1795
|
+
...baseInfo,
|
|
1796
|
+
...rect,
|
|
1797
|
+
};
|
|
1798
|
+
};
|
|
1799
|
+
// GETTERS
|
|
1800
|
+
const getScaledCalloutRect = (_element, _widthScale) => {
|
|
1801
|
+
return {
|
|
1802
|
+
width: 230,
|
|
1803
|
+
height: 268,
|
|
1804
|
+
};
|
|
1805
|
+
};
|
|
1806
|
+
const getStyleFromCandidate = (candidate, widthScale) => {
|
|
1807
|
+
const { horizontalAlign, placement, top, left } = candidate;
|
|
1808
|
+
const yTransformAlign = placement === 'top' ? 'bottom' : 'top';
|
|
1809
|
+
const xTransformAlign = horizontalAlign === 'left' ? 'left' : 'right';
|
|
1810
|
+
return {
|
|
1811
|
+
top,
|
|
1812
|
+
left,
|
|
1813
|
+
zIndex: Z_INDEX$1.CALLOUT,
|
|
1814
|
+
transform: `scale(${1 / widthScale})`, // TODO: remove this when we have a better way to handle the scale
|
|
1815
|
+
transformOrigin: `${xTransformAlign} ${yTransformAlign}`,
|
|
1816
|
+
};
|
|
1817
|
+
};
|
|
1818
1818
|
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
let elementsAtPoint = doc.elementsFromPoint(x, y);
|
|
1826
|
-
// Filter out canvas elements if requested
|
|
1827
|
-
if (ignoreCanvas) {
|
|
1828
|
-
elementsAtPoint = elementsAtPoint.filter((el) => !isIgnoredCanvas(el));
|
|
1819
|
+
const getContainerViewport = (containerElm, _scale) => {
|
|
1820
|
+
if (containerElm) {
|
|
1821
|
+
const containerRect = containerElm.getBoundingClientRect();
|
|
1822
|
+
const width = containerRect.width;
|
|
1823
|
+
const height = containerRect.height;
|
|
1824
|
+
return { width, height };
|
|
1829
1825
|
}
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1826
|
+
return {
|
|
1827
|
+
width: window.innerWidth,
|
|
1828
|
+
height: window.innerHeight,
|
|
1829
|
+
};
|
|
1830
|
+
};
|
|
1831
|
+
const getVisualDomViewport = (visualDomElm, scale = 1) => {
|
|
1832
|
+
if (visualDomElm) {
|
|
1833
|
+
const rect = visualDomElm.getBoundingClientRect();
|
|
1834
|
+
return {
|
|
1835
|
+
width: rect.width,
|
|
1836
|
+
height: rect.height,
|
|
1837
|
+
scrollTop: visualDomElm.scrollTop,
|
|
1838
|
+
scrollLeft: visualDomElm.scrollLeft,
|
|
1839
|
+
};
|
|
1841
1840
|
}
|
|
1842
|
-
return
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1841
|
+
return {
|
|
1842
|
+
width: window.innerWidth,
|
|
1843
|
+
height: window.innerHeight,
|
|
1844
|
+
scrollTop: 0,
|
|
1845
|
+
scrollLeft: 0,
|
|
1846
|
+
};
|
|
1847
|
+
};
|
|
1848
|
+
const getElementDimensions = (options) => {
|
|
1849
|
+
const { targetElm, calloutElm, scale, containerElm, visualViewport } = options;
|
|
1850
|
+
const targetRect = targetElm.getBoundingClientRect();
|
|
1851
|
+
const calloutRect = getScaledCalloutRect();
|
|
1852
|
+
const containerRect = containerElm.getBoundingClientRect();
|
|
1853
|
+
const scaledCalloutRect = getScaledCalloutRect();
|
|
1854
|
+
if (scale && containerRect) {
|
|
1855
|
+
const relativeTop = (targetRect.top - containerRect.top) / scale;
|
|
1856
|
+
const relativeLeft = (targetRect.left - containerRect.left) / scale;
|
|
1857
|
+
const scaledWidth = targetRect.width / scale;
|
|
1858
|
+
const scaledHeight = targetRect.height / scale;
|
|
1859
|
+
const hasSpaceForCallout = hasCalloutSpaceInViewport(targetElm, containerRect, visualViewport);
|
|
1860
|
+
const bottom = hasSpaceForCallout ? relativeTop + scaledHeight : relativeTop + 1;
|
|
1861
|
+
const left = hasSpaceForCallout ? relativeLeft : relativeLeft + 10;
|
|
1862
|
+
return {
|
|
1863
|
+
targetRect: {
|
|
1864
|
+
...targetRect,
|
|
1865
|
+
top: relativeTop,
|
|
1866
|
+
left: left,
|
|
1867
|
+
right: relativeLeft + scaledWidth,
|
|
1868
|
+
bottom,
|
|
1869
|
+
width: scaledWidth,
|
|
1870
|
+
height: scaledHeight,
|
|
1871
|
+
},
|
|
1872
|
+
calloutRect: scaledCalloutRect,
|
|
1873
|
+
};
|
|
1851
1874
|
}
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1875
|
+
if (scale) {
|
|
1876
|
+
return {
|
|
1877
|
+
targetRect,
|
|
1878
|
+
calloutRect: scaledCalloutRect,
|
|
1879
|
+
};
|
|
1856
1880
|
}
|
|
1857
|
-
return
|
|
1881
|
+
return { targetRect, calloutRect };
|
|
1882
|
+
};
|
|
1883
|
+
const hasCalloutSpaceInViewport = (targetElm, containerRect, visualViewport) => {
|
|
1884
|
+
const targetRect = targetElm.getBoundingClientRect();
|
|
1885
|
+
const calloutTotalHeight = getScaledCalloutRect().height + CALLOUT_ARROW_SIZE;
|
|
1886
|
+
// Use visual viewport height (visible area), fallback to container
|
|
1887
|
+
const viewportHeight = visualViewport?.height ?? containerRect.height;
|
|
1888
|
+
// Screen pixels — element position relative to container top
|
|
1889
|
+
const elementTop = targetRect.top - containerRect.top;
|
|
1890
|
+
const elementBottom = elementTop + targetRect.height;
|
|
1891
|
+
const fitsOnTop = elementTop >= calloutTotalHeight;
|
|
1892
|
+
const fitsOnBottom = elementBottom + calloutTotalHeight <= viewportHeight;
|
|
1893
|
+
return fitsOnTop || fitsOnBottom;
|
|
1858
1894
|
};
|
|
1859
|
-
function getElementHash(element) {
|
|
1860
|
-
if (!element)
|
|
1861
|
-
return null;
|
|
1862
|
-
return element.getAttribute('data-clarity-hashbeta') || element.getAttribute('data-clarity-hashalpha');
|
|
1863
|
-
}
|
|
1864
1895
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
* @param config - Cấu hình logger
|
|
1874
|
-
*/
|
|
1875
|
-
configure(config) {
|
|
1876
|
-
this.config = { ...this.config, ...config };
|
|
1877
|
-
}
|
|
1878
|
-
/**
|
|
1879
|
-
* Lấy cấu hình hiện tại
|
|
1880
|
-
*/
|
|
1881
|
-
getConfig() {
|
|
1882
|
-
return { ...this.config };
|
|
1896
|
+
const getAlignmentOrder = (alignment) => {
|
|
1897
|
+
switch (alignment) {
|
|
1898
|
+
case 'center':
|
|
1899
|
+
return ['center', 'left', 'right'];
|
|
1900
|
+
case 'left':
|
|
1901
|
+
return ['left', 'center', 'right'];
|
|
1902
|
+
case 'right':
|
|
1903
|
+
return ['right', 'center', 'left'];
|
|
1883
1904
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1905
|
+
};
|
|
1906
|
+
const calculateLeftPosition = (align, options) => {
|
|
1907
|
+
const { rectDimensions, offset } = options;
|
|
1908
|
+
const { targetRect, calloutRect } = rectDimensions;
|
|
1909
|
+
const { x: hozOffset } = offset;
|
|
1910
|
+
const relLeft = targetRect.left;
|
|
1911
|
+
const relRight = targetRect.right;
|
|
1912
|
+
const relWidth = targetRect.width;
|
|
1913
|
+
const calloutWidth = calloutRect.width;
|
|
1914
|
+
let left;
|
|
1915
|
+
switch (align) {
|
|
1916
|
+
case 'left':
|
|
1917
|
+
left = relLeft + hozOffset;
|
|
1918
|
+
break;
|
|
1919
|
+
case 'right':
|
|
1920
|
+
left = relRight - calloutWidth - hozOffset;
|
|
1921
|
+
break;
|
|
1922
|
+
case 'center':
|
|
1923
|
+
default:
|
|
1924
|
+
left = relLeft + relWidth / 2 - calloutWidth / 2;
|
|
1925
|
+
break;
|
|
1889
1926
|
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
if (!this.config.enabled)
|
|
1949
|
-
return;
|
|
1950
|
-
console.debug(...this.formatMessage(...args));
|
|
1951
|
-
}
|
|
1952
|
-
/**
|
|
1953
|
-
* Log table data
|
|
1954
|
-
*/
|
|
1955
|
-
table(data) {
|
|
1956
|
-
if (!this.config.enabled)
|
|
1957
|
-
return;
|
|
1958
|
-
console.table(data);
|
|
1959
|
-
}
|
|
1960
|
-
/**
|
|
1961
|
-
* Start a group
|
|
1962
|
-
*/
|
|
1963
|
-
group(label) {
|
|
1964
|
-
if (!this.config.enabled)
|
|
1965
|
-
return;
|
|
1966
|
-
console.group(...this.formatMessage(label));
|
|
1967
|
-
}
|
|
1968
|
-
/**
|
|
1969
|
-
* Start a collapsed group
|
|
1970
|
-
*/
|
|
1971
|
-
groupCollapsed(label) {
|
|
1972
|
-
if (!this.config.enabled)
|
|
1973
|
-
return;
|
|
1974
|
-
console.groupCollapsed(...this.formatMessage(label));
|
|
1975
|
-
}
|
|
1976
|
-
/**
|
|
1977
|
-
* End a group
|
|
1978
|
-
*/
|
|
1979
|
-
groupEnd() {
|
|
1980
|
-
if (!this.config.enabled)
|
|
1981
|
-
return;
|
|
1982
|
-
console.groupEnd();
|
|
1983
|
-
}
|
|
1984
|
-
/**
|
|
1985
|
-
* Start a timer
|
|
1986
|
-
*/
|
|
1987
|
-
time(label) {
|
|
1988
|
-
if (!this.config.enabled)
|
|
1989
|
-
return;
|
|
1990
|
-
console.time(label);
|
|
1927
|
+
// No clamping - let validation determine if position is valid
|
|
1928
|
+
// If position would overflow, valid = false and system chooses different placement
|
|
1929
|
+
return left;
|
|
1930
|
+
};
|
|
1931
|
+
const calculateVerticalPosition = (placement, options) => {
|
|
1932
|
+
const { rectDimensions, padding, arrowSize, offset } = options;
|
|
1933
|
+
const { targetRect, calloutRect } = rectDimensions;
|
|
1934
|
+
const { y: offsetY } = offset;
|
|
1935
|
+
return placement === 'top'
|
|
1936
|
+
? targetRect.top - calloutRect.height - padding - arrowSize + offsetY
|
|
1937
|
+
: targetRect.bottom + padding + arrowSize + offsetY;
|
|
1938
|
+
};
|
|
1939
|
+
const calculateHorizontalPosition = (placement, options) => {
|
|
1940
|
+
const { rectDimensions, padding, arrowSize } = options;
|
|
1941
|
+
const { targetRect, calloutRect } = rectDimensions;
|
|
1942
|
+
const top = targetRect.top + targetRect.height / 2 - calloutRect.height / 2;
|
|
1943
|
+
const left = placement === 'right'
|
|
1944
|
+
? targetRect.right + padding + arrowSize
|
|
1945
|
+
: targetRect.left - calloutRect.width - padding - arrowSize;
|
|
1946
|
+
return { top, left };
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
const EPSILON = 0.1; // Tolerance for floating point errors
|
|
1950
|
+
const isLeftPositionValid = (leftPos, options) => {
|
|
1951
|
+
const { rectDimensions, viewport, padding, offset } = options;
|
|
1952
|
+
const { width: calloutWidth } = rectDimensions.calloutRect;
|
|
1953
|
+
const { width: viewportWidth } = viewport;
|
|
1954
|
+
const absLeft = rectDimensions.targetAbsoluteRect?.left ?? 0;
|
|
1955
|
+
const relLeftPos = absLeft + leftPos - offset.x;
|
|
1956
|
+
const calloutWidthScaled = calloutWidth / options.widthScale;
|
|
1957
|
+
const maxViewportWidth = viewportWidth + EPSILON;
|
|
1958
|
+
const isValidLeft = relLeftPos >= padding - EPSILON;
|
|
1959
|
+
const isRectCalloutShowValid = relLeftPos + calloutWidthScaled - EPSILON <= maxViewportWidth / options.widthScale;
|
|
1960
|
+
return isValidLeft && isRectCalloutShowValid;
|
|
1961
|
+
};
|
|
1962
|
+
const isRightPositionValid = (leftPos, options) => {
|
|
1963
|
+
const { rectDimensions, viewport } = options;
|
|
1964
|
+
const { width: calloutWidth } = rectDimensions.calloutRect;
|
|
1965
|
+
const { width: viewportWidth } = viewport;
|
|
1966
|
+
const calloutWidthScaled = calloutWidth / options.widthScale;
|
|
1967
|
+
const absLeft = rectDimensions.targetAbsoluteRect?.left ?? 0;
|
|
1968
|
+
const relLeftPos = absLeft + leftPos;
|
|
1969
|
+
const maxViewportWidth = viewportWidth + EPSILON;
|
|
1970
|
+
const isValidRight = relLeftPos - calloutWidthScaled - EPSILON <= maxViewportWidth / options.widthScale;
|
|
1971
|
+
return isValidRight;
|
|
1972
|
+
};
|
|
1973
|
+
const isVerticalPositionValid = (placement, options) => {
|
|
1974
|
+
const { rectDimensions, viewport, padding, arrowSize } = options;
|
|
1975
|
+
const { targetRect, targetAbsoluteRect, calloutRect } = rectDimensions;
|
|
1976
|
+
const { height: viewportHeight } = viewport;
|
|
1977
|
+
const { height: calloutHeight } = calloutRect;
|
|
1978
|
+
const relativeTop = (targetAbsoluteRect?.top ?? 0) + targetRect.top;
|
|
1979
|
+
const calloutHeightScaled = calloutHeight / options.widthScale;
|
|
1980
|
+
switch (placement) {
|
|
1981
|
+
case 'top':
|
|
1982
|
+
return relativeTop - calloutHeightScaled - padding - arrowSize > -EPSILON;
|
|
1983
|
+
case 'bottom':
|
|
1984
|
+
return targetRect.bottom + calloutHeight + padding + arrowSize < viewportHeight + EPSILON;
|
|
1991
1985
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1986
|
+
};
|
|
1987
|
+
const isHorizontalPositionValid = (placement, options) => {
|
|
1988
|
+
const { rectDimensions, viewport, padding, arrowSize } = options;
|
|
1989
|
+
const { targetRect, targetAbsoluteRect, calloutRect } = rectDimensions;
|
|
1990
|
+
const { width: viewportWidth } = viewport;
|
|
1991
|
+
const { width: calloutWidth } = calloutRect;
|
|
1992
|
+
const relativeLeft = (targetAbsoluteRect?.left ?? 0) + targetRect.left;
|
|
1993
|
+
const relativeRight = relativeLeft + targetRect.width;
|
|
1994
|
+
switch (placement) {
|
|
1995
|
+
case 'right':
|
|
1996
|
+
return relativeRight + calloutWidth + padding + arrowSize < viewportWidth + EPSILON;
|
|
1997
|
+
case 'left':
|
|
1998
|
+
return relativeLeft - calloutWidth - padding - arrowSize > -EPSILON;
|
|
1999
1999
|
}
|
|
2000
|
-
}
|
|
2001
|
-
// Export singleton instance
|
|
2002
|
-
const logger$3 = new Logger();
|
|
2003
|
-
// Export factory function để tạo logger với config riêng
|
|
2004
|
-
function createLogger(config = {}) {
|
|
2005
|
-
const instance = new Logger();
|
|
2006
|
-
instance.configure(config);
|
|
2007
|
-
return instance;
|
|
2008
|
-
}
|
|
2000
|
+
};
|
|
2009
2001
|
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2002
|
+
const generateVerticalPositionCandidates = (options) => {
|
|
2003
|
+
const { alignment } = options;
|
|
2004
|
+
const candidates = [];
|
|
2005
|
+
const placements = ['top', 'bottom'];
|
|
2006
|
+
placements.forEach((placement) => {
|
|
2007
|
+
const verticalPos = calculateVerticalPosition(placement, options);
|
|
2008
|
+
const verticalValid = isVerticalPositionValid(placement, options);
|
|
2009
|
+
const alignmentOrder = getAlignmentOrder(alignment);
|
|
2010
|
+
alignmentOrder.forEach((align) => {
|
|
2011
|
+
const leftPos = calculateLeftPosition(align, options);
|
|
2012
|
+
const isValidLeft = isLeftPositionValid(leftPos, options);
|
|
2013
|
+
const isValidRight = isRightPositionValid(leftPos, options);
|
|
2014
|
+
const candidate = { placement, top: verticalPos, left: leftPos, horizontalAlign: align, valid: false };
|
|
2015
|
+
switch (align) {
|
|
2016
|
+
case 'left':
|
|
2017
|
+
candidate.valid = verticalValid && isValidLeft;
|
|
2018
|
+
break;
|
|
2019
|
+
case 'right':
|
|
2020
|
+
candidate.valid = verticalValid && isValidRight;
|
|
2021
|
+
break;
|
|
2022
2022
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
const sorted = [...items].sort((a, b) => getPosition(a) - getPosition(b));
|
|
2046
|
-
return sorted.map((item, i) => ({
|
|
2047
|
-
...item,
|
|
2048
|
-
startY: getPosition(item),
|
|
2049
|
-
endY: sorted[i + 1] !== undefined ? getPosition(sorted[i + 1]) : 100,
|
|
2023
|
+
candidates.push(candidate);
|
|
2024
|
+
});
|
|
2025
|
+
});
|
|
2026
|
+
return candidates;
|
|
2027
|
+
};
|
|
2028
|
+
const generateHorizontalPositionCandidates = (options) => {
|
|
2029
|
+
const placements = ['left', 'right'];
|
|
2030
|
+
return placements.map((placement) => {
|
|
2031
|
+
const { top, left } = calculateHorizontalPosition(placement, options);
|
|
2032
|
+
const isValidHorizontal = isHorizontalPositionValid(placement, options);
|
|
2033
|
+
const candidate = { placement, top, left, horizontalAlign: 'center', valid: false };
|
|
2034
|
+
candidate.valid = isValidHorizontal;
|
|
2035
|
+
return candidate;
|
|
2036
|
+
});
|
|
2037
|
+
};
|
|
2038
|
+
const generateAllCandidates = (options) => {
|
|
2039
|
+
const verticalCandidates = generateVerticalPositionCandidates(options);
|
|
2040
|
+
const horizontalCandidates = generateHorizontalPositionCandidates(options);
|
|
2041
|
+
const allCandidates = [...verticalCandidates, ...horizontalCandidates];
|
|
2042
|
+
return allCandidates.map((candidate) => ({
|
|
2043
|
+
...candidate,
|
|
2044
|
+
isVisibleInViewport: isElementRectInViewport({ candidate, options }),
|
|
2050
2045
|
}));
|
|
2051
|
-
}
|
|
2046
|
+
};
|
|
2052
2047
|
|
|
2053
|
-
|
|
2054
|
-
return
|
|
2055
|
-
|
|
2048
|
+
const selectBestPosition = (candidates) => {
|
|
2049
|
+
// return candidates.find((p) => p.valid && p.isVisibleInViewport) || candidates[0];
|
|
2050
|
+
return candidates.find((p) => p.valid) || candidates[0];
|
|
2051
|
+
};
|
|
2052
|
+
const selectBestPositionForClick = (candidates) => {
|
|
2053
|
+
return candidates.find((p) => p.isVisibleInViewport) || candidates.find((p) => p.valid) || candidates[0];
|
|
2054
|
+
};
|
|
2055
|
+
const constrainToViewport = (candidate, options) => {
|
|
2056
|
+
const { containerRect, padding, rectDimensions, viewport } = options;
|
|
2057
|
+
const { calloutRect } = rectDimensions;
|
|
2058
|
+
const { left: leftPos, top: topPos } = candidate;
|
|
2059
|
+
if (containerRect) {
|
|
2060
|
+
const containerTop = containerRect.top + padding;
|
|
2061
|
+
const containerLeft = containerRect.left + padding;
|
|
2062
|
+
const containerRight = containerRect.right - calloutRect.width - padding;
|
|
2063
|
+
const containerBottom = containerRect.bottom - calloutRect.height - padding;
|
|
2064
|
+
const left = Math.max(containerLeft, Math.min(leftPos, containerRight));
|
|
2065
|
+
const top = Math.max(containerTop, Math.min(topPos, containerBottom));
|
|
2066
|
+
return { top, left };
|
|
2067
|
+
}
|
|
2068
|
+
const viewportLeft = padding;
|
|
2069
|
+
const viewportTop = padding;
|
|
2070
|
+
const viewportRight = viewport.width - calloutRect.width - padding;
|
|
2071
|
+
const viewportBottom = viewport.height - calloutRect.height - padding;
|
|
2072
|
+
const left = Math.max(viewportLeft, Math.min(leftPos, viewportRight));
|
|
2073
|
+
const top = Math.max(viewportTop, Math.min(topPos, viewportBottom));
|
|
2074
|
+
return { top, left };
|
|
2075
|
+
};
|
|
2056
2076
|
|
|
2077
|
+
const getScrollOffset = (visualRef) => {
|
|
2078
|
+
if (!visualRef?.current)
|
|
2079
|
+
return;
|
|
2080
|
+
return {
|
|
2081
|
+
top: visualRef.current.scrollTop,
|
|
2082
|
+
left: visualRef.current.scrollLeft,
|
|
2083
|
+
};
|
|
2084
|
+
};
|
|
2057
2085
|
/**
|
|
2058
|
-
*
|
|
2059
|
-
*
|
|
2086
|
+
* Create adjusted container rect for absolute positioning
|
|
2087
|
+
* - With scroll: represents visible area in container coordinates
|
|
2088
|
+
* - Without scroll: represents full container in container coordinates
|
|
2060
2089
|
*/
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2090
|
+
const createAdjustedContainerRect = (options) => {
|
|
2091
|
+
const { containerElm, scale, isAbsolute, visualRef } = options;
|
|
2092
|
+
const containerRect = containerElm.getBoundingClientRect();
|
|
2093
|
+
const scrollOffset = getScrollOffset(visualRef);
|
|
2094
|
+
// No scale = fixed positioning, use raw rect
|
|
2095
|
+
if (!scale)
|
|
2096
|
+
return containerRect;
|
|
2097
|
+
const scaledWidth = containerRect.width / scale;
|
|
2098
|
+
const scaledHeight = containerRect.height / scale;
|
|
2099
|
+
// Absolute positioning with scroll offset
|
|
2100
|
+
if (isAbsolute && scrollOffset) {
|
|
2101
|
+
return {
|
|
2102
|
+
...containerRect,
|
|
2103
|
+
top: scrollOffset.top,
|
|
2104
|
+
left: scrollOffset.left,
|
|
2105
|
+
right: scrollOffset.left + scaledWidth,
|
|
2106
|
+
bottom: scrollOffset.top + scaledHeight,
|
|
2107
|
+
width: scaledWidth,
|
|
2108
|
+
height: scaledHeight,
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
// Absolute positioning without scroll
|
|
2112
|
+
return {
|
|
2113
|
+
...containerRect,
|
|
2114
|
+
top: 0,
|
|
2115
|
+
left: 0,
|
|
2116
|
+
right: scaledWidth,
|
|
2117
|
+
width: scaledWidth,
|
|
2118
|
+
bottom: scaledHeight,
|
|
2119
|
+
height: scaledHeight,
|
|
2120
|
+
};
|
|
2121
|
+
};
|
|
2122
|
+
const calcCalloutPosition = (options) => {
|
|
2123
|
+
const { targetElm, calloutElm, setPosition, positionMode, widthScale, visualRef } = options;
|
|
2124
|
+
const offset = options.offset ?? CALLOUT_OFFSET;
|
|
2125
|
+
const alignment = options.alignment ?? CALLOUT_ALIGNMENT;
|
|
2126
|
+
const padding = CALLOUT_PADDING;
|
|
2127
|
+
const arrowSize = CALLOUT_ARROW_SIZE;
|
|
2128
|
+
return () => {
|
|
2129
|
+
const isAbsolute = positionMode === 'absolute';
|
|
2130
|
+
const scale = isAbsolute ? widthScale : 1;
|
|
2131
|
+
// Determine container element based on positioning mode
|
|
2132
|
+
// - Absolute: portal container (parent of callout)
|
|
2133
|
+
// - Fixed: visual container (scrollable area)
|
|
2134
|
+
const containerElm = isAbsolute ? calloutElm.parentElement : visualRef?.current;
|
|
2135
|
+
if (!containerElm)
|
|
2069
2136
|
return;
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2137
|
+
const viewport = getContainerViewport(containerElm);
|
|
2138
|
+
const visualViewport = getVisualDomViewport(visualRef?.current, scale);
|
|
2139
|
+
const rectDimensions = getElementDimensions({ targetElm, calloutElm, scale, containerElm, visualViewport });
|
|
2140
|
+
const containerRect = createAdjustedContainerRect({ containerElm, scale, isAbsolute, visualRef });
|
|
2141
|
+
const options = {
|
|
2142
|
+
rectDimensions,
|
|
2143
|
+
viewport,
|
|
2144
|
+
visualViewport,
|
|
2145
|
+
alignment,
|
|
2146
|
+
offset,
|
|
2147
|
+
padding,
|
|
2148
|
+
arrowSize,
|
|
2149
|
+
containerRect,
|
|
2150
|
+
widthScale,
|
|
2151
|
+
};
|
|
2152
|
+
const candidates = generateAllCandidates(options);
|
|
2153
|
+
const candidate = selectBestPosition(candidates);
|
|
2154
|
+
// Constrain to viewport/container bounds
|
|
2155
|
+
const constrainedCandidate = constrainToViewport(candidate, options);
|
|
2156
|
+
// Final callout position
|
|
2157
|
+
const finalPosition = {
|
|
2158
|
+
top: constrainedCandidate.top,
|
|
2159
|
+
left: constrainedCandidate.left,
|
|
2160
|
+
placement: candidate.placement,
|
|
2161
|
+
horizontalAlign: candidate.horizontalAlign,
|
|
2162
|
+
};
|
|
2163
|
+
setPosition(finalPosition);
|
|
2164
|
+
};
|
|
2165
|
+
};
|
|
2166
|
+
const getClickTargetRect = (options) => {
|
|
2167
|
+
const { mouseX, mouseY, element, widthScale } = options;
|
|
2168
|
+
const scaledWidth = element.width * widthScale;
|
|
2169
|
+
const scaledHeight = element.height * widthScale;
|
|
2170
|
+
// Small elements: use element rect so callout aligns to the full element
|
|
2171
|
+
// targetRect is relative to the overlay div (.heatmapElement), so origin = (0, 0)
|
|
2172
|
+
if (scaledWidth < 500 && scaledHeight < 500) {
|
|
2173
|
+
return {
|
|
2174
|
+
top: 0,
|
|
2175
|
+
left: 0,
|
|
2176
|
+
right: element.width,
|
|
2177
|
+
bottom: element.height,
|
|
2178
|
+
width: element.width,
|
|
2179
|
+
height: element.height,
|
|
2180
|
+
x: 0,
|
|
2181
|
+
y: 0,
|
|
2182
|
+
toJSON: () => ({}),
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
// Large elements: use mouse position (also relative to overlay div)
|
|
2186
|
+
return {
|
|
2187
|
+
top: mouseY,
|
|
2188
|
+
left: mouseX,
|
|
2189
|
+
right: mouseX + 1,
|
|
2190
|
+
bottom: mouseY + 1,
|
|
2191
|
+
width: 1,
|
|
2192
|
+
height: 1,
|
|
2193
|
+
x: mouseX,
|
|
2194
|
+
y: mouseY,
|
|
2195
|
+
toJSON: () => ({}),
|
|
2196
|
+
};
|
|
2197
|
+
};
|
|
2198
|
+
const calcCalloutPositionAbsolute = (props) => {
|
|
2199
|
+
const { widthScale, calloutElm, containerElm, element, visualRef, setPosition } = props;
|
|
2200
|
+
const mousePosition = element?.mousePosition;
|
|
2201
|
+
if (!mousePosition)
|
|
2202
|
+
return;
|
|
2203
|
+
const padding = props.padding ?? CALLOUT_PADDING;
|
|
2204
|
+
const arrowSize = props.arrowSize ?? CALLOUT_ARROW_SIZE;
|
|
2205
|
+
const rawCalloutRect = calloutElm.getBoundingClientRect();
|
|
2206
|
+
if (rawCalloutRect.width === 0 || rawCalloutRect.height === 0)
|
|
2207
|
+
return;
|
|
2208
|
+
const containerRect = containerElm.getBoundingClientRect();
|
|
2209
|
+
const mouseX = mousePosition.x;
|
|
2210
|
+
const mouseY = mousePosition.y;
|
|
2211
|
+
const targetRect = getClickTargetRect({ mouseX, mouseY, element, widthScale });
|
|
2212
|
+
const rectDimensions = {
|
|
2213
|
+
targetRect,
|
|
2214
|
+
calloutRect: getScaledCalloutRect(),
|
|
2215
|
+
targetAbsoluteRect: {
|
|
2216
|
+
top: element.top,
|
|
2217
|
+
left: element.left,
|
|
2218
|
+
},
|
|
2219
|
+
};
|
|
2220
|
+
const { viewportInfo } = calcPositionDetail({ element, widthScale, visualRef: visualRef ?? undefined });
|
|
2221
|
+
const options = {
|
|
2222
|
+
rectDimensions,
|
|
2223
|
+
viewport: viewportInfo,
|
|
2224
|
+
alignment: CALLOUT_ALIGNMENT,
|
|
2225
|
+
offset: CALLOUT_OFFSET,
|
|
2226
|
+
padding,
|
|
2227
|
+
arrowSize,
|
|
2228
|
+
containerRect,
|
|
2229
|
+
widthScale,
|
|
2230
|
+
visualViewport: viewportInfo,
|
|
2231
|
+
};
|
|
2232
|
+
const candidates = generateAllCandidates(options);
|
|
2233
|
+
const bestPosition = selectBestPositionForClick(candidates);
|
|
2234
|
+
setPosition(bestPosition);
|
|
2235
|
+
};
|
|
2236
|
+
const calcPositionDetail = ({ element, widthScale, visualRef }) => {
|
|
2237
|
+
const mousePosition = element.mousePosition;
|
|
2238
|
+
const visual = visualRef?.current;
|
|
2239
|
+
const viewportInfo = getVisualDomViewport(visual, widthScale);
|
|
2240
|
+
const elementInfo = {
|
|
2241
|
+
...element,
|
|
2242
|
+
top: element.top * widthScale,
|
|
2243
|
+
left: element.left * widthScale,
|
|
2244
|
+
width: element.width * widthScale,
|
|
2245
|
+
height: element.height * widthScale,
|
|
2078
2246
|
};
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
latestArgs = null;
|
|
2085
|
-
}
|
|
2247
|
+
const result = {
|
|
2248
|
+
elementInfo,
|
|
2249
|
+
viewportInfo,
|
|
2250
|
+
elementToIframe: { x: elementInfo.left, y: elementInfo.top },
|
|
2251
|
+
elementToViewport: { x: elementInfo.left - viewportInfo.scrollLeft, y: elementInfo.top - viewportInfo.scrollTop },
|
|
2086
2252
|
};
|
|
2087
|
-
|
|
2253
|
+
if (mousePosition) {
|
|
2254
|
+
// mousePosition is relative to the element (0,0 = top-left of element)
|
|
2255
|
+
const mx = mousePosition.x;
|
|
2256
|
+
const my = mousePosition.y;
|
|
2257
|
+
// Convert to iframe coordinate space
|
|
2258
|
+
const iframeMx = elementInfo.left + mx;
|
|
2259
|
+
const iframeMy = elementInfo.top + my;
|
|
2260
|
+
result.mouseToIframe = { x: iframeMx, y: iframeMy };
|
|
2261
|
+
result.mouseToElement = { x: mx, y: my };
|
|
2262
|
+
result.mouseToViewport = { x: iframeMx - viewportInfo.scrollLeft, y: iframeMy - viewportInfo.scrollTop };
|
|
2263
|
+
}
|
|
2264
|
+
return result;
|
|
2088
2265
|
};
|
|
2089
2266
|
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
const clampedIndex = Math.min(index, maxIndex);
|
|
2100
|
-
const [r, g, b] = AREA_COLOR_GRADIENT[clampedIndex];
|
|
2101
|
-
// Return rgba with 60% opacity
|
|
2102
|
-
return `rgba(${r}, ${g}, ${b}, 0.6)`;
|
|
2103
|
-
}
|
|
2104
|
-
/**
|
|
2105
|
-
* Get hover color (slightly lighter) from click distribution
|
|
2106
|
-
*/
|
|
2107
|
-
function getHoverColorFromClickDist(clickDist) {
|
|
2108
|
-
const normalizedDist = Math.max(0, Math.min(100, clickDist));
|
|
2109
|
-
const maxIndex = AREA_COLOR_GRADIENT.length - 1;
|
|
2110
|
-
const index = Math.floor((normalizedDist / 100) * maxIndex);
|
|
2111
|
-
const clampedIndex = Math.min(index, maxIndex);
|
|
2112
|
-
const [r, g, b] = AREA_COLOR_GRADIENT[clampedIndex];
|
|
2113
|
-
// Return rgba with 80% opacity for hover
|
|
2114
|
-
return `rgba(${r}, ${g}, ${b}, 0.8)`;
|
|
2267
|
+
function isElementInViewport(elementRect, visualRef, scale) {
|
|
2268
|
+
if (!elementRect)
|
|
2269
|
+
return false;
|
|
2270
|
+
const viewport = getVisualDomViewport(visualRef.current, scale);
|
|
2271
|
+
const elementTop = elementRect.top * scale;
|
|
2272
|
+
const elementBottom = (elementRect.top + elementRect.height) * scale;
|
|
2273
|
+
const viewportTop = viewport.scrollTop;
|
|
2274
|
+
const viewportBottom = viewport.scrollTop + viewport.height;
|
|
2275
|
+
return elementBottom > viewportTop && elementTop < viewportBottom;
|
|
2115
2276
|
}
|
|
2116
2277
|
/**
|
|
2117
|
-
*
|
|
2278
|
+
* Check if element + callout fit entirely within the visible viewport.
|
|
2279
|
+
* Used when deciding whether to scroll to an element on click.
|
|
2118
2280
|
*/
|
|
2119
|
-
function
|
|
2120
|
-
if (
|
|
2121
|
-
return 0;
|
|
2122
|
-
return (elementClicks / totalClicks) * 100;
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
function getElementRect(element, _shadowRoot) {
|
|
2126
|
-
const rect = element.getBoundingClientRect();
|
|
2127
|
-
const width = rect.width;
|
|
2128
|
-
const height = rect.height;
|
|
2129
|
-
const doc = element.ownerDocument || document;
|
|
2130
|
-
const scrollTop = doc.documentElement?.scrollTop || doc.body?.scrollTop || 0;
|
|
2131
|
-
const scrollLeft = doc.documentElement?.scrollLeft || doc.body?.scrollLeft || 0;
|
|
2132
|
-
const top = rect.top + scrollTop;
|
|
2133
|
-
const left = rect.left + scrollLeft;
|
|
2134
|
-
const absoluteLeft = left;
|
|
2135
|
-
const absoluteTop = top;
|
|
2136
|
-
const absoluteRight = absoluteLeft + width;
|
|
2137
|
-
const absoluteBottom = absoluteTop + height;
|
|
2138
|
-
return {
|
|
2139
|
-
width,
|
|
2140
|
-
height,
|
|
2141
|
-
top,
|
|
2142
|
-
left,
|
|
2143
|
-
absoluteLeft,
|
|
2144
|
-
absoluteTop,
|
|
2145
|
-
absoluteRight,
|
|
2146
|
-
absoluteBottom,
|
|
2147
|
-
outOfBounds: false,
|
|
2148
|
-
};
|
|
2149
|
-
}
|
|
2150
|
-
function isElementFixed(element) {
|
|
2151
|
-
if (getComputedStyle(element).position === 'fixed') {
|
|
2152
|
-
return true;
|
|
2153
|
-
}
|
|
2154
|
-
if (element.nodeName === 'HTML') {
|
|
2281
|
+
function isElementWithCalloutInViewport(element, visualRef, widthScale) {
|
|
2282
|
+
if (!element)
|
|
2155
2283
|
return false;
|
|
2284
|
+
const { elementInfo, viewportInfo } = calcPositionDetail({ element, widthScale, visualRef });
|
|
2285
|
+
const calloutTotalHeight = getScaledCalloutRect().height + CALLOUT_ARROW_SIZE;
|
|
2286
|
+
const elementTop = elementInfo.top;
|
|
2287
|
+
const elementBottom = elementInfo.top + elementInfo.height;
|
|
2288
|
+
const viewportTop = viewportInfo.scrollTop;
|
|
2289
|
+
const viewportBottom = viewportInfo.scrollTop + viewportInfo.height;
|
|
2290
|
+
return elementTop - calloutTotalHeight >= viewportTop && elementBottom <= viewportBottom;
|
|
2291
|
+
}
|
|
2292
|
+
const scrollToPosition = (visualRef, scrollTop, onScrollComplete) => {
|
|
2293
|
+
if (!visualRef.current)
|
|
2294
|
+
return;
|
|
2295
|
+
// Already at target position — no scroll will happen
|
|
2296
|
+
if (Math.abs(visualRef.current.scrollTop - scrollTop) < 1) {
|
|
2297
|
+
onScrollComplete?.();
|
|
2298
|
+
return;
|
|
2156
2299
|
}
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
r1.absoluteRight > r2.absoluteLeft &&
|
|
2168
|
-
r1.absoluteLeft < r2.absoluteRight) ||
|
|
2169
|
-
(r2.absoluteBottom > r1.absoluteTop &&
|
|
2170
|
-
r2.absoluteTop < r1.absoluteBottom &&
|
|
2171
|
-
r2.absoluteRight > r1.absoluteLeft &&
|
|
2172
|
-
r2.absoluteLeft < r1.absoluteRight));
|
|
2173
|
-
}
|
|
2174
|
-
function isAreaContainedIn(area1, area2) {
|
|
2175
|
-
const r1 = area1.rect.value;
|
|
2176
|
-
const r2 = area2.rect.value;
|
|
2177
|
-
if (!r1 || !r2)
|
|
2178
|
-
return false;
|
|
2179
|
-
return (r1.absoluteTop >= r2.absoluteTop &&
|
|
2180
|
-
r1.absoluteBottom <= r2.absoluteBottom &&
|
|
2181
|
-
r1.absoluteLeft >= r2.absoluteLeft &&
|
|
2182
|
-
r1.absoluteRight <= r2.absoluteRight);
|
|
2183
|
-
}
|
|
2184
|
-
function isElementAncestorOf(ancestor, descendant, doc) {
|
|
2185
|
-
return ancestor.contains(descendant);
|
|
2186
|
-
}
|
|
2187
|
-
function isElementSelectable(element, index, elements) {
|
|
2188
|
-
if (isIgnoredCanvas(element)) {
|
|
2189
|
-
return false;
|
|
2300
|
+
if (onScrollComplete) {
|
|
2301
|
+
let scrollTimeout;
|
|
2302
|
+
const handleScroll = () => {
|
|
2303
|
+
clearTimeout(scrollTimeout);
|
|
2304
|
+
scrollTimeout = setTimeout(() => {
|
|
2305
|
+
visualRef.current?.removeEventListener('scroll', handleScroll);
|
|
2306
|
+
onScrollComplete();
|
|
2307
|
+
}, 16);
|
|
2308
|
+
};
|
|
2309
|
+
visualRef.current.addEventListener('scroll', handleScroll);
|
|
2190
2310
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2311
|
+
visualRef.current.scrollTo({ top: scrollTop });
|
|
2312
|
+
};
|
|
2313
|
+
const scrollToElementWithCallout = (visualRef, element, widthScale, onScrollComplete) => {
|
|
2314
|
+
if (!visualRef.current)
|
|
2315
|
+
return;
|
|
2316
|
+
if (isElementWithCalloutInViewport(element, visualRef, widthScale)) {
|
|
2317
|
+
onScrollComplete?.();
|
|
2318
|
+
return;
|
|
2193
2319
|
}
|
|
2194
|
-
|
|
2320
|
+
const { elementInfo } = calcPositionDetail({ element, widthScale, visualRef });
|
|
2321
|
+
const calloutTotalHeight = getScaledCalloutRect().height + CALLOUT_ARROW_SIZE;
|
|
2322
|
+
// Scroll so callout (top) + element fit in viewport, maximizing viewport usage
|
|
2323
|
+
const targetScrollTop = Math.max(0, elementInfo.top - calloutTotalHeight);
|
|
2324
|
+
scrollToPosition(visualRef, targetScrollTop, onScrollComplete);
|
|
2325
|
+
};
|
|
2326
|
+
function isElementRectInViewport(params) {
|
|
2327
|
+
const { candidate, options } = params;
|
|
2328
|
+
const { rectDimensions, visualViewport: visualRect, widthScale: scale, containerRect } = options;
|
|
2329
|
+
if (!visualRect)
|
|
2195
2330
|
return false;
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
return
|
|
2222
|
-
}
|
|
2223
|
-
/**
|
|
2224
|
-
* Calculate total clicks for an element including all its child elements
|
|
2225
|
-
* @param element - The parent element
|
|
2226
|
-
* @param elementMapInfo - Map of hash to element click info
|
|
2227
|
-
* @returns Total clicks for element + all descendants
|
|
2228
|
-
*/
|
|
2229
|
-
function calculateTotalClicksWithChildren(element, clickMapMetrics) {
|
|
2230
|
-
let totalClicks = 0;
|
|
2231
|
-
// Get clicks for the element itself
|
|
2232
|
-
const elementHash = getElementHash(element);
|
|
2233
|
-
if (elementHash) {
|
|
2234
|
-
const elementInfo = clickMapMetrics[elementHash];
|
|
2235
|
-
totalClicks += elementInfo?.totalClicked.value ?? 0;
|
|
2236
|
-
}
|
|
2237
|
-
const children = element.querySelectorAll('*');
|
|
2238
|
-
children.forEach((child) => {
|
|
2239
|
-
const childHash = getElementHash(child);
|
|
2240
|
-
if (childHash) {
|
|
2241
|
-
const childInfo = clickMapMetrics[childHash];
|
|
2242
|
-
totalClicks += childInfo?.totalClicked.value ?? 0;
|
|
2243
|
-
}
|
|
2244
|
-
});
|
|
2245
|
-
return totalClicks;
|
|
2246
|
-
}
|
|
2247
|
-
function buildAreaNode(element, hash, heatmapInfo, shadowRoot, persistedData) {
|
|
2248
|
-
if (!heatmapInfo.clickMapMetrics)
|
|
2249
|
-
return;
|
|
2250
|
-
const totalClicks = heatmapInfo.totalClicks || 0;
|
|
2251
|
-
const elementInfo = heatmapInfo.clickMapMetrics[hash];
|
|
2252
|
-
// Calculate total clicks including all child elements
|
|
2253
|
-
const elementClicks = calculateTotalClicksWithChildren(element, heatmapInfo.clickMapMetrics);
|
|
2254
|
-
const clickDist = calculateClickDistribution(elementClicks, totalClicks);
|
|
2255
|
-
const rect = getElementRect(element);
|
|
2256
|
-
const color = getColorFromClickDist(clickDist);
|
|
2257
|
-
const hoverColor = getHoverColorFromClickDist(clickDist);
|
|
2258
|
-
const areaNode = {
|
|
2259
|
-
kind: persistedData?.kind || 'area',
|
|
2260
|
-
id: persistedData?.id || `${hash}_${Date.now()}`,
|
|
2261
|
-
hash,
|
|
2262
|
-
selector: persistedData?.selector || elementInfo?.selector || getElementSelector(element),
|
|
2263
|
-
// DOM references
|
|
2264
|
-
element,
|
|
2265
|
-
areaElement: null,
|
|
2266
|
-
shadowElement: null,
|
|
2267
|
-
shadowStyleElement: null,
|
|
2268
|
-
// Graph structure
|
|
2269
|
-
parentNode: null,
|
|
2270
|
-
childNodes: new Set(),
|
|
2271
|
-
// Position
|
|
2272
|
-
rect: createObservable(rect),
|
|
2273
|
-
isFixed: isElementFixed(element),
|
|
2274
|
-
priority: false,
|
|
2275
|
-
// Click tracking
|
|
2276
|
-
totalclicks: elementClicks,
|
|
2277
|
-
cumulativeClicks: elementClicks,
|
|
2278
|
-
cumulativeMaxClicks: totalClicks,
|
|
2279
|
-
clickDist,
|
|
2280
|
-
hasClickInfo: true,
|
|
2281
|
-
// Visual
|
|
2282
|
-
color,
|
|
2283
|
-
hoverColor,
|
|
2284
|
-
// Lifecycle
|
|
2285
|
-
changeObserver: null,
|
|
2286
|
-
};
|
|
2287
|
-
return areaNode;
|
|
2331
|
+
const { calloutRect, targetAbsoluteRect } = rectDimensions;
|
|
2332
|
+
const { placement, horizontalAlign } = candidate;
|
|
2333
|
+
// candidate position (relative to element) scaled + element absolute position in iframe
|
|
2334
|
+
const offsetTop = (targetAbsoluteRect?.top ?? 0) * scale;
|
|
2335
|
+
const offsetLeft = (targetAbsoluteRect?.left ?? 0) * scale;
|
|
2336
|
+
// Base position in scroll space
|
|
2337
|
+
const baseTop = candidate.top * scale + offsetTop;
|
|
2338
|
+
const baseLeft = candidate.left * scale + offsetLeft;
|
|
2339
|
+
const { width: calloutWidth, height: calloutHeight } = calloutRect;
|
|
2340
|
+
const transformOffsetY = placement === 'top' ? calloutHeight * (scale - 1) : 0;
|
|
2341
|
+
const transformOffsetX = horizontalAlign !== 'left' ? calloutWidth * (scale - 1) : 0;
|
|
2342
|
+
const visualTop = baseTop + transformOffsetY + CALLOUT_ARROW_SIZE;
|
|
2343
|
+
const visualBottom = visualTop + calloutHeight;
|
|
2344
|
+
const visualLeft = baseLeft + transformOffsetX;
|
|
2345
|
+
const visualRight = visualLeft + calloutWidth;
|
|
2346
|
+
// Viewport bounds
|
|
2347
|
+
const scrollTop = visualRect?.scrollTop || 0;
|
|
2348
|
+
const scrollLeft = visualRect?.scrollLeft || 0;
|
|
2349
|
+
const viewportTop = scrollTop;
|
|
2350
|
+
const viewportBottom = scrollTop + visualRect.height;
|
|
2351
|
+
const viewportLeft = scrollLeft;
|
|
2352
|
+
const viewportRight = scrollLeft + (containerRect?.width ?? visualRect.width);
|
|
2353
|
+
// Check if element is fully within the visible viewport
|
|
2354
|
+
const isInVertical = visualTop > viewportTop && visualBottom < viewportBottom;
|
|
2355
|
+
const isInHorizontal = visualLeft > viewportLeft && visualRight < viewportRight;
|
|
2356
|
+
return isInVertical && isInHorizontal;
|
|
2288
2357
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
selector: info.selector || '',
|
|
2295
|
-
}))
|
|
2296
|
-
.sort((a, b) => b.totalclicks - a.totalclicks)
|
|
2297
|
-
.slice(0, topN);
|
|
2298
|
-
return elements;
|
|
2358
|
+
|
|
2359
|
+
function isMobileDevice(userAgent) {
|
|
2360
|
+
if (!userAgent)
|
|
2361
|
+
return false;
|
|
2362
|
+
return /android|webos|iphone|ipad|ipod|blackberry|windows phone|opera mini|iemobile|mobile|silk|fennec|bada|tizen|symbian|nokia|palmsource|meego|sailfish|kindle|playbook|bb10|rim/i.test(userAgent);
|
|
2299
2363
|
}
|
|
2300
2364
|
|
|
2301
2365
|
/**
|
|
2302
|
-
*
|
|
2303
|
-
* @param areas - Array of area nodes to build relationships for
|
|
2366
|
+
* Get all elements at a specific point (x, y), with support for Shadow DOM
|
|
2304
2367
|
*/
|
|
2305
|
-
function
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
if (area.parentNode) {
|
|
2324
|
-
area.parentNode.childNodes.delete(area);
|
|
2325
|
-
}
|
|
2326
|
-
// Set new parent
|
|
2327
|
-
area.parentNode = otherArea;
|
|
2328
|
-
otherArea.childNodes.add(area);
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2368
|
+
function getElementsAtPoint(doc, x, y, options = {}) {
|
|
2369
|
+
const { filterFn, ignoreCanvas = true, visitedShadowRoots = new Set() } = options;
|
|
2370
|
+
// Get all elements at this point
|
|
2371
|
+
let elementsAtPoint = doc.elementsFromPoint(x, y);
|
|
2372
|
+
// Filter out canvas elements if requested
|
|
2373
|
+
if (ignoreCanvas) {
|
|
2374
|
+
elementsAtPoint = elementsAtPoint.filter((el) => !isIgnoredCanvas(el));
|
|
2375
|
+
}
|
|
2376
|
+
// Apply custom filter if provided
|
|
2377
|
+
if (filterFn) {
|
|
2378
|
+
const matchedElement = elementsAtPoint.find(filterFn);
|
|
2379
|
+
// If matched element has Shadow DOM and we haven't visited it yet, recurse
|
|
2380
|
+
if (matchedElement?.shadowRoot && !visitedShadowRoots.has(matchedElement.shadowRoot)) {
|
|
2381
|
+
visitedShadowRoots.add(matchedElement.shadowRoot);
|
|
2382
|
+
return getElementsAtPoint(matchedElement.shadowRoot, x, y, {
|
|
2383
|
+
...options,
|
|
2384
|
+
visitedShadowRoots,
|
|
2385
|
+
});
|
|
2331
2386
|
}
|
|
2332
2387
|
}
|
|
2388
|
+
return elementsAtPoint;
|
|
2333
2389
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2390
|
+
/**
|
|
2391
|
+
* Get the element at a specific point (x, y)
|
|
2392
|
+
*/
|
|
2393
|
+
const getElementAtPoint = (doc, x, y) => {
|
|
2394
|
+
let el = null;
|
|
2395
|
+
if ('caretPositionFromPoint' in doc) {
|
|
2396
|
+
el = doc.caretPositionFromPoint(x, y)?.offsetNode ?? null;
|
|
2338
2397
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2398
|
+
el = el ?? doc.elementFromPoint(x, y);
|
|
2399
|
+
let element = el;
|
|
2400
|
+
while (element && element.nodeType === Node.TEXT_NODE) {
|
|
2401
|
+
element = element.parentElement;
|
|
2341
2402
|
}
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2403
|
+
return element;
|
|
2404
|
+
};
|
|
2405
|
+
function getElementHash(element) {
|
|
2406
|
+
if (!element)
|
|
2407
|
+
return null;
|
|
2408
|
+
return element.getAttribute('data-clarity-hashbeta') || element.getAttribute('data-clarity-hashalpha');
|
|
2345
2409
|
}
|
|
2346
2410
|
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2411
|
+
class Logger {
|
|
2412
|
+
config = {
|
|
2413
|
+
enabled: false,
|
|
2414
|
+
prefix: '',
|
|
2415
|
+
timestamp: false,
|
|
2416
|
+
};
|
|
2417
|
+
/**
|
|
2418
|
+
* Cấu hình logger
|
|
2419
|
+
* @param config - Cấu hình logger
|
|
2420
|
+
*/
|
|
2421
|
+
configure(config) {
|
|
2422
|
+
this.config = { ...this.config, ...config };
|
|
2352
2423
|
}
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2424
|
+
/**
|
|
2425
|
+
* Lấy cấu hình hiện tại
|
|
2426
|
+
*/
|
|
2427
|
+
getConfig() {
|
|
2428
|
+
return { ...this.config };
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Bật logger
|
|
2432
|
+
*/
|
|
2433
|
+
enable() {
|
|
2434
|
+
this.config.enabled = true;
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Tắt logger
|
|
2438
|
+
*/
|
|
2439
|
+
disable() {
|
|
2440
|
+
this.config.enabled = false;
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Format message với prefix và timestamp
|
|
2444
|
+
*/
|
|
2445
|
+
formatMessage(...args) {
|
|
2446
|
+
const parts = [];
|
|
2447
|
+
if (this.config.timestamp) {
|
|
2448
|
+
parts.push(`[${new Date().toISOString()}]`);
|
|
2449
|
+
}
|
|
2450
|
+
if (this.config.prefix) {
|
|
2451
|
+
parts.push(`[${this.config.prefix}]`);
|
|
2360
2452
|
}
|
|
2453
|
+
if (parts.length > 0) {
|
|
2454
|
+
return [parts.join(' '), ...args];
|
|
2455
|
+
}
|
|
2456
|
+
return args;
|
|
2361
2457
|
}
|
|
2362
|
-
|
|
2363
|
-
|
|
2458
|
+
/**
|
|
2459
|
+
* Log message
|
|
2460
|
+
*/
|
|
2461
|
+
log(...args) {
|
|
2462
|
+
if (!this.config.enabled)
|
|
2463
|
+
return;
|
|
2464
|
+
console.log(...this.formatMessage(...args));
|
|
2364
2465
|
}
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2466
|
+
/**
|
|
2467
|
+
* Log info message
|
|
2468
|
+
*/
|
|
2469
|
+
info(...args) {
|
|
2470
|
+
if (!this.config.enabled)
|
|
2471
|
+
return;
|
|
2472
|
+
console.info(...this.formatMessage(...args));
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Log warning message
|
|
2476
|
+
*/
|
|
2477
|
+
warn(...args) {
|
|
2478
|
+
if (!this.config.enabled)
|
|
2479
|
+
return;
|
|
2480
|
+
console.warn(...this.formatMessage(...args));
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Log error message
|
|
2484
|
+
*/
|
|
2485
|
+
error(...args) {
|
|
2486
|
+
if (!this.config.enabled)
|
|
2487
|
+
return;
|
|
2488
|
+
console.error(...this.formatMessage(...args));
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Log debug message
|
|
2492
|
+
*/
|
|
2493
|
+
debug(...args) {
|
|
2494
|
+
if (!this.config.enabled)
|
|
2495
|
+
return;
|
|
2496
|
+
console.debug(...this.formatMessage(...args));
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Log table data
|
|
2500
|
+
*/
|
|
2501
|
+
table(data) {
|
|
2502
|
+
if (!this.config.enabled)
|
|
2503
|
+
return;
|
|
2504
|
+
console.table(data);
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Start a group
|
|
2508
|
+
*/
|
|
2509
|
+
group(label) {
|
|
2510
|
+
if (!this.config.enabled)
|
|
2511
|
+
return;
|
|
2512
|
+
console.group(...this.formatMessage(label));
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Start a collapsed group
|
|
2516
|
+
*/
|
|
2517
|
+
groupCollapsed(label) {
|
|
2518
|
+
if (!this.config.enabled)
|
|
2519
|
+
return;
|
|
2520
|
+
console.groupCollapsed(...this.formatMessage(label));
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* End a group
|
|
2524
|
+
*/
|
|
2525
|
+
groupEnd() {
|
|
2526
|
+
if (!this.config.enabled)
|
|
2527
|
+
return;
|
|
2528
|
+
console.groupEnd();
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Start a timer
|
|
2532
|
+
*/
|
|
2533
|
+
time(label) {
|
|
2534
|
+
if (!this.config.enabled)
|
|
2535
|
+
return;
|
|
2536
|
+
console.time(label);
|
|
2387
2537
|
}
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
const hydratedAreas = [];
|
|
2396
|
-
for (const persistedData of clickAreas) {
|
|
2397
|
-
const area = hydrateAreaNode({ persistedData, iframeDocument, heatmapInfo, vizRef, shadowRoot });
|
|
2398
|
-
if (area) {
|
|
2399
|
-
hydratedAreas.push(area);
|
|
2400
|
-
}
|
|
2538
|
+
/**
|
|
2539
|
+
* End a timer
|
|
2540
|
+
*/
|
|
2541
|
+
timeEnd(label) {
|
|
2542
|
+
if (!this.config.enabled)
|
|
2543
|
+
return;
|
|
2544
|
+
console.timeEnd(label);
|
|
2401
2545
|
}
|
|
2402
|
-
logger$3.info(`Hydrated ${hydratedAreas.length} of ${clickAreas.length} persisted areas`);
|
|
2403
|
-
return hydratedAreas;
|
|
2404
2546
|
}
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
function
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
hash: area.hash,
|
|
2413
|
-
selector: area.selector,
|
|
2414
|
-
};
|
|
2547
|
+
// Export singleton instance
|
|
2548
|
+
const logger$3 = new Logger();
|
|
2549
|
+
// Export factory function để tạo logger với config riêng
|
|
2550
|
+
function createLogger(config = {}) {
|
|
2551
|
+
const instance = new Logger();
|
|
2552
|
+
instance.configure(config);
|
|
2553
|
+
return instance;
|
|
2415
2554
|
}
|
|
2555
|
+
|
|
2416
2556
|
/**
|
|
2417
|
-
*
|
|
2557
|
+
* Create an observable value with subscribe/unsubscribe pattern
|
|
2418
2558
|
*/
|
|
2419
|
-
function
|
|
2420
|
-
|
|
2559
|
+
function createObservable(initialValue) {
|
|
2560
|
+
const subscribers = new Set();
|
|
2561
|
+
const observable = {
|
|
2562
|
+
value: initialValue,
|
|
2563
|
+
observe: (callback) => {
|
|
2564
|
+
subscribers.add(callback);
|
|
2565
|
+
// Immediately call with current value
|
|
2566
|
+
if (observable.value !== undefined) {
|
|
2567
|
+
callback(observable.value);
|
|
2568
|
+
}
|
|
2569
|
+
},
|
|
2570
|
+
unobserve: (callback) => {
|
|
2571
|
+
subscribers.delete(callback);
|
|
2572
|
+
},
|
|
2573
|
+
update: (newValue) => {
|
|
2574
|
+
observable.value = newValue;
|
|
2575
|
+
// Notify all subscribers
|
|
2576
|
+
subscribers.forEach((callback) => {
|
|
2577
|
+
callback(newValue);
|
|
2578
|
+
});
|
|
2579
|
+
},
|
|
2580
|
+
};
|
|
2581
|
+
return observable;
|
|
2421
2582
|
}
|
|
2422
2583
|
|
|
2423
2584
|
/**
|
|
2424
|
-
*
|
|
2585
|
+
* Given a list of items where each item represents the START of a bucket,
|
|
2586
|
+
* returns each item enriched with `startY` (= position) and `endY` (= next position, or 100 for the last bucket).
|
|
2425
2587
|
*
|
|
2426
|
-
*
|
|
2427
|
-
* 1. Priority flag (manually set areas win)
|
|
2428
|
-
* 2. Click distribution (higher % wins)
|
|
2429
|
-
* 3. Total clicks (more clicks wins)
|
|
2430
|
-
* 4. DOM containment (parent contains child, parent wins)
|
|
2431
|
-
* 5. Size (smaller areas win - more specific)
|
|
2432
|
-
*/
|
|
2433
|
-
function resolveOverlaps(areas, iframeDocument) {
|
|
2434
|
-
if (areas.length === 0)
|
|
2435
|
-
return [];
|
|
2436
|
-
// Group overlapping areas
|
|
2437
|
-
const overlapGroups = findOverlapGroups(areas);
|
|
2438
|
-
// Resolve each group
|
|
2439
|
-
const visibleAreas = new Set();
|
|
2440
|
-
overlapGroups.forEach((group) => {
|
|
2441
|
-
const winner = resolveOverlapGroup(group, iframeDocument);
|
|
2442
|
-
visibleAreas.add(winner);
|
|
2443
|
-
});
|
|
2444
|
-
// Add non-overlapping areas
|
|
2445
|
-
areas.forEach((area) => {
|
|
2446
|
-
const hasOverlap = overlapGroups.some((group) => group.areas.includes(area));
|
|
2447
|
-
if (!hasOverlap) {
|
|
2448
|
-
visibleAreas.add(area);
|
|
2449
|
-
}
|
|
2450
|
-
});
|
|
2451
|
-
return Array.from(visibleAreas);
|
|
2452
|
-
}
|
|
2453
|
-
/**
|
|
2454
|
-
* Find groups of overlapping areas
|
|
2588
|
+
* Works for any scroll data shape (depth, attention, revenue).
|
|
2455
2589
|
*/
|
|
2456
|
-
function
|
|
2457
|
-
const
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
const overlapping = areas.filter((other) => other.id !== area.id && doAreasOverlap(area, other));
|
|
2464
|
-
if (overlapping.length === 0) {
|
|
2465
|
-
// No overlap, skip grouping
|
|
2466
|
-
return;
|
|
2467
|
-
}
|
|
2468
|
-
// Create group with this area and all overlapping
|
|
2469
|
-
const groupAreas = [area, ...overlapping];
|
|
2470
|
-
groupAreas.forEach((a) => processed.add(a.id));
|
|
2471
|
-
// Placeholder - will be resolved later
|
|
2472
|
-
groups.push({
|
|
2473
|
-
areas: groupAreas,
|
|
2474
|
-
winner: area,
|
|
2475
|
-
hidden: [],
|
|
2476
|
-
});
|
|
2477
|
-
});
|
|
2478
|
-
return groups;
|
|
2590
|
+
function buildBuckets(items, getPosition) {
|
|
2591
|
+
const sorted = [...items].sort((a, b) => getPosition(a) - getPosition(b));
|
|
2592
|
+
return sorted.map((item, i) => ({
|
|
2593
|
+
...item,
|
|
2594
|
+
startY: getPosition(item),
|
|
2595
|
+
endY: sorted[i + 1] !== undefined ? getPosition(sorted[i + 1]) : 100,
|
|
2596
|
+
}));
|
|
2479
2597
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
function resolveOverlapGroup(group, iframeDocument) {
|
|
2484
|
-
const { areas } = group;
|
|
2485
|
-
if (areas.length === 1)
|
|
2486
|
-
return areas[0];
|
|
2487
|
-
// Sort by priority rules
|
|
2488
|
-
const sorted = [...areas].sort((a, b) => {
|
|
2489
|
-
// Rule 1: Priority flag
|
|
2490
|
-
if (a.priority !== b.priority) {
|
|
2491
|
-
return a.priority ? -1 : 1;
|
|
2492
|
-
}
|
|
2493
|
-
// Rule 2: Click distribution
|
|
2494
|
-
if (a.clickDist !== b.clickDist) {
|
|
2495
|
-
return b.clickDist - a.clickDist;
|
|
2496
|
-
}
|
|
2497
|
-
// Rule 3: Total clicks
|
|
2498
|
-
if (a.totalclicks !== b.totalclicks) {
|
|
2499
|
-
return b.totalclicks - a.totalclicks;
|
|
2500
|
-
}
|
|
2501
|
-
// Rule 4: DOM containment - parent beats child
|
|
2502
|
-
if (iframeDocument) {
|
|
2503
|
-
const aContainsB = isElementAncestorOf(a.element, b.element);
|
|
2504
|
-
const bContainsA = isElementAncestorOf(b.element, a.element);
|
|
2505
|
-
if (aContainsB)
|
|
2506
|
-
return -1; // a is parent, a wins
|
|
2507
|
-
if (bContainsA)
|
|
2508
|
-
return 1; // b is parent, b wins
|
|
2509
|
-
}
|
|
2510
|
-
// Rule 5: Size - smaller (more specific) wins
|
|
2511
|
-
const aSize = (a.rect.value?.width || 0) * (a.rect.value?.height || 0);
|
|
2512
|
-
const bSize = (b.rect.value?.width || 0) * (b.rect.value?.height || 0);
|
|
2513
|
-
return aSize - bSize;
|
|
2514
|
-
});
|
|
2515
|
-
const winner = sorted[0];
|
|
2516
|
-
group.winner = winner;
|
|
2517
|
-
group.hidden = sorted.slice(1);
|
|
2518
|
-
return winner;
|
|
2598
|
+
|
|
2599
|
+
function sortEvents(a, b) {
|
|
2600
|
+
return a.time - b.time;
|
|
2519
2601
|
}
|
|
2602
|
+
|
|
2520
2603
|
/**
|
|
2521
|
-
*
|
|
2522
|
-
*
|
|
2604
|
+
* Throttle a function using requestAnimationFrame
|
|
2605
|
+
* Ensures the callback is called at most once per animation frame
|
|
2523
2606
|
*/
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
if (
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
if (other.clickDist > area.clickDist)
|
|
2541
|
-
return true;
|
|
2542
|
-
if (other.clickDist < area.clickDist)
|
|
2543
|
-
return false;
|
|
2544
|
-
// Compare by total clicks
|
|
2545
|
-
return other.totalclicks > area.totalclicks;
|
|
2607
|
+
const throttleRAF = (callback) => {
|
|
2608
|
+
let rafId = null;
|
|
2609
|
+
let latestArgs = null;
|
|
2610
|
+
const throttled = (...args) => {
|
|
2611
|
+
// Store the latest arguments
|
|
2612
|
+
latestArgs = args;
|
|
2613
|
+
// If already scheduled, do nothing
|
|
2614
|
+
if (rafId !== null)
|
|
2615
|
+
return;
|
|
2616
|
+
// Schedule the callback for the next animation frame
|
|
2617
|
+
rafId = requestAnimationFrame(() => {
|
|
2618
|
+
if (latestArgs !== null) {
|
|
2619
|
+
callback(...latestArgs);
|
|
2620
|
+
latestArgs = null;
|
|
2621
|
+
}
|
|
2622
|
+
rafId = null;
|
|
2546
2623
|
});
|
|
2547
|
-
|
|
2548
|
-
|
|
2624
|
+
};
|
|
2625
|
+
// Add cancel method to clear pending RAF
|
|
2626
|
+
throttled.cancel = () => {
|
|
2627
|
+
if (rafId !== null) {
|
|
2628
|
+
cancelAnimationFrame(rafId);
|
|
2629
|
+
rafId = null;
|
|
2630
|
+
latestArgs = null;
|
|
2549
2631
|
}
|
|
2550
|
-
}
|
|
2551
|
-
return
|
|
2552
|
-
}
|
|
2632
|
+
};
|
|
2633
|
+
return throttled;
|
|
2634
|
+
};
|
|
2635
|
+
|
|
2553
2636
|
/**
|
|
2554
|
-
* Get
|
|
2637
|
+
* Get color from click distribution percentage (0-100)
|
|
2555
2638
|
*/
|
|
2556
|
-
function
|
|
2557
|
-
//
|
|
2558
|
-
|
|
2559
|
-
//
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2639
|
+
function getColorFromClickDist(clickDist) {
|
|
2640
|
+
// Ensure clickDist is in range [0, 100]
|
|
2641
|
+
const normalizedDist = Math.max(0, Math.min(100, clickDist));
|
|
2642
|
+
// Calculate gradient index
|
|
2643
|
+
const maxIndex = AREA_COLOR_GRADIENT.length - 1;
|
|
2644
|
+
const index = Math.floor((normalizedDist / 100) * maxIndex);
|
|
2645
|
+
const clampedIndex = Math.min(index, maxIndex);
|
|
2646
|
+
const [r, g, b] = AREA_COLOR_GRADIENT[clampedIndex];
|
|
2647
|
+
// Return rgba with 60% opacity
|
|
2648
|
+
return `rgba(${r}, ${g}, ${b}, 0.6)`;
|
|
2563
2649
|
}
|
|
2564
|
-
|
|
2565
|
-
/**
|
|
2566
|
-
* Helper functions for setting up area renderer
|
|
2567
|
-
*/
|
|
2568
2650
|
/**
|
|
2569
|
-
*
|
|
2651
|
+
* Get hover color (slightly lighter) from click distribution
|
|
2570
2652
|
*/
|
|
2571
|
-
function
|
|
2572
|
-
const
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2653
|
+
function getHoverColorFromClickDist(clickDist) {
|
|
2654
|
+
const normalizedDist = Math.max(0, Math.min(100, clickDist));
|
|
2655
|
+
const maxIndex = AREA_COLOR_GRADIENT.length - 1;
|
|
2656
|
+
const index = Math.floor((normalizedDist / 100) * maxIndex);
|
|
2657
|
+
const clampedIndex = Math.min(index, maxIndex);
|
|
2658
|
+
const [r, g, b] = AREA_COLOR_GRADIENT[clampedIndex];
|
|
2659
|
+
// Return rgba with 80% opacity for hover
|
|
2660
|
+
return `rgba(${r}, ${g}, ${b}, 0.8)`;
|
|
2576
2661
|
}
|
|
2577
2662
|
/**
|
|
2578
|
-
*
|
|
2663
|
+
* Calculate click distribution percentage from total clicks
|
|
2579
2664
|
*/
|
|
2580
|
-
function
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
return innerContainer;
|
|
2665
|
+
function calculateClickDistribution(elementClicks, totalClicks) {
|
|
2666
|
+
if (totalClicks === 0)
|
|
2667
|
+
return 0;
|
|
2668
|
+
return (elementClicks / totalClicks) * 100;
|
|
2585
2669
|
}
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2670
|
+
|
|
2671
|
+
function getElementRect(element, _shadowRoot) {
|
|
2672
|
+
const rect = element.getBoundingClientRect();
|
|
2673
|
+
const width = rect.width;
|
|
2674
|
+
const height = rect.height;
|
|
2675
|
+
const doc = element.ownerDocument || document;
|
|
2676
|
+
const scrollTop = doc.documentElement?.scrollTop || doc.body?.scrollTop || 0;
|
|
2677
|
+
const scrollLeft = doc.documentElement?.scrollLeft || doc.body?.scrollLeft || 0;
|
|
2678
|
+
const top = rect.top + scrollTop;
|
|
2679
|
+
const left = rect.left + scrollLeft;
|
|
2680
|
+
const absoluteLeft = left;
|
|
2681
|
+
const absoluteTop = top;
|
|
2682
|
+
const absoluteRight = absoluteLeft + width;
|
|
2683
|
+
const absoluteBottom = absoluteTop + height;
|
|
2684
|
+
return {
|
|
2685
|
+
width,
|
|
2686
|
+
height,
|
|
2687
|
+
top,
|
|
2688
|
+
left,
|
|
2689
|
+
absoluteLeft,
|
|
2690
|
+
absoluteTop,
|
|
2691
|
+
absoluteRight,
|
|
2692
|
+
absoluteBottom,
|
|
2693
|
+
outOfBounds: false,
|
|
2694
|
+
};
|
|
2599
2695
|
}
|
|
2600
|
-
function
|
|
2601
|
-
if (
|
|
2602
|
-
return
|
|
2696
|
+
function isElementFixed(element) {
|
|
2697
|
+
if (getComputedStyle(element).position === 'fixed') {
|
|
2698
|
+
return true;
|
|
2603
2699
|
}
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
function getOrCreateInnerContainer(shadowRoot, iframeDocument) {
|
|
2607
|
-
let innerContainer = shadowRoot.querySelector(AREA_RENDERER_SELECTORS.innerContainerSelector);
|
|
2608
|
-
if (!innerContainer) {
|
|
2609
|
-
innerContainer = createInnerContainer(iframeDocument);
|
|
2610
|
-
shadowRoot.appendChild(innerContainer);
|
|
2700
|
+
if (element.nodeName === 'HTML') {
|
|
2701
|
+
return false;
|
|
2611
2702
|
}
|
|
2612
|
-
|
|
2703
|
+
const parent = element.parentElement;
|
|
2704
|
+
return parent ? isElementFixed(parent) : false;
|
|
2613
2705
|
}
|
|
2614
|
-
function
|
|
2615
|
-
const
|
|
2616
|
-
const
|
|
2617
|
-
|
|
2618
|
-
|
|
2706
|
+
function doAreasOverlap(area1, area2) {
|
|
2707
|
+
const r1 = area1.rect.value;
|
|
2708
|
+
const r2 = area2.rect.value;
|
|
2709
|
+
if (!r1 || !r2)
|
|
2710
|
+
return false;
|
|
2711
|
+
return ((r1.absoluteBottom > r2.absoluteTop &&
|
|
2712
|
+
r1.absoluteTop < r2.absoluteBottom &&
|
|
2713
|
+
r1.absoluteRight > r2.absoluteLeft &&
|
|
2714
|
+
r1.absoluteLeft < r2.absoluteRight) ||
|
|
2715
|
+
(r2.absoluteBottom > r1.absoluteTop &&
|
|
2716
|
+
r2.absoluteTop < r1.absoluteBottom &&
|
|
2717
|
+
r2.absoluteRight > r1.absoluteLeft &&
|
|
2718
|
+
r2.absoluteLeft < r1.absoluteRight));
|
|
2619
2719
|
}
|
|
2620
|
-
function
|
|
2621
|
-
|
|
2622
|
-
|
|
2720
|
+
function isAreaContainedIn(area1, area2) {
|
|
2721
|
+
const r1 = area1.rect.value;
|
|
2722
|
+
const r2 = area2.rect.value;
|
|
2723
|
+
if (!r1 || !r2)
|
|
2724
|
+
return false;
|
|
2725
|
+
return (r1.absoluteTop >= r2.absoluteTop &&
|
|
2726
|
+
r1.absoluteBottom <= r2.absoluteBottom &&
|
|
2727
|
+
r1.absoluteLeft >= r2.absoluteLeft &&
|
|
2728
|
+
r1.absoluteRight <= r2.absoluteRight);
|
|
2729
|
+
}
|
|
2730
|
+
function isElementAncestorOf(ancestor, descendant, doc) {
|
|
2731
|
+
return ancestor.contains(descendant);
|
|
2732
|
+
}
|
|
2733
|
+
function isElementSelectable(element, index, elements) {
|
|
2734
|
+
if (isIgnoredCanvas(element)) {
|
|
2735
|
+
return false;
|
|
2736
|
+
}
|
|
2737
|
+
if (element.hasAttribute(AREA_MAP_DIV_ATTRIBUTE)) {
|
|
2738
|
+
return false;
|
|
2739
|
+
}
|
|
2740
|
+
if (index === 0 && elements.length > 1 && element.nodeName === 'BODY') {
|
|
2741
|
+
return false;
|
|
2623
2742
|
}
|
|
2743
|
+
return true;
|
|
2744
|
+
}
|
|
2745
|
+
function sortAreasByClickDist(areas) {
|
|
2746
|
+
return [...areas].sort((a, b) => {
|
|
2747
|
+
if (a.clickDist !== b.clickDist) {
|
|
2748
|
+
return b.clickDist - a.clickDist;
|
|
2749
|
+
}
|
|
2750
|
+
return b.totalclicks - a.totalclicks;
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
function isRectTooSmallForLabel(rect) {
|
|
2754
|
+
return rect.width < 67 || rect.height < 30;
|
|
2624
2755
|
}
|
|
2625
2756
|
|
|
2757
|
+
function getElementSelector(element) {
|
|
2758
|
+
if (element.id) {
|
|
2759
|
+
return `#${element.id}`;
|
|
2760
|
+
}
|
|
2761
|
+
if (element.className) {
|
|
2762
|
+
const classes = Array.from(element.classList).join('.');
|
|
2763
|
+
if (classes) {
|
|
2764
|
+
return `${element.tagName.toLowerCase()}.${classes}`;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return element.tagName.toLowerCase();
|
|
2768
|
+
}
|
|
2626
2769
|
/**
|
|
2627
|
-
*
|
|
2628
|
-
* @param
|
|
2629
|
-
* @param
|
|
2630
|
-
* @returns
|
|
2770
|
+
* Calculate total clicks for an element including all its child elements
|
|
2771
|
+
* @param element - The parent element
|
|
2772
|
+
* @param elementMapInfo - Map of hash to element click info
|
|
2773
|
+
* @returns Total clicks for element + all descendants
|
|
2631
2774
|
*/
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
const
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
const
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
return null;
|
|
2650
|
-
return {
|
|
2651
|
-
top: rect.top,
|
|
2652
|
-
left: rect.left,
|
|
2653
|
-
width: rect.width,
|
|
2654
|
-
height: rect.height,
|
|
2655
|
-
};
|
|
2775
|
+
function calculateTotalClicksWithChildren(element, clickMapMetrics) {
|
|
2776
|
+
let totalClicks = 0;
|
|
2777
|
+
// Get clicks for the element itself
|
|
2778
|
+
const elementHash = getElementHash(element);
|
|
2779
|
+
if (elementHash) {
|
|
2780
|
+
const elementInfo = clickMapMetrics[elementHash];
|
|
2781
|
+
totalClicks += elementInfo?.totalClicked.value ?? 0;
|
|
2782
|
+
}
|
|
2783
|
+
const children = element.querySelectorAll('*');
|
|
2784
|
+
children.forEach((child) => {
|
|
2785
|
+
const childHash = getElementHash(child);
|
|
2786
|
+
if (childHash) {
|
|
2787
|
+
const childInfo = clickMapMetrics[childHash];
|
|
2788
|
+
totalClicks += childInfo?.totalClicked.value ?? 0;
|
|
2789
|
+
}
|
|
2790
|
+
});
|
|
2791
|
+
return totalClicks;
|
|
2656
2792
|
}
|
|
2657
|
-
|
|
2658
|
-
if (!
|
|
2659
|
-
return
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
const
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
const
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
zIndex: Z_INDEX$1.CALLOUT,
|
|
2697
|
-
transform: `scale(${1 / widthScale})`, // TODO: remove this when we have a better way to handle the scale
|
|
2698
|
-
transformOrigin: `${xTransformAlign} ${yTransformAlign}`,
|
|
2699
|
-
};
|
|
2700
|
-
};
|
|
2701
|
-
|
|
2702
|
-
function calculateRankPosition(rect, widthScale) {
|
|
2703
|
-
const top = rect.top <= 18 ? rect.top + 3 : rect.top - 18;
|
|
2704
|
-
const left = rect.left <= 18 ? rect.left + 3 : rect.left - 18;
|
|
2705
|
-
return {
|
|
2706
|
-
transform: `scale(${1.2 * widthScale})`,
|
|
2707
|
-
top: Number.isNaN(top) ? undefined : top,
|
|
2708
|
-
left: Number.isNaN(left) ? undefined : left,
|
|
2793
|
+
function buildAreaNode(element, hash, heatmapInfo, shadowRoot, persistedData) {
|
|
2794
|
+
if (!heatmapInfo.clickMapMetrics)
|
|
2795
|
+
return;
|
|
2796
|
+
const totalClicks = heatmapInfo.totalClicks || 0;
|
|
2797
|
+
const elementInfo = heatmapInfo.clickMapMetrics[hash];
|
|
2798
|
+
// Calculate total clicks including all child elements
|
|
2799
|
+
const elementClicks = calculateTotalClicksWithChildren(element, heatmapInfo.clickMapMetrics);
|
|
2800
|
+
const clickDist = calculateClickDistribution(elementClicks, totalClicks);
|
|
2801
|
+
const rect = getElementRect(element);
|
|
2802
|
+
const color = getColorFromClickDist(clickDist);
|
|
2803
|
+
const hoverColor = getHoverColorFromClickDist(clickDist);
|
|
2804
|
+
const areaNode = {
|
|
2805
|
+
kind: persistedData?.kind || 'area',
|
|
2806
|
+
id: persistedData?.id || `${hash}_${Date.now()}`,
|
|
2807
|
+
hash,
|
|
2808
|
+
selector: persistedData?.selector || elementInfo?.selector || getElementSelector(element),
|
|
2809
|
+
// DOM references
|
|
2810
|
+
element,
|
|
2811
|
+
areaElement: null,
|
|
2812
|
+
shadowElement: null,
|
|
2813
|
+
shadowStyleElement: null,
|
|
2814
|
+
// Graph structure
|
|
2815
|
+
parentNode: null,
|
|
2816
|
+
childNodes: new Set(),
|
|
2817
|
+
// Position
|
|
2818
|
+
rect: createObservable(rect),
|
|
2819
|
+
isFixed: isElementFixed(element),
|
|
2820
|
+
priority: false,
|
|
2821
|
+
// Click tracking
|
|
2822
|
+
totalclicks: elementClicks,
|
|
2823
|
+
cumulativeClicks: elementClicks,
|
|
2824
|
+
cumulativeMaxClicks: totalClicks,
|
|
2825
|
+
clickDist,
|
|
2826
|
+
hasClickInfo: true,
|
|
2827
|
+
// Visual
|
|
2828
|
+
color,
|
|
2829
|
+
hoverColor,
|
|
2830
|
+
// Lifecycle
|
|
2831
|
+
changeObserver: null,
|
|
2709
2832
|
};
|
|
2833
|
+
return areaNode;
|
|
2834
|
+
}
|
|
2835
|
+
function getTopElementsByClicks(clickMapMetrics, topN = 10) {
|
|
2836
|
+
const elements = Object.entries(clickMapMetrics)
|
|
2837
|
+
.map(([hash, info]) => ({
|
|
2838
|
+
hash,
|
|
2839
|
+
totalclicks: info.totalClicked.value ?? 0,
|
|
2840
|
+
selector: info.selector || '',
|
|
2841
|
+
}))
|
|
2842
|
+
.sort((a, b) => b.totalclicks - a.totalclicks)
|
|
2843
|
+
.slice(0, topN);
|
|
2844
|
+
return elements;
|
|
2710
2845
|
}
|
|
2711
2846
|
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
const
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2847
|
+
/**
|
|
2848
|
+
* Build parent-child relationships between areas based on DOM hierarchy
|
|
2849
|
+
* @param areas - Array of area nodes to build relationships for
|
|
2850
|
+
*/
|
|
2851
|
+
function buildAreaGraph(areas) {
|
|
2852
|
+
// Clear existing relationships
|
|
2853
|
+
areas.forEach((area) => {
|
|
2854
|
+
area.parentNode = null;
|
|
2855
|
+
area.childNodes.clear();
|
|
2856
|
+
});
|
|
2857
|
+
// Build relationships based on DOM containment
|
|
2858
|
+
for (let i = 0; i < areas.length; i++) {
|
|
2859
|
+
const area = areas[i];
|
|
2860
|
+
for (let j = 0; j < areas.length; j++) {
|
|
2861
|
+
if (i === j)
|
|
2862
|
+
continue;
|
|
2863
|
+
const otherArea = areas[j];
|
|
2864
|
+
// Check if area's element is contained within otherArea's element
|
|
2865
|
+
if (otherArea.element.contains(area.element)) {
|
|
2866
|
+
// Find the closest parent (not just any ancestor)
|
|
2867
|
+
if (!area.parentNode || area.parentNode.element.contains(otherArea.element)) {
|
|
2868
|
+
// Remove from old parent if exists
|
|
2869
|
+
if (area.parentNode) {
|
|
2870
|
+
area.parentNode.childNodes.delete(area);
|
|
2871
|
+
}
|
|
2872
|
+
// Set new parent
|
|
2873
|
+
area.parentNode = otherArea;
|
|
2874
|
+
otherArea.childNodes.add(area);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2733
2878
|
}
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
};
|
|
2740
|
-
};
|
|
2741
|
-
const getElementDimensions = (options) => {
|
|
2742
|
-
const { targetElm, calloutElm, scale, containerElm } = options;
|
|
2743
|
-
const targetRect = targetElm.getBoundingClientRect();
|
|
2744
|
-
const calloutRect = getScaledCalloutRect();
|
|
2745
|
-
const containerRect = containerElm.getBoundingClientRect();
|
|
2746
|
-
const scaledCalloutRect = getScaledCalloutRect();
|
|
2747
|
-
if (scale && containerRect) {
|
|
2748
|
-
const relativeTop = (targetRect.top - containerRect.top) / scale;
|
|
2749
|
-
const relativeLeft = (targetRect.left - containerRect.left) / scale;
|
|
2750
|
-
const scaledWidth = targetRect.width / scale;
|
|
2751
|
-
const scaledHeight = targetRect.height / scale;
|
|
2752
|
-
return {
|
|
2753
|
-
targetRect: {
|
|
2754
|
-
...targetRect,
|
|
2755
|
-
top: relativeTop,
|
|
2756
|
-
left: relativeLeft,
|
|
2757
|
-
right: relativeLeft + scaledWidth,
|
|
2758
|
-
bottom: relativeTop + scaledHeight,
|
|
2759
|
-
width: scaledWidth,
|
|
2760
|
-
height: scaledHeight,
|
|
2761
|
-
},
|
|
2762
|
-
calloutRect: scaledCalloutRect,
|
|
2763
|
-
};
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
function decodeClarity(payload) {
|
|
2882
|
+
try {
|
|
2883
|
+
return decode(payload);
|
|
2764
2884
|
}
|
|
2765
|
-
|
|
2766
|
-
return
|
|
2767
|
-
targetRect,
|
|
2768
|
-
calloutRect: scaledCalloutRect,
|
|
2769
|
-
};
|
|
2885
|
+
catch (_error) {
|
|
2886
|
+
return null;
|
|
2770
2887
|
}
|
|
2771
|
-
|
|
2772
|
-
|
|
2888
|
+
}
|
|
2889
|
+
function decodeArrayClarity(items) {
|
|
2890
|
+
return items.map((item) => decodeClarity(item)).filter((item) => item !== null);
|
|
2891
|
+
}
|
|
2773
2892
|
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
return ['left', 'center', 'right'];
|
|
2780
|
-
case 'right':
|
|
2781
|
-
return ['right', 'center', 'left'];
|
|
2893
|
+
function findElementByHash(props) {
|
|
2894
|
+
const { hash, selector, iframeDocument, vizRef } = props;
|
|
2895
|
+
if (vizRef) {
|
|
2896
|
+
const element = vizRef.get(hash);
|
|
2897
|
+
return element;
|
|
2782
2898
|
}
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
const calloutWidth = calloutRect.width;
|
|
2792
|
-
let left;
|
|
2793
|
-
switch (align) {
|
|
2794
|
-
case 'left':
|
|
2795
|
-
left = relLeft + hozOffset;
|
|
2796
|
-
break;
|
|
2797
|
-
case 'right':
|
|
2798
|
-
left = relRight - calloutWidth - hozOffset;
|
|
2799
|
-
break;
|
|
2800
|
-
case 'center':
|
|
2801
|
-
default:
|
|
2802
|
-
left = relLeft + relWidth / 2 - calloutWidth / 2;
|
|
2803
|
-
break;
|
|
2899
|
+
// Fallback
|
|
2900
|
+
if (!iframeDocument)
|
|
2901
|
+
return null;
|
|
2902
|
+
try {
|
|
2903
|
+
const element = selector ? iframeDocument.querySelector(selector) : null;
|
|
2904
|
+
if (element) {
|
|
2905
|
+
return element;
|
|
2906
|
+
}
|
|
2804
2907
|
}
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
};
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
const { targetRect, calloutRect } = rectDimensions;
|
|
2812
|
-
const { y: offsetY } = offset;
|
|
2813
|
-
return placement === 'top'
|
|
2814
|
-
? targetRect.top - calloutRect.height - padding - arrowSize + offsetY
|
|
2815
|
-
: targetRect.bottom + padding + arrowSize + offsetY;
|
|
2816
|
-
};
|
|
2817
|
-
const calculateHorizontalPosition = (placement, options) => {
|
|
2818
|
-
const { rectDimensions, padding, arrowSize } = options;
|
|
2819
|
-
const { targetRect, calloutRect } = rectDimensions;
|
|
2820
|
-
const top = targetRect.top + targetRect.height / 2 - calloutRect.height / 2;
|
|
2821
|
-
const left = placement === 'right'
|
|
2822
|
-
? targetRect.right + padding + arrowSize
|
|
2823
|
-
: targetRect.left - calloutRect.width - padding - arrowSize;
|
|
2824
|
-
return { top, left };
|
|
2825
|
-
};
|
|
2908
|
+
catch (error) {
|
|
2909
|
+
logger$3.warn(`Invalid selector "${selector}":`, error);
|
|
2910
|
+
}
|
|
2911
|
+
const elementByHash = iframeDocument.querySelector(`[data-clarity-hashalpha="${hash}"], [data-clarity-hash="${hash}"], [data-clarity-hashbeta="${hash}"]`);
|
|
2912
|
+
return elementByHash;
|
|
2913
|
+
}
|
|
2826
2914
|
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
};
|
|
2840
|
-
const
|
|
2841
|
-
const {
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
const absLeft = rectDimensions.targetAbsoluteRect?.left ?? 0;
|
|
2846
|
-
const relLeftPos = absLeft + leftPos;
|
|
2847
|
-
const maxViewportWidth = viewportWidth + EPSILON;
|
|
2848
|
-
const isValidRight = relLeftPos - calloutWidthScaled - EPSILON <= maxViewportWidth / options.widthScale;
|
|
2849
|
-
return isValidRight;
|
|
2850
|
-
};
|
|
2851
|
-
const isVerticalPositionValid = (placement, options) => {
|
|
2852
|
-
const { rectDimensions, viewport, padding, arrowSize } = options;
|
|
2853
|
-
const { targetRect, targetAbsoluteRect, calloutRect } = rectDimensions;
|
|
2854
|
-
const { height: viewportHeight } = viewport;
|
|
2855
|
-
const { height: calloutHeight } = calloutRect;
|
|
2856
|
-
const relativeTop = (targetAbsoluteRect?.top ?? 0) + targetRect.top;
|
|
2857
|
-
const calloutHeightScaled = calloutHeight / options.widthScale;
|
|
2858
|
-
switch (placement) {
|
|
2859
|
-
case 'top':
|
|
2860
|
-
return relativeTop - calloutHeightScaled - padding - arrowSize > -EPSILON;
|
|
2861
|
-
case 'bottom':
|
|
2862
|
-
return targetRect.bottom + calloutHeight + padding + arrowSize < viewportHeight + EPSILON;
|
|
2915
|
+
/**
|
|
2916
|
+
* Hydrates persisted area data into full area node
|
|
2917
|
+
* Finds element in DOM and calculates all runtime values
|
|
2918
|
+
*
|
|
2919
|
+
* @param persistedData - Minimal data from database
|
|
2920
|
+
* @param iframeDocument - Document to find element in
|
|
2921
|
+
* @param heatmapInfo - Heatmap data for click calculations
|
|
2922
|
+
* @param vizRef - Map of hash to elements
|
|
2923
|
+
* @param shadowRoot - Optional shadow root for rect calculation
|
|
2924
|
+
* @returns Full area node or null if element not found
|
|
2925
|
+
*/
|
|
2926
|
+
function hydrateAreaNode(props) {
|
|
2927
|
+
const { persistedData, iframeDocument, heatmapInfo, vizRef, shadowRoot } = props;
|
|
2928
|
+
const { id, hash, selector } = persistedData;
|
|
2929
|
+
const element = findElementByHash({ hash, selector, iframeDocument, vizRef });
|
|
2930
|
+
if (!element) {
|
|
2931
|
+
logger$3.warn(`Cannot hydrate area ${id}: element not found for hash ${hash} or selector ${selector}`);
|
|
2932
|
+
return null;
|
|
2863
2933
|
}
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
const
|
|
2871
|
-
const
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2934
|
+
const areaNode = buildAreaNode(element, hash, heatmapInfo, shadowRoot, persistedData);
|
|
2935
|
+
if (!areaNode)
|
|
2936
|
+
return null;
|
|
2937
|
+
return areaNode;
|
|
2938
|
+
}
|
|
2939
|
+
function hydrateAreas(props) {
|
|
2940
|
+
const { clickAreas, iframeDocument, heatmapInfo, vizRef, shadowRoot } = props;
|
|
2941
|
+
const hydratedAreas = [];
|
|
2942
|
+
for (const persistedData of clickAreas) {
|
|
2943
|
+
const area = hydrateAreaNode({ persistedData, iframeDocument, heatmapInfo, vizRef, shadowRoot });
|
|
2944
|
+
if (area) {
|
|
2945
|
+
hydratedAreas.push(area);
|
|
2946
|
+
}
|
|
2877
2947
|
}
|
|
2878
|
-
};
|
|
2948
|
+
logger$3.info(`Hydrated ${hydratedAreas.length} of ${clickAreas.length} persisted areas`);
|
|
2949
|
+
return hydratedAreas;
|
|
2950
|
+
}
|
|
2951
|
+
/**
|
|
2952
|
+
* Serializes area node to persisted data for database storage
|
|
2953
|
+
*/
|
|
2954
|
+
function serializeAreaNode(area) {
|
|
2955
|
+
return {
|
|
2956
|
+
kind: area.kind,
|
|
2957
|
+
id: area.id,
|
|
2958
|
+
hash: area.hash,
|
|
2959
|
+
selector: area.selector,
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Serializes multiple areas for database storage
|
|
2964
|
+
*/
|
|
2965
|
+
function serializeAreas(areas) {
|
|
2966
|
+
return areas.map(serializeAreaNode);
|
|
2967
|
+
}
|
|
2879
2968
|
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2969
|
+
/**
|
|
2970
|
+
* Resolve overlapping areas by priority rules
|
|
2971
|
+
*
|
|
2972
|
+
* Priority Rules (in order):
|
|
2973
|
+
* 1. Priority flag (manually set areas win)
|
|
2974
|
+
* 2. Click distribution (higher % wins)
|
|
2975
|
+
* 3. Total clicks (more clicks wins)
|
|
2976
|
+
* 4. DOM containment (parent contains child, parent wins)
|
|
2977
|
+
* 5. Size (smaller areas win - more specific)
|
|
2978
|
+
*/
|
|
2979
|
+
function resolveOverlaps(areas, iframeDocument) {
|
|
2980
|
+
if (areas.length === 0)
|
|
2981
|
+
return [];
|
|
2982
|
+
// Group overlapping areas
|
|
2983
|
+
const overlapGroups = findOverlapGroups(areas);
|
|
2984
|
+
// Resolve each group
|
|
2985
|
+
const visibleAreas = new Set();
|
|
2986
|
+
overlapGroups.forEach((group) => {
|
|
2987
|
+
const winner = resolveOverlapGroup(group, iframeDocument);
|
|
2988
|
+
visibleAreas.add(winner);
|
|
2989
|
+
});
|
|
2990
|
+
// Add non-overlapping areas
|
|
2991
|
+
areas.forEach((area) => {
|
|
2992
|
+
const hasOverlap = overlapGroups.some((group) => group.areas.includes(area));
|
|
2993
|
+
if (!hasOverlap) {
|
|
2994
|
+
visibleAreas.add(area);
|
|
2995
|
+
}
|
|
2996
|
+
});
|
|
2997
|
+
return Array.from(visibleAreas);
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Find groups of overlapping areas
|
|
3001
|
+
*/
|
|
3002
|
+
function findOverlapGroups(areas) {
|
|
3003
|
+
const groups = [];
|
|
3004
|
+
const processed = new Set();
|
|
3005
|
+
areas.forEach((area) => {
|
|
3006
|
+
if (processed.has(area.id))
|
|
3007
|
+
return;
|
|
3008
|
+
// Find all areas that overlap with this one
|
|
3009
|
+
const overlapping = areas.filter((other) => other.id !== area.id && doAreasOverlap(area, other));
|
|
3010
|
+
if (overlapping.length === 0) {
|
|
3011
|
+
// No overlap, skip grouping
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
// Create group with this area and all overlapping
|
|
3015
|
+
const groupAreas = [area, ...overlapping];
|
|
3016
|
+
groupAreas.forEach((a) => processed.add(a.id));
|
|
3017
|
+
// Placeholder - will be resolved later
|
|
3018
|
+
groups.push({
|
|
3019
|
+
areas: groupAreas,
|
|
3020
|
+
winner: area,
|
|
3021
|
+
hidden: [],
|
|
2902
3022
|
});
|
|
2903
3023
|
});
|
|
2904
|
-
return
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
3024
|
+
return groups;
|
|
3025
|
+
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Resolve a single overlap group to find the winner
|
|
3028
|
+
*/
|
|
3029
|
+
function resolveOverlapGroup(group, iframeDocument) {
|
|
3030
|
+
const { areas } = group;
|
|
3031
|
+
if (areas.length === 1)
|
|
3032
|
+
return areas[0];
|
|
3033
|
+
// Sort by priority rules
|
|
3034
|
+
const sorted = [...areas].sort((a, b) => {
|
|
3035
|
+
// Rule 1: Priority flag
|
|
3036
|
+
if (a.priority !== b.priority) {
|
|
3037
|
+
return a.priority ? -1 : 1;
|
|
3038
|
+
}
|
|
3039
|
+
// Rule 2: Click distribution
|
|
3040
|
+
if (a.clickDist !== b.clickDist) {
|
|
3041
|
+
return b.clickDist - a.clickDist;
|
|
3042
|
+
}
|
|
3043
|
+
// Rule 3: Total clicks
|
|
3044
|
+
if (a.totalclicks !== b.totalclicks) {
|
|
3045
|
+
return b.totalclicks - a.totalclicks;
|
|
3046
|
+
}
|
|
3047
|
+
// Rule 4: DOM containment - parent beats child
|
|
3048
|
+
if (iframeDocument) {
|
|
3049
|
+
const aContainsB = isElementAncestorOf(a.element, b.element);
|
|
3050
|
+
const bContainsA = isElementAncestorOf(b.element, a.element);
|
|
3051
|
+
if (aContainsB)
|
|
3052
|
+
return -1; // a is parent, a wins
|
|
3053
|
+
if (bContainsA)
|
|
3054
|
+
return 1; // b is parent, b wins
|
|
3055
|
+
}
|
|
3056
|
+
// Rule 5: Size - smaller (more specific) wins
|
|
3057
|
+
const aSize = (a.rect.value?.width || 0) * (a.rect.value?.height || 0);
|
|
3058
|
+
const bSize = (b.rect.value?.width || 0) * (b.rect.value?.height || 0);
|
|
3059
|
+
return aSize - bSize;
|
|
2914
3060
|
});
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
3061
|
+
const winner = sorted[0];
|
|
3062
|
+
group.winner = winner;
|
|
3063
|
+
group.hidden = sorted.slice(1);
|
|
3064
|
+
return winner;
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Filter out areas that are completely contained within others
|
|
3068
|
+
* and have lower priority
|
|
3069
|
+
*/
|
|
3070
|
+
function filterContainedAreas(areas) {
|
|
3071
|
+
const visible = [];
|
|
3072
|
+
areas.forEach((area) => {
|
|
3073
|
+
// Check if this area is contained by a higher priority area
|
|
3074
|
+
const isContained = areas.some((other) => {
|
|
3075
|
+
if (other.id === area.id)
|
|
3076
|
+
return false;
|
|
3077
|
+
// Check containment
|
|
3078
|
+
if (!isAreaContainedIn(area, other))
|
|
3079
|
+
return false;
|
|
3080
|
+
// Check priority
|
|
3081
|
+
if (other.priority && !area.priority)
|
|
3082
|
+
return true;
|
|
3083
|
+
if (!other.priority && area.priority)
|
|
3084
|
+
return false;
|
|
3085
|
+
// Compare by click dist
|
|
3086
|
+
if (other.clickDist > area.clickDist)
|
|
3087
|
+
return true;
|
|
3088
|
+
if (other.clickDist < area.clickDist)
|
|
3089
|
+
return false;
|
|
3090
|
+
// Compare by total clicks
|
|
3091
|
+
return other.totalclicks > area.totalclicks;
|
|
3092
|
+
});
|
|
3093
|
+
if (!isContained) {
|
|
3094
|
+
visible.push(area);
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
return visible;
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Get visible areas after resolving overlaps
|
|
3101
|
+
*/
|
|
3102
|
+
function getVisibleAreas(areas, iframeDocument) {
|
|
3103
|
+
// First pass: filter contained areas
|
|
3104
|
+
let visible = filterContainedAreas(areas);
|
|
3105
|
+
// Second pass: resolve overlaps
|
|
3106
|
+
visible = resolveOverlaps(visible, iframeDocument);
|
|
3107
|
+
// Sort by click dist for rendering order
|
|
3108
|
+
return sortAreasByClickDist(visible);
|
|
3109
|
+
}
|
|
2957
3110
|
|
|
2958
|
-
const getScrollOffset = (visualRef) => {
|
|
2959
|
-
if (!visualRef?.current)
|
|
2960
|
-
return;
|
|
2961
|
-
return {
|
|
2962
|
-
top: visualRef.current.scrollTop,
|
|
2963
|
-
left: visualRef.current.scrollLeft,
|
|
2964
|
-
};
|
|
2965
|
-
};
|
|
2966
3111
|
/**
|
|
2967
|
-
*
|
|
2968
|
-
* - With scroll: represents visible area in container coordinates
|
|
2969
|
-
* - Without scroll: represents full container in container coordinates
|
|
3112
|
+
* Helper functions for setting up area renderer
|
|
2970
3113
|
*/
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
3114
|
+
/**
|
|
3115
|
+
* Create the outer container for area rendering
|
|
3116
|
+
*/
|
|
3117
|
+
function createAreaContainer(iframeDocument) {
|
|
3118
|
+
const container = iframeDocument.createElement('div');
|
|
3119
|
+
container.setAttribute(AREA_MAP_DIV_ATTRIBUTE, 'true');
|
|
3120
|
+
container.style.cssText = AREA_CONTAINER_STYLES;
|
|
3121
|
+
return container;
|
|
3122
|
+
}
|
|
3123
|
+
/**
|
|
3124
|
+
* Create the inner container for React portal
|
|
3125
|
+
*/
|
|
3126
|
+
function createInnerContainer(iframeDocument) {
|
|
3127
|
+
const innerContainer = iframeDocument.createElement('div');
|
|
3128
|
+
innerContainer.className = AREA_RENDERER_SELECTORS.innerContainerClass;
|
|
3129
|
+
innerContainer.style.cssText = AREA_INNER_CONTAINER_STYLES;
|
|
3130
|
+
return innerContainer;
|
|
3131
|
+
}
|
|
3132
|
+
/**
|
|
3133
|
+
* Get or create the outer container element
|
|
3134
|
+
*/
|
|
3135
|
+
function getOrCreateAreaContainer(iframeDocument, customShadowRoot) {
|
|
3136
|
+
let container = iframeDocument.querySelector(AREA_RENDERER_SELECTORS.containerSelector);
|
|
3137
|
+
if (!container) {
|
|
3138
|
+
container = createAreaContainer(iframeDocument);
|
|
3139
|
+
const targetRoot = customShadowRoot || iframeDocument.body;
|
|
3140
|
+
if (targetRoot) {
|
|
3141
|
+
targetRoot.appendChild(container);
|
|
3142
|
+
}
|
|
2991
3143
|
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3144
|
+
return container;
|
|
3145
|
+
}
|
|
3146
|
+
function getOrCreateContainerShadowRoot(container) {
|
|
3147
|
+
if (container.shadowRoot) {
|
|
3148
|
+
return container.shadowRoot;
|
|
3149
|
+
}
|
|
3150
|
+
return container.attachShadow({ mode: 'open' });
|
|
3151
|
+
}
|
|
3152
|
+
function getOrCreateInnerContainer(shadowRoot, iframeDocument) {
|
|
3153
|
+
let innerContainer = shadowRoot.querySelector(AREA_RENDERER_SELECTORS.innerContainerSelector);
|
|
3154
|
+
if (!innerContainer) {
|
|
3155
|
+
innerContainer = createInnerContainer(iframeDocument);
|
|
3156
|
+
shadowRoot.appendChild(innerContainer);
|
|
3157
|
+
}
|
|
3158
|
+
return innerContainer;
|
|
3159
|
+
}
|
|
3160
|
+
function setupAreaRenderingContainer(iframeDocument, customShadowRoot) {
|
|
3161
|
+
const container = getOrCreateAreaContainer(iframeDocument, customShadowRoot);
|
|
3162
|
+
const shadowRoot = getOrCreateContainerShadowRoot(container);
|
|
3163
|
+
const innerContainer = getOrCreateInnerContainer(shadowRoot, iframeDocument);
|
|
3164
|
+
return innerContainer;
|
|
3165
|
+
}
|
|
3166
|
+
function cleanupAreaRenderingContainer(container) {
|
|
3167
|
+
if (container && container.parentNode) {
|
|
3168
|
+
container.parentNode.removeChild(container);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
/**
|
|
3173
|
+
* Generate unique element ID for a specific view
|
|
3174
|
+
* @param baseId - Base element ID
|
|
3175
|
+
* @param viewId - View ID
|
|
3176
|
+
* @returns Unique element ID (e.g., 'gx-hm-clicked-element-view-0')
|
|
3177
|
+
*/
|
|
3178
|
+
const getElementId = (baseId, viewId) => {
|
|
3179
|
+
return `${baseId}-${viewId}`;
|
|
3002
3180
|
};
|
|
3003
|
-
const
|
|
3004
|
-
const
|
|
3005
|
-
|
|
3006
|
-
const alignment = options.alignment ?? CALLOUT_ALIGNMENT;
|
|
3007
|
-
const padding = CALLOUT_PADDING;
|
|
3008
|
-
const arrowSize = CALLOUT_ARROW_SIZE;
|
|
3009
|
-
return () => {
|
|
3010
|
-
const isAbsolute = positionMode === 'absolute';
|
|
3011
|
-
const scale = isAbsolute ? widthScale : 1;
|
|
3012
|
-
// Determine container element based on positioning mode
|
|
3013
|
-
// - Absolute: portal container (parent of callout)
|
|
3014
|
-
// - Fixed: visual container (scrollable area)
|
|
3015
|
-
const containerElm = isAbsolute ? calloutElm.parentElement : visualRef?.current;
|
|
3016
|
-
if (!containerElm)
|
|
3017
|
-
return;
|
|
3018
|
-
const viewport = getContainerViewport(containerElm);
|
|
3019
|
-
const visualViewport = getVisualDomViewport(visualRef?.current, scale);
|
|
3020
|
-
const rectDimensions = getElementDimensions({ targetElm, calloutElm, scale, containerElm });
|
|
3021
|
-
const containerRect = createAdjustedContainerRect({ containerElm, scale, isAbsolute, visualRef });
|
|
3022
|
-
const options = {
|
|
3023
|
-
rectDimensions,
|
|
3024
|
-
viewport,
|
|
3025
|
-
visualViewport,
|
|
3026
|
-
alignment,
|
|
3027
|
-
offset,
|
|
3028
|
-
padding,
|
|
3029
|
-
arrowSize,
|
|
3030
|
-
containerRect,
|
|
3031
|
-
widthScale,
|
|
3032
|
-
};
|
|
3033
|
-
const candidates = generateAllCandidates(options);
|
|
3034
|
-
const candidate = selectBestPosition(candidates);
|
|
3035
|
-
// Constrain to viewport/container bounds
|
|
3036
|
-
const constrainedCandidate = constrainToViewport(candidate, options);
|
|
3037
|
-
// Final callout position
|
|
3038
|
-
const finalPosition = {
|
|
3039
|
-
top: constrainedCandidate.top,
|
|
3040
|
-
left: constrainedCandidate.left,
|
|
3041
|
-
placement: candidate.placement,
|
|
3042
|
-
horizontalAlign: candidate.horizontalAlign,
|
|
3043
|
-
};
|
|
3044
|
-
setPosition(finalPosition);
|
|
3045
|
-
};
|
|
3181
|
+
const getClickedElementId = (viewId, isSecondary = false) => {
|
|
3182
|
+
const baseId = isSecondary ? SECONDARY_CLICKED_ELEMENT_ID_BASE : CLICKED_ELEMENT_ID_BASE;
|
|
3183
|
+
return getElementId(baseId, viewId);
|
|
3046
3184
|
};
|
|
3047
|
-
const
|
|
3048
|
-
const
|
|
3049
|
-
|
|
3050
|
-
if (!mousePosition)
|
|
3051
|
-
return;
|
|
3052
|
-
const padding = props.padding ?? CALLOUT_PADDING;
|
|
3053
|
-
const arrowSize = props.arrowSize ?? CALLOUT_ARROW_SIZE;
|
|
3054
|
-
const rawCalloutRect = calloutElm.getBoundingClientRect();
|
|
3055
|
-
if (rawCalloutRect.width === 0 || rawCalloutRect.height === 0)
|
|
3056
|
-
return;
|
|
3057
|
-
const containerRect = containerElm.getBoundingClientRect();
|
|
3058
|
-
const mouseX = mousePosition.x;
|
|
3059
|
-
const mouseY = mousePosition.y;
|
|
3060
|
-
const targetRect = {
|
|
3061
|
-
top: mouseY,
|
|
3062
|
-
left: mouseX,
|
|
3063
|
-
right: mouseX + 1,
|
|
3064
|
-
bottom: mouseY + 1,
|
|
3065
|
-
width: 1,
|
|
3066
|
-
height: 1,
|
|
3067
|
-
x: mouseX,
|
|
3068
|
-
y: mouseY,
|
|
3069
|
-
toJSON: () => ({}),
|
|
3070
|
-
};
|
|
3071
|
-
const rectDimensions = {
|
|
3072
|
-
targetRect,
|
|
3073
|
-
calloutRect: getScaledCalloutRect(),
|
|
3074
|
-
targetAbsoluteRect: {
|
|
3075
|
-
top: element.top,
|
|
3076
|
-
left: element.left,
|
|
3077
|
-
},
|
|
3078
|
-
};
|
|
3079
|
-
const { viewportInfo } = calcPositionDetail({ element, widthScale, visualRef: visualRef ?? undefined });
|
|
3080
|
-
const options = {
|
|
3081
|
-
rectDimensions,
|
|
3082
|
-
viewport: viewportInfo,
|
|
3083
|
-
alignment: CALLOUT_ALIGNMENT,
|
|
3084
|
-
offset: CALLOUT_OFFSET,
|
|
3085
|
-
padding,
|
|
3086
|
-
arrowSize,
|
|
3087
|
-
containerRect,
|
|
3088
|
-
widthScale,
|
|
3089
|
-
visualViewport: viewportInfo,
|
|
3090
|
-
};
|
|
3091
|
-
const candidates = generateAllCandidates(options);
|
|
3092
|
-
const bestPosition = selectBestPositionForClick(candidates);
|
|
3093
|
-
setPosition(bestPosition);
|
|
3185
|
+
const getHoveredElementId = (viewId, isSecondary = false) => {
|
|
3186
|
+
const baseId = isSecondary ? SECONDARY_HOVERED_ELEMENT_ID_BASE : HOVERED_ELEMENT_ID_BASE;
|
|
3187
|
+
return getElementId(baseId, viewId);
|
|
3094
3188
|
};
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
const
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
scrollLeft: visual?.scrollLeft ?? 0,
|
|
3104
|
-
};
|
|
3105
|
-
const elementInfo = {
|
|
3106
|
-
...element,
|
|
3107
|
-
top: element.top * widthScale,
|
|
3108
|
-
left: element.left * widthScale,
|
|
3109
|
-
width: element.width * widthScale,
|
|
3110
|
-
height: element.height * widthScale,
|
|
3111
|
-
};
|
|
3112
|
-
const result = {
|
|
3113
|
-
elementInfo,
|
|
3114
|
-
viewportInfo,
|
|
3115
|
-
elementToIframe: { x: elementInfo.left, y: elementInfo.top },
|
|
3116
|
-
elementToViewport: { x: elementInfo.left - viewportInfo.scrollLeft, y: elementInfo.top - viewportInfo.scrollTop },
|
|
3189
|
+
|
|
3190
|
+
function calculateRankPosition(rect, widthScale) {
|
|
3191
|
+
const top = rect.top <= 18 ? rect.top + 3 : rect.top - 18;
|
|
3192
|
+
const left = rect.left <= 18 ? rect.left + 3 : rect.left - 18;
|
|
3193
|
+
return {
|
|
3194
|
+
transform: `scale(${1.2 * widthScale})`,
|
|
3195
|
+
top: Number.isNaN(top) ? undefined : top,
|
|
3196
|
+
left: Number.isNaN(left) ? undefined : left,
|
|
3117
3197
|
};
|
|
3118
|
-
|
|
3119
|
-
// mousePosition is relative to the element (0,0 = top-left of element)
|
|
3120
|
-
const mx = mousePosition.x;
|
|
3121
|
-
const my = mousePosition.y;
|
|
3122
|
-
// Convert to iframe coordinate space
|
|
3123
|
-
const iframeMx = elementInfo.left + mx;
|
|
3124
|
-
const iframeMy = elementInfo.top + my;
|
|
3125
|
-
result.mouseToIframe = { x: iframeMx, y: iframeMy };
|
|
3126
|
-
result.mouseToElement = { x: mx, y: my };
|
|
3127
|
-
result.mouseToViewport = { x: iframeMx - viewportInfo.scrollLeft, y: iframeMy - viewportInfo.scrollTop };
|
|
3128
|
-
}
|
|
3129
|
-
return result;
|
|
3130
|
-
};
|
|
3198
|
+
}
|
|
3131
3199
|
|
|
3132
3200
|
function validateAreaCreation(dataInfo, hash, areas) {
|
|
3133
3201
|
if (!dataInfo?.clickMapMetrics || !dataInfo?.totalClicks) {
|
|
@@ -3752,10 +3820,16 @@ const useClickedElement = ({ visualRef, getRect }) => {
|
|
|
3752
3820
|
}
|
|
3753
3821
|
setShowMissingElement(false);
|
|
3754
3822
|
setShouldShowCallout(true);
|
|
3755
|
-
|
|
3756
|
-
|
|
3823
|
+
if (mousePosition) {
|
|
3824
|
+
// Clicked directly on element — already visible, no scroll needed
|
|
3757
3825
|
setClickedElement(elementInfo);
|
|
3758
|
-
}
|
|
3826
|
+
}
|
|
3827
|
+
else {
|
|
3828
|
+
// Clicked from sidebar — scroll to element with space for callout
|
|
3829
|
+
scrollToElementWithCallout(visualRef, elementInfo, widthScale, () => {
|
|
3830
|
+
setClickedElement(elementInfo);
|
|
3831
|
+
});
|
|
3832
|
+
}
|
|
3759
3833
|
}, [selectedElement, dataInfo, visualRef, widthScale]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3760
3834
|
return { clickedElement, showMissingElement, shouldShowCallout };
|
|
3761
3835
|
};
|