@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/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 isElementInViewport(elementRect, visualRef, scale) {
1762
- if (!elementRect)
1763
- return false;
1764
- const visualRect = visualRef.current?.getBoundingClientRect();
1765
- if (!visualRect)
1766
- return false;
1767
- // Element position relative to the document (or container's content)
1768
- const elementTop = elementRect.top * scale;
1769
- const elementBottom = (elementRect.top + elementRect.height) * scale;
1770
- // Current scroll position
1771
- const scrollTop = visualRef.current?.scrollTop || 0;
1772
- const viewportHeight = visualRect.height;
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
- * Get all elements at a specific point (x, y), with support for Shadow DOM
1821
- */
1822
- function getElementsAtPoint(doc, x, y, options = {}) {
1823
- const { filterFn, ignoreCanvas = true, visitedShadowRoots = new Set() } = options;
1824
- // Get all elements at this point
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
- // Apply custom filter if provided
1831
- if (filterFn) {
1832
- const matchedElement = elementsAtPoint.find(filterFn);
1833
- // If matched element has Shadow DOM and we haven't visited it yet, recurse
1834
- if (matchedElement?.shadowRoot && !visitedShadowRoots.has(matchedElement.shadowRoot)) {
1835
- visitedShadowRoots.add(matchedElement.shadowRoot);
1836
- return getElementsAtPoint(matchedElement.shadowRoot, x, y, {
1837
- ...options,
1838
- visitedShadowRoots,
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 elementsAtPoint;
1843
- }
1844
- /**
1845
- * Get the element at a specific point (x, y)
1846
- */
1847
- const getElementAtPoint = (doc, x, y) => {
1848
- let el = null;
1849
- if ('caretPositionFromPoint' in doc) {
1850
- el = doc.caretPositionFromPoint(x, y)?.offsetNode ?? null;
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
- el = el ?? doc.elementFromPoint(x, y);
1853
- let element = el;
1854
- while (element && element.nodeType === Node.TEXT_NODE) {
1855
- element = element.parentElement;
1875
+ if (scale) {
1876
+ return {
1877
+ targetRect,
1878
+ calloutRect: scaledCalloutRect,
1879
+ };
1856
1880
  }
1857
- return element;
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
- class Logger {
1866
- config = {
1867
- enabled: false,
1868
- prefix: '',
1869
- timestamp: false,
1870
- };
1871
- /**
1872
- * Cấu hình logger
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
- * Bật logger
1886
- */
1887
- enable() {
1888
- this.config.enabled = true;
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
- * Tắt logger
1892
- */
1893
- disable() {
1894
- this.config.enabled = false;
1895
- }
1896
- /**
1897
- * Format message với prefix timestamp
1898
- */
1899
- formatMessage(...args) {
1900
- const parts = [];
1901
- if (this.config.timestamp) {
1902
- parts.push(`[${new Date().toISOString()}]`);
1903
- }
1904
- if (this.config.prefix) {
1905
- parts.push(`[${this.config.prefix}]`);
1906
- }
1907
- if (parts.length > 0) {
1908
- return [parts.join(' '), ...args];
1909
- }
1910
- return args;
1911
- }
1912
- /**
1913
- * Log message
1914
- */
1915
- log(...args) {
1916
- if (!this.config.enabled)
1917
- return;
1918
- console.log(...this.formatMessage(...args));
1919
- }
1920
- /**
1921
- * Log info message
1922
- */
1923
- info(...args) {
1924
- if (!this.config.enabled)
1925
- return;
1926
- console.info(...this.formatMessage(...args));
1927
- }
1928
- /**
1929
- * Log warning message
1930
- */
1931
- warn(...args) {
1932
- if (!this.config.enabled)
1933
- return;
1934
- console.warn(...this.formatMessage(...args));
1935
- }
1936
- /**
1937
- * Log error message
1938
- */
1939
- error(...args) {
1940
- if (!this.config.enabled)
1941
- return;
1942
- console.error(...this.formatMessage(...args));
1943
- }
1944
- /**
1945
- * Log debug message
1946
- */
1947
- debug(...args) {
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
- * End a timer
1994
- */
1995
- timeEnd(label) {
1996
- if (!this.config.enabled)
1997
- return;
1998
- console.timeEnd(label);
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
- * Create an observable value with subscribe/unsubscribe pattern
2012
- */
2013
- function createObservable(initialValue) {
2014
- const subscribers = new Set();
2015
- const observable = {
2016
- value: initialValue,
2017
- observe: (callback) => {
2018
- subscribers.add(callback);
2019
- // Immediately call with current value
2020
- if (observable.value !== undefined) {
2021
- callback(observable.value);
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
- unobserve: (callback) => {
2025
- subscribers.delete(callback);
2026
- },
2027
- update: (newValue) => {
2028
- observable.value = newValue;
2029
- // Notify all subscribers
2030
- subscribers.forEach((callback) => {
2031
- callback(newValue);
2032
- });
2033
- },
2034
- };
2035
- return observable;
2036
- }
2037
-
2038
- /**
2039
- * Given a list of items where each item represents the START of a bucket,
2040
- * returns each item enriched with `startY` (= position) and `endY` (= next position, or 100 for the last bucket).
2041
- *
2042
- * Works for any scroll data shape (depth, attention, revenue).
2043
- */
2044
- function buildBuckets(items, getPosition) {
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
- function sortEvents(a, b) {
2054
- return a.time - b.time;
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
- * Throttle a function using requestAnimationFrame
2059
- * Ensures the callback is called at most once per animation frame
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 throttleRAF = (callback) => {
2062
- let rafId = null;
2063
- let latestArgs = null;
2064
- const throttled = (...args) => {
2065
- // Store the latest arguments
2066
- latestArgs = args;
2067
- // If already scheduled, do nothing
2068
- if (rafId !== null)
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
- // Schedule the callback for the next animation frame
2071
- rafId = requestAnimationFrame(() => {
2072
- if (latestArgs !== null) {
2073
- callback(...latestArgs);
2074
- latestArgs = null;
2075
- }
2076
- rafId = null;
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
- // Add cancel method to clear pending RAF
2080
- throttled.cancel = () => {
2081
- if (rafId !== null) {
2082
- cancelAnimationFrame(rafId);
2083
- rafId = null;
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
- return throttled;
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
- * Get color from click distribution percentage (0-100)
2092
- */
2093
- function getColorFromClickDist(clickDist) {
2094
- // Ensure clickDist is in range [0, 100]
2095
- const normalizedDist = Math.max(0, Math.min(100, clickDist));
2096
- // Calculate gradient index
2097
- const maxIndex = AREA_COLOR_GRADIENT.length - 1;
2098
- const index = Math.floor((normalizedDist / 100) * maxIndex);
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
- * Calculate click distribution percentage from total clicks
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 calculateClickDistribution(elementClicks, totalClicks) {
2120
- if (totalClicks === 0)
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
- const parent = element.parentElement;
2158
- return parent ? isElementFixed(parent) : false;
2159
- }
2160
- function doAreasOverlap(area1, area2) {
2161
- const r1 = area1.rect.value;
2162
- const r2 = area2.rect.value;
2163
- if (!r1 || !r2)
2164
- return false;
2165
- return ((r1.absoluteBottom > r2.absoluteTop &&
2166
- r1.absoluteTop < r2.absoluteBottom &&
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
- if (element.hasAttribute(AREA_MAP_DIV_ATTRIBUTE)) {
2192
- return false;
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
- if (index === 0 && elements.length > 1 && element.nodeName === 'BODY') {
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
- return true;
2198
- }
2199
- function sortAreasByClickDist(areas) {
2200
- return [...areas].sort((a, b) => {
2201
- if (a.clickDist !== b.clickDist) {
2202
- return b.clickDist - a.clickDist;
2203
- }
2204
- return b.totalclicks - a.totalclicks;
2205
- });
2206
- }
2207
- function isRectTooSmallForLabel(rect) {
2208
- return rect.width < 67 || rect.height < 30;
2209
- }
2210
-
2211
- function getElementSelector(element) {
2212
- if (element.id) {
2213
- return `#${element.id}`;
2214
- }
2215
- if (element.className) {
2216
- const classes = Array.from(element.classList).join('.');
2217
- if (classes) {
2218
- return `${element.tagName.toLowerCase()}.${classes}`;
2219
- }
2220
- }
2221
- return element.tagName.toLowerCase();
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
- function getTopElementsByClicks(clickMapMetrics, topN = 10) {
2290
- const elements = Object.entries(clickMapMetrics)
2291
- .map(([hash, info]) => ({
2292
- hash,
2293
- totalclicks: info.totalClicked.value ?? 0,
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
- * Build parent-child relationships between areas based on DOM hierarchy
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 buildAreaGraph(areas) {
2306
- // Clear existing relationships
2307
- areas.forEach((area) => {
2308
- area.parentNode = null;
2309
- area.childNodes.clear();
2310
- });
2311
- // Build relationships based on DOM containment
2312
- for (let i = 0; i < areas.length; i++) {
2313
- const area = areas[i];
2314
- for (let j = 0; j < areas.length; j++) {
2315
- if (i === j)
2316
- continue;
2317
- const otherArea = areas[j];
2318
- // Check if area's element is contained within otherArea's element
2319
- if (otherArea.element.contains(area.element)) {
2320
- // Find the closest parent (not just any ancestor)
2321
- if (!area.parentNode || area.parentNode.element.contains(otherArea.element)) {
2322
- // Remove from old parent if exists
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
- function decodeClarity(payload) {
2336
- try {
2337
- return decode(payload);
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
- catch (_error) {
2340
- return null;
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
- function decodeArrayClarity(items) {
2344
- return items.map((item) => decodeClarity(item)).filter((item) => item !== null);
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
- function findElementByHash(props) {
2348
- const { hash, selector, iframeDocument, vizRef } = props;
2349
- if (vizRef) {
2350
- const element = vizRef.get(hash);
2351
- return element;
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
- // Fallback
2354
- if (!iframeDocument)
2355
- return null;
2356
- try {
2357
- const element = selector ? iframeDocument.querySelector(selector) : null;
2358
- if (element) {
2359
- return element;
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
- catch (error) {
2363
- logger$3.warn(`Invalid selector "${selector}":`, error);
2458
+ /**
2459
+ * Log message
2460
+ */
2461
+ log(...args) {
2462
+ if (!this.config.enabled)
2463
+ return;
2464
+ console.log(...this.formatMessage(...args));
2364
2465
  }
2365
- const elementByHash = iframeDocument.querySelector(`[data-clarity-hashalpha="${hash}"], [data-clarity-hash="${hash}"], [data-clarity-hashbeta="${hash}"]`);
2366
- return elementByHash;
2367
- }
2368
-
2369
- /**
2370
- * Hydrates persisted area data into full area node
2371
- * Finds element in DOM and calculates all runtime values
2372
- *
2373
- * @param persistedData - Minimal data from database
2374
- * @param iframeDocument - Document to find element in
2375
- * @param heatmapInfo - Heatmap data for click calculations
2376
- * @param vizRef - Map of hash to elements
2377
- * @param shadowRoot - Optional shadow root for rect calculation
2378
- * @returns Full area node or null if element not found
2379
- */
2380
- function hydrateAreaNode(props) {
2381
- const { persistedData, iframeDocument, heatmapInfo, vizRef, shadowRoot } = props;
2382
- const { id, hash, selector } = persistedData;
2383
- const element = findElementByHash({ hash, selector, iframeDocument, vizRef });
2384
- if (!element) {
2385
- logger$3.warn(`Cannot hydrate area ${id}: element not found for hash ${hash} or selector ${selector}`);
2386
- return null;
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
- const areaNode = buildAreaNode(element, hash, heatmapInfo, shadowRoot, persistedData);
2389
- if (!areaNode)
2390
- return null;
2391
- return areaNode;
2392
- }
2393
- function hydrateAreas(props) {
2394
- const { clickAreas, iframeDocument, heatmapInfo, vizRef, shadowRoot } = props;
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
- * Serializes area node to persisted data for database storage
2407
- */
2408
- function serializeAreaNode(area) {
2409
- return {
2410
- kind: area.kind,
2411
- id: area.id,
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
- * Serializes multiple areas for database storage
2557
+ * Create an observable value with subscribe/unsubscribe pattern
2418
2558
  */
2419
- function serializeAreas(areas) {
2420
- return areas.map(serializeAreaNode);
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
- * Resolve overlapping areas by priority rules
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
- * Priority Rules (in order):
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 findOverlapGroups(areas) {
2457
- const groups = [];
2458
- const processed = new Set();
2459
- areas.forEach((area) => {
2460
- if (processed.has(area.id))
2461
- return;
2462
- // Find all areas that overlap with this one
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
- * Resolve a single overlap group to find the winner
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
- * Filter out areas that are completely contained within others
2522
- * and have lower priority
2604
+ * Throttle a function using requestAnimationFrame
2605
+ * Ensures the callback is called at most once per animation frame
2523
2606
  */
2524
- function filterContainedAreas(areas) {
2525
- const visible = [];
2526
- areas.forEach((area) => {
2527
- // Check if this area is contained by a higher priority area
2528
- const isContained = areas.some((other) => {
2529
- if (other.id === area.id)
2530
- return false;
2531
- // Check containment
2532
- if (!isAreaContainedIn(area, other))
2533
- return false;
2534
- // Check priority
2535
- if (other.priority && !area.priority)
2536
- return true;
2537
- if (!other.priority && area.priority)
2538
- return false;
2539
- // Compare by click dist
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
- if (!isContained) {
2548
- visible.push(area);
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 visible;
2552
- }
2632
+ };
2633
+ return throttled;
2634
+ };
2635
+
2553
2636
  /**
2554
- * Get visible areas after resolving overlaps
2637
+ * Get color from click distribution percentage (0-100)
2555
2638
  */
2556
- function getVisibleAreas(areas, iframeDocument) {
2557
- // First pass: filter contained areas
2558
- let visible = filterContainedAreas(areas);
2559
- // Second pass: resolve overlaps
2560
- visible = resolveOverlaps(visible, iframeDocument);
2561
- // Sort by click dist for rendering order
2562
- return sortAreasByClickDist(visible);
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
- * Create the outer container for area rendering
2651
+ * Get hover color (slightly lighter) from click distribution
2570
2652
  */
2571
- function createAreaContainer(iframeDocument) {
2572
- const container = iframeDocument.createElement('div');
2573
- container.setAttribute(AREA_MAP_DIV_ATTRIBUTE, 'true');
2574
- container.style.cssText = AREA_CONTAINER_STYLES;
2575
- return container;
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
- * Create the inner container for React portal
2663
+ * Calculate click distribution percentage from total clicks
2579
2664
  */
2580
- function createInnerContainer(iframeDocument) {
2581
- const innerContainer = iframeDocument.createElement('div');
2582
- innerContainer.className = AREA_RENDERER_SELECTORS.innerContainerClass;
2583
- innerContainer.style.cssText = AREA_INNER_CONTAINER_STYLES;
2584
- return innerContainer;
2665
+ function calculateClickDistribution(elementClicks, totalClicks) {
2666
+ if (totalClicks === 0)
2667
+ return 0;
2668
+ return (elementClicks / totalClicks) * 100;
2585
2669
  }
2586
- /**
2587
- * Get or create the outer container element
2588
- */
2589
- function getOrCreateAreaContainer(iframeDocument, customShadowRoot) {
2590
- let container = iframeDocument.querySelector(AREA_RENDERER_SELECTORS.containerSelector);
2591
- if (!container) {
2592
- container = createAreaContainer(iframeDocument);
2593
- const targetRoot = customShadowRoot || iframeDocument.body;
2594
- if (targetRoot) {
2595
- targetRoot.appendChild(container);
2596
- }
2597
- }
2598
- return container;
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 getOrCreateContainerShadowRoot(container) {
2601
- if (container.shadowRoot) {
2602
- return container.shadowRoot;
2696
+ function isElementFixed(element) {
2697
+ if (getComputedStyle(element).position === 'fixed') {
2698
+ return true;
2603
2699
  }
2604
- return container.attachShadow({ mode: 'open' });
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
- return innerContainer;
2703
+ const parent = element.parentElement;
2704
+ return parent ? isElementFixed(parent) : false;
2613
2705
  }
2614
- function setupAreaRenderingContainer(iframeDocument, customShadowRoot) {
2615
- const container = getOrCreateAreaContainer(iframeDocument, customShadowRoot);
2616
- const shadowRoot = getOrCreateContainerShadowRoot(container);
2617
- const innerContainer = getOrCreateInnerContainer(shadowRoot, iframeDocument);
2618
- return innerContainer;
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 cleanupAreaRenderingContainer(container) {
2621
- if (container && container.parentNode) {
2622
- container.parentNode.removeChild(container);
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
- * Generate unique element ID for a specific view
2628
- * @param baseId - Base element ID
2629
- * @param viewId - View ID
2630
- * @returns Unique element ID (e.g., 'gx-hm-clicked-element-view-0')
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
- const getElementId = (baseId, viewId) => {
2633
- return `${baseId}-${viewId}`;
2634
- };
2635
- const getClickedElementId = (viewId, isSecondary = false) => {
2636
- const baseId = isSecondary ? SECONDARY_CLICKED_ELEMENT_ID_BASE : CLICKED_ELEMENT_ID_BASE;
2637
- return getElementId(baseId, viewId);
2638
- };
2639
- const getHoveredElementId = (viewId, isSecondary = false) => {
2640
- const baseId = isSecondary ? SECONDARY_HOVERED_ELEMENT_ID_BASE : HOVERED_ELEMENT_ID_BASE;
2641
- return getElementId(baseId, viewId);
2642
- };
2643
-
2644
- function getElementLayout(element) {
2645
- if (!element?.getBoundingClientRect)
2646
- return null;
2647
- const rect = element.getBoundingClientRect();
2648
- if (rect.width === 0 && rect.height === 0)
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
- const getElementRank = (hash, elements) => {
2658
- if (!elements)
2659
- return 0;
2660
- return elements.findIndex((e) => e.hash === hash) + 1;
2661
- };
2662
- const buildElementInfo = (hash, rect, heatmapInfo) => {
2663
- if (!rect || !heatmapInfo)
2664
- return null;
2665
- const info = heatmapInfo.clickMapMetrics?.[hash];
2666
- if (!info)
2667
- return null;
2668
- const rank = getElementRank(hash, heatmapInfo.sortedElements);
2669
- const clicks = info.totalClicked.value ?? 0;
2670
- const selector = info.selector ?? '';
2671
- const baseInfo = {
2672
- hash,
2673
- clicks,
2674
- rank,
2675
- selector,
2676
- };
2677
- return {
2678
- ...baseInfo,
2679
- ...rect,
2680
- };
2681
- };
2682
- // GETTERS
2683
- const getScaledCalloutRect = (_element, _widthScale) => {
2684
- return {
2685
- width: 230,
2686
- height: 268,
2687
- };
2688
- };
2689
- const getStyleFromCandidate = (candidate, widthScale) => {
2690
- const { horizontalAlign, placement, top, left } = candidate;
2691
- const yTransformAlign = placement === 'top' ? 'bottom' : 'top';
2692
- const xTransformAlign = horizontalAlign === 'left' ? 'left' : 'right';
2693
- return {
2694
- top,
2695
- left,
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
- const getContainerViewport = (containerElm, _scale) => {
2713
- if (containerElm) {
2714
- const containerRect = containerElm.getBoundingClientRect();
2715
- const width = containerRect.width;
2716
- const height = containerRect.height;
2717
- return { width, height };
2718
- }
2719
- return {
2720
- width: window.innerWidth,
2721
- height: window.innerHeight,
2722
- };
2723
- };
2724
- const getVisualDomViewport = (visualDomElm, scale = 1) => {
2725
- if (visualDomElm) {
2726
- const rect = visualDomElm.getBoundingClientRect();
2727
- return {
2728
- width: rect.width,
2729
- height: rect.height,
2730
- scrollTop: visualDomElm.scrollTop,
2731
- scrollLeft: visualDomElm.scrollLeft,
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
- return {
2735
- width: window.innerWidth,
2736
- height: window.innerHeight,
2737
- scrollTop: 0,
2738
- scrollLeft: 0,
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
- if (scale) {
2766
- return {
2767
- targetRect,
2768
- calloutRect: scaledCalloutRect,
2769
- };
2885
+ catch (_error) {
2886
+ return null;
2770
2887
  }
2771
- return { targetRect, calloutRect };
2772
- };
2888
+ }
2889
+ function decodeArrayClarity(items) {
2890
+ return items.map((item) => decodeClarity(item)).filter((item) => item !== null);
2891
+ }
2773
2892
 
2774
- const getAlignmentOrder = (alignment) => {
2775
- switch (alignment) {
2776
- case 'center':
2777
- return ['center', 'left', 'right'];
2778
- case 'left':
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
- const calculateLeftPosition = (align, options) => {
2785
- const { rectDimensions, offset } = options;
2786
- const { targetRect, calloutRect } = rectDimensions;
2787
- const { x: hozOffset } = offset;
2788
- const relLeft = targetRect.left;
2789
- const relRight = targetRect.right;
2790
- const relWidth = targetRect.width;
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
- // No clamping - let validation determine if position is valid
2806
- // If position would overflow, valid = false and system chooses different placement
2807
- return left;
2808
- };
2809
- const calculateVerticalPosition = (placement, options) => {
2810
- const { rectDimensions, padding, arrowSize, offset } = options;
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
- const EPSILON = 0.1; // Tolerance for floating point errors
2828
- const isLeftPositionValid = (leftPos, options) => {
2829
- const { rectDimensions, viewport, padding, offset } = options;
2830
- const { width: calloutWidth } = rectDimensions.calloutRect;
2831
- const { width: viewportWidth } = viewport;
2832
- const absLeft = rectDimensions.targetAbsoluteRect?.left ?? 0;
2833
- const relLeftPos = absLeft + leftPos - offset.x;
2834
- const calloutWidthScaled = calloutWidth / options.widthScale;
2835
- const maxViewportWidth = viewportWidth + EPSILON;
2836
- const isValidLeft = relLeftPos >= padding - EPSILON;
2837
- const isRectCalloutShowValid = relLeftPos + calloutWidthScaled - EPSILON <= maxViewportWidth / options.widthScale;
2838
- return isValidLeft && isRectCalloutShowValid;
2839
- };
2840
- const isRightPositionValid = (leftPos, options) => {
2841
- const { rectDimensions, viewport } = options;
2842
- const { width: calloutWidth } = rectDimensions.calloutRect;
2843
- const { width: viewportWidth } = viewport;
2844
- const calloutWidthScaled = calloutWidth / options.widthScale;
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
- const isHorizontalPositionValid = (placement, options) => {
2866
- const { rectDimensions, viewport, padding, arrowSize } = options;
2867
- const { targetRect, targetAbsoluteRect, calloutRect } = rectDimensions;
2868
- const { width: viewportWidth } = viewport;
2869
- const { width: calloutWidth } = calloutRect;
2870
- const relativeLeft = (targetAbsoluteRect?.left ?? 0) + targetRect.left;
2871
- const relativeRight = relativeLeft + targetRect.width;
2872
- switch (placement) {
2873
- case 'right':
2874
- return relativeRight + calloutWidth + padding + arrowSize < viewportWidth + EPSILON;
2875
- case 'left':
2876
- return relativeLeft - calloutWidth - padding - arrowSize > -EPSILON;
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
- const generateVerticalPositionCandidates = (options) => {
2881
- const { alignment } = options;
2882
- const candidates = [];
2883
- const placements = ['top', 'bottom'];
2884
- placements.forEach((placement) => {
2885
- const verticalPos = calculateVerticalPosition(placement, options);
2886
- const verticalValid = isVerticalPositionValid(placement, options);
2887
- const alignmentOrder = getAlignmentOrder(alignment);
2888
- alignmentOrder.forEach((align) => {
2889
- const leftPos = calculateLeftPosition(align, options);
2890
- const isValidLeft = isLeftPositionValid(leftPos, options);
2891
- const isValidRight = isRightPositionValid(leftPos, options);
2892
- const candidate = { placement, top: verticalPos, left: leftPos, horizontalAlign: align, valid: false };
2893
- switch (align) {
2894
- case 'left':
2895
- candidate.valid = verticalValid && isValidLeft;
2896
- break;
2897
- case 'right':
2898
- candidate.valid = verticalValid && isValidRight;
2899
- break;
2900
- }
2901
- candidates.push(candidate);
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 candidates;
2905
- };
2906
- const generateHorizontalPositionCandidates = (options) => {
2907
- const placements = ['left', 'right'];
2908
- return placements.map((placement) => {
2909
- const { top, left } = calculateHorizontalPosition(placement, options);
2910
- const isValidHorizontal = isHorizontalPositionValid(placement, options);
2911
- const candidate = { placement, top, left, horizontalAlign: 'center', valid: false };
2912
- candidate.valid = isValidHorizontal;
2913
- return candidate;
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
- const generateAllCandidates = (options) => {
2917
- const verticalCandidates = generateVerticalPositionCandidates(options);
2918
- const horizontalCandidates = generateHorizontalPositionCandidates(options);
2919
- const allCandidates = [...verticalCandidates, ...horizontalCandidates];
2920
- return allCandidates.map((candidate) => ({
2921
- ...candidate,
2922
- isVisibleInViewport: isElementRectInViewport({ candidate, options }),
2923
- }));
2924
- };
2925
-
2926
- const selectBestPosition = (candidates) => {
2927
- // return candidates.find((p) => p.valid && p.isVisibleInViewport) || candidates[0];
2928
- return candidates.find((p) => p.valid) || candidates[0];
2929
- };
2930
- const selectBestPositionForClick = (candidates) => {
2931
- return (candidates.find((p) => p.valid && p.isVisibleInViewport) ||
2932
- candidates.find((p) => p.isVisibleInViewport) ||
2933
- candidates.find((p) => p.valid) ||
2934
- candidates[0]);
2935
- };
2936
- const constrainToViewport = (candidate, options) => {
2937
- const { containerRect, padding, rectDimensions, viewport } = options;
2938
- const { calloutRect } = rectDimensions;
2939
- const { left: leftPos, top: topPos } = candidate;
2940
- if (containerRect) {
2941
- const containerTop = containerRect.top + padding;
2942
- const containerLeft = containerRect.left + padding;
2943
- const containerRight = containerRect.right - calloutRect.width - padding;
2944
- const containerBottom = containerRect.bottom - calloutRect.height - padding;
2945
- const left = Math.max(containerLeft, Math.min(leftPos, containerRight));
2946
- const top = Math.max(containerTop, Math.min(topPos, containerBottom));
2947
- return { top, left };
2948
- }
2949
- const viewportLeft = padding;
2950
- const viewportTop = padding;
2951
- const viewportRight = viewport.width - calloutRect.width - padding;
2952
- const viewportBottom = viewport.height - calloutRect.height - padding;
2953
- const left = Math.max(viewportLeft, Math.min(leftPos, viewportRight));
2954
- const top = Math.max(viewportTop, Math.min(topPos, viewportBottom));
2955
- return { top, left };
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
- * Create adjusted container rect for absolute positioning
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
- const createAdjustedContainerRect = (options) => {
2972
- const { containerElm, scale, isAbsolute, visualRef } = options;
2973
- const containerRect = containerElm.getBoundingClientRect();
2974
- const scrollOffset = getScrollOffset(visualRef);
2975
- // No scale = fixed positioning, use raw rect
2976
- if (!scale)
2977
- return containerRect;
2978
- const scaledWidth = containerRect.width / scale;
2979
- const scaledHeight = containerRect.height / scale;
2980
- // Absolute positioning with scroll offset
2981
- if (isAbsolute && scrollOffset) {
2982
- return {
2983
- ...containerRect,
2984
- top: scrollOffset.top,
2985
- left: scrollOffset.left,
2986
- right: scrollOffset.left + scaledWidth,
2987
- bottom: scrollOffset.top + scaledHeight,
2988
- width: scaledWidth,
2989
- height: scaledHeight,
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
- // Absolute positioning without scroll
2993
- return {
2994
- ...containerRect,
2995
- top: 0,
2996
- left: 0,
2997
- right: scaledWidth,
2998
- width: scaledWidth,
2999
- bottom: scaledHeight,
3000
- height: scaledHeight,
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 calcCalloutPosition = (options) => {
3004
- const { targetElm, calloutElm, setPosition, positionMode, widthScale, visualRef } = options;
3005
- const offset = options.offset ?? CALLOUT_OFFSET;
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 calcCalloutPositionAbsolute = (props) => {
3048
- const { widthScale, calloutElm, containerElm, element, visualRef, setPosition } = props;
3049
- const mousePosition = element?.mousePosition;
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
- const calcPositionDetail = ({ element, widthScale, visualRef }) => {
3096
- const mousePosition = element.mousePosition;
3097
- // visualRef = scrollable viewport (gx-hm-visual), clientWidth/Height = visible area
3098
- const visual = visualRef?.current;
3099
- const viewportInfo = {
3100
- width: visual?.clientWidth ?? 0,
3101
- height: visual?.clientHeight ?? 0,
3102
- scrollTop: visual?.scrollTop ?? 0,
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
- if (mousePosition) {
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
- // Scroll to element and set clicked element after scroll completes
3756
- scrollToElementIfNeeded(visualRef, rect, widthScale, () => {
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
  };