/**
 * Heatmap Detail Page Controller
 *
 * Handles the heatmap detail page interactions, data loading, and visualization.
 *
 * @package OptiBehaviorPro
 * @since 1.5.0
 */

(function($) {
	'use strict';

	/**
	 * Heatmap Detail Controller Class
	 */
	class HeatmapDetailController {
		/**
		 * Constructor
		 */
		constructor() {
			this.config = window.optiHeatmapDetail || {};
			this.currentDevice = this.config.device || 'desktop';
			this.currentType = this.config.type || 'click';
			this.pageId = parseInt(this.config.page_id, 10) || 0;
			// Coerce to integer — wp_localize_script emits integers as JS strings,
			// and `"0"` is truthy, which previously caused the A/B-variant branch
			// below to misfire on plain heatmap views and wrongly default the
			// visitor_type filter to "All Visitors" instead of "Guest Visitors".
			this.abTestId = parseInt(this.config.ab_test_id, 10) || 0;
			this.variantId = parseInt(this.config.variant_id, 10) || 0;
			this.heatmapInstance = null;
			this.isLoading = false;
			this.stopLoadingRequested = false; // Flag to stop batch loading on user request
			this.heatmapAlreadyRendered = false; // Flag to prevent double rendering in iframe onload
			this.pendingResetForm = null; // Form waiting for modal confirmation

			// Tab visibility handling - prevents crash when switching tabs during load
			this.isTabVisible = !document.hidden;
			this.isPausedDueToVisibility = false;
			this.pendingBatchNumber = null;
			this.batchRetryCount = 0;
			this.maxBatchRetries = 3;

			// Initialize filter values
			// country and browser support multi-select (arrays stored as comma-separated strings)
			this.filters = {
				date_range: 'all',
				country: '',      // comma-separated country codes (e.g., "US,FR,DE")
				browser: '',      // comma-separated browser names (e.g., "Chrome,Firefox")
				visitor_type: 'guest'  // Default to Guest Visitors
			};

			// When viewing a per-variant heatmap from an A/B test, default to All Visitors
			// so that admin/logged-in testing sessions (stored as "L") are not filtered out.
			// Use strict > 0 checks to avoid string-"0" truthiness traps.
			if (this.abTestId > 0 && this.variantId > 0) {
				this.filters.visitor_type = '';
			}

			if (!this.pageId) {
				this.debug('error', 'No page ID provided');
				return;
			}

			// Setup visibility change listener to handle tab switching
			this.setupVisibilityChangeHandler();

			this.init();
		}

		/**
		 * Setup visibility change handler to pause/resume loading when tab is hidden/visible
		 * This prevents the page from crashing when users switch tabs during large data loads
		 */
		setupVisibilityChangeHandler() {
			document.addEventListener('visibilitychange', () => {
				const wasVisible = this.isTabVisible;
				this.isTabVisible = !document.hidden;

				if (this.isTabVisible && !wasVisible) {
					// Tab became visible again
					this.debug('debug', 'Tab became visible - resuming operations');

					// Resume batch loading if it was paused
					if (this.isPausedDueToVisibility && this.pendingBatchNumber !== null) {
						this.debug('debug', `Resuming batch loading from batch ${this.pendingBatchNumber}`);
						this.isPausedDueToVisibility = false;

						// Show a brief message to user
						this.showBatchProgress(
							this.pendingBatchNumber - 1,
							this.totalBatches || 10,
							this.allCoordinates ? this.allCoordinates.length : 0,
							'Resuming...'
						);

						// Small delay before resuming to let the browser settle
						setTimeout(() => {
							if (!this.stopLoadingRequested) {
								this.loadHeatmapBatch(this.pendingBatchNumber);
							}
						}, 100);
					}
				} else if (!this.isTabVisible && wasVisible) {
					// Tab became hidden
					this.debug('debug', 'Tab became hidden - operations may be throttled');
				}
			});

			// Also handle page show event (for bfcache restoration)
			window.addEventListener('pageshow', (event) => {
				if (event.persisted) {
					// Page was restored from bfcache
					this.debug('debug', 'Page restored from bfcache');
					this.isTabVisible = true;

					// Check if we need to resume loading
					if (this.isPausedDueToVisibility && this.pendingBatchNumber !== null) {
						this.isPausedDueToVisibility = false;
						setTimeout(() => {
							if (!this.stopLoadingRequested) {
								this.loadHeatmapBatch(this.pendingBatchNumber);
							}
						}, 100);
					}
				}
			});
		}

		/**
		 * Debug helper - safely calls OptiBehaviorDebug if available
		 */
		debug(level, message, data = null) {
			if (typeof window.OptiBehaviorDebug !== 'undefined' && window.OptiBehaviorDebug[level]) {
				if (data !== null) {
					window.OptiBehaviorDebug[level](message, 'heatmap-detail', data);
				} else {
					window.OptiBehaviorDebug[level](message, 'heatmap-detail');
				}
			}
		}

		/**
		 * Measure the real iframe document height, including late footer/theme wrappers.
		 *
		 * scrollHeight can under-report on some themes when footers are positioned inside
		 * flex/page wrappers, so also inspect bottom edges for common WordPress wrappers.
		 */
		_measureIframeContentHeight(doc, minimumHeight = 0) {
			if (!doc || !doc.body || !doc.documentElement) {
				return minimumHeight || 0;
			}

			const win = doc.defaultView || {};
			const body = doc.body;
			const html = doc.documentElement;
			const scrollTop = win.pageYOffset || html.scrollTop || body.scrollTop || 0;
			const heights = [
				minimumHeight || 0,
				body.scrollHeight || 0,
				html.scrollHeight || 0,
				body.offsetHeight || 0,
				html.offsetHeight || 0,
				body.clientHeight || 0,
				html.clientHeight || 0
			];

			const selector = [
				'footer',
				'#footer',
				'.site-footer',
				'.main-footer',
				'.elementor-location-footer',
				'#page',
				'#wrap',
				'#wrapper',
				'.site',
				'.site-content',
				'#content',
				'main',
				'body > *'
			].join(',');

			try {
				doc.querySelectorAll(selector).forEach(function(el) {
					const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
					if (!rect || rect.height <= 0) {
						return;
					}

					let marginBottom = 0;
					if (win.getComputedStyle) {
						const style = win.getComputedStyle(el);
						if (style && style.display === 'none') {
							return;
						}
						marginBottom = parseFloat(style && style.marginBottom) || 0;
					}

					const bottom = rect.bottom + scrollTop + marginBottom;
					if (isFinite(bottom) && bottom > 0) {
						heights.push(bottom);
					}
				});
			} catch (selectorErr) {
				this.debug('warn', 'Footer/wrapper height scan failed:', selectorErr);
			}

			return Math.ceil(Math.max.apply(Math, heights));
		}

		/**
		 * Apply iframe/container dimensions consistently and keep coordinate mapping in sync.
		 */
		_applyIframeContentSize(iframe, container, actualWidth, actualHeight, containerWidth) {
			const safeWidth = Math.max(Math.ceil(actualWidth || 0), 1);
			const safeHeight = Math.max(Math.ceil(actualHeight || 0), 1);
			const availableWidth = Math.max(Math.ceil(containerWidth || safeWidth), 1);
			const scale = safeWidth > availableWidth ? availableWidth / safeWidth : 1;

			this.idealWidth = safeWidth;
			this.idealHeight = safeHeight;
			this.canvasScaleFactor = scale;

			iframe.style.width = safeWidth + 'px';
			iframe.style.height = safeHeight + 'px';

			if (scale < 1) {
				iframe.style.transformOrigin = 'top left';
				iframe.style.transform = 'scale(' + scale + ')';
				container.style.width = availableWidth + 'px';
				container.style.height = (safeHeight * scale) + 'px';
			} else {
				iframe.style.transform = 'none';
				container.style.width = safeWidth + 'px';
				container.style.height = safeHeight + 'px';
			}

			return scale;
		}

		/**
		 * Watch for late iframe layout growth from lazy images, Elementor, or theme footers.
		 */
		_startIframeHeightWatcher(iframe, container, actualWidth, containerWidth, initialHeight, data, viewport) {
			const self = this;
			let lastMeasuredH = initialHeight || 0;
			let debounceTimer = null;
			let stopped = false;
			let resizeObserver = null;
			let mutationObserver = null;
			const referenceHeight = data && data.reference_height ? parseFloat(data.reference_height) : 0;
			const maxAllowedHeight = Math.max(
				(initialHeight || 0) * 1.6,
				(initialHeight || 0) + 3000,
				referenceHeight * 1.6,
				8000
			);

			const stopWatching = function() {
				stopped = true;
				if (debounceTimer) {
					clearTimeout(debounceTimer);
				}
				if (resizeObserver) {
					resizeObserver.disconnect();
				}
				if (mutationObserver) {
					mutationObserver.disconnect();
				}
			};

			const remeasure = function(reason) {
				if (stopped) {
					return;
				}

				try {
					const rDoc = iframe.contentDocument || iframe.contentWindow.document;
					if (!rDoc || !rDoc.body) {
						return;
					}

					let measuredH = self._measureIframeContentHeight(rDoc, lastMeasuredH);
					if (measuredH > maxAllowedHeight) {
						self.debug('warn', 'Late iframe height capped to prevent viewport feedback loop (' + reason + '): ' + measuredH + ' -> ' + maxAllowedHeight, 'heatmap-detail');
						measuredH = maxAllowedHeight;
						stopped = true;
					}

					if (measuredH > lastMeasuredH + 50) {
						self.debug('debug', 'Late iframe height correction (' + reason + '): ' + measuredH + ' vs ' + lastMeasuredH, 'heatmap-detail');
						lastMeasuredH = measuredH;
						self._applyIframeContentSize(iframe, container, actualWidth, measuredH, containerWidth);
						self.createOverlayAndRenderHeatmap(data, viewport);
					}
				} catch (watchErr) {
					self.debug('warn', 'Late iframe height check failed (' + reason + '):', watchErr);
				}
			};

			const scheduleRemeasure = function(reason) {
				if (stopped) {
					return;
				}
				if (debounceTimer) {
					clearTimeout(debounceTimer);
				}
				debounceTimer = setTimeout(function() {
					remeasure(reason);
				}, 200);
			};

			try {
				const watchedDoc = iframe.contentDocument || iframe.contentWindow.document;
				// Do not attach continuous ResizeObserver/MutationObserver here.
				// On pages with 100vh/min-height:100vh wrappers, changing iframe height
				// changes the iframe viewport, which changes document height again and can
				// create an unbounded feedback loop. Fixed delayed checks below are safer.
				if (false && watchedDoc && watchedDoc.body) {
					if (typeof ResizeObserver !== 'undefined') {
						resizeObserver = new ResizeObserver(function() {
							scheduleRemeasure('resize-observer');
						});
						resizeObserver.observe(watchedDoc.documentElement);
						resizeObserver.observe(watchedDoc.body);
					}

					if (typeof MutationObserver !== 'undefined') {
						mutationObserver = new MutationObserver(function() {
							scheduleRemeasure('mutation-observer');
						});
						mutationObserver.observe(watchedDoc.body, {
							childList: true,
							subtree: true,
							attributes: true,
							attributeFilter: ['class', 'style', 'height']
						});
					}
				}
			} catch (observerErr) {
				this.debug('warn', 'Could not start iframe height observers:', observerErr);
			}

			[2000, 5000, 10000, 15000].forEach(function(delay) {
				setTimeout(function() {
					remeasure(delay + 'ms');
				}, delay);
			});

			setTimeout(stopWatching, 20000);
		}

		/**
		 * Initialize controller
		 */
		init() {
			this.bindEvents();
			this.updateStatItemsVisibility();

			// Set initial filter values in UI
			$('#filter-visitor-type').val(this.filters.visitor_type).addClass('has-value');

			// Set initial A/B variant selector value if loaded from URL params
			if (this.abTestId > 0 && this.variantId > 0) {
				$('#filter-ab-variant').val(this.abTestId + ':' + this.variantId).addClass('has-value');
			}

			// Initialize Lucide icons
			this.debug('debug', 'Initializing Lucide icons...');
			this.debug('debug', 'typeof lucide:', typeof lucide);
			if (typeof lucide !== 'undefined' && typeof lucide.createIcons === 'function') {
				this.debug('debug', 'Calling lucide.createIcons()...');
				lucide.createIcons();
				this.debug('debug', 'Lucide icons initialized successfully');
			} else {
				this.debug('error', 'Lucide library not loaded!');
			}

			// Launch parallel async operations (two-thread approach):
			// Thread 1 (Fast): Load filter options from filenames - populates dropdowns quickly
			// Thread 2 (Slower): Load heatmap data and stats - renders heatmap progressively
			this.loadFilterOptions();  // Fast - scans filenames only
			this.loadTotalRecordingsCount(); // Unfiltered total for header - called once, never changes with filters
			this.loadDeviceCounts();
			this.loadHeatmap();        // Slower - reads file contents
			this.loadStats();
		}

		/**
		 * Update stat items visibility and labels based on current type
		 */
		updateStatItemsVisibility() {
			// Attention heatmap uses scroll data, so show scroll stats for both scroll and attention types
			if (this.currentType === 'scroll' || this.currentType === 'attention') {
				$('.stat-clicks-item').hide();
				$('.stat-scroll-item').show();
				$('.stat-move-item').hide();
			} else if (this.currentType === 'move') {
				$('.stat-clicks-item').hide();
				$('.stat-scroll-item').hide();
				$('.stat-move-item').show();
			} else {
				$('.stat-clicks-item').show();
				$('.stat-scroll-item').hide();
				$('.stat-move-item').hide();
			}
		}

		/**
		 * Bind UI event handlers
		 */
		bindEvents() {
			// Device switcher
			$('.device-btn').on('click', (e) => {
				const device = $(e.currentTarget).data('device');
				if (device && device !== this.currentDevice) {
					this.switchDevice(device);
				}
			});

			// Type tabs
			$('.heatmap-tab').on('click', (e) => {
				const $btn = $(e.currentTarget);
				if ($btn.hasClass('disabled') || $btn.hasClass('pro-only')) {
					return;
				}
				const type = $btn.data('type');
				if (type && type !== this.currentType) {
					this.switchType(type);
				}
			});

			// Refresh button
			$('#refresh-heatmap').on('click', () => {
				this.refreshHeatmap();
			});

			// Download button
			$('#download-heatmap').on('click', () => {
				this.downloadHeatmapImage();
			});

			this.initResetHeatmapModal();

			// View toggle (Simple/Detailed)
			$('.view-btn').on('click', (e) => {
				const view = $(e.currentTarget).data('view');
				$('.view-btn').removeClass('active');
				$(e.currentTarget).addClass('active');
				// Add view switching logic here if needed
			});

			// Filter dropdowns (single-select)
			$('.filter-select').on('change', (e) => {
				const $select = $(e.currentTarget);
				const filterName = $select.data('filter');
				const filterValue = $select.val();

				// A/B variant selector: parse "test_id:variant_id" and reload
				if (filterName === 'ab_variant') {
					this.switchVariant(filterValue);
					return;
				}

				this.updateFilter(filterName, filterValue);
			});

			// Multi-select dropdowns
			this.initMultiSelectDropdowns();

			// Reset filters button
			$('#reset-filters').on('click', () => {
				this.resetFilters();
			});
		}

		/**
		 * Initialize the custom reset confirmation modal.
		 */
		initResetHeatmapModal() {
			const self = this;
			const $modal = $('#opti-heatmap-reset-modal');

			$('.opti-reset-heatmap-form').on('submit', function(e) {
				e.preventDefault();
				self.pendingResetForm = this;
				self.showResetHeatmapModal();
			});

			$('#opti-heatmap-reset-modal-cancel').on('click', () => {
				this.hideResetHeatmapModal();
			});

			$('#opti-heatmap-reset-modal-confirm').on('click', () => {
				const form = this.pendingResetForm;
				this.hideResetHeatmapModal();

				if (form) {
					HTMLFormElement.prototype.submit.call(form);
				}
			});

			$modal.on('click', (e) => {
				if (e.target === e.currentTarget) {
					this.hideResetHeatmapModal();
				}
			});

			$(document).on('keydown.optiHeatmapResetModal', (e) => {
				if (e.key !== 'Escape' && e.keyCode !== 27) {
					return;
				}

				if ($modal.hasClass('is-visible')) {
					this.hideResetHeatmapModal();
					e.preventDefault();
					e.stopPropagation();
				}
			});
		}

		/**
		 * Show the custom reset confirmation modal.
		 */
		showResetHeatmapModal() {
			$('#opti-heatmap-reset-modal')
				.removeAttr('style')
				.addClass('opti-ab-confirm-overlay is-visible');
			$('#opti-heatmap-reset-modal-cancel').trigger('focus');

			if (typeof lucide !== 'undefined' && typeof lucide.createIcons === 'function') {
				lucide.createIcons();
			}
		}

		/**
		 * Hide the custom reset confirmation modal.
		 */
		hideResetHeatmapModal() {
			$('#opti-heatmap-reset-modal')
				.removeClass('is-visible opti-ab-confirm-overlay')
				.css('display', 'none');
			this.pendingResetForm = null;
		}

		/**
		 * Initialize multi-select dropdown components
		 */
		initMultiSelectDropdowns() {
			const self = this;

			// Toggle dropdown on trigger click
			$(document).on('click', '.multiselect-trigger', function(e) {
				e.stopPropagation();
				const $dropdown = $(this).closest('.multiselect-dropdown');
				const wasOpen = $dropdown.hasClass('open');

				// Close all other dropdowns
				$('.multiselect-dropdown').removeClass('open');

				// Toggle this dropdown
				if (!wasOpen) {
					$dropdown.addClass('open');
				}
			});

			// Close dropdowns when clicking outside
			$(document).on('click', function(e) {
				if (!$(e.target).closest('.multiselect-dropdown').length) {
					$('.multiselect-dropdown').removeClass('open');
				}
			});

			// Search functionality
			$(document).on('input', '.multiselect-search-input', function() {
				const searchTerm = $(this).val().toLowerCase();
				const $options = $(this).closest('.multiselect-menu').find('.multiselect-option');

				$options.each(function() {
					const text = $(this).text().toLowerCase();
					$(this).toggle(text.includes(searchTerm));
				});
			});

			// Clear button
			$(document).on('click', '.multiselect-clear', function(e) {
				e.stopPropagation();
				const $dropdown = $(this).closest('.multiselect-dropdown');
				$dropdown.find('input[type="checkbox"]').prop('checked', false);
				self.updateMultiSelectLabel($dropdown);
			});

			// Apply button - applies the filter and closes dropdown
			$(document).on('click', '.multiselect-apply', function(e) {
				e.stopPropagation();
				const $dropdown = $(this).closest('.multiselect-dropdown');
				const filterName = $dropdown.data('filter');

				// Get selected values
				const selectedValues = [];
				$dropdown.find('input[type="checkbox"]:checked').each(function() {
					selectedValues.push($(this).val());
				});

				// Update label
				self.updateMultiSelectLabel($dropdown);

				// Close dropdown
				$dropdown.removeClass('open');

				// Apply filter (comma-separated values)
				self.updateFilterWithDependencies(filterName, selectedValues.join(','));
			});

			// Checkbox change - update label in real-time but don't apply filter
			$(document).on('change', '.multiselect-option input[type="checkbox"]', function(e) {
				e.stopPropagation();
				const $dropdown = $(this).closest('.multiselect-dropdown');
				self.updateMultiSelectLabel($dropdown);
			});

			// Prevent menu clicks from closing dropdown
			$(document).on('click', '.multiselect-menu', function(e) {
				e.stopPropagation();
			});
		}

		/**
		 * Update multi-select dropdown label based on selections
		 */
		updateMultiSelectLabel($dropdown) {
			const $label = $dropdown.find('.multiselect-label');
			const filterName = $dropdown.data('filter');
			const $checked = $dropdown.find('input[type="checkbox"]:checked');
			const count = $checked.length;

			if (count === 0) {
				$label.text(filterName === 'country' ? (optiHeatmapDetail.i18n.all_countries || 'All Countries') : (optiHeatmapDetail.i18n.all_browsers || 'All Browsers'));
				$dropdown.removeClass('has-selection');
			} else if (count === 1) {
				const text = $checked.first().closest('.multiselect-option').find('.option-label').text();
				$label.text(text);
				$dropdown.addClass('has-selection');
			} else {
				$label.text(count + ' selected');
				$dropdown.addClass('has-selection');
			}
		}

		/**
		 * Get selected values from a multi-select dropdown
		 */
		getMultiSelectValues(filterName) {
			const $dropdown = $(`.multiselect-dropdown[data-filter="${filterName}"]`);
			const selectedValues = [];
			$dropdown.find('input[type="checkbox"]:checked').each(function() {
				selectedValues.push($(this).val());
			});
			return selectedValues.join(',');
		}

		/**
		 * Update a filter value and reload data
		 */
		updateFilter(name, value) {
			this.filters[name] = value;

			// Update visual state of select
			const $select = $(`.filter-select[data-filter="${name}"]`);
			if (value && value !== '' && (name !== 'date_range' || value !== 'all')) {
				$select.addClass('has-value');
			} else {
				$select.removeClass('has-value');
			}

			// Reload heatmap and stats with new filters
			this.loadHeatmap();
			this.loadStats();
		}

		/**
		 * Update a filter value, reload data, and update dependent filter options
		 * This is called when Apply button is clicked on multi-select dropdowns
		 */
		updateFilterWithDependencies(name, value) {
			this.filters[name] = value;

			// Update visual state of dropdown
			const $dropdown = $(`.multiselect-dropdown[data-filter="${name}"]`);
			if (value && value !== '') {
				$dropdown.addClass('has-selection');
			} else {
				$dropdown.removeClass('has-selection');
			}

			// Load dependent filter options (updates other dropdowns based on current selection)
			this.loadDependentFilterOptions();

			// Reload heatmap and stats with new filters
			this.loadHeatmap();
			this.loadStats();
		}

		/**
		 * Load dependent filter options based on current filter selections
		 * When a user selects a country, this updates the browser dropdown to show only
		 * browsers that have data for that country (and vice versa)
		 */
		async loadDependentFilterOptions() {
			this.debug('debug', 'Loading dependent filter options...');

			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_get_dependent_filter_options',
						nonce: this.config.nonce,
						page_id: this.pageId,
						device: this.currentDevice,
						type: this.currentType,
						date_range: this.filters.date_range,
						country: this.filters.country,
						browser: this.filters.browser,
						visitor_type: this.filters.visitor_type
					}
				});

				if (response.success && response.data) {
					this.updateDependentDropdowns(response.data);
				} else {
					this.debug('warning', 'No dependent filter options available');
				}
			} catch (error) {
				this.debug('error', 'Failed to load dependent filter options:', error);
			}
		}

		/**
		 * Update dropdown options based on dependent filter data
		 * Preserves current selections if they are still valid
		 */
		updateDependentDropdowns(options) {
			this.debug('debug', 'Updating dependent dropdowns:', options);

			// Get currently selected values
			const currentCountries = this.filters.country ? this.filters.country.split(',') : [];
			const currentBrowsers = this.filters.browser ? this.filters.browser.split(',') : [];

			// Update country dropdown (only if browser filter is active or we have data)
			if (options.countries) {
				this.updateCountryDropdown(options.countries, currentCountries);
			}

			// Update browser dropdown (only if country filter is active or we have data)
			if (options.browsers) {
				this.updateBrowserDropdown(options.browsers, currentBrowsers);
			}

			// Update device counts
			if (options.device_counts) {
				this.updateDeviceCounts(options.device_counts);
			}

			this.debug('debug', 'Dependent dropdowns updated successfully');
		}

		/**
		 * Update country dropdown with available options
		 * Preserves valid selections and clears invalid ones
		 */
		updateCountryDropdown(countries, currentSelections) {
			const $countryOptions = $('#filter-country-dropdown .multiselect-options');
			const $dropdown = $('#filter-country-dropdown');

			// Remember scroll position
			const scrollTop = $countryOptions.scrollTop();

			$countryOptions.empty();

			const availableCountryCodes = Object.keys(countries);

			if (availableCountryCodes.length > 0) {
				// countries is an object: { code: name, ... }
				Object.entries(countries).forEach(([code, name]) => {
					const flag = this.getCountryFlag(code);
					const isChecked = currentSelections.includes(code) ? 'checked' : '';
					const $option = $(`
						<label class="multiselect-option" data-value="${this.escapeHtml(code)}">
							<input type="checkbox" value="${this.escapeHtml(code)}" ${isChecked} />
							<span class="country-flag">${flag}</span>
							<span class="option-label">${this.escapeHtml(name)}</span>
						</label>
					`);
					$countryOptions.append($option);
				});

				// Update internal filter state to remove any invalid selections
				const validSelections = currentSelections.filter(code => availableCountryCodes.includes(code));
				this.filters.country = validSelections.join(',');
			} else {
				$countryOptions.append('<div class="multiselect-empty">No countries available</div>');
				this.filters.country = '';
			}

			// Update label
			this.updateMultiSelectLabel($dropdown);

			// Restore scroll position
			$countryOptions.scrollTop(scrollTop);
		}

		/**
		 * Update browser dropdown with available options
		 * Preserves valid selections and clears invalid ones
		 */
		updateBrowserDropdown(browsers, currentSelections) {
			const $browserOptions = $('#filter-browser-dropdown .multiselect-options');
			const $dropdown = $('#filter-browser-dropdown');

			// Remember scroll position
			const scrollTop = $browserOptions.scrollTop();

			$browserOptions.empty();

			if (browsers && browsers.length > 0) {
				browsers.forEach(browser => {
					const icon = this.getBrowserIcon(browser);
					const isChecked = currentSelections.includes(browser) ? 'checked' : '';
					const $option = $(`
						<label class="multiselect-option" data-value="${this.escapeHtml(browser)}">
							<input type="checkbox" value="${this.escapeHtml(browser)}" ${isChecked} />
							<span class="browser-icon-wrapper">${icon}</span>
							<span class="option-label">${this.escapeHtml(browser)}</span>
						</label>
					`);
					$browserOptions.append($option);
				});

				// Update internal filter state to remove any invalid selections
				const validSelections = currentSelections.filter(b => browsers.includes(b));
				this.filters.browser = validSelections.join(',');
			} else {
				$browserOptions.append('<div class="multiselect-empty">No browsers available</div>');
				this.filters.browser = '';
			}

			// Update label
			this.updateMultiSelectLabel($dropdown);

			// Restore scroll position
			$browserOptions.scrollTop(scrollTop);
		}

		/**
		 * Reset all filters to default values
		 */
		resetFilters() {
			this.filters = {
				date_range: 'all',
				country: '',
				browser: '',
				visitor_type: 'guest'  // Default to Guest Visitors
			};

			// Reset A/B variant filter
			this.abTestId = 0;
			this.variantId = 0;
			$('#filter-ab-variant').val('').removeClass('has-value');

			// Remove variant params from URL
			const url = new URL(window.location.href);
			url.searchParams.delete('ab_test_id');
			url.searchParams.delete('variant_id');
			window.history.pushState({}, '', url.toString());

			// Reset single-select elements
			$('#filter-date-range').val('all').removeClass('has-value');
			$('#filter-visitor-type').val('guest').addClass('has-value');

			// Reset multi-select dropdowns
			$('.multiselect-dropdown').each((index, dropdown) => {
				const $dropdown = $(dropdown);
				$dropdown.find('input[type="checkbox"]').prop('checked', false);
				$dropdown.removeClass('has-selection');
				this.updateMultiSelectLabel($dropdown);
			});

			// Reload full filter options (shows all available countries/browsers)
			this.loadFilterOptions();

			// Reload device counts
			this.loadDeviceCounts();

			// Reload data
			this.loadHeatmap();
			this.loadStats();
		}

		/**
		 * Load device counts
		 */
		async loadDeviceCounts() {
			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_get_device_counts',
						nonce: this.config.nonce,
						page_id: this.pageId,
						type: this.currentType,  // Send current heatmap type filter
						date_range: this.filters.date_range,
						country: this.filters.country,
						browser: this.filters.browser,
						visitor_type: this.filters.visitor_type,
						ab_test_id: this.abTestId || '',
						variant_id: this.variantId || ''
					}
				});

				if (response.success && response.data) {
					this.updateDeviceCounts(response.data);
				}
			} catch (error) {
				this.debug('error', 'Failed to load device counts:', error);
			}
		}

		/**
		 * Update device count badges
		 */
		updateDeviceCounts(counts) {
			$('.device-count[data-device="desktop"]').text(counts.desktop || 0);
			$('.device-count[data-device="mobile"]').text(counts.mobile || 0);
			$('.device-count[data-device="tablet"]').text(counts.tablet || 0);
			// Note: Header total recordings count is set by loadTotalRecordingsCount() (unfiltered, called once on init)
		}

		/**
		 * Load total recordings count (unfiltered) for the header display.
		 * Called once on init — NOT re-called when filters change.
		 * This ensures the header always shows the true total sessions for the page.
		 */
		async loadTotalRecordingsCount() {
			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_get_total_recordings',
						nonce: this.config.nonce,
						page_id: this.pageId
					}
				});

				if (response.success && response.data) {
					$(".recordings-count .count-num").text(response.data.total || 0);
				}
			} catch (error) {
				this.debug('error', 'Failed to load total recordings count:', error);
			}
		}

	/**
	 * Load filter options from filenames (FAST - Thread 1)
	 * This scans filenames only, not file contents, so it completes quickly
	 * Allows users to see and interact with filters while heatmap data loads
	 */
		async loadFilterOptions() {
			this.debug('debug', 'Loading filter options from filenames...');

			// Show loading state in dropdowns
			this.showFilterLoading();

			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_get_filter_options',
						nonce: this.config.nonce,
						page_id: this.pageId,
						device: this.currentDevice,  // Filter by current device
						type: this.currentType,
						date_range: this.filters.date_range
					}
				});

				if (response.success && response.data) {
					this.populateFilterDropdowns(response.data);
				} else {
					this.debug('warning', 'No filter options available');
					this.hideFilterLoading();
				}
			} catch (error) {
				this.debug('error', 'Failed to load filter options:', error);
				this.hideFilterLoading();
			}
		}

		/**
		 * Show loading state in filter dropdowns
		 */
		showFilterLoading() {
			// Add loading class to multiselect dropdowns
			$('.multiselect-dropdown').addClass('loading');

			// Update labels to show loading
			$('#filter-country-dropdown .multiselect-label').text('Loading countries...');
			$('#filter-browser-dropdown .multiselect-label').text('Loading browsers...');
		}

		/**
		 * Hide loading state in filter dropdowns
		 */
		hideFilterLoading() {
			$('.multiselect-dropdown').removeClass('loading');

			// Reset labels
			$('#filter-country-dropdown .multiselect-label').text(optiHeatmapDetail.i18n.all_countries || 'All Countries');
			$('#filter-browser-dropdown .multiselect-label').text(optiHeatmapDetail.i18n.all_browsers || 'All Browsers');
		}

		/**
		 * Populate filter dropdowns with options from filenames
		 */
		populateFilterDropdowns(options) {
			this.debug('debug', 'Populating filter dropdowns:', options);

			// Populate country dropdown
			const $countryOptions = $('#filter-country-dropdown .multiselect-options');
			$countryOptions.empty();

			if (options.countries && Object.keys(options.countries).length > 0) {
				// countries is an object: { code: name, ... }
				Object.entries(options.countries).forEach(([code, name]) => {
					const flag = this.getCountryFlag(code);
					const $option = $(`
						<label class="multiselect-option" data-value="${this.escapeHtml(code)}">
							<input type="checkbox" value="${this.escapeHtml(code)}" />
							<span class="country-flag">${flag}</span>
							<span class="option-label">${this.escapeHtml(name)}</span>
						</label>
					`);
					$countryOptions.append($option);
				});
			} else {
				$countryOptions.append('<div class="multiselect-empty">No countries found</div>');
			}

			// Populate browser dropdown
			const $browserOptions = $('#filter-browser-dropdown .multiselect-options');
			$browserOptions.empty();

			if (options.browsers && options.browsers.length > 0) {
				options.browsers.forEach(browser => {
					const icon = this.getBrowserIcon(browser);
					const $option = $(`
						<label class="multiselect-option" data-value="${this.escapeHtml(browser)}">
							<input type="checkbox" value="${this.escapeHtml(browser)}" />
							<span class="browser-icon-wrapper">${icon}</span>
							<span class="option-label">${this.escapeHtml(browser)}</span>
						</label>
					`);
					$browserOptions.append($option);
				});
			} else {
				$browserOptions.append('<div class="multiselect-empty">No browsers found</div>');
			}

			// Remove loading state and reset labels
			this.hideFilterLoading();

			this.debug('debug', 'Filter dropdowns populated successfully');
		}

		/**
		 * Load heatmap data and render (with progressive batched loading)
		 */
		async loadHeatmap() {
			if (this.isLoading) {
				return;
			}

			this.isLoading = true;
			this.stopLoadingRequested = false; // Reset stop flag for new load
			this.showLoading();
			this.allCoordinates = []; // Reset accumulated coordinates
			this.allTrajectories = []; // Reset accumulated trajectories (for move heatmaps)
			this.allDwellSpots = []; // Reset accumulated dwell-time spots (for move heatmaps)
			this.currentBatch = 1;
			this.batchSize = 100; // Batch size for progressive loading

			// Reset render state flags for new load
			this.heatmapAlreadyRendered = false;
			this.iframeRenderPending = false;
			this.pendingHeatmapData = null;
			this.firstBatchRendered = false; // Track if we've rendered the first time
			this.lastRenderedIndex = 0; // Reset incremental render index

			// Destroy old heatmap instance to prevent memory leaks
			if (this.heatmapInstance) {
				this.heatmapInstance = null;
			}

			// Remove old heatmap overlay to ensure fresh render
			const oldOverlay = document.getElementById('heatmap-overlay');
			if (oldOverlay) {
				oldOverlay.remove();
			}

			try {
				// Load first batch
				await this.loadHeatmapBatch(1);
			} catch (error) {
				this.debug('error', 'Failed to load heatmap:', error);
				this.showError(this.config.i18n.error);
				this.isLoading = false;
			}
		}

		/**
		 * Load a single batch of heatmap data
		 * Uses small batches (20 files) and async delays to keep UI responsive
		 * Handles tab visibility changes to prevent crashes when switching tabs
		 */
		async loadHeatmapBatch(batchNumber) {
			this.debug('debug', `Loading heatmap batch ${batchNumber}...`);

			// Check if tab is hidden - if so, pause loading and resume when visible
			if (!this.isTabVisible) {
				this.debug('debug', `Tab is hidden, pausing at batch ${batchNumber}`);
				this.isPausedDueToVisibility = true;
				this.pendingBatchNumber = batchNumber;

				// Update UI to show paused state
				if (this.firstBatchRendered) {
					this.showBatchProgress(
						batchNumber - 1,
						this.totalBatches || 10,
						this.allCoordinates ? this.allCoordinates.length : 0,
						'Paused (tab hidden)'
					);
				} else {
					$('.heatmap-loading p').text(`Loading paused (tab hidden)...`);
				}
				return; // Will resume from visibility change handler
			}

			// Reset pause state
			this.isPausedDueToVisibility = false;
			this.pendingBatchNumber = null;

			// Update loading message with progress
			$('.heatmap-loading p').text(`Loading heatmap data... (batch ${batchNumber})`);

			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					timeout: 60000, // 60 second timeout to handle slow connections
					data: {
						action: 'opti_behavior_get_heatmap_batch',
						nonce: this.config.nonce,
						page_id: this.pageId,
						device: this.currentDevice,
						type: this.currentType,
						date_range: this.filters.date_range,
						country: this.filters.country,
						browser: this.filters.browser,
						visitor_type: this.filters.visitor_type,
						ab_test_id: this.abTestId,
						variant_id: this.variantId,
						batch: batchNumber,
						batch_size: this.batchSize
					}
				});

				// Reset retry count on success
				this.batchRetryCount = 0;

				if (response.success && response.data) {
					const data = response.data;

					// Accumulate coordinates from this batch
					if (data.coordinates && data.coordinates.length > 0) {
						this.allCoordinates.push(...data.coordinates);
							this.debug('debug', `Batch ${batchNumber}: Added ${data.coordinates.length} coordinates (total: ${this.allCoordinates.length})`);
					}

					// Accumulate trajectories from this batch (for move heatmaps)
					if (data.trajectories && data.trajectories.length > 0) {
						if (!this.allTrajectories) {
							this.allTrajectories = [];
						}
						this.allTrajectories.push(...data.trajectories);
							this.debug('debug', `Batch ${batchNumber}: Added ${data.trajectories.length} trajectories (total: ${this.allTrajectories.length})`);
					}

					// Accumulate dwell-time spots from this batch (for move heatmaps)
					if (data.dwell_spots && data.dwell_spots.length > 0) {
						if (!this.allDwellSpots) {
							this.allDwellSpots = [];
						}
						this.allDwellSpots.push(...data.dwell_spots);
						this.debug('debug', `Batch ${batchNumber}: Added ${data.dwell_spots.length} dwell spots (total: ${this.allDwellSpots.length})`);
					}

					// Store viewport/reference info from first batch
					if (batchNumber === 1) {
						this.heatmapViewport = data.viewport;
						this.heatmapReferenceWidth = data.reference_width;
						this.heatmapReferenceHeight = data.reference_height;
					}

					// IMMEDIATELY render after first batch - don't wait for all batches
					if (!this.firstBatchRendered && this.allCoordinates.length > 0) {
						this.firstBatchRendered = true;
							this.debug('debug', `First batch complete - rendering heatmap immediately with ${this.allCoordinates.length} points`);

						// Hide loading spinner and show container
						$('.heatmap-loading').hide();
						$('#heatmap-container').show();

						// Enable download button now that heatmap is visible
						this.enableDownloadButton();

						// Render the heatmap with current data
						this.renderHeatmapWithData({
							coordinates: this.allCoordinates,
							trajectories: this.allTrajectories || [],
							viewport: this.heatmapViewport,
							reference_width: this.heatmapReferenceWidth,
							reference_height: this.heatmapReferenceHeight,
							recordings_processed: data.recordings_processed,
							loading_more: data.has_more,
							current_batch: batchNumber,
							total_batches: data.total_batches
						});

						// Show progress indicator
						if (data.has_more) {
							this.showBatchProgress(batchNumber, data.total_batches, this.allCoordinates.length);
						}
					}

					// Check if there are more batches to load
					if (data.has_more && batchNumber < data.total_batches) {
						// Store total batches for visibility handler
						this.totalBatches = data.total_batches;

						// Check if user requested to stop loading
						if (this.stopLoadingRequested) {
									this.debug('debug', 'Loading stopped by user after batch ' + batchNumber);
							this.isLoading = false;
							return; // Exit without loading more batches
						}

						// Update progress overlay if heatmap already rendered
						if (this.firstBatchRendered) {
							this.showBatchProgress(batchNumber, data.total_batches, this.allCoordinates.length);

							// Incrementally update heatmap (non-blocking)
							if (this.heatmapAlreadyRendered) {
								if (this.currentType === 'move' && this.trajectoryCanvas) {
									// For move heatmaps, re-render trajectory canvas with all accumulated data
									this.updateTrajectoryCanvas();
								} else if (this.heatmapInstance) {
									// For click/scroll heatmaps, add points incrementally
									this.updateHeatmapOverlay();
								}
							}
						}

						// CRITICAL: Yield control to browser with setTimeout to prevent page freeze
						// This allows the UI to update and prevents "page not responding"
						await new Promise(resolve => setTimeout(resolve, 50));

						// Check again after yield in case stop was requested during yield
						if (this.stopLoadingRequested) {
									this.debug('debug', 'Loading stopped by user during yield after batch ' + batchNumber);
							this.isLoading = false;
							return;
						}

						// Check if tab became hidden during yield - pause instead of continuing
						if (!this.isTabVisible) {
							this.debug('debug', `Tab hidden during batch ${batchNumber}, pausing for next batch`);
							this.isPausedDueToVisibility = true;
							this.pendingBatchNumber = batchNumber + 1;
							return; // Will resume from visibility change handler
						}

						// Load next batch
						await this.loadHeatmapBatch(batchNumber + 1);
					} else {
						// All batches loaded - DO NOT re-render everything (causes freeze)
							this.debug('debug', `All batches loaded! Total coordinates: ${this.allCoordinates.length}`);
						this.isLoading = false;

						// Hide loading spinner and show container
						$('.heatmap-loading').hide();
						$('#heatmap-container').show();

						// Hide the batch progress indicator
						this.hideBatchProgress();

						if (this.allCoordinates.length > 0) {
							// Only do a lightweight update of the heatmap overlay
							// Skip full re-render to avoid freezing with 15000+ points
							if (this.heatmapAlreadyRendered && this.heatmapInstance) {
								// Just update the heatmap data incrementally
									this.debug('debug', 'Updating heatmap overlay with final data (no full re-render)');
								this.updateHeatmapOverlay();
							} else if (!this.firstBatchRendered) {
								// Only render if we never rendered before
								this.renderHeatmapWithData({
									coordinates: this.allCoordinates,
									trajectories: this.allTrajectories || [],
									viewport: this.heatmapViewport,
									reference_width: this.heatmapReferenceWidth,
									reference_height: this.heatmapReferenceHeight,
									recordings_processed: data.total_files || data.recordings_processed,
									loading_more: false
								});
							}

							// Show completion message briefly
							this.showBatchComplete(this.allCoordinates.length);
						} else {
							this.showNoData();
						}
					}
				} else {
					// No data or error
					if (batchNumber === 1) {
						// First batch failed - try fallback to non-batched endpoint
						this.debug('debug', 'Batched loading returned no data, trying fallback...');
						await this.loadHeatmapFallback();
					} else {
						// Subsequent batch failed - render what we have
						this.debug('debug', `Batch ${batchNumber} returned no data, rendering accumulated data`);
						this.isLoading = false;
						if (this.allCoordinates.length > 0 || (this.allTrajectories && this.allTrajectories.length > 0)) {
							this.renderHeatmapWithData({
								coordinates: this.allCoordinates,
								trajectories: this.allTrajectories || [],
								viewport: this.heatmapViewport,
								reference_width: this.heatmapReferenceWidth,
								reference_height: this.heatmapReferenceHeight,
								loading_more: false
							});
							this.showBatchComplete(this.allCoordinates.length);
						} else {
							this.showNoData();
						}
					}
				}
			} catch (error) {
				this.debug('error', `Failed to load batch ${batchNumber}:`, error);

				// Check if this is a timeout or network error that might be due to tab switching
				const isTimeoutOrNetworkError = error.statusText === 'timeout' ||
					error.status === 0 ||
					error.statusText === 'error' ||
					(error.message && error.message.includes('timeout'));

				// If tab is hidden and we got a network error, pause and retry when visible
				if (!this.isTabVisible && isTimeoutOrNetworkError) {
					this.debug('debug', `Network error while tab hidden, will retry batch ${batchNumber} when visible`);
					this.isPausedDueToVisibility = true;
					this.pendingBatchNumber = batchNumber;
					this.batchRetryCount = 0; // Reset retry count for when we resume

					if (this.firstBatchRendered) {
						this.showBatchProgress(
							batchNumber - 1,
							this.totalBatches || 10,
							this.allCoordinates ? this.allCoordinates.length : 0,
							'Paused (will retry)'
						);
					}
					return; // Will resume from visibility change handler
				}

				// Retry logic for transient errors (timeout, network issues)
				if (isTimeoutOrNetworkError && this.batchRetryCount < this.maxBatchRetries) {
					this.batchRetryCount++;
					this.debug('debug', `Retrying batch ${batchNumber} (attempt ${this.batchRetryCount}/${this.maxBatchRetries})`);

					// Show retry message
					if (this.firstBatchRendered) {
						this.showBatchProgress(
							batchNumber - 1,
							this.totalBatches || 10,
							this.allCoordinates ? this.allCoordinates.length : 0,
							`Retrying... (${this.batchRetryCount}/${this.maxBatchRetries})`
						);
					} else {
						$('.heatmap-loading p').text(`Retrying batch ${batchNumber}... (attempt ${this.batchRetryCount})`);
					}

					// Wait a bit before retrying (exponential backoff)
					const retryDelay = Math.min(1000 * Math.pow(2, this.batchRetryCount - 1), 5000);
					await new Promise(resolve => setTimeout(resolve, retryDelay));

					// Check if we should still retry (user might have stopped or tab hidden)
					if (!this.stopLoadingRequested && this.isTabVisible) {
						await this.loadHeatmapBatch(batchNumber);
					} else if (!this.isTabVisible) {
						// Tab became hidden during retry delay - pause
						this.isPausedDueToVisibility = true;
						this.pendingBatchNumber = batchNumber;
					}
					return;
				}

				// Reset retry count for next batch
				this.batchRetryCount = 0;

				if (batchNumber === 1) {
					// First batch failed - try fallback
					await this.loadHeatmapFallback();
				} else {
					// Render what we have
					this.isLoading = false;
					if (this.allCoordinates.length > 0 || (this.allTrajectories && this.allTrajectories.length > 0)) {
						this.renderHeatmapWithData({
							coordinates: this.allCoordinates,
							trajectories: this.allTrajectories || [],
							viewport: this.heatmapViewport,
							reference_width: this.heatmapReferenceWidth,
							reference_height: this.heatmapReferenceHeight,
							loading_more: false
						});
						this.showBatchComplete(this.allCoordinates.length);
					} else {
						this.showError(this.config.i18n.error);
					}
				}
			}
		}

		/**
		 * Fallback to non-batched heatmap loading (for compatibility)
		 */
		async loadHeatmapFallback() {
			this.debug('debug', 'Using fallback non-batched heatmap loading...');

			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_get_heatmap_data',
						nonce: this.config.nonce,
						page_id: this.pageId,
						device: this.currentDevice,
						type: this.currentType,
						date_range: this.filters.date_range,
						country: this.filters.country,
						browser: this.filters.browser,
						visitor_type: this.filters.visitor_type,
						ab_test_id: this.abTestId,
						variant_id: this.variantId
					}
				});

				if (response.success && response.data) {
					this.renderHeatmap(response.data);
				} else {
					this.showError(response.data?.message || this.config.i18n.error);
				}
			} catch (error) {
				this.debug('error', 'Fallback loading also failed:', error);
				this.showError(this.config.i18n.error);
			} finally {
				this.isLoading = false;
			}
		}

		/**
		 * Render heatmap with provided data (used by batched loading)
		 * For progressive rendering, we need to update the overlay without destroying the iframe
		 */
		renderHeatmapWithData(data) {
			// Check if this is a progressive update (iframe already exists)
			const existingIframe = document.getElementById('heatmap-iframe');

			if (existingIframe && this.heatmapAlreadyRendered) {
				// Progressive update - just update the heatmap overlay, don't recreate iframe
				this.debug('debug', 'Progressive update - updating heatmap overlay only');
				const viewport = data.viewport || { width: 1366, height: 768 };
				this.createOverlayAndRenderHeatmap(data, viewport);
			} else if (existingIframe && this.iframeRenderPending) {
				// Iframe exists but first render is still pending - store data for when iframe loads
				this.debug('debug', 'Iframe render pending - storing data for later');
				this.pendingHeatmapData = data;
			} else {
				// Initial render - use full renderHeatmap which creates iframe
				this.iframeRenderPending = true; // Mark that we're waiting for iframe
				this.renderHeatmap(data);
			}
		}

		/**
		 * Render heatmap visualization
		 * Uses measurement iframe to get correct page height BEFORE rendering heatmap
		 * This ensures the canvas is created with correct dimensions from the start
		 */
		renderHeatmap(data) {
			const container = document.getElementById('heatmap-container');

			if (!container) {
				this.debug('error', 'Heatmap container not found');
				return;
			}

			this.debug('debug', 'Rendering heatmap with data:', data);

			// Track if we have actual data to render
			const hasData = data.coordinates && data.coordinates.length > 0;
			if (!hasData) {
				this.debug('info', 'No coordinates found - will show page without heatmap overlay');
			}

			// Clear previous heatmap
			$(container).empty();

			// Set container dimensions based on reference dimensions from backend (for normalized coordinates)
			// OR viewport for legacy data
			const viewport = data.viewport || { width: 1366, height: 768 };
			const referenceWidth = data.reference_width || viewport.width;
			const referenceHeight = data.reference_height || viewport.height;

			this.debug('debug', 'Using dimensions:', {
				viewport: viewport,
				reference: { width: referenceWidth, height: referenceHeight },
				hasNormalizedData: !!(data.reference_width && data.reference_height)
			});

			// Get available width from parent container (admin page content area)
			const containerWidth = container.parentElement ? container.parentElement.clientWidth : referenceWidth;

			// Use reference dimensions for container sizing (normalized system)
			// This ensures the iframe/canvas matches the size of the denormalized coordinates
			container.style.position = 'relative';
			container.style.width = referenceWidth + 'px';
			container.style.overflow = 'hidden'; // Clip content to container bounds
			container.style.backgroundColor = '#f5f5f5';
			container.style.border = '1px solid #ddd';

			// No need for inner wrapper anymore
			const innerWrapper = container; // Use container directly

			// Get page URL and modify based on visitor_type filter
			let pageUrl = this.config.page_url;

			// Always mark URL as heatmap iframe preview so the server removes
			// X-Frame-Options headers that would otherwise block iframe loading.
			if (pageUrl) {
				const sep0 = pageUrl.includes('?') ? '&' : '?';
				pageUrl = pageUrl + sep0 + 'opti_heatmap_preview=1';
			}

			// If filtering by "guest" visitors, append parameter to show page as logged-out user
			if (this.filters.visitor_type === 'guest' && pageUrl) {
				pageUrl = pageUrl + '&opti_preview_as_guest=1';
			}

			// A/B test variant preview: apply variant changes in the iframe
			// so the page looks exactly like what that variant's visitors saw.
			if (this.abTestId && this.variantId && pageUrl) {
				pageUrl = pageUrl + '&opti_ab_preview_variant=' + this.variantId + '&opti_ab_preview_test=' + this.abTestId;
			}

			const self = this;
			this.heatmapAlreadyRendered = false;
			this._proxyAttempted = false; // Reset proxy fallback flag for each new render

			// Create iframe for page background (if page URL available)
			if (pageUrl) {
				const iframe = document.createElement('iframe');

				// Set onload handler BEFORE setting src to ensure we catch the load event
				iframe.onload = function() {
					if (self.heatmapAlreadyRendered) {
						self.debug('debug', 'Heatmap already rendered, skipping onload handler');
						return;
					}
					self.debug('debug', 'Iframe onload fired!');

					const waitForPageReady = function() {
						try {
							const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;

							if (!iframeDoc || !iframeDoc.body) {
								self.debug('debug', 'Iframe document not ready yet, waiting...');
								setTimeout(waitForPageReady, 100);
								return;
							}

							if (iframeDoc.readyState !== 'complete') {
								self.debug('debug', 'Iframe readyState: ' + iframeDoc.readyState + ' - waiting for complete...');
								setTimeout(waitForPageReady, 100);
								return;
							}

							// Detect blank/blocked iframe caused by X-Frame-Options or CSP frame-ancestors
							// headers set at the server/CDN level (PHP header_remove() cannot strip these).
							// When blocked, the browser silently loads about:blank — body is empty.
							if (!self._proxyAttempted &&
								iframeDoc.body.children.length === 0 &&
								iframeDoc.title === '' &&
								iframeDoc.body.innerHTML.trim() === '') {
								self.debug('warn', 'Iframe appears blank — X-Frame-Options/CSP blocked direct load. Falling back to server-side proxy...');
								self._loadIframeViaProxy(iframe, pageUrl, data, viewport, container, innerWrapper, containerWidth, referenceWidth, referenceHeight);
								return;
							}

							self.debug('debug', 'Iframe loaded! Measuring page height...');

							const doc = iframe.contentDocument || iframe.contentWindow.document;

							// Wait a bit for page content to settle, then measure from actual iframe
							setTimeout(() => {
								try {
									// FIX: Inject a temporary <style> tag with !important to override
									// stylesheet rules like min-height:100% and flex-grow that cause page
									// elements to stretch to fill the iframe's initial 10000px height.
									// Must target html, body AND common theme wrappers that also use
									// min-height:100% + flexbox, otherwise scrollHeight stays inflated.
									// SLIDER-SAFE: shrink iframe so min-height:100% wrappers become small
									var origIframeH = iframe.style.height;
									iframe.style.height = '100px';
									var measureStyle = doc.createElement('style');
									measureStyle.textContent = 'html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; } ' +
											'rs-fullwidth-wrap, rs-module-wrap, ' + // RevSlider 6
											'.slick-slider, .flexslider, ' + // Slick, Flexslider
											'.swiper-container, .swiper, ' + // Swiper
											'.ls-wp-container, .ls-container, ' + // LayerSlider
											'.n2-ss-slider, ' + // Smart Slider 3
											'.owl-carousel, ' + // Owl Carousel
											'.elementor-slides-wrapper, .elementor-widget-slides ' + // Elementor
											'{ display: none !important; }';
									doc.head.appendChild(measureStyle);
									void doc.body.offsetHeight; // force reflow

									// Measure actual page dimensions from iframe
									const bodyWidth = doc.body.scrollWidth;
									const docWidth = doc.documentElement.scrollWidth;
									const bodyHeight = doc.body.scrollHeight;

									// Remove temporary style
									doc.head.removeChild(measureStyle);
									iframe.style.height = origIframeH;

									// Use bodyHeight as primary since it reflects true content size
									const actualWidth = Math.max(bodyWidth, docWidth, referenceWidth);
									let actualHeight = bodyHeight > 100 ? bodyHeight : referenceHeight;

										self.debug('debug', 'Actual page size: ' + actualWidth + 'x' + actualHeight + ' (body: ' + bodyWidth + ', doc: ' + docWidth + ', container: ' + containerWidth + ')', 'heatmap-detail');

									// Calculate scale factor to fit within container width
									const scale = actualWidth > containerWidth ? containerWidth / actualWidth : 1;
									const scaledHeight = actualHeight * scale;

										self.debug('debug', 'Scale factor: ' + scale.toFixed(3), 'heatmap-detail');

									// Store actual (unscaled) dimensions for heatmap coordinate mapping
									self.idealWidth = actualWidth;
									self.idealHeight = actualHeight;
									self.canvasScaleFactor = scale;

									// Set iframe to actual page size (unscaled)
									iframe.style.width = actualWidth + 'px';
									iframe.style.height = actualHeight + 'px';

									// Apply CSS transform to scale down if needed
									if (scale < 1) {
										iframe.style.transformOrigin = 'top left';
										iframe.style.transform = 'scale(' + scale + ')';
										// Container holds the scaled content
										container.style.width = containerWidth + 'px';
										container.style.height = scaledHeight + 'px';
									} else {
										iframe.style.transform = 'none';
										container.style.width = actualWidth + 'px';
										container.style.height = actualHeight + 'px';
									}

									if (doc && doc.body) {
										// body overflow preserved for slider compatibility
										doc.documentElement.style.overflow = 'hidden';
									}

									// FIX v6: UNIVERSAL slider/hero height cap.
									// Many slider frameworks (RevSlider, Swiper, Slick, etc.) and hero
									// sections read the container/viewport height during init and stretch
									// to fill it. Inside an iframe, "viewport" = iframe height, so if the
									// iframe was tall during init, these elements become huge gray blocks.
									// We apply: (1) CSS caps for known frameworks, (2) generic JS scan
									// for ANY element with inflated inline height.
									var sliderViewportH = Math.max(window.innerHeight || 800, 800);
									try {
										var sliderFixStyle = doc.createElement('style');
										sliderFixStyle.id = 'opti-slider-fix';
										sliderFixStyle.textContent =
											// RevSlider 6
											'rs-fullwidth-wrap, rs-module-wrap { max-height: ' + sliderViewportH + 'px !important; } ' +
											'rs-module { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// Swiper
											'.swiper-container, .swiper { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// Slick
											'.slick-slider, .slick-list { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// Flexslider
											'.flexslider { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// LayerSlider
											'.ls-wp-container, .ls-container { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// Smart Slider 3
											'.n2-ss-slider { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// Owl Carousel
											'.owl-carousel, .owl-stage-outer { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; } ' +
											// Elementor slides
											'.elementor-slides-wrapper, .elementor-widget-slides { max-height: ' + sliderViewportH + 'px !important; overflow: hidden !important; }';
										doc.head.appendChild(sliderFixStyle);

										// GENERIC: Scan ALL elements for inflated inline heights.
										// Any element with style.height > 1.5x viewport was likely inflated
										// by JS reading the iframe height during init. Cap it.
										// This catches unknown/custom slider frameworks automatically.
										var inflatedThreshold = sliderViewportH * 1.5;
										var allEls = doc.querySelectorAll('*');
										allEls.forEach(function(el) {
											var inlineH = parseInt(el.style.height);
											var elWidth = el.offsetWidth || el.clientWidth || 0;
											if (!isNaN(inlineH) && inlineH > inflatedThreshold) {
												// Only cap elements that are reasonably wide (likely full-width heroes/sliders)
												// Skip narrow elements like sidebars, tall scrollable lists, etc.
												if (elWidth > sliderViewportH * 0.4) {
													el.style.maxHeight = sliderViewportH + 'px';
													el.style.overflow = 'hidden';
												}
											}

											try {
												var computedStyle = iframe.contentWindow.getComputedStyle(el);
												var computedMinHeight = parseFloat(computedStyle && computedStyle.minHeight) || 0;
												var className = String(el.className || '').toLowerCase();
												var hasViewportHeightClass =
													className.indexOf('elementor-section-height-full') !== -1 ||
													className.indexOf('elementor-section-height-min-height') !== -1 ||
													className.indexOf('min-vh-100') !== -1 ||
													className.indexOf('vh-100') !== -1 ||
													className.indexOf('min-h-screen') !== -1 ||
													className.indexOf('h-screen') !== -1;
												var minHeightTracksIframe = computedMinHeight > inflatedThreshold && computedMinHeight >= Math.max(actualHeight * 0.75, inflatedThreshold);

												if (elWidth > sliderViewportH * 0.4 && (minHeightTracksIframe || hasViewportHeightClass)) {
													el.style.minHeight = sliderViewportH + 'px';
												}
											} catch(minHeightCapErr) {
												// Ignore per-element style read failures; the broad scan must stay best-effort.
											}
										});

										// RevSlider-specific: rescale layer positions proportionally.
										// (Only runs if rs-layer-wrap elements exist - harmless on non-RS pages)
										if (actualHeight > sliderViewportH) {
											var layerScaleFactor = sliderViewportH / actualHeight;
											var layerWraps = doc.querySelectorAll('rs-layer-wrap');
											layerWraps.forEach(function(wrap) {
												var origTop = parseFloat(wrap.style.top);
												if (!isNaN(origTop) && origTop > sliderViewportH) {
													wrap.style.top = (origTop * layerScaleFactor) + 'px';
												}
											});
										}
									// NOTE: Do NOT call revredraw() or dispatchEvent('resize') here.
									// Those cause a feedback loop with slider frameworks.
									} catch(sliderFixErr) {}

									// FIX v5: Re-measure height with sliders VISIBLE (but capped).
									// The initial measurement hid sliders with display:none, so
									// actualHeight doesn't include slider space. Now that sliders
									// are capped to viewport height, measure again to get the TRUE
									// page height including the capped sliders.
										try {
											var postSliderH = self._measureIframeContentHeight(doc, actualHeight);
											if (postSliderH > actualHeight + 50) {
												actualHeight = postSliderH;
												self.idealHeight = actualHeight;
												var newScaledH = actualHeight * scale;
												iframe.style.height = actualHeight + 'px';
												if (scale < 1) {
													container.style.height = newScaledH + 'px';
												} else {
													container.style.height = actualHeight + 'px';
												}
											}
										} catch(postSliderErr) {
											self.debug('warn', 'Post-slider height re-measurement failed:', postSliderErr);
										}

									self.createOverlayAndRenderHeatmap(data, viewport);

									// Schedule delayed re-measurements to catch dynamically growing content
									// (lazy images, Elementor, Slider Revolution, etc.)
									// Multiple checks at 2s, 5s, 10s for heavy pages.
									var lastMeasuredH = actualHeight;
									var cssFixRule = 'html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; } ' +
											'rs-fullwidth-wrap, rs-module-wrap, ' + // RevSlider 6
											'.slick-slider, .flexslider, ' + // Slick, Flexslider
											'.swiper-container, .swiper, ' + // Swiper
											'.ls-wp-container, .ls-container, ' + // LayerSlider
											'.n2-ss-slider, ' + // Smart Slider 3
											'.owl-carousel, ' + // Owl Carousel
											'.elementor-slides-wrapper, .elementor-widget-slides ' + // Elementor
											'{ display: none !important; }';
									[2000, 5000, 10000].forEach(function(delay) {
										setTimeout(function() {
											try {
												var rDoc = iframe.contentDocument || iframe.contentWindow.document;
												if (!rDoc || !rDoc.body) return;
											// Keep slider-fix CSS during measurement (do not remove)
												var prevH = iframe.style.height;
												iframe.style.height = '100px'; // Shrink to measure natural content overflow
												var rStyle = rDoc.createElement('style');
												rStyle.textContent = cssFixRule;
												rDoc.head.appendChild(rStyle);
												void rDoc.body.offsetHeight;
												var rBodyH = rDoc.body.scrollHeight;
												rDoc.head.removeChild(rStyle);
												// Restore iframe height, then re-measure with sliders visible (capped)
												iframe.style.height = prevH;
												void rDoc.body.offsetHeight;
												var rFullH = Math.max(rDoc.body.scrollHeight, rDoc.documentElement.scrollHeight);
												var rContentH = self._measureIframeContentHeight(rDoc, lastMeasuredH);
												var rFinalH = Math.max(rBodyH, rFullH, rContentH);
												if (rFinalH > lastMeasuredH + 50) {
													self.debug('debug', 'Re-measurement at ' + delay + 'ms: ' + rFinalH + ' vs ' + lastMeasuredH);
													lastMeasuredH = rFinalH;
													var nScale = actualWidth > containerWidth ? containerWidth / actualWidth : 1;
													self.idealHeight = rFinalH;
													iframe.style.height = rFinalH + 'px';
													container.style.height = (nScale < 1 ? rFinalH * nScale : rFinalH) + 'px';
													self.createOverlayAndRenderHeatmap(data, viewport);
												}
											} catch (e2) {
												self.debug('warn', 'Re-measurement at ' + delay + 'ms failed:', e2);
											}
										}, delay);
									});

									self._startIframeHeightWatcher(iframe, container, actualWidth, containerWidth, actualHeight, data, viewport);
								} catch (e) {
										self.debug('error', 'Error measuring page dimensions:', e);
									// Fallback: use container width without scaling
									self.idealWidth = containerWidth;
									self.idealHeight = referenceHeight;
									self.canvasScaleFactor = 1;
									iframe.style.width = containerWidth + 'px';
									iframe.style.height = referenceHeight + 'px';
									container.style.width = containerWidth + 'px';
									container.style.height = referenceHeight + 'px';

									if (doc && doc.body) {
										// body overflow preserved for slider compatibility
										doc.documentElement.style.overflow = 'hidden';
									}

									self.createOverlayAndRenderHeatmap(data, viewport);
								}
							}, 800);
						} catch (e) {
							self.debug('error', 'Error accessing iframe content:', e);
							// SecurityError or cross-origin error — likely X-Frame-Options: DENY
							// or CSP frame-ancestors blocking the iframe load.
							// Try proxy fallback before rendering without page background.
							if (!self._proxyAttempted && pageUrl) {
								self.debug('warn', 'Iframe access blocked (SecurityError) — falling back to server-side proxy...');
								self._loadIframeViaProxy(iframe, pageUrl, data, viewport, container, innerWrapper, containerWidth, referenceWidth, referenceHeight);
								return;
							}
							self.idealWidth = containerWidth;
							self.idealHeight = referenceHeight;
							self.canvasScaleFactor = 1;
							iframe.style.width = containerWidth + 'px';
							iframe.style.height = referenceHeight + 'px';
							container.style.width = containerWidth + 'px';
							container.style.height = referenceHeight + 'px';
							self.createOverlayAndRenderHeatmap(data, viewport);
						}
					};

					waitForPageReady();
				};

				// Set iframe styles and load directly via src
				iframe.style.width = referenceWidth + 'px';
				iframe.style.height = Math.max(window.innerHeight || 800, 800) + 'px';
				iframe.style.border = 'none';
				iframe.style.position = 'absolute';
				iframe.style.top = '0';
				iframe.style.left = '0';
				iframe.style.pointerEvents = 'none';
				iframe.style.zIndex = '1';
				iframe.id = 'heatmap-iframe';
				iframe.scrolling = 'no';
				iframe.setAttribute('scrolling', 'no');

				// Load the page directly via iframe.src for pixel-perfect rendering.
				// The opti_heatmap_preview=1 parameter (already added to pageUrl)
				// triggers PHP to remove X-Frame-Options headers and skip tracking
				// scripts, so the page loads cleanly inside the iframe.
				self.debug('debug', 'Loading iframe via direct src: ' + pageUrl);
				iframe.src = pageUrl;

				innerWrapper.appendChild(iframe);
			}

			// If there's no iframe, create overlay and render immediately
			if (!pageUrl) {
				this.debug('debug', 'No page URL, rendering heatmap in parent document...');
				this.idealWidth = referenceWidth;
				this.idealHeight = referenceHeight;
				this.canvasScaleFactor = 1;
				container.style.height = referenceHeight + 'px';
				this.createOverlayAndRenderHeatmap(data, viewport);
			} else {
				this.debug('debug', 'Page URL present, waiting for iframe to load before rendering heatmap...');
			}
		}

		/**
		 * Fallback rendering method when measurement fails (kept for compatibility)
		 * This method is no longer used but kept for safety
		 */
		renderHeatmapWithFallback(data, viewport, container, innerWrapper, containerWidth, referenceWidth, referenceHeight, pageUrl) {
			// Store dimensions - use actual page size
			this.idealWidth = referenceWidth;
			this.idealHeight = referenceHeight;
			this.canvasScaleFactor = 1;

			// Set container dimensions to actual page size
			container.style.height = referenceHeight + 'px';
			container.style.width = referenceWidth + 'px';

			this.debug('debug', 'Fallback dimensions: ' + referenceWidth + 'x' + referenceHeight);

			if (pageUrl) {
				// Create iframe with actual page dimensions
				const iframe = document.createElement('iframe');
				iframe.src = pageUrl;
				iframe.style.width = referenceWidth + 'px';
				iframe.style.height = referenceHeight + 'px';
				iframe.style.border = 'none';
				iframe.style.position = 'absolute';
				iframe.style.top = '0';
				iframe.style.left = '0';
				iframe.style.pointerEvents = 'none';
				iframe.style.zIndex = '1';
				iframe.id = 'heatmap-iframe';
				iframe.scrolling = 'no';
				iframe.setAttribute('scrolling', 'no');

				iframe.onload = function() {
					try {
						const doc = iframe.contentDocument || iframe.contentWindow.document;
						if (doc && doc.body) {
							// body overflow preserved for slider compatibility
							doc.documentElement.style.overflow = 'hidden';
						}
					} catch (e) {
						// Cross-origin, ignore
					}
				};

				innerWrapper.appendChild(iframe);
			}

			// Render heatmap
			this.createOverlayAndRenderHeatmap(data, viewport);
		}

		/**
		 * Load the iframe background via server-side proxy (srcdoc fallback).
		 *
		 * Called automatically when the direct iframe.src load is blocked by
		 * X-Frame-Options or CSP frame-ancestors headers added at the server/CDN
		 * level (which PHP's header_remove() cannot strip). Using iframe.srcdoc
		 * bypasses these restrictions because the browser treats the content as
		 * same-origin inline HTML — no HTTP response headers apply.
		 *
		 * After setting srcdoc the existing iframe.onload fires again and handles
		 * all measurement and rendering — no additional code is required here.
		 *
		 * @param {HTMLIFrameElement} iframe
		 * @param {string}            pageUrl           URL originally loaded (includes opti_heatmap_preview=1)
		 * @param {Object}            data              Heatmap data object
		 * @param {Object}            viewport          Viewport dimensions
		 * @param {HTMLElement}       container         Heatmap container element
		 * @param {HTMLElement}       innerWrapper      Inner wrapper element
		 * @param {number}            containerWidth    Available width in px
		 * @param {number}            referenceWidth    Reference/capture width in px
		 * @param {number}            referenceHeight   Reference/capture height in px
		 */
		_loadIframeViaProxy(iframe, pageUrl, data, viewport, container, innerWrapper, containerWidth, referenceWidth, referenceHeight) {
			const self = this;
			self._proxyAttempted = true; // Prevent recursive proxy attempts
			self.debug('info', 'Loading iframe via server-side proxy (blob URL) — URL: ' + pageUrl);

			$.ajax({
				url:     self.config.ajax_url,
				type:    'POST',
				timeout: 30000,
				data: {
					action: 'opti_behavior_proxy_page',
					nonce:  self.config.nonce,
					url:    pageUrl
				},
				success: function(response) {
					if (response.success && response.data && response.data.html) {
						self.debug('info', 'Proxy returned HTML (' + response.data.html.length + ' bytes). Injecting via blob URL...');
						// Use blob URL instead of srcdoc. srcdoc creates a null-origin document
						// where jQuery, Elementor, and theme scripts fail with "jQuery is not defined".
						// Blob URLs inherit the parent page's origin, so all scripts execute normally
						// AND X-Frame-Options does not apply (blob: scheme, no HTTP response headers).
						var blob = new Blob([response.data.html], { type: 'text/html' });
						var blobUrl = URL.createObjectURL(blob);
						// Set onload to handle measurement after blob page loads
						iframe.onload = function() {
							// Revoke blob URL to free memory (page is already loaded)
							URL.revokeObjectURL(blobUrl);
							// Re-run the page ready check (inline, since free plugin uses closures)
							if (self.heatmapAlreadyRendered) {
								self.debug('debug', 'Heatmap already rendered, skipping proxy onload');
								return;
							}
							self.debug('debug', 'Proxy blob iframe onload fired!');
							var waitForPageReady = function() {
								try {
									var iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
									if (!iframeDoc || !iframeDoc.body) {
										setTimeout(waitForPageReady, 100);
										return;
									}
									if (iframeDoc.readyState !== 'complete') {
										setTimeout(waitForPageReady, 100);
										return;
									}
									// Proceed with normal measurement — delegate to the same
									// measurement logic by re-triggering the existing onload path.
									// Since _proxyAttempted is true, blank detection is skipped.
									self.debug('debug', 'Proxy blob page ready, measuring...');
									var doc = iframeDoc;
									setTimeout(function() {
										try {
											var origIframeH = iframe.style.height;
											iframe.style.visibility = 'hidden';
											iframe.style.height = '100px';
											var measureStyle = doc.createElement('style');
											measureStyle.textContent = 'html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; }';
											doc.head.appendChild(measureStyle);
											var bodyWidth = Math.max(doc.body.scrollWidth, doc.body.offsetWidth);
											var bodyHeight = Math.max(doc.body.scrollHeight, doc.body.offsetHeight);
											var docWidth = Math.max(doc.documentElement.scrollWidth, doc.documentElement.offsetWidth);
											var docHeight = Math.max(doc.documentElement.scrollHeight, doc.documentElement.offsetHeight);
											doc.head.removeChild(measureStyle);
											iframe.style.height = origIframeH;
											iframe.style.visibility = 'visible';
											var actualWidth = Math.max(bodyWidth, docWidth, referenceWidth);
											var measuredHeight = bodyHeight > 100 ? bodyHeight : Math.max(bodyHeight, docHeight);
											var actualHeight = self._measureIframeContentHeight(doc, measuredHeight > 100 ? measuredHeight : referenceHeight);
											self._applyIframeContentSize(iframe, container, actualWidth, actualHeight, containerWidth);
											self.contentOffsetX = 0;
											try {
												var bodyStyle = iframe.contentWindow.getComputedStyle(doc.body);
												var marginLeft = parseInt(bodyStyle.marginLeft) || 0;
												var paddingLeft = parseInt(bodyStyle.paddingLeft) || 0;
												self.contentOffsetX = marginLeft + paddingLeft;
											} catch(oE) {}
											if (doc && doc.body) {
												doc.documentElement.style.overflow = 'hidden';
											}
											self.createOverlayAndRenderHeatmap(data, viewport);
											self._startIframeHeightWatcher(iframe, container, actualWidth, containerWidth, actualHeight, data, viewport);
										} catch(mErr) {
											self.debug('error', 'Proxy measurement error:', mErr);
											self.idealWidth = containerWidth;
											self.idealHeight = referenceHeight;
											self.canvasScaleFactor = 1;
											self.createOverlayAndRenderHeatmap(data, viewport);
										}
									}, 800);
								} catch(e) {
									self.debug('error', 'Proxy iframe access error:', e);
									self.idealWidth = containerWidth;
									self.idealHeight = referenceHeight;
									self.canvasScaleFactor = 1;
									self.createOverlayAndRenderHeatmap(data, viewport);
								}
							};
							waitForPageReady();
						};
						iframe.src = blobUrl;
					} else {
						self.debug('error', 'Proxy returned no usable HTML. Rendering heatmap without page background.', response);
						// Last resort: render heatmap dots without a page background.
						self.idealWidth  = referenceWidth;
						self.idealHeight = referenceHeight;
						self.canvasScaleFactor = 1;
						self.createOverlayAndRenderHeatmap(data, viewport);
					}
				},
				error: function(xhr, status, err) {
					self.debug('error', 'Proxy AJAX request failed (status: ' + status + '). Rendering without page background.', err);
					// Last resort: render heatmap dots without a page background.
					self.idealWidth  = referenceWidth;
					self.idealHeight = referenceHeight;
					self.canvasScaleFactor = 1;
					self.createOverlayAndRenderHeatmap(data, viewport);
				}
			});
		}

		/**
		 * Create overlay and render heatmap
		 */
		createOverlayAndRenderHeatmap(data, viewport) {
			// Clear the pending render flag since we're now rendering
			this.iframeRenderPending = false;

			// If we have pending data from a newer batch, use that instead
			if (this.pendingHeatmapData && this.pendingHeatmapData.coordinates &&
				this.pendingHeatmapData.coordinates.length > data.coordinates.length) {
				this.debug('debug', 'Using pending data with more coordinates: ' + this.pendingHeatmapData.coordinates.length + ' vs ' + data.coordinates.length);
				data = this.pendingHeatmapData;
				viewport = data.viewport || viewport;
			}
			this.pendingHeatmapData = null; // Clear pending data

			this.debug('debug', 'createOverlayAndRenderHeatmap called with:', {
				hasData: !!data,
				hasCoordinates: data && data.coordinates ? data.coordinates.length : 0,
				viewportWidth: viewport ? viewport.width : 0,
				viewportHeight: viewport ? viewport.height : 0
			});

			// Always use the parent container, not iframe document
			// We want the heatmap overlay to be on top of the iframe, not inside it
			const container = document.getElementById('heatmap-container');
			if (!container) {
				this.debug('error', 'Heatmap container not found');
				return;
			}

			// Create heatmap overlay in parent document (not iframe)
			let heatmapOverlay = document.getElementById('heatmap-overlay');
			if (!heatmapOverlay) {
				heatmapOverlay = document.createElement('div');
				heatmapOverlay.id = 'heatmap-overlay';
				heatmapOverlay.style.position = 'absolute';
				heatmapOverlay.style.top = '0';
				heatmapOverlay.style.left = '0';
				heatmapOverlay.style.pointerEvents = 'none';
				heatmapOverlay.style.zIndex = '10';

				container.appendChild(heatmapOverlay);
				this.debug('debug', 'Created heatmap overlay in parent container');
			} else {
				// Clear existing overlay
				heatmapOverlay.innerHTML = '';
				this.debug('debug', 'Cleared existing heatmap overlay');
			}

			// Add scroll heatmap legend if this is a scroll heatmap
			if (this.currentType === 'scroll') {
				this.createScrollHeatmapLegend(container);
			}

			// Set explicit pixel dimensions on overlay BEFORE creating heatmap
			// This ensures h337.create() creates the canvas with correct dimensions

			// Use idealWidth/idealHeight for the overlay dimensions (actual page size, not scaled)
			let overlayWidth = this.idealWidth || viewport.width;
			let overlayHeight = this.idealHeight || viewport.height;

			heatmapOverlay.style.width = overlayWidth + 'px';
			heatmapOverlay.style.height = overlayHeight + 'px';

			// Apply same scale transform as iframe if scaling is active
			const scale = this.canvasScaleFactor || 1;
			if (scale < 1) {
				heatmapOverlay.style.transformOrigin = 'top left';
				heatmapOverlay.style.transform = 'scale(' + scale + ')';
			} else {
				heatmapOverlay.style.transform = 'none';
			}

			this.debug('debug', 'Set overlay dimensions: ' + overlayWidth + 'x' + overlayHeight + ', scale: ' + scale.toFixed(3), 'heatmap-detail');

			// Check if heatmap.js library is loaded
			if (!window.h337) {
				this.debug('error', 'Heatmap.js library not loaded');
				this.showError('Heatmap library not loaded');
				return;
			}

			try {
				// Create heatmap instance with different settings for scroll heatmap
				this.debug('debug', 'Creating heatmap instance for type: ' + this.currentType);

				let config = {
					container: heatmapOverlay,
					radius: 40,
					maxOpacity: 0.8,
					minOpacity: 0.05,
					blur: 0.85,
					gradient: {
						0.0: 'rgba(0, 0, 255, 0)',
						0.2: 'rgba(0, 0, 255, 1)',
						0.4: 'rgba(0, 255, 255, 1)',
						0.6: 'rgba(0, 255, 0, 1)',
						0.8: 'rgba(255, 255, 0, 1)',
						1.0: 'rgba(255, 0, 0, 1)'
					}
				};

				// Customize settings for scroll heatmap
				if (this.currentType === 'scroll') {
					config.radius = 60; // Balanced radius for full coverage without excessive horizontal bleeding
					config.blur = 0.5; // Lower blur to maintain horizontal uniformity while allowing vertical gradients
					config.maxOpacity = 0.75; // Good opacity for clear visibility
					config.minOpacity = 0.4; // Higher minimum to ensure all areas show color
					config.gradient = {
						0.0: 'rgba(173, 216, 230, 0.5)',  // Light blue (low scroll reach)
						0.1: 'rgba(135, 206, 250, 0.55)', // Sky blue
						0.2: 'rgba(100, 149, 237, 0.6)',  // Cornflower blue
						0.35: 'rgba(64, 224, 208, 0.65)', // Turquoise
						0.5: 'rgba(50, 205, 50, 0.7)',    // Lime green
						0.65: 'rgba(255, 255, 0, 0.75)',  // Yellow
						0.8: 'rgba(255, 140, 0, 0.8)',    // Dark orange
						0.9: 'rgba(255, 69, 0, 0.85)',    // Orange-red
						1.0: 'rgba(220, 20, 20, 0.9)'     // Hot intense red (high scroll reach)
					};
				} else if (this.currentType === 'move') {
					// Move heatmap - show continuous mouse trajectory paths
					config.radius = 8; // Small radius for thin, precise path lines
					config.blur = 0.75; // Moderate blur for smooth but visible lines
					config.maxOpacity = 0.7; // Good visibility
					config.minOpacity = 0.15; // Show all paths clearly
					config.gradient = {
						0.0: 'rgba(100, 200, 100, 0)',     // Transparent (no movement)
						0.15: 'rgba(120, 255, 120, 0.4)',  // Light green (light traffic)
						0.3: 'rgba(150, 255, 100, 0.5)',   // Yellow-green
						0.45: 'rgba(200, 255, 80, 0.6)',   // Yellow (medium traffic)
						0.6: 'rgba(255, 220, 0, 0.7)',     // Gold
						0.75: 'rgba(255, 180, 0, 0.8)',    // Orange (high traffic)
						0.9: 'rgba(255, 100, 0, 0.85)',    // Deep orange
						1.0: 'rgba(255, 50, 0, 0.9)'       // Red-orange (very high traffic)
					};
				}

				this.heatmapInstance = window.h337.create(config);

				// Prepare data with proper format
				let heatmapData;

				// Scale coordinates if we applied canvas scaling
				const scaleFactor = this.canvasScaleFactor || 1;
				const normalizedScaleY = this.normalizedScaleY || scaleFactor;

				// Calculate coordinate scale factors from reference dimensions to display dimensions
				// Coordinates from backend are in reference_width x reference_height space
				// Display canvas is in idealWidth x idealHeight space
				const refWidth = this.heatmapReferenceWidth || overlayWidth;
				const refHeight = this.heatmapReferenceHeight || overlayHeight;
				const displayWidth = this.idealWidth || overlayWidth;
				const displayHeight = this.idealHeight || overlayHeight;

				// Scale factors to convert from reference space to display space
				const coordScaleX = displayWidth / refWidth;
				const coordScaleY = displayHeight / refHeight;

				// Only apply X scaling if viewport widths differ significantly (>10%)
				const shouldScaleX = Math.abs(coordScaleX - 1) > 0.1;

				this.debug('debug', `Coordinate scaling for heatmap: ref(${refWidth}x${refHeight}) -> display(${displayWidth}x${displayHeight}), X scale: ${shouldScaleX ? coordScaleX.toFixed(3) : 'none'}, Y scale: none (absolute coords)`);

				if (this.currentType === 'scroll') {
					// SCROLL HEATMAP: Draw uniform horizontal bands using Canvas
					// This creates homogeneous color bands that only vary vertically based on scroll depth
						this.debug('debug', 'Rendering scroll heatmap with ' + data.coordinates.length + ' scroll events');
					this.debug('debug', '[Before Render] overlayWidth: ' + overlayWidth + ', overlayHeight: ' + overlayHeight + ', normalizedScaleY: ' + normalizedScaleY);
					this.debug('debug', '[Before Render] idealWidth: ' + this.idealWidth + ', idealHeight: ' + this.idealHeight + ', viewport: ' + (this.viewport ? (this.viewport.width + 'x' + this.viewport.height) : 'N/A'));
					this.renderScrollHeatmap(data.coordinates, heatmapOverlay, overlayWidth, overlayHeight, normalizedScaleY);

					// Skip heatmap.js for scroll type - we use canvas bands instead
					this.heatmapInstance = null;
				} else if (this.currentType === 'attention') {
					// ATTENTION HEATMAP: Show where users spent the most time
					// Uses scroll coordinates to create time-based density zones
					// RED = High engagement, BLUE = Low engagement
					if (data.coordinates && data.coordinates.length > 0) {
							this.debug('debug', 'Rendering attention heatmap with ' + data.coordinates.length + ' scroll events');
						// Pass viewport height to correctly map scroll positions to viewing areas
						this.renderAttentionHeatmap(data.coordinates, heatmapOverlay, overlayWidth, overlayHeight, scaleFactor, viewport);
					}

					// Skip heatmap.js for attention type - we use custom attention canvas instead
					this.heatmapInstance = null;
				} else if (this.currentType === 'move' && this.allTrajectories && this.allTrajectories.length > 0) {

					// MOVE HEATMAP: Draw actual trajectory lines using Canvas


					this.renderMoveTrajectories(this.allTrajectories, heatmapOverlay, overlayWidth, overlayHeight, scaleFactor);

					this.heatmapInstance = null;

				} else if (this.currentType === 'move') {

					// Fallback: Show mouse movement as heatmap points if no trajectories
					// NO Y scaling - coordinates are absolute page positions


					const scaledCoordinates = data.coordinates.map(coord => {
						return {
							// X: Scale only if viewport widths are significantly different
							x: Math.round((coord.x || 0) * (shouldScaleX ? coordScaleX : 1)),
							// Y: NO scaling - coordinates are absolute page positions
							y: coord.y || 0,
							value: coord.value || 1
						};
					});



					this.heatmapInstance = h337.create({

						container: heatmapOverlay,

						radius: 40,

						maxOpacity: 0.6,

						minOpacity: 0,

						blur: 0.75

					});



					this.heatmapInstance.setData({

						max: 100,

						data: scaledCoordinates

					});

				} else if (data.coordinates && data.coordinates.length > 0) {
					// For click heatmaps from simple tracker:
					// Coordinates are ABSOLUTE page coordinates (pageX/pageY) - NO Y scaling needed!
					// Only apply X scaling if viewport widths differ significantly
					// Y coordinates are already in document space and should not be scaled

					// Check if we should apply X scaling (for different viewport widths)
					const shouldScaleX = Math.abs(coordScaleX - 1) > 0.1; // Only scale if >10% difference

					const scaledCoordinates = data.coordinates.map(coord => {
						return {
							// X: Scale only if viewport widths are significantly different
							x: Math.round((coord.x || 0) * (shouldScaleX ? coordScaleX : 1)),
							// Y: NO scaling - coordinates are absolute page positions
							y: coord.y || 0,
							value: coord.value || 1
						};
					});

					heatmapData = {
						max: Math.max(10, this.getMaxValue(scaledCoordinates)),
						data: scaledCoordinates
					};
					this.debug('debug', 'Click heatmap: Using ' + data.coordinates.length + ' absolute coordinates (X scale: ' + (shouldScaleX ? coordScaleX.toFixed(3) : 'none') + ', Y scale: none)');

					this.debug('debug', 'Setting heatmap data: ' + heatmapData.data.length + ' points');
					this.heatmapInstance.setData(heatmapData);
				} else {
					// No data - just show the page without heatmap overlay
					this.debug('info', 'No heatmap data to render - showing page only');
					this.heatmapInstance = null;
				}

				// Track how many points we've rendered for incremental updates
				// (Not applicable for move trajectories which don't use heatmapData)
				if (heatmapData && heatmapData.data) {
					this.lastRenderedIndex = heatmapData.data.length;
				}

				// Show container
				$('.heatmap-loading').hide();
				$('.heatmap-no-data').hide();
				$('#heatmap-container').show();

				this.debug('debug', 'Heatmap rendered successfully');

				// CRITICAL: Mark heatmap as rendered so incremental updates can happen
				this.heatmapAlreadyRendered = true;
				this.firstBatchRendered = true;

				// Auto-scroll to center of heatmap
				this.scrollToCenter();
			} catch (error) {
				this.debug('error', 'Error creating heatmap:', error);
				this.showError('Failed to render heatmap: ' + error.message);
			}
		}

	/**
	 * Get adaptive trajectory color based on website background
	 * Analyzes background color and returns contrasting color for trajectories
	 *
	 * @returns {Object} Color object with r, g, b values
	 */
	getAdaptiveTrajectoryColor() {
		try {
			// Get the iframe content background color
			const iframe = document.getElementById('heatmap-iframe');
			if (!iframe || !iframe.contentDocument) {
				// Default to yellow if we can't access iframe
				return { r: 255, g: 255, b: 100 };
			}

			const iframeBody = iframe.contentDocument.body;
			const computedStyle = window.getComputedStyle(iframeBody);
			let bgColor = computedStyle.backgroundColor;

			// If body is transparent, check html element
			if (bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
				const htmlElement = iframe.contentDocument.documentElement;
				bgColor = window.getComputedStyle(htmlElement).backgroundColor;
			}

			// Parse RGB/RGBA color
			const rgbMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
			if (!rgbMatch) {
				// Default to yellow if parsing fails
				return { r: 255, g: 255, b: 100 };
			}

			const r = parseInt(rgbMatch[1]);
			const g = parseInt(rgbMatch[2]);
			const b = parseInt(rgbMatch[3]);

			// Calculate relative luminance (perceived brightness)
			// Formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
			const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;

			this.debug('debug', `Background color: rgb(${r}, ${g}, ${b}), luminance: ${luminance.toFixed(2)}`);

			// Choose trajectory color based on background brightness
			if (luminance > 0.6) {
				// Light background -> use dark/saturated colors for visibility
				// Deep magenta/purple stands out well on light backgrounds
				return { r: 200, g: 0, b: 200 };
			} else if (luminance < 0.3) {
				// Dark background -> use bright/light colors
				// Bright yellow/lime for high visibility on dark backgrounds
				return { r: 255, g: 255, b: 100 };
			} else {
				// Medium background -> check color temperature
				// Use color that contrasts with background hue
				const isWarmBackground = r > b; // More red than blue = warm
				if (isWarmBackground) {
					// Warm background -> use cool color (cyan/turquoise)
					return { r: 0, g: 255, b: 255 };
				} else {
					// Cool background -> use warm color (orange/coral)
					return { r: 255, g: 150, b: 0 };
				}
			}
		} catch (error) {
			this.debug('error', 'Error detecting background color:', error);
			// Default to yellow on error
			return { r: 255, g: 255, b: 100 };
		}
	}

		/**
		 * Render mouse movement trajectories as connected lines
		 * Uses Canvas 2D API to draw actual paths showing mouse movement
		 *
		 * @param {Array} trajectories Array of trajectory paths, each containing {x, y} points
		 * @param {HTMLElement} overlay The overlay element to render into
		 * @param {number} width Canvas width
		 * @param {number} height Canvas height
		 * @param {number} scaleFactor Scale factor for coordinates
		 */
	renderMoveTrajectories(trajectories, overlay, width, height, scaleFactor) {
			// Clear any existing content in overlay
			overlay.innerHTML = '';

			// For move heatmaps from simple tracker/file storage:
			// Coordinates are ABSOLUTE page coordinates - NO Y scaling needed!
			// Only apply X scaling if viewport widths differ significantly
			const refWidth = this.heatmapReferenceWidth || width;
			const displayWidth = this.idealWidth || width;

			// Check if we should apply X scaling (for different viewport widths)
			const coordScaleX = displayWidth / refWidth;
			const shouldScaleX = Math.abs(coordScaleX - 1) > 0.1; // Only scale if >10% difference

			this.debug('debug', `Move heatmap: ref width ${refWidth} -> display width ${displayWidth}, X scale: ${shouldScaleX ? coordScaleX.toFixed(3) : 'none'}, Y scale: none (absolute coords)`);

			// STEP 1: Render dwell-time heatmap layer FIRST (underneath trajectories)
			// This shows where mouse stayed longest - red = most time, blue = less time
			// NO Y scaling - coordinates are absolute page positions
			if (this.allDwellSpots && this.allDwellSpots.length > 0) {
				const scaledDwellSpots = this.allDwellSpots.map(spot => {
					return {
						x: Math.round((spot.x || 0) * (shouldScaleX ? coordScaleX : 1)),
						y: spot.y || 0, // NO Y scaling
						value: spot.value || 1
					};
				});

				// Create heatmap instance with blue-to-red gradient for dwell time
				const heatmapConfig = {
					container: overlay,
					radius: 40,  // Slightly larger radius for dwell spots
					maxOpacity: 0.7,
					minOpacity: 0,
					blur: 0.85,
					gradient: {
						0.0: 'rgba(0, 0, 255, 0)',      // Transparent (no dwell time)
						0.2: 'rgba(0, 0, 255, 0.4)',    // Blue (low dwell time)
						0.4: 'rgba(0, 255, 255, 0.5)',  // Cyan
						0.6: 'rgba(0, 255, 0, 0.6)',    // Green
						0.8: 'rgba(255, 255, 0, 0.7)',  // Yellow
						1.0: 'rgba(255, 0, 0, 0.85)'    // Red (high dwell time)
					}
				};

				this.heatmapInstance = window.h337.create(heatmapConfig);
				this.heatmapInstance.setData({
					max: 100,
					data: scaledDwellSpots
				});

				this.debug('debug', 'Move heatmap dwell-time layer rendered with ' + scaledDwellSpots.length + ' spots');
			}

			// STEP 2: Create trajectory canvas layer ON TOP of heatmap
			// This shows mouse movement paths in yellow/light thin lines
			const canvas = document.createElement('canvas');
			canvas.id = 'trajectory-canvas';
			canvas.width = width;
			canvas.height = height;
			canvas.style.position = 'absolute';
			canvas.style.top = '0';
			canvas.style.left = '0';
			canvas.style.pointerEvents = 'none';
			canvas.style.zIndex = '10'; // Ensure trajectories appear above heatmap

			overlay.appendChild(canvas);

			const ctx = canvas.getContext('2d');

			// Count total points for intensity calculation
			let totalPoints = 0;
			trajectories.forEach(traj => {
				totalPoints += traj.length;
			});

		// Get adaptive color based on background
		const trajectoryColor = this.getAdaptiveTrajectoryColor();

		// Opacity varies based on number of trajectories to prevent over-saturation
		const baseOpacity = Math.min(0.6, Math.max(0.2, 40 / trajectories.length));

		this.debug('debug', `Drawing ${trajectories.length} trajectories with adaptive color rgb(${trajectoryColor.r}, ${trajectoryColor.g}, ${trajectoryColor.b}), opacity: ${baseOpacity.toFixed(2)}`);

	// Draw each trajectory with adaptive color
	// NO Y scaling - coordinates are absolute page positions
	trajectories.forEach((trajectory, index) => {
		if (trajectory.length < 2) return;

				ctx.beginPath();

				// Use adaptive color that contrasts with background
				ctx.strokeStyle = `rgba(${trajectoryColor.r}, ${trajectoryColor.g}, ${trajectoryColor.b}, ${baseOpacity})`;
				ctx.lineWidth = 2; // Slightly thicker for better visibility
				ctx.lineCap = 'round';
				ctx.lineJoin = 'round';

				// Move to first point - X scaling only if needed, NO Y scaling
				const firstPoint = trajectory[0];
				const startX = Math.round((firstPoint.x || 0) * (shouldScaleX ? coordScaleX : 1));
				const startY = firstPoint.y || 0; // NO Y scaling - absolute coords
				ctx.moveTo(startX, startY);

				// Draw line to each subsequent point - NO Y scaling
				for (let i = 1; i < trajectory.length; i++) {
					const point = trajectory[i];
					const x = Math.round((point.x || 0) * (shouldScaleX ? coordScaleX : 1));
					const y = point.y || 0; // NO Y scaling - absolute coords
					ctx.lineTo(x, y);
				}

				ctx.stroke();
			});

			// Store reference to canvas for later updates
			this.trajectoryCanvas = canvas;

		this.debug('debug', 'Move trajectories rendered successfully with adaptive colors and heatmap density layer');
		}

		/**
		 * Render scroll heatmap with uniform horizontal bands using canvas
		 * This creates perfectly homogeneous horizontal color bands based on scroll depth
		 * Unlike radial gradients, this produces consistent colors across the X-axis
		 */
		renderScrollHeatmap(scrollCoordinates, overlay, width, height, scaleFactor) {
			// For scroll heatmaps:
			// Coordinates are absolute page positions - NO Y scaling needed!
			// The reference_height from backend should match page height, so scaling would be ~1 anyway

			// Debug: Log scroll heatmap dimensions
			const refHeight = this.heatmapReferenceHeight || height;
			const displayHeight = this.idealHeight || height;
			this.debug('debug', '[Scroll Heatmap] Rendering with dimensions: ' + width + 'x' + height);
			this.debug('debug', '[Scroll Heatmap] refHeight: ' + refHeight + ', displayHeight: ' + displayHeight + ' (NO Y scaling applied)');
			this.debug('debug', '[Scroll Heatmap] idealHeight: ' + this.idealHeight + ', viewport height: ' + (this.viewport ? this.viewport.height : 'N/A'));

			// Check max scroll Y in coordinates
			if (scrollCoordinates && scrollCoordinates.length > 0) {
				const maxScrollY = Math.max(...scrollCoordinates.map(c => c.y || 0));
				const minScrollY = Math.min(...scrollCoordinates.map(c => c.y || 0));
				this.debug('debug', '[Scroll Heatmap] Scroll Y range in data: ' + minScrollY + ' to ' + maxScrollY + ', canvas height: ' + height);
			}

			// Create canvas element for scroll heatmap rendering
			const canvas = document.createElement('canvas');
			canvas.id = 'scroll-heatmap-canvas';
			canvas.width = width;
			canvas.height = height;
			canvas.style.position = 'absolute';
			canvas.style.top = '0';
			canvas.style.left = '0';
			canvas.style.pointerEvents = 'none';

			// Clear any existing content in overlay
			overlay.innerHTML = '';
			overlay.appendChild(canvas);

			const ctx = canvas.getContext('2d');

			// Group scroll events by Y position to calculate scroll depth/intensity
			// NO Y scaling - coordinates are absolute page positions
			const scrollDepthMap = new Map();
			scrollCoordinates.forEach(coord => {
				const y = coord.y || 0; // NO scaling - absolute coords
				const value = coord.value || 1;

				if (!scrollDepthMap.has(y)) {
					scrollDepthMap.set(y, { count: 0, totalValue: 0 });
				}

				const entry = scrollDepthMap.get(y);
				entry.count++;
				entry.totalValue += value;
			});

			// Find the maximum scroll depth and maximum intensity for normalization
			const maxScrollY = Math.max(...Array.from(scrollDepthMap.keys()));
			let maxIntensity = 0;
			scrollDepthMap.forEach((data) => {
				const avgValue = data.totalValue / data.count;
				if (avgValue > maxIntensity) maxIntensity = avgValue;
			});

			// Helper function to interpolate between colors smoothly
			const interpolateColor = (normalizedIntensity) => {
				// RED = High engagement/time spent at this area
				// BLUE = Low engagement/time spent (users scrolled past quickly)
				// normalizedIntensity = value/maxValue (higher value = more time/engagement)

				let r, g, b, a;

				if (normalizedIntensity < 0.2) {
					// Light blue to sky blue - very low engagement
					const t = normalizedIntensity / 0.2;
					r = 173 + (135 - 173) * t;
					g = 216 + (206 - 216) * t;
					b = 230 + (250 - 230) * t;
					a = 0.40 + (0.45 - 0.40) * t;
				} else if (normalizedIntensity < 0.4) {
					// Sky blue to turquoise - low engagement
					const t = (normalizedIntensity - 0.2) / 0.2;
					r = 135 + (64 - 135) * t;
					g = 206 + (224 - 206) * t;
					b = 250 + (208 - 250) * t;
					a = 0.45 + (0.50 - 0.45) * t;
				} else if (normalizedIntensity < 0.6) {
					// Turquoise to lime green - moderate engagement
					const t = (normalizedIntensity - 0.4) / 0.2;
					r = 64 + (50 - 64) * t;
					g = 224 + (205 - 224) * t;
					b = 208 + (50 - 208) * t;
					a = 0.50 + (0.58 - 0.50) * t;
				} else if (normalizedIntensity < 0.75) {
					// Lime green to yellow - good engagement
					const t = (normalizedIntensity - 0.6) / 0.15;
					r = 50 + (255 - 50) * t;
					g = 205 + (255 - 255) * t;
					b = 50 + (0 - 50) * t;
					a = 0.58 + (0.65 - 0.58) * t;
				} else if (normalizedIntensity < 0.9) {
					// Yellow to orange - high engagement
					const t = (normalizedIntensity - 0.75) / 0.15;
					r = 255;
					g = 255 + (165 - 255) * t;
					b = 0;
					a = 0.65 + (0.70 - 0.65) * t;
				} else {
					// Orange to red - maximum engagement/time spent
					const t = (normalizedIntensity - 0.9) / 0.1;
					r = 255;
					g = 165 + (80 - 165) * t;
					b = 0 + (0 - 0) * t;
					a = 0.70 + (0.75 - 0.70) * t;
				}

				return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;
			};

			// Draw smooth vertical gradient using thin horizontal slices
			const sliceHeight = 10; // Smaller slices for smoother gradient
			for (let y = 0; y < height; y += sliceHeight) {
				const actualSliceHeight = Math.min(sliceHeight, height - y);

				// Calculate intensity for this Y position
				let intensity = 0;

				if (y <= maxScrollY) {
					// Within scrolled area: find nearest scroll data
					let nearestValue = 0;
					let minDistance = Infinity;

					scrollDepthMap.forEach((data, scrollY) => {
						const distance = Math.abs(scrollY - y);
						if (distance < minDistance) {
							minDistance = distance;
							nearestValue = data.totalValue / data.count;
						}
					});

					intensity = nearestValue;

					// Gradual decay as we get further from actual scroll data
					if (minDistance > 500) {
						intensity = intensity * (1 - (minDistance - 500) / maxScrollY);
					}
				} else {
					// Beyond max scroll: create gradient that fades out
					const beyondRatio = (y - maxScrollY) / (height - maxScrollY);
					intensity = maxIntensity * Math.exp(-beyondRatio * 3);
				}

				// Normalize intensity to 0-1 range
				const normalizedIntensity = Math.max(0, Math.min(1, intensity / (maxIntensity || 1)));

				// Get smoothly interpolated color for this intensity
				const color = interpolateColor(normalizedIntensity);

				// Draw horizontal slice with uniform color across entire width
				ctx.fillStyle = color;
				ctx.fillRect(0, y, width, actualSliceHeight);
			}

			this.debug('debug', 'Scroll heatmap rendered with uniform horizontal bands');
		}

		/**
		 * Render attention heatmap showing where users spent the most time
		 * Uses time-based density to create colored heat zones
		 * RED = Users spent most time here (high engagement)
		 * BLUE = Users spent least time here (low engagement)
		 */
		renderAttentionHeatmap(scrollCoordinates, overlay, width, height, scaleFactor, viewport) {
			// ATTENTION HEATMAP: Uses scroll data to show where users spent time viewing
			// Creates full-width horizontal colored bands based on scroll dwell time
			// Uses Gaussian distribution to create smooth gradients

			// For attention heatmaps:
			// Coordinates are absolute page positions - NO Y scaling needed!
			const refHeight = this.heatmapReferenceHeight || height;
			const displayHeight = this.idealHeight || height;
			this.debug('debug', 'Attention heatmap: refHeight: ' + refHeight + ', displayHeight: ' + displayHeight + ' (NO Y scaling applied)');

			// Create canvas element for attention heatmap rendering
			const canvas = document.createElement('canvas');
			canvas.id = 'attention-heatmap-canvas';
			canvas.width = width;
			canvas.height = height;
			canvas.style.position = 'absolute';
			canvas.style.top = '0';
			canvas.style.left = '0';
			canvas.style.pointerEvents = 'none';

			// Clear any existing content in overlay
			overlay.innerHTML = '';
			overlay.appendChild(canvas);

			// Create floating legend for attention heatmap (append to document body, not overlay/iframe)
			const legend = this.createAttentionLegend();
			// Append to document body so it floats outside the iframe like scroll legend
			document.body.appendChild(legend);

			const ctx = canvas.getContext('2d');

			// STEP 1: Fill entire page with base cold blue color (never seen areas)
			// This ensures all areas of the page are visible, even if not viewed
			ctx.fillStyle = 'rgba(173, 216, 230, 0.25)'; // Light cold blue with low opacity
			ctx.fillRect(0, 0, width, height);

			// Get actual browser window height for attention heatmap rendering
			// Priority: 1) wh from individual coordinates, 2) fallback to 900px for old data
			// NOTE: viewport.height contains the full PAGE height (scrollHeight), NOT the browser window height
			let attentionViewportHeight = 0;
			const whValues = scrollCoordinates.filter(c => c.wh && c.wh > 0).map(c => c.wh);
			if (whValues.length > 0) {
				attentionViewportHeight = Math.round(whValues.reduce((a, b) => a + b, 0) / whValues.length);
			}
			if (!attentionViewportHeight) {
				attentionViewportHeight = 900; // Reasonable default for old data without wh field
			}
			const viewportHeight = attentionViewportHeight;

			this.debug('debug', 'Attention heatmap: Using browser window height ' + viewportHeight + 'px (from ' + whValues.length + ' wh values)');

			// STEP 2: Create vertical density map (scroll positions are Y coordinates)
			// Each row represents a horizontal band across the full page width
			const bandHeight = 2; // Smaller bands = smoother gradients
			const numBands = Math.ceil(height / bandHeight);
			const densityMap = new Array(numBands).fill(0);

			// Populate density map from scroll coordinates
			// Apply Gaussian distribution for smooth blending
			// NO Y scaling - coordinates are absolute page positions
			scrollCoordinates.forEach(coord => {
				// coord.y is the BOTTOM of the visible viewport (scrollTop + innerHeight)
				// The viewing region extends from (y - windowHeight) to y
				const scrollY = coord.y || 0; // NO scaling - absolute coords
				const value = coord.value || 1;

				// Calculate the viewing region - NO scaling needed
				const viewingStartY = Math.max(0, scrollY - viewportHeight);
				const viewingEndY = scrollY;
				const viewingCenterY = (viewingStartY + viewingEndY) / 2;

				// Sigma for smooth gradients within the viewport
				// Using viewport/2.5 gives smooth transitions while keeping most weight in viewport
				const viewportSigma = viewportHeight / 2.5;

				// Apply heat primarily within the viewport with small extension for smoothness
				const startBand = Math.floor(viewingStartY / bandHeight);
				const endBand = Math.ceil(viewingEndY / bandHeight);

				// Small extension (20% of viewport) for smooth blending at edges
				const extension = Math.ceil(viewportHeight * 0.2 / bandHeight);
				const extendedStart = Math.max(0, startBand - extension);
				const extendedEnd = Math.min(numBands - 1, endBand + extension);

				for (let band = extendedStart; band <= extendedEnd; band++) {
					const bandY = band * bandHeight;

					// Distance from center of viewing region
					const distance = Math.abs(bandY - viewingCenterY);

					// Gaussian weight - smooth falloff from center
					const gaussianWeight = Math.exp(-(distance * distance) / (2 * viewportSigma * viewportSigma));

					densityMap[band] += value * gaussianWeight;
				}
			});

			// Find max density for normalization
			const maxDensity = Math.max(...densityMap, 1);

			// Helper function to get color based on attention density
			const getAttentionColor = (normalizedDensity) => {
				// Color scale: Blue (low) -> Green -> Yellow -> Orange -> Red (high)
				let r, g, b, a;

				if (normalizedDensity < 0.15) {
					// Very low attention - light blue
					const t = normalizedDensity / 0.15;
					r = 173 + (135 - 173) * t;
					g = 216 + (206 - 216) * t;
					b = 230 + (250 - 230) * t;
					a = 0.25 + (0.30 - 0.25) * t;
				} else if (normalizedDensity < 0.35) {
					// Low attention - blue to turquoise
					const t = (normalizedDensity - 0.15) / 0.2;
					r = 135 + (64 - 135) * t;
					g = 206 + (224 - 206) * t;
					b = 250 + (208 - 250) * t;
					a = 0.30 + (0.40 - 0.30) * t;
				} else if (normalizedDensity < 0.55) {
					// Moderate attention - turquoise to green
					const t = (normalizedDensity - 0.35) / 0.2;
					r = 64 + (50 - 64) * t;
					g = 224 + (205 - 224) * t;
					b = 208 + (50 - 208) * t;
					a = 0.40 + (0.50 - 0.40) * t;
				} else if (normalizedDensity < 0.70) {
					// Good attention - green to yellow
					const t = (normalizedDensity - 0.55) / 0.15;
					r = 50 + (255 - 50) * t;
					g = 205 + (255 - 205) * t;
					b = 50 + (0 - 50) * t;
					a = 0.50 + (0.60 - 0.50) * t;
				} else if (normalizedDensity < 0.85) {
					// High attention - yellow to orange
					const t = (normalizedDensity - 0.70) / 0.15;
					r = 255;
					g = 255 + (165 - 255) * t;
					b = 0;
					a = 0.60 + (0.70 - 0.60) * t;
				} else {
					// Maximum attention - orange to red
					const t = (normalizedDensity - 0.85) / 0.15;
					r = 255;
					g = 165 + (80 - 165) * t;
					b = 0;
					a = 0.70 + (0.80 - 0.70) * t;
				}

				return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;
			};

			// Draw full-width horizontal bands for attention heatmap
			for (let band = 0; band < numBands; band++) {
				const density = densityMap[band];
				if (density === 0) continue; // Skip empty bands

				const normalizedDensity = density / maxDensity;
				const color = getAttentionColor(normalizedDensity);
				const y = band * bandHeight;

				ctx.fillStyle = color;
				ctx.fillRect(0, y, width, bandHeight);
			}

			this.debug('debug', 'Attention heatmap rendered with ' + scrollCoordinates.length + ' scroll events across full page width');
		}

		/**
		 * Create floating attention heatmap legend as DOM element
		 * Displays vertical gradient from High (red) to Low (blue)
		 * Positioned fixed in top-right corner, stays visible when scrolling
		 */
		createAttentionLegend() {
			const legend = document.createElement('div');
			legend.className = 'attention-heatmap-legend';
			legend.style.cssText = `
				position: fixed;
				top: 50%;
				right: 30px;
				transform: translateY(-50%);
				width: 35px;
				background: rgba(255, 255, 255, 0.95);
				border: 1px solid rgba(0, 0, 0, 0.2);
				border-radius: 6px;
				padding: 6px 4px;
				box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
				z-index: 1000;
				font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
			`;

			// High label
			const highLabel = document.createElement('div');
			highLabel.textContent = (this.config.i18n && this.config.i18n.high) ? this.config.i18n.high : 'High';
			highLabel.style.cssText = `
				text-align: center;
				font-size: 9px;
				font-weight: bold;
				color: #333;
				margin-bottom: 4px;
			`;
			legend.appendChild(highLabel);

			// Gradient container
			const gradientContainer = document.createElement('div');
			gradientContainer.style.cssText = `
				width: 100%;
				height: 120px;
				border: 1px solid rgba(0, 0, 0, 0.3);
				border-radius: 3px;
				background: linear-gradient(to bottom,
					rgba(255, 80, 0, 0.8) 0%,
					rgba(255, 165, 0, 0.7) 15%,
					rgba(255, 255, 0, 0.6) 30%,
					rgba(50, 205, 50, 0.5) 45%,
					rgba(64, 224, 208, 0.4) 65%,
					rgba(135, 206, 250, 0.3) 85%,
					rgba(173, 216, 230, 0.25) 100%
				);
			`;
			legend.appendChild(gradientContainer);

			// Low label
			const lowLabel = document.createElement('div');
			lowLabel.textContent = (this.config.i18n && this.config.i18n.low) ? this.config.i18n.low : 'Low';
			lowLabel.style.cssText = `
				text-align: center;
				font-size: 9px;
				font-weight: bold;
				color: #333;
				margin-top: 4px;
			`;
			legend.appendChild(lowLabel);

			// Attention label
			const attentionLabel = document.createElement('div');
			attentionLabel.textContent = 'Attention';
			attentionLabel.style.cssText = `
				text-align: center;
				font-size: 8px;
				color: #666;
				margin-top: 6px;
				padding-top: 6px;
				border-top: 1px solid rgba(0, 0, 0, 0.1);
			`;
			legend.appendChild(attentionLabel);

			return legend;
		}


		/**
		 * Update trajectory canvas with new data (for incremental batch updates)
		 * Re-renders all trajectories to include newly loaded data
		 */
		updateTrajectoryCanvas() {
			if (!this.trajectoryCanvas || !this.allTrajectories || this.allTrajectories.length === 0) {
				return;
			}

			const canvas = this.trajectoryCanvas;
			const ctx = canvas.getContext('2d');

			// Clear canvas
			ctx.clearRect(0, 0, canvas.width, canvas.height);

			// For move heatmaps: NO Y scaling - coordinates are absolute page positions
			// Only apply X scaling if viewport widths differ significantly
			const refWidth = this.heatmapReferenceWidth || canvas.width;
			const displayWidth = this.idealWidth || canvas.width;
			const coordScaleX = displayWidth / refWidth;
			const shouldScaleX = Math.abs(coordScaleX - 1) > 0.1; // Only scale if >10% difference

			// Calculate opacity based on number of trajectories
			const baseOpacity = Math.min(0.6, Math.max(0.15, 50 / this.allTrajectories.length));

			this.debug('debug', 'Updating trajectory canvas with ' + this.allTrajectories.length + ' trajectories, X scale: ' + (shouldScaleX ? coordScaleX.toFixed(3) : 'none') + ', Y scale: none');

			// Draw each trajectory - NO Y scaling (absolute coords)
			this.allTrajectories.forEach((trajectory, index) => {
				if (trajectory.length < 2) return;

				ctx.beginPath();
				const hue = (index * 137.5) % 360;
				ctx.strokeStyle = `hsla(${hue}, 70%, 50%, ${baseOpacity})`;
				ctx.lineWidth = 2;
				ctx.lineCap = 'round';
				ctx.lineJoin = 'round';

				const firstPoint = trajectory[0];
				const startX = Math.round((firstPoint.x || 0) * (shouldScaleX ? coordScaleX : 1));
				const startY = firstPoint.y || 0; // NO Y scaling
				ctx.moveTo(startX, startY);

				for (let i = 1; i < trajectory.length; i++) {
					const point = trajectory[i];
					const x = Math.round((point.x || 0) * (shouldScaleX ? coordScaleX : 1));
					const y = point.y || 0; // NO Y scaling
					ctx.lineTo(x, y);
				}

				ctx.stroke();
			});

			// Add glow effect - NO Y scaling
			ctx.globalCompositeOperation = 'lighter';
			this.allTrajectories.forEach((trajectory, index) => {
				if (trajectory.length < 2) return;

				ctx.beginPath();
				const hue = (index * 137.5) % 360;
				ctx.strokeStyle = `hsla(${hue}, 70%, 60%, ${baseOpacity * 0.3})`;
				ctx.lineWidth = 6;

				const firstPoint = trajectory[0];
				const startX = Math.round((firstPoint.x || 0) * (shouldScaleX ? coordScaleX : 1));
				const startY = firstPoint.y || 0; // NO Y scaling
				ctx.moveTo(startX, startY);

				for (let i = 1; i < trajectory.length; i++) {
					const point = trajectory[i];
					const x = Math.round((point.x || 0) * (shouldScaleX ? coordScaleX : 1));
					const y = point.y || 0; // NO Y scaling
					ctx.lineTo(x, y);
				}

				ctx.stroke();
			});

			ctx.globalCompositeOperation = 'source-over';
		}

		/**
		 * Get maximum value from coordinates
		 */
		getMaxValue(coordinates) {
			if (!coordinates || coordinates.length === 0) {
				return 1;
			}

			return Math.max(...coordinates.map(c => c.value || 1));
		}

		/**
		 * Generate scroll heatmap data that covers the full page height
		 * Creates horizontal bands across the entire canvas based on scroll depth
		 */
		generateScrollHeatmapData(scrollCoordinates, canvasWidth, canvasHeight, scaleFactor = 1) {
			if (!scrollCoordinates || scrollCoordinates.length === 0) {
				return [];
			}

			// Group scroll events by Y position to calculate scroll depth/intensity
			// Apply scale factor to coordinates
			const scrollDepthMap = new Map();

			scrollCoordinates.forEach(coord => {
				const y = Math.round((coord.y || 0) * scaleFactor);
				const value = coord.value || 1;

				if (!scrollDepthMap.has(y)) {
					scrollDepthMap.set(y, { count: 0, totalValue: 0 });
				}

				const entry = scrollDepthMap.get(y);
				entry.count++;
				entry.totalValue += value;
			});

			// Find the maximum scroll depth (highest Y coordinate users reached) - already scaled
			const maxScrollY = Math.max(...Array.from(scrollDepthMap.keys()));

			// Generate data points for FULL canvas height
			const enhancedData = [];
			const bandHeight = 50; // Create a band every 50px
			const pointsPerBand = 100; // Dense point distribution for seamless horizontal coverage

			// Create horizontal bands from top (0) to full canvas height
			for (let y = 0; y < canvasHeight; y += bandHeight) {
				// Calculate the intensity value for this Y position
				// Areas above maxScrollY get decreasing values (fewer users scrolled there)
				let intensity;

				if (y <= maxScrollY) {
					// Within scrolled area: check if we have actual data for this Y
					let nearestValue = 0;
					let minDistance = Infinity;

					// Find the nearest scroll data point
					scrollDepthMap.forEach((data, scrollY) => {
						const distance = Math.abs(scrollY - y);
						if (distance < minDistance) {
							minDistance = distance;
							nearestValue = data.totalValue / data.count;
						}
					});

					// Use interpolated value with fallback
					intensity = nearestValue > 0 ? nearestValue : 10;

					// Gradual decay as we get further from actual scroll data
					if (minDistance > 500) {
						intensity = intensity * (1 - (minDistance - 500) / maxScrollY);
					}
				} else {
					// Beyond max scroll: create gradient that fades out
					const beyondRatio = (y - maxScrollY) / (canvasHeight - maxScrollY);
					// Exponential decay for smooth gradient to bottom
					intensity = 10 * Math.exp(-beyondRatio * 3);
				}

				// Ensure minimum visibility
				intensity = Math.max(intensity, 1);

				// Create points across the width for this horizontal band
				for (let i = 0; i < pointsPerBand; i++) {
					const x = (canvasWidth / (pointsPerBand - 1)) * i;
					enhancedData.push({
						x: Math.round(x),
						y: y,
						value: Math.round(intensity)
					});
				}
			}

			return enhancedData;
		}

		/**
		 * Regenerate scroll heatmap with new height
		 * Called after iframe loads and we know the full page height
		 * IMPORTANT: Must recreate the heatmap instance because heatmap.js doesn't resize canvas
		 */
		regenerateScrollHeatmap(newOverlayHeight) {
			if (!this.allCoordinates || this.allCoordinates.length === 0) {
				return;
			}

			this.debug('debug', 'Regenerating scroll heatmap with full page height: ' + newOverlayHeight);

			const overlay = document.getElementById('heatmap-overlay');
			if (!overlay) return;

			const overlayWidth = parseInt(overlay.style.width) || 1138;
			const scaleFactor = this.canvasScaleFactor || 1;

			// CRITICAL FIX: Canvas has a maximum size limit in browsers
			// Cap the canvas height and scale coordinates accordingly
			const MAX_CANVAS_HEIGHT = 16000;
			let actualHeight = newOverlayHeight;
			let canvasHeightScale = 1;

			if (newOverlayHeight > MAX_CANVAS_HEIGHT) {
				this.debug('debug', 'Canvas height ' + newOverlayHeight + 'px exceeds maximum ' + MAX_CANVAS_HEIGHT + 'px, scaling down');
				canvasHeightScale = MAX_CANVAS_HEIGHT / newOverlayHeight;
				actualHeight = MAX_CANVAS_HEIGHT;
				this.canvasHeightScale = canvasHeightScale;
				this.originalOverlayHeight = newOverlayHeight;
			} else {
				this.canvasHeightScale = 1;
				this.originalOverlayHeight = newOverlayHeight;
			}

			// Clear the overlay
			overlay.innerHTML = '';
			overlay.style.height = Math.round(actualHeight) + 'px';

			// Destroy old instance (we're not using heatmap.js for scroll anymore)
			this.heatmapInstance = null;

			// Render scroll heatmap using custom canvas renderer
			// Apply the canvas height scale to coordinates
			const combinedScale = scaleFactor * canvasHeightScale;
			this.renderScrollHeatmap(
				this.allCoordinates,
				overlay,
				overlayWidth,
				actualHeight,
				combinedScale
			);

			this.debug('debug', 'Scroll heatmap regenerated with ' + this.allCoordinates.length + ' scroll events, canvas height: ' + actualHeight + (canvasHeightScale < 1 ? ' (scaled from ' + newOverlayHeight + ')' : ''), 'heatmap-detail');
		}

		/**
		 * Regenerate click heatmap with new dimensions
		 * Called after iframe loads and we know the full page height
		 */
		regenerateClickHeatmap(newOverlayHeight, newOverlayWidth) {
			if (!this.allCoordinates || this.allCoordinates.length === 0) {
				return;
			}

			this.debug('debug', 'Regenerating click heatmap with dimensions: ' + newOverlayWidth + ' x ' + newOverlayHeight);

			const overlay = document.getElementById('heatmap-overlay');
			if (!overlay) return;

			// CRITICAL FIX: Canvas has a maximum size limit in browsers
			// Cap the canvas height and scale coordinates accordingly
			const MAX_CANVAS_HEIGHT = 16000;
			let actualHeight = newOverlayHeight;
			let canvasHeightScale = 1;

			if (newOverlayHeight > MAX_CANVAS_HEIGHT) {
				this.debug('debug', 'Canvas height ' + newOverlayHeight + 'px exceeds maximum ' + MAX_CANVAS_HEIGHT + 'px, scaling down');
				canvasHeightScale = MAX_CANVAS_HEIGHT / newOverlayHeight;
				actualHeight = MAX_CANVAS_HEIGHT;
				this.canvasHeightScale = canvasHeightScale;
				this.originalOverlayHeight = newOverlayHeight;
			} else {
				this.canvasHeightScale = 1;
				this.originalOverlayHeight = newOverlayHeight;
			}

			// CRITICAL: Clear the overlay and recreate the heatmap instance
			overlay.innerHTML = '';
			overlay.style.width = Math.round(newOverlayWidth) + 'px';
			overlay.style.height = Math.round(actualHeight) + 'px';

			// CRITICAL FIX: Force browser reflow BEFORE creating heatmap instance
			// heatmap.js reads container.offsetWidth/offsetHeight at creation time
			const _forceReflow = overlay.offsetHeight;
			this.debug('debug', 'Forced reflow, overlay offsetHeight now: ' + _forceReflow);

			// Destroy old instance
			this.heatmapInstance = null;

			// Create new heatmap instance with correct dimensions
			const config = {
				container: overlay,
				radius: 40,
				maxOpacity: 0.8,
				minOpacity: 0.05,
				blur: 0.85,
				gradient: {
					0.0: 'rgba(0, 0, 255, 0)',
					0.2: 'rgba(0, 0, 255, 1)',
					0.4: 'rgba(0, 255, 255, 1)',
					0.6: 'rgba(0, 255, 0, 1)',
					0.8: 'rgba(255, 255, 0, 1)',
					1.0: 'rgba(255, 0, 0, 1)'
				}
			};

			this.heatmapInstance = window.h337.create(config);

			// Verify the canvas was created with correct dimensions
			const canvas = overlay.querySelector('canvas');
			if (canvas) {
				this.debug('debug', 'Heatmap canvas created with dimensions: ' + canvas.width + ' x ' + canvas.height);
				// If canvas height is still wrong, manually resize it (but respect the max)
				const targetHeight = Math.min(actualHeight, MAX_CANVAS_HEIGHT);
				if (canvas.height < targetHeight * 0.9) {
					this.debug('debug', 'Canvas height mismatch! Manually resizing canvas to: ' + newOverlayWidth + ' x ' + Math.round(targetHeight), 'heatmap-detail');
					canvas.width = Math.round(newOverlayWidth);
					canvas.height = Math.round(targetHeight);
				}
			}

			// Scale coordinates - apply both iframe scale factor and canvas height scale
			const scaleFactor = this.canvasScaleFactor || 1;
			const offsetX = this.contentOffsetX || 0;
			const yScale = scaleFactor * canvasHeightScale;

			const scaledCoordinates = this.allCoordinates.map(coord => ({
				x: Math.round((coord.x - offsetX) * scaleFactor),
				y: Math.round(coord.y * yScale),
				value: coord.value || 1
			})).filter(coord =>
				coord.x >= 0 && coord.x < newOverlayWidth &&
				coord.y >= 0 && coord.y < actualHeight
			);

			const heatmapData = {
				max: Math.max(10, this.getMaxValue(scaledCoordinates)),
				data: scaledCoordinates
			};

			this.heatmapInstance.setData(heatmapData);
			this.debug('debug', 'Click heatmap regenerated with ' + scaledCoordinates.length + ' points' + (canvasHeightScale < 1 ? ' (height scaled from ' + newOverlayHeight + ')' : ''), 'heatmap-detail');
		}

		/**
		 * Regenerate move heatmap (trajectory canvas) with new dimensions
		 * Called after iframe loads and we know the full page height
		 */
		regenerateMoveHeatmap(newOverlayHeight, newOverlayWidth) {
			const overlay = document.getElementById('heatmap-overlay');
			if (!overlay) return;

			// CRITICAL FIX: Canvas has a maximum size limit in browsers
			// Cap the canvas height and scale coordinates accordingly
			const MAX_CANVAS_HEIGHT = 16000;
			let actualHeight = newOverlayHeight;
			let canvasHeightScale = 1;

			if (newOverlayHeight > MAX_CANVAS_HEIGHT) {
				this.debug('debug', 'Canvas height ' + newOverlayHeight + 'px exceeds maximum ' + MAX_CANVAS_HEIGHT + 'px, scaling down');
				canvasHeightScale = MAX_CANVAS_HEIGHT / newOverlayHeight;
				actualHeight = MAX_CANVAS_HEIGHT;
				this.canvasHeightScale = canvasHeightScale;
				this.originalOverlayHeight = newOverlayHeight;
			} else {
				this.canvasHeightScale = 1;
				this.originalOverlayHeight = newOverlayHeight;
			}

			// Update overlay dimensions
			overlay.style.width = Math.round(newOverlayWidth) + 'px';
			overlay.style.height = Math.round(actualHeight) + 'px';

			// CRITICAL FIX: Force browser reflow BEFORE creating canvas/heatmap instance
			const _forceReflow = overlay.offsetHeight;
			this.debug('debug', 'Forced reflow, overlay offsetHeight now: ' + _forceReflow);

			// Check if we have trajectories to render
			if (this.allTrajectories && this.allTrajectories.length > 0) {
				this.debug('debug', 'Regenerating move heatmap with dimensions: ' + newOverlayWidth + ' x ' + actualHeight + (canvasHeightScale < 1 ? ' (scaled from ' + newOverlayHeight + ')' : ''), 'heatmap-detail');

				// Clear overlay and re-render trajectories with new dimensions
				overlay.innerHTML = '';
				const scaleFactor = this.canvasScaleFactor || 1;
				// Apply both scale factors for trajectory rendering
				this.renderMoveTrajectories(this.allTrajectories, overlay, newOverlayWidth, actualHeight, 1);
			} else if (this.allCoordinates && this.allCoordinates.length > 0) {
				// Fallback: render as heat points if no trajectories
				this.debug('debug', 'Regenerating move heatmap (fallback) with ' + this.allCoordinates.length + ' points');

				overlay.innerHTML = '';

				const config = {
					container: overlay,
					radius: 8,
					blur: 0.75,
					maxOpacity: 0.7,
					minOpacity: 0.15,
					gradient: {
						0.0: 'rgba(100, 200, 100, 0)',
						0.15: 'rgba(120, 255, 120, 0.4)',
						0.3: 'rgba(150, 255, 100, 0.5)',
						0.45: 'rgba(200, 255, 80, 0.6)',
						0.6: 'rgba(255, 220, 0, 0.7)',
						0.75: 'rgba(255, 180, 0, 0.8)',
						0.9: 'rgba(255, 100, 0, 0.85)',
						1.0: 'rgba(255, 50, 0, 0.9)'
					}
				};

				this.heatmapInstance = window.h337.create(config);

				// Verify the canvas was created with correct dimensions
				const canvas = overlay.querySelector('canvas');
				if (canvas) {
						this.debug('debug', 'Heatmap canvas created with dimensions: ' + canvas.width + ' x ' + canvas.height);
					// If canvas height is still wrong, manually resize it (but respect the max)
					const targetHeight = Math.min(actualHeight, MAX_CANVAS_HEIGHT);
					if (canvas.height < targetHeight * 0.9) {
							this.debug('debug', 'Canvas height mismatch! Manually resizing canvas to: ' + newOverlayWidth + ' x ' + Math.round(targetHeight), 'heatmap-detail');
						canvas.width = Math.round(newOverlayWidth);
						canvas.height = Math.round(targetHeight);
					}
				}

				const scaleFactor = this.canvasScaleFactor || 1;
				const offsetX = this.contentOffsetX || 0;
				const yScale = scaleFactor * canvasHeightScale;

				const scaledCoordinates = this.allCoordinates.map(coord => ({
					x: Math.round((coord.x - offsetX) * scaleFactor),
					y: Math.round(coord.y * yScale),
					value: coord.value || 1
				})).filter(coord =>
					coord.x >= 0 && coord.x < newOverlayWidth &&
					coord.y >= 0 && coord.y < actualHeight
				);

				const heatmapData = {
					max: Math.max(10, this.getMaxValue(scaledCoordinates)),
					data: scaledCoordinates
				};

				this.heatmapInstance.setData(heatmapData);
			}
		}

		/**
		 * Load statistics
		 */
		async loadStats() {
			$('.stats-loading').show();
			$('#top-elements-list').empty();

			try {
				const response = await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_get_heatmap_stats',
						nonce: this.config.nonce,
						page_id: this.pageId,
						device: this.currentDevice,
						type: this.currentType,
						date_range: this.filters.date_range,
						country: this.filters.country,
						browser: this.filters.browser,
						visitor_type: this.filters.visitor_type,
						ab_test_id: this.abTestId,
						variant_id: this.variantId
					}
				});

				if (response.success && response.data) {
					this.renderStats(response.data);
				} else {
					// Show coming soon message when no data
					this.showComingSoonMessage();
				}
			} catch (error) {
				this.debug('error', 'Failed to load stats:', error);
				// Show coming soon message on error
				this.showComingSoonMessage();
			} finally {
				$('.stats-loading').hide();
			}
		}

		/**
		 * Show coming soon message in top elements list
		 */
		showComingSoonMessage() {
			const $list = $('#top-elements-list');
			$list.html(`
				<div class="opti-behavior-coming-soon">
					<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="coming-soon-icon"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
					<p>${this.config.i18n.coming_soon || 'Will be available soon'}</p>
				</div>
			`);
		}

		/**
		 * Render statistics
		 */
		renderStats(stats) {
			// Update quick stats bar
			$("#stat-views").text(stats.total_views || 0);

			// Show/hide appropriate stats based on heatmap type
			// Attention heatmap uses scroll data, so show scroll stats for both scroll and attention types
			if (this.currentType === 'scroll' || this.currentType === 'attention') {
				$('.stat-clicks-item').hide();
				$('.stat-scroll-item').show();
				$('.stat-move-item').hide();
				$('#stat-scrolls').text(stats.total_scrolls || 0);
			} else if (this.currentType === 'move') {
				$('.stat-clicks-item').hide();
				$('.stat-scroll-item').hide();
				$('.stat-move-item').show();
				$('#stat-moves').text(stats.total_moves || 0);
			} else {
				$('.stat-clicks-item').show();
				$('.stat-scroll-item').hide();
				$('.stat-move-item').hide();
				$('#stat-clicks').text(stats.total_clicks || 0);
			}

			$('#stat-avg-time').text(this.formatTime(stats.avg_time || 0));

			// Render top elements list
			if (stats.top_elements && stats.top_elements.length > 0) {
				this.renderTopElements(stats.top_elements);
			} else {
				this.showComingSoonMessage();
			}
		}

		/**
		 * Render top elements list
		 */
		renderTopElements(elements) {
			const list = $('#top-elements-list');
			list.empty();

			elements.forEach((el, index) => {
				const item = $('<div class="element-item"></div>');
				item.html(`
					<div class="element-rank">${index + 1}</div>
					<div class="element-details">
						<div class="element-selector">${this.escapeHtml(el.selector || 'Unknown')}</div>
						<div class="element-stats">
							${el.clicks || 0} clicks (${el.percentage || 0}%)
						</div>
					</div>
				`);
				list.append(item);
			});
		}

		/**
		 * Switch A/B test variant filter
		 * @param {string} value - Format "testId:variantId" or empty for all variants
		 */
		switchVariant(value) {
			if (value && value.includes(':')) {
				const parts = value.split(':');
				this.abTestId = parseInt(parts[0], 10) || 0;
				this.variantId = parseInt(parts[1], 10) || 0;
			} else {
				this.abTestId = 0;
				this.variantId = 0;
			}

			// Update visual state of select
			const $select = $('#filter-ab-variant');
			if (this.variantId) {
				$select.addClass('has-value');
			} else {
				$select.removeClass('has-value');
			}

			// Update URL with variant params
			const url = new URL(window.location.href);
			if (this.abTestId > 0 && this.variantId > 0) {
				url.searchParams.set('ab_test_id', this.abTestId);
				url.searchParams.set('variant_id', this.variantId);
			} else {
				url.searchParams.delete('ab_test_id');
				url.searchParams.delete('variant_id');
			}
			window.history.pushState({}, '', url.toString());

			// Reload heatmap (which rebuilds iframe with variant preview) and stats
			this.loadHeatmap();
			this.loadStats();
		}

		/**
		 * Switch device
		 */
		switchDevice(device) {
			this.currentDevice = device;

			// Update UI
			$('.device-btn').removeClass('active');
			$(`.device-btn[data-device="${device}"]`).addClass('active');

			// Update URL
			this.updateUrl();

			// Clear current filter selections since they may not be valid for the new device
			this.filters.country = '';
			this.filters.browser = '';

			// Reset multi-select dropdowns UI
			$('.multiselect-dropdown').each((index, dropdown) => {
				const $dropdown = $(dropdown);
				$dropdown.find('input[type="checkbox"]').prop('checked', false);
				$dropdown.removeClass('has-selection');
				this.updateMultiSelectLabel($dropdown);
			});

			// Reload filter options for the new device
			this.loadFilterOptions();

			// Reload data
			this.loadHeatmap();
			this.loadStats();
		}

		/**
		 * Switch heatmap type
		 */
		switchType(type) {
			// Remove scroll heatmap legend when switching away from scroll type
			if (this.currentType === 'scroll' && type !== 'scroll') {
				const existingLegend = document.querySelector('.scroll-heatmap-legend');
				if (existingLegend) {
					existingLegend.remove();
					this.debug('debug', 'Removed scroll heatmap legend on type switch');
				}
			}

			// Remove attention heatmap legend when switching away from attention type
			if (this.currentType === 'attention' && type !== 'attention') {
				const existingAttentionLegend = document.querySelector('.attention-heatmap-legend');
				if (existingAttentionLegend) {
					existingAttentionLegend.remove();
					this.debug('debug', 'Removed attention heatmap legend on type switch');
				}
			}

			this.currentType = type;

			// Update UI
			$('.heatmap-tab').removeClass('active');
			$(`.heatmap-tab[data-type="${type}"]`).addClass('active');

			// Update container data attribute
			$('#heatmap-container').attr('data-type', type);

			// Update stat items visibility
			this.updateStatItemsVisibility();

			// Update URL
			this.updateUrl();

			// Reload heatmap and stats
			this.loadHeatmap();
			this.loadStats();
		}

		/**
		 * Refresh heatmap (clear cache and reload)
		 */
		async refreshHeatmap() {
			const $btn = $('#refresh-heatmap');
			$btn.prop('disabled', true);

			try {
				await $.ajax({
					url: this.config.ajax_url,
					type: 'POST',
					data: {
						action: 'opti_behavior_refresh_heatmap_cache',
						nonce: this.config.nonce,
						page_id: this.pageId
					}
				});

				// Reload data
				await this.loadHeatmap();
				await this.loadStats();
			} catch (error) {
				this.debug('error', 'Failed to refresh:', error);
			} finally {
				$btn.prop('disabled', false);
			}
		}

		/**
		 * Update browser URL without reload
		 */
		updateUrl() {
			const url = new URL(window.location.href);
			url.searchParams.set('device', this.currentDevice);
			url.searchParams.set('type', this.currentType);
			window.history.pushState({}, '', url.toString());
		}

		/**
		 * Show batch progress indicator on the rendered heatmap
		 * This shows which batch just completed while more are loading
		 * @param {number} currentBatch - Current batch number
		 * @param {number} totalBatches - Total number of batches
		 * @param {number} totalPoints - Total points loaded so far
		 * @param {string} statusMessage - Optional status message (e.g., "Paused", "Resuming...")
		 */
		showBatchProgress(currentBatch, totalBatches, totalPoints, statusMessage = null) {
			// Remove any existing progress indicator
			$('#batch-progress-indicator').remove();

			// Determine the text to display
			let progressText;
			if (statusMessage) {
				progressText = `${statusMessage} - ${totalPoints.toLocaleString()} points loaded`;
			} else {
				progressText = `Batch ${currentBatch}/${totalBatches} loaded (${totalPoints.toLocaleString()} points)`;
			}

			// Determine spinner animation based on status
			const isPaused = statusMessage && (statusMessage.includes('Paused') || statusMessage.includes('hidden'));
			const spinnerStyle = isPaused
				? 'animation: none; border-top-color: #ffc107;' // Yellow, no spin when paused
				: 'animation: batch-spin 1s linear infinite;';

			// Create a small progress indicator that overlays on the heatmap with Stop button
			const progressHtml = `
				<div id="batch-progress-indicator" style="
					position: absolute;
					top: 10px;
					left: 50%;
					transform: translateX(-50%);
					background: rgba(0, 0, 0, 0.85);
					color: #fff;
					padding: 8px 12px;
					border-radius: 6px;
					font-size: 12px;
					z-index: 100;
					display: flex;
					align-items: center;
					gap: 10px;
				">
					<span class="batch-spinner" style="
						width: 14px;
						height: 14px;
						border: 2px solid rgba(255,255,255,0.3);
						border-top-color: #fff;
						border-radius: 50%;
						${spinnerStyle}
					"></span>
					<span class="batch-progress-text">${progressText}</span>
					<button id="stop-loading-btn" style="
						background: #dc3545;
						color: #fff;
						border: none;
						padding: 4px 10px;
						border-radius: 3px;
						cursor: pointer;
						font-size: 9px;
						font-weight: 500;
						display: flex;
						align-items: center;
						gap: 4px;
						transition: background 0.2s;
					" title="Stop loading more data">
						<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
							<rect x="6" y="6" width="12" height="12" rx="1"/>
						</svg>
						Stop
					</button>
				</div>
				<style>
					@keyframes batch-spin {
						to { transform: rotate(360deg); }
					}
					#stop-loading-btn:hover {
						background: #c82333 !important;
					}
				</style>
			`;

			$('#heatmap-container').append(progressHtml);

			// Bind stop button click handler
			$('#stop-loading-btn').off('click').on('click', () => {
				this.stopLoading();
			});
		}

		/**
		 * Stop the batch loading process
		 * Called when user clicks the Stop button
		 */
		stopLoading() {
			this.debug('debug', 'User requested to stop loading');
			this.stopLoadingRequested = true;
			this.isLoading = false;

			// Update the progress indicator to show stopped state
			const $indicator = $('#batch-progress-indicator');
			$indicator.find('.batch-spinner').css({
				'animation': 'none',
				'border-color': '#ffc107',
				'border-top-color': '#ffc107'
			});
			$indicator.find('.batch-progress-text').text('Loading stopped');
			$indicator.find('#stop-loading-btn').remove();

			// Show a brief "stopped" message then hide the indicator
			setTimeout(() => {
				this.hideBatchProgress();
				this.showBatchComplete(this.allCoordinates ? this.allCoordinates.length : 0, true);
			}, 1500);
		}

		/**
		 * Update heatmap overlay incrementally without freezing
		 * Adds new points in small chunks using requestAnimationFrame
		 */
		updateHeatmapOverlay() {
			if (!this.heatmapInstance || !this.allCoordinates || this.allCoordinates.length === 0) {
				this.debug('debug', 'No heatmap instance or coordinates to update');
				return;
			}

			// Get scaling values
			const scaleFactor = this.canvasScaleFactor || 0.78;
			const offsetX = this.contentOffsetX || 0;
			// Apply canvas height scale for very tall pages
			const canvasHeightScale = this.canvasHeightScale || 1;
			const yScale = scaleFactor * canvasHeightScale;

			// Start from where we left off (after the initial 500 points)
			const startIndex = this.lastRenderedIndex || 500;

			if (startIndex >= this.allCoordinates.length) {
				this.debug('debug', 'All coordinates already rendered');
				return;
			}

			this.debug('debug', `Incrementally adding points from ${startIndex} to ${this.allCoordinates.length}`);

			// Process in small chunks to avoid freeze
			const chunkSize = 200;
			let currentIndex = startIndex;
			const self = this;

			// Get canvas dimensions for bounds checking
			// Use the overlay dimensions which may be capped for very tall pages
			const overlay = document.getElementById('heatmap-overlay');
			const maxX = overlay ? parseInt(overlay.style.width) || 2000 : 2000;
			const maxY = overlay ? parseInt(overlay.style.height) || 2000 : 2000;

			const processChunk = () => {
				if (currentIndex >= self.allCoordinates.length) {
					this.debug('debug', 'Incremental heatmap update complete');
					self.lastRenderedIndex = self.allCoordinates.length;
					return;
				}

				const endIndex = Math.min(currentIndex + chunkSize, self.allCoordinates.length);
				const chunk = self.allCoordinates.slice(currentIndex, endIndex);

				// Scale data and filter out points outside canvas bounds to prevent IndexSizeError
				// Apply canvas height scale for Y coordinates on very tall pages
				const scaledData = chunk
					.map(point => ({
						x: Math.round((point.x - offsetX) * scaleFactor),
						y: Math.round(point.y * yScale),
						value: point.value || 1
					}))
					.filter(point => point.x >= 0 && point.x < maxX && point.y >= 0 && point.y < maxY);

				// Add data incrementally (don't clear existing data)
				if (scaledData.length > 0) {
					try {
						self.heatmapInstance.addData(scaledData);
					} catch (e) {
						// Silently ignore canvas errors for out-of-bounds points
					}
				}

				currentIndex = endIndex;
				self.lastRenderedIndex = currentIndex;

				// Schedule next chunk with requestAnimationFrame to keep UI responsive
				if (currentIndex < self.allCoordinates.length) {
					requestAnimationFrame(processChunk);
				}
			};

			// Start processing
			requestAnimationFrame(processChunk);
		}

		/**
		 * Hide batch progress indicator (called when all batches complete)
		 */
		hideBatchProgress() {
			$('#batch-progress-indicator').remove();
		}

		/**
		 * Show completion message when all batches are loaded or stopped
		 * @param {number} totalPoints - Total points loaded
		 * @param {boolean} stopped - Whether loading was stopped by user
		 */
		showBatchComplete(totalPoints, stopped = false) {
			// Remove any existing progress indicator
			$('#batch-progress-indicator').remove();

			// Different colors/messages for complete vs stopped vs no data
			let bgColor, textColor, icon, message;

			if (totalPoints === 0) {
				// No data case - show info message
				bgColor = 'rgba(23, 162, 184, 0.9)'; // Info blue
				textColor = '#fff';
				icon = '&#9432;'; // Info icon
				message = 'No heatmap data yet for this page';
			} else if (stopped) {
				bgColor = 'rgba(255, 193, 7, 0.9)';
				textColor = '#212529';
				icon = '&#9632;'; // Square for stop
				message = `Stopped - ${totalPoints.toLocaleString()} points loaded`;
			} else {
				bgColor = 'rgba(40, 167, 69, 0.9)';
				textColor = '#fff';
				icon = '&#10003;'; // Checkmark for complete
				message = `Complete! ${totalPoints.toLocaleString()} points loaded`;
			}

			// Create a completion indicator that fades out
			const completeHtml = `
				<div id="batch-progress-indicator" style="
					position: absolute;
					top: 10px;
					left: 50%;
					transform: translateX(-50%);
					background: ${bgColor};
					color: ${textColor};
					padding: 8px 12px;
					border-radius: 6px;
					font-size: 12px;
					z-index: 100;
					display: flex;
					align-items: center;
					gap: 8px;
					transition: opacity 0.5s ease;
				">
					<span style="font-size: 14px;">${icon}</span>
					<span>${message}</span>
				</div>
			`;

			$('#heatmap-container').append(completeHtml);

			// Fade out and remove after 3 seconds
			setTimeout(() => {
				$('#batch-progress-indicator').css('opacity', '0');
				setTimeout(() => {
					$('#batch-progress-indicator').remove();
				}, 500);
			}, 3000);
		}

		/**
		 * Show loading state
		 */
		showLoading() {
			$('.heatmap-loading').css('display', 'flex');
			$('.heatmap-no-data').hide();
			$('#heatmap-container').hide();
			this.disableDownloadButton();
		}

		/**
		 * Show no data state - still displays the page but with a notification
		 */
		showNoData() {
			$('.heatmap-loading').hide();

			// Still show the page even with no heatmap data
			// Call renderHeatmap directly (not renderHeatmapWithData to avoid loop)
			this.renderHeatmap({
				coordinates: [],
				trajectories: [],
				viewport: this.heatmapViewport || { width: 1920, height: 1080 },
				reference_width: this.heatmapReferenceWidth || 1920,
				reference_height: this.heatmapReferenceHeight || 5000,
				loading_more: false
			});

			// Show a subtle notification that there's no data yet
			this.showBatchComplete(0, true);
		}

		/**
		 * Show error state
		 */
		showError(message) {
			$('.heatmap-loading').hide();
			$('#heatmap-container').hide();
			$('.heatmap-no-data p').text(message || this.config.i18n.error);
			$('.heatmap-no-data').css('display', 'flex');
		}

		/**
		 * Format time in seconds to human readable
		 */
		formatTime(seconds) {
			if (seconds < 60) {
				return seconds + 's';
			}
			const minutes = Math.floor(seconds / 60);
			const secs = seconds % 60;
			return minutes + 'm ' + secs + 's';
		}

		/**
		 * Escape HTML
		 */
		escapeHtml(text) {
			const map = {
				'&': '&amp;',
				'<': '&lt;',
				'>': '&gt;',
				'"': '&quot;',
				"'": '&#039;'
			};
			return text.replace(/[&<>"']/g, m => map[m]);
		}

		/**
		 * Get country flag emoji from country code
		 * Converts ISO 3166-1 alpha-2 country codes to flag emojis
		 * @param {string} countryCode - Two-letter country code (e.g., "US", "FR")
		 * @returns {string} Flag emoji or empty string if invalid
		 */
		getCountryFlag(countryCode) {
			if (!countryCode || countryCode.length !== 2) {
				return '';
			}
			// Convert country code to regional indicator symbols
			// Each letter A-Z maps to Unicode regional indicator A-Z (U+1F1E6 to U+1F1FF)
			const codePoints = countryCode
				.toUpperCase()
				.split('')
				.map(char => 127397 + char.charCodeAt(0));
			return String.fromCodePoint(...codePoints);
		}

		/**
		 * Get browser icon SVG from browser name - supports ANY browser in the world
		 * @param {string} browserName - Browser name (e.g., "Chrome", "Firefox")
		 * @returns {string} SVG icon HTML or default globe icon
		 */
		getBrowserIcon(browserName) {
			const name = (browserName || '').toLowerCase();

			// Browser icon mapping with brand colors
			const browserIcons = {
				// Major browsers
				'chrome': '<svg class="browser-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#fff"/><path d="M12 1a11 11 0 1 0 11 11A11 11 0 0 0 12 1zm0 2a9 9 0 0 1 7.87 4.5H12a4.5 4.5 0 0 0-3.9 2.25L4.65 5.4A9 9 0 0 1 12 3z" fill="#DB4437"/><path d="M4.65 5.4l3.45 6.35A4.5 4.5 0 0 0 12 16.5a4.47 4.47 0 0 0 2.25-.6l-3.45 6A9 9 0 0 1 4.65 5.4z" fill="#0F9D58"/><path d="M19.87 7.5A9 9 0 0 1 10.8 21.9l3.45-6A4.5 4.5 0 0 0 16.5 12a4.47 4.47 0 0 0-.6-2.25z" fill="#F4B400"/><circle cx="12" cy="12" r="4.5" fill="#4285F4"/><circle cx="12" cy="12" r="3" fill="#fff"/></svg>',
				'firefox': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><radialGradient id="ffGrad1"><stop offset="0%" stop-color="#FFBD4F"/><stop offset="100%" stop-color="#FF9640"/></radialGradient><radialGradient id="ffGrad2"><stop offset="0%" stop-color="#FF9640"/><stop offset="100%" stop-color="#E63950"/></radialGradient></defs><circle cx="12" cy="12" r="11" fill="url(#ffGrad1)"/><path d="M12 2C8 2 5 4 4 7c0 0 2-1 4-1 0-2 2-3 4-3 3 0 5 2 5 5 0 2-1 3-2 4-2 1-3 2-3 4v1h3v-1c0-1 1-2 2-3 2-1 3-3 3-5 0-4-3-7-8-7z" fill="url(#ffGrad2)"/><ellipse cx="12" cy="19" rx="2" ry="2.5" fill="#fff"/><path d="M8 10c1-2 3-3 5-3 1 0 2 0 3 1-1-2-3-3-5-3-3 0-5 2-5 5 0 1 0 2 1 3 0-1 0-2 1-3z" fill="#FFF44F" opacity="0.8"/></svg>',
				'safari': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="safGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#1AC8FC"/><stop offset="100%" stop-color="#0D66D0"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#safGrad)"/><circle cx="12" cy="12" r="9" fill="none" stroke="#fff" stroke-width="0.5"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2" stroke="#fff" stroke-width="0.8"/><path d="M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4" stroke="#fff" stroke-width="0.5"/><path d="M12 12L8 16" fill="#fff"/><path d="M12 12L16 8" fill="#E63950"/><polygon points="12,12 8,16 10,14 12,12" fill="#fff"/><polygon points="12,12 16,8 14,10 12,12" fill="#E63950"/></svg>',
				'edge': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="edgeGrad1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#0078D4"/><stop offset="100%" stop-color="#1490DF"/></linearGradient><linearGradient id="edgeGrad2" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#2DCCFF"/><stop offset="100%" stop-color="#00BCF2"/></linearGradient></defs><path d="M3 12c0-2 1-4 2-5 2-2 5-4 9-4 3 0 5 1 7 3-1-2-3-3-6-3-5 0-9 4-9 9 0 3 1 5 3 7 1 1 3 2 5 2h1c-3 0-6-1-8-3-2-2-4-4-4-6z" fill="url(#edgeGrad1)"/><path d="M21 9c1 1 2 3 2 5 0 5-4 9-9 9-2 0-4-1-6-2 2 1 4 2 6 2 5 0 9-4 9-9 0-2-1-4-2-5z" fill="url(#edgeGrad2)"/><path d="M12 7c-3 0-5 2-5 5h10c0-3-2-5-5-5z" fill="#0078D4"/></svg>',
				'opera': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><radialGradient id="opGrad"><stop offset="0%" stop-color="#FF1B2D"/><stop offset="100%" stop-color="#A02020"/></radialGradient></defs><circle cx="12" cy="12" r="11" fill="url(#opGrad)"/><ellipse cx="12" cy="12" rx="5" ry="8" fill="none" stroke="#fff" stroke-width="1.5"/><path d="M12 4c-2.5 0-4.5 3.5-4.5 8s2 8 4.5 8 4.5-3.5 4.5-8-2-8-4.5-8z" fill="#fff" opacity="0.3"/></svg>',
				'brave': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="braveGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FB542B"/><stop offset="100%" stop-color="#CD3A1F"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#braveGrad)"/><path d="M12 4l-1 3-2-1-1 3-3 1 1 2-2 2 3 1v3l2-1 2 1v-3l3-1-2-2 1-2-3-1-1-3z" fill="#fff"/><circle cx="12" cy="12" r="2" fill="#FB542B"/></svg>',
				'vivaldi': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="vivGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#EF3939"/><stop offset="100%" stop-color="#C72828"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#vivGrad)"/><path d="M6 12 Q12 6 18 12" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/><circle cx="12" cy="14" r="3" fill="#fff"/></svg>',
				'yandex': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="yandexGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FF0000"/><stop offset="100%" stop-color="#CC0000"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#yandexGrad)"/><path d="M10 6h2c2 0 3 1 3 3 0 1-1 2-2 2l3 7h-2l-3-7h-1v7H8V6h2zm0 2v3h1c1 0 2 0 2-1s-1-2-2-2h-1z" fill="#fff"/></svg>',
				'samsung': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="samsungGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#7B4FFF"/><stop offset="100%" stop-color="#5A2FD6"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#samsungGrad)"/><path d="M8 8h8v8H8z" fill="none" stroke="#fff" stroke-width="1.5" rx="1"/><circle cx="12" cy="12" r="3" fill="#fff"/><circle cx="12" cy="12" r="1.5" fill="url(#samsungGrad)"/></svg>',
				'android': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="androidGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#A4C639"/><stop offset="100%" stop-color="#7FA82E"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#androidGrad)"/><path d="M8 9h8v6c0 1-1 2-2 2h-4c-1 0-2-1-2-2V9z" fill="#fff"/><circle cx="10" cy="11" r="0.8" fill="url(#androidGrad)"/><circle cx="14" cy="11" r="0.8" fill="url(#androidGrad)"/><path d="M9 7l-1-2M15 7l1-2" stroke="#fff" stroke-width="1" stroke-linecap="round"/><rect x="7" y="15" width="1.5" height="3" rx="0.5" fill="#fff"/><rect x="15.5" y="15" width="1.5" height="3" rx="0.5" fill="#fff"/></svg>',
				'webview': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="webviewGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#A4C639"/><stop offset="100%" stop-color="#7FA82E"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#webviewGrad)"/><path d="M8 9h8v6c0 1-1 2-2 2h-4c-1 0-2-1-2-2V9z" fill="#fff"/><circle cx="10" cy="11" r="0.8" fill="url(#webviewGrad)"/><circle cx="14" cy="11" r="0.8" fill="url(#webviewGrad)"/><path d="M9 7l-1-2M15 7l1-2" stroke="#fff" stroke-width="1" stroke-linecap="round"/><rect x="7" y="15" width="1.5" height="3" rx="0.5" fill="#fff"/><rect x="15.5" y="15" width="1.5" height="3" rx="0.5" fill="#fff"/></svg>',
				'huawei': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="huaweiGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FF0000"/><stop offset="100%" stop-color="#C00000"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#huaweiGrad)"/><path d="M12 6l-3 6h2v6l3-6h-2V6z" fill="#fff"/><circle cx="12" cy="12" r="1" fill="url(#huaweiGrad)"/></svg>',
				// Additional browsers
				'tor': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="torGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#7D4698"/><stop offset="100%" stop-color="#59316B"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#torGrad)"/><circle cx="12" cy="12" r="8" fill="none" stroke="#fff" stroke-width="1"/><circle cx="12" cy="12" r="5" fill="none" stroke="#fff" stroke-width="1"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>',
				'uc': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="ucGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#FF6600"/><stop offset="100%" stop-color="#CC5200"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#ucGrad)"/><path d="M8 7v6c0 2 2 4 4 4s4-2 4-4V7h-2v6c0 1-1 2-2 2s-2-1-2-2V7H8z" fill="#fff"/></svg>',
				'maxthon': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="maxGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00A2E8"/><stop offset="100%" stop-color="#0078A8"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#maxGrad)"/><path d="M12 5l-5 7h3v5l5-7h-3V5z" fill="#fff"/></svg>',
				'seamonkey': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="seaGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#0099CC"/><stop offset="100%" stop-color="#006699"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#seaGrad)"/><path d="M7 12c0-3 2-5 5-5s5 2 5 5-2 5-5 5-5-2-5-5zm2 0c0 2 1 3 3 3s3-1 3-3-1-3-3-3-3 1-3 3z" fill="#fff"/></svg>',
				'pale moon': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="paleGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#4A90D9"/><stop offset="100%" stop-color="#2E5C8A"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#paleGrad)"/><path d="M12 5l-2 4-4 1 3 3-1 4 4-2 4 2-1-4 3-3-4-1z" fill="#fff"/></svg>',
				'waterfox': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="waterGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#0099FF"/><stop offset="100%" stop-color="#0066CC"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#waterGrad)"/><path d="M12 4c-2 0-4 1-5 3 0 0 1-1 2-1 0-1 1-2 3-2 2 0 3 1 3 3s-1 2-2 3c-1 1-2 1-2 2v1h2v-1c0-1 1-1 2-2 1-1 2-2 2-3 0-3-2-5-5-5z" fill="#fff"/><circle cx="12" cy="17" r="1.5" fill="#fff"/></svg>',
				'qutebrowser': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="quteGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00AA00"/><stop offset="100%" stop-color="#007700"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#quteGrad)"/><path d="M12 5c-4 0-7 3-7 7s3 7 7 7c2 0 4-1 5-2l-2-2c-1 1-2 1-3 1-3 0-5-2-5-5s2-5 5-5c1 0 2 0 3 1l2-2c-1-1-3-2-5-2z" fill="#fff"/></svg>',
				'falkon': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="falkGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#3DAEE9"/><stop offset="100%" stop-color="#1D99D2"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#falkGrad)"/><path d="M12 5l-4 7h3v5l4-7h-3V5z" fill="#fff"/></svg>',
				'midori': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="midGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#8BC34A"/><stop offset="100%" stop-color="#689F38"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#midGrad)"/><path d="M12 5c-4 0-7 3-7 7s3 7 7 7 7-3 7-7-3-7-7-7zm0 2c3 0 5 2 5 5s-2 5-5 5-5-2-5-5 2-5 5-5z" fill="#fff"/></svg>',
				'epiphany': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="epiGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#4A86CF"/><stop offset="100%" stop-color="#2E5C8A"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#epiGrad)"/><circle cx="12" cy="12" r="7" fill="none" stroke="#fff" stroke-width="1.5"/><path d="M12 5v14M5 12h14" stroke="#fff" stroke-width="1.5"/></svg>',
				'konqueror': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="konqGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#0057AE"/><stop offset="100%" stop-color="#003D7A"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#konqGrad)"/><path d="M8 7h8l-4 10-4-10z" fill="#fff"/></svg>',
				'lynx': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="lynxGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#666666"/><stop offset="100%" stop-color="#333333"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#lynxGrad)"/><path d="M7 9h10v2H7zm0 4h10v2H7z" fill="#fff"/></svg>',
				'links': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="linksGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#888888"/><stop offset="100%" stop-color="#555555"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#linksGrad)"/><path d="M7 8h10v2H7zm0 4h10v2H7zm0 4h10v2H7z" fill="#fff"/></svg>',
				'w3m': '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="w3mGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#777777"/><stop offset="100%" stop-color="#444444"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#w3mGrad)"/><path d="M6 8l2 8h2l2-6 2 6h2l2-8h-2l-1 6-2-6h-2l-2 6-1-6H6z" fill="#fff"/></svg>',
				'ie': '<svg class="browser-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#1EBBEE"/><path d="M6 12h12M12 8c-2 0-4 1.5-4 4s2 4 4 4" stroke="#FFFFFF" stroke-width="2"/></svg>'
			};

			// Check if we have a predefined icon for this browser
			for (const [key, icon] of Object.entries(browserIcons)) {
				if (name.includes(key)) {
					return icon;
				}
			}

			// Default browser icon (generic compass with gradient)
			return '<svg class="browser-icon" viewBox="0 0 24 24"><defs><linearGradient id="defGrad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#9CA3AF"/><stop offset="100%" stop-color="#6B7280"/></linearGradient></defs><circle cx="12" cy="12" r="11" fill="url(#defGrad)"/><circle cx="12" cy="12" r="8" fill="none" stroke="#fff" stroke-width="0.5"/><path d="M12 4v3M12 17v3M4 12h3M17 12h3" stroke="#fff" stroke-width="1"/><circle cx="12" cy="12" r="2" fill="#fff"/></svg>';
		}

		/**
		 * Scroll heatmap to center position
		 */
		scrollToCenter() {
			// Use setTimeout to ensure the DOM is fully updated
			setTimeout(() => {
				const wrapper = $('.heatmap-canvas-wrapper')[0];
				const container = $('#heatmap-container')[0];

				if (!wrapper || !container) {
					return;
				}

				// Calculate center position
				const scrollWidth = container.scrollWidth;
				const wrapperWidth = wrapper.clientWidth;
				const scrollHeight = container.scrollHeight;
				const wrapperHeight = wrapper.clientHeight;

				// Scroll to center horizontally
				const centerX = (scrollWidth - wrapperWidth) / 2;
				wrapper.scrollLeft = centerX;

				// Scroll to center vertically (or top 1/3 for better visibility)
				const centerY = (scrollHeight - wrapperHeight) / 3; // Top third instead of exact center
				wrapper.scrollTop = centerY;

				this.debug('debug', 'Scrolled to center:', {
					scrollLeft: centerX,
					scrollTop: centerY,
					scrollWidth: scrollWidth,
					scrollHeight: scrollHeight
				});
			}, 100);
		}

		/**
		 * Create scroll heatmap legend (color scale indicator)
		 * Styled to match the Attention Heatmap legend with fixed positioning
		 */
		createScrollHeatmapLegend(container) {
			// Remove existing legend if present
			const existingLegend = document.querySelector('.scroll-heatmap-legend');
			if (existingLegend) {
				existingLegend.remove();
			}

			// Create legend element with fixed positioning (matches Attention Heatmap)
			const legend = document.createElement('div');
			legend.className = 'scroll-heatmap-legend';
			legend.style.cssText = `
				position: fixed;
				top: 50%;
				right: 30px;
				transform: translateY(-50%);
				width: 35px;
				background: rgba(255, 255, 255, 0.95);
				border: 1px solid rgba(0, 0, 0, 0.2);
				border-radius: 6px;
				padding: 8px 6px;
				box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
				z-index: 1000;
				font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
			`;

			// High label
			const highLabel = document.createElement('div');
			highLabel.textContent = (this.config.i18n && this.config.i18n.high) ? this.config.i18n.high : 'High';
			highLabel.style.cssText = `
				text-align: center;
				font-size: 9px;
				font-weight: bold;
				color: #333;
				margin-bottom: 4px;
			`;
			legend.appendChild(highLabel);

			// Gradient container with scroll heatmap colors
			const gradientContainer = document.createElement('div');
			gradientContainer.style.cssText = `
				width: 100%;
				height: 120px;
				border: 1px solid rgba(0, 0, 0, 0.3);
				border-radius: 3px;
				background: linear-gradient(to bottom,
					rgba(220, 20, 20, 0.9) 0%,
					rgba(255, 69, 0, 0.85) 10%,
					rgba(255, 140, 0, 0.8) 20%,
					rgba(255, 255, 0, 0.75) 35%,
					rgba(50, 205, 50, 0.7) 50%,
					rgba(64, 224, 208, 0.65) 65%,
					rgba(100, 149, 237, 0.6) 80%,
					rgba(135, 206, 250, 0.55) 90%,
					rgba(173, 216, 230, 0.5) 100%
				);
			`;
			legend.appendChild(gradientContainer);

			// Low label
			const lowLabel = document.createElement('div');
			lowLabel.textContent = (this.config.i18n && this.config.i18n.low) ? this.config.i18n.low : 'Low';
			lowLabel.style.cssText = `
				text-align: center;
				font-size: 9px;
				font-weight: bold;
				color: #333;
				margin-top: 4px;
			`;
			legend.appendChild(lowLabel);

			// Scroll label (matches "Attention" label style)
			const scrollLabel = document.createElement('div');
			scrollLabel.textContent = 'Scroll';
			scrollLabel.style.cssText = `
				text-align: center;
				font-size: 8px;
				color: #666;
				margin-top: 6px;
				padding-top: 6px;
				border-top: 1px solid rgba(0, 0, 0, 0.1);
			`;
			legend.appendChild(scrollLabel);

			// Append legend to document body (not container) for fixed positioning
			document.body.appendChild(legend);
			this.debug('debug', 'Created scroll heatmap legend with fixed positioning');
		}

		/**
		 * Download heatmap as PNG image
		 * Opens the page URL in a new window, injects heatmap data, and captures
		 */
		async downloadHeatmapImage() {
			const container = document.getElementById('heatmap-container');
			const iframe = document.getElementById('heatmap-iframe');
			const overlay = document.getElementById('heatmap-overlay');

			if (!container) {
				this.debug('error', 'Heatmap container not found for download');
				return;
			}

			const $downloadBtn = $('#download-heatmap');
			const originalHtml = $downloadBtn.html();

			// Set loading state
			$downloadBtn.prop('disabled', true).html('<i data-lucide="loader-2" class="spin"></i> ' + (this.config.i18n.downloading || 'Downloading...'));
			if (typeof lucide !== 'undefined' && typeof lucide.createIcons === 'function') {
				lucide.createIcons();
			}

			try {
				this.debug('debug', 'Starting heatmap image capture...');

				// Get dimensions
				const width = overlay ? parseInt(overlay.style.width) || container.scrollWidth : container.scrollWidth;
				const height = overlay ? parseInt(overlay.style.height) || container.scrollHeight : container.scrollHeight;
				const scale = 2;

				// Create final canvas
				const finalCanvas = document.createElement('canvas');
				finalCanvas.width = width * scale;
				finalCanvas.height = height * scale;
				const ctx = finalCanvas.getContext('2d');

				// Fill with white background
				ctx.fillStyle = '#ffffff';
				ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);

				let iframeCaptured = false;

				// Try to capture iframe content by injecting html2canvas INTO the iframe
				if (iframe && iframe.contentWindow && iframe.contentDocument) {
					this.debug('debug', 'Attempting to capture iframe content...');

					try {
						const iframeDoc = iframe.contentDocument;
						const iframeWin = iframe.contentWindow;

						// Inject html2canvas if not already present
						if (typeof iframeWin.html2canvas !== 'function') {
							this.debug('debug', 'Injecting html2canvas into iframe...');

							const script = iframeDoc.createElement('script');
							script.src = this.config.assets_url + 'js/html2canvas.min.js';

							await new Promise((resolve, reject) => {
								script.onload = resolve;
								script.onerror = reject;
								iframeDoc.head.appendChild(script);
							});

							this.debug('debug', 'html2canvas injected successfully');
						}

						// Run html2canvas inside the iframe on the documentElement
						if (typeof iframeWin.html2canvas === 'function') {
							this.debug('debug', 'Running html2canvas inside iframe on documentElement...');

							// Reset scroll position for capture
							const originalScrollTop = iframeWin.scrollY;
							iframeWin.scrollTo(0, 0);

							// Wait a moment for scroll to settle
							await new Promise(resolve => setTimeout(resolve, 100));

							// Monkey-patch getComputedStyle to sanitize CSS Color Level 4 color() functions
							// html2canvas 1.4.1 doesn't support color() function which modern browsers
							// return in computed styles for certain CSS features (even if not in stylesheets)
							const originalGetComputedStyle = iframeWin.getComputedStyle;
							iframeWin.getComputedStyle = function(element, pseudoElement) {
								const computedStyle = originalGetComputedStyle.call(iframeWin, element, pseudoElement);

								// Create a proxy that sanitizes color() functions in computed style values
								return new Proxy(computedStyle, {
									get(target, prop) {
										const value = target[prop];

										// Handle function properties (like getPropertyValue)
										if (typeof value === 'function') {
											return function(...args) {
												const result = value.apply(target, args);
												if (typeof result === 'string' && result.includes('color(')) {
													return result.replace(/color\s*\([^)]+\)/gi, 'rgb(0, 0, 0)');
												}
												return result;
											};
										}

										// Handle string properties that might contain color()
										if (typeof value === 'string' && value.includes('color(')) {
											return value.replace(/color\s*\([^)]+\)/gi, 'rgb(0, 0, 0)');
										}

										return value;
									}
								});
							};

							let iframeCanvas;
							try {
								iframeCanvas = await iframeWin.html2canvas(iframeDoc.documentElement, {
									useCORS: true,
									allowTaint: true,
									backgroundColor: '#ffffff',
									scale: scale,
									logging: false,
									width: width,
									height: height,
									scrollX: 0,
									scrollY: 0,
									windowWidth: width,
									windowHeight: height,
									x: 0,
									y: 0
								});
							} finally {
								// Always restore original getComputedStyle
								iframeWin.getComputedStyle = originalGetComputedStyle;
							}

							// Restore scroll position
							iframeWin.scrollTo(0, originalScrollTop);

							// Check if canvas has content by sampling multiple regions
							// The page might have white areas at certain coordinates
							const tempCtx = iframeCanvas.getContext('2d');
							let hasContent = false;

							// Sample multiple regions across the canvas
							const sampleRegions = [
								{ x: 50, y: 50 },                                    // Top-left area
								{ x: Math.floor(iframeCanvas.width / 2), y: 100 },   // Top-center
								{ x: 100, y: Math.floor(iframeCanvas.height / 4) },  // Upper-left quarter
								{ x: Math.floor(iframeCanvas.width / 2), y: Math.floor(iframeCanvas.height / 4) } // Center-upper
							];

							for (const region of sampleRegions) {
								if (hasContent) break;

								const sampleX = Math.min(region.x, iframeCanvas.width - 100);
								const sampleY = Math.min(region.y, iframeCanvas.height - 100);
								const imageData = tempCtx.getImageData(sampleX, sampleY, 100, 100);

								for (let i = 0; i < imageData.data.length; i += 4) {
									if (imageData.data[i] !== 255 || imageData.data[i+1] !== 255 || imageData.data[i+2] !== 255) {
										hasContent = true;
										break;
									}
								}
							}

							this.debug('debug', 'Iframe canvas has content:', hasContent, 'Size:', iframeCanvas.width, 'x', iframeCanvas.height);

							if (hasContent) {
								ctx.drawImage(iframeCanvas, 0, 0, finalCanvas.width, finalCanvas.height);
								iframeCaptured = true;
								this.debug('debug', 'Iframe content captured successfully');
							} else {
								this.debug('warn', 'Iframe canvas appears empty/white');
							}
						}
					} catch (iframeError) {
						this.debug('warn', 'Iframe capture failed:', iframeError);
					}
				}

				if (!iframeCaptured) {
					this.debug('warn', 'Could not capture iframe content - downloading heatmap overlay only');
				}

				// Draw heatmap overlay on top
				if (overlay) {
					this.debug('debug', 'Drawing heatmap overlay...');
					const heatmapCanvas = overlay.querySelector('canvas');
					if (heatmapCanvas) {
						ctx.drawImage(heatmapCanvas, 0, 0, finalCanvas.width, finalCanvas.height);
						this.debug('debug', 'Heatmap canvas drawn');
					}
				}

				// Generate filename and download
				const filename = this.generateFilename();

				finalCanvas.toBlob((blob) => {
					if (!blob) {
						this.debug('error', 'Failed to generate blob');
						alert(this.config.i18n.download_error || 'Failed to generate image');
						$downloadBtn.prop('disabled', false).html(originalHtml);
						if (typeof lucide !== 'undefined' && typeof lucide.createIcons === 'function') {
							lucide.createIcons();
						}
						return;
					}

					const url = URL.createObjectURL(blob);
					const link = document.createElement('a');
					link.href = url;
					link.download = filename;
					document.body.appendChild(link);
					link.click();
					document.body.removeChild(link);
					URL.revokeObjectURL(url);

					this.debug('debug', 'Heatmap image downloaded:', filename);

					$downloadBtn.prop('disabled', false).html(originalHtml);
					if (typeof lucide !== 'undefined' && typeof lucide.createIcons === 'function') {
						lucide.createIcons();
					}
				}, 'image/png');

			} catch (error) {
				this.debug('error', 'Failed to generate heatmap image:', error);
				alert(error.message || this.config.i18n.download_error || 'Failed to generate image');

				$downloadBtn.prop('disabled', false).html(originalHtml);
				if (typeof lucide !== 'undefined' && typeof lucide.createIcons === 'function') {
					lucide.createIcons();
				}
			}
		}

		/**
		 * Clone a node with all computed styles inlined
		 */
		async cloneNodeWithStyles(node, doc, win) {
			const clone = node.cloneNode(false);

			// Copy computed styles as inline style
			if (node.nodeType === Node.ELEMENT_NODE) {
				const computedStyle = win.getComputedStyle(node);
				let styleText = '';
				for (let i = 0; i < computedStyle.length; i++) {
					const prop = computedStyle[i];
					styleText += `${prop}:${computedStyle.getPropertyValue(prop)};`;
				}
				clone.setAttribute('style', styleText);

				// Handle special elements
				if (node.tagName === 'CANVAS') {
					// Convert canvas to image
					try {
						const dataUrl = node.toDataURL('image/png');
						const img = doc.createElement('img');
						img.src = dataUrl;
						img.setAttribute('style', styleText);
						return img;
					} catch (e) {
						// Canvas might be tainted
					}
				}

				if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
					clone.setAttribute('value', node.value);
				}

				if (node.tagName === 'SELECT') {
					clone.value = node.value;
				}
			}

			// Recursively clone children
			for (const child of node.childNodes) {
				if (child.nodeType === Node.TEXT_NODE) {
					clone.appendChild(child.cloneNode(true));
				} else if (child.nodeType === Node.ELEMENT_NODE) {
					// Skip script tags and hidden elements
					if (child.tagName !== 'SCRIPT' && child.tagName !== 'NOSCRIPT') {
						const computedDisplay = win.getComputedStyle(child).display;
						if (computedDisplay !== 'none') {
							clone.appendChild(await this.cloneNodeWithStyles(child, doc, win));
						}
					}
				}
			}

			return clone;
		}

		/**
		 * Convert all images in an element to data URLs
		 */
		async convertImagesToDataUrls(element) {
			const images = element.querySelectorAll('img');

			for (const img of images) {
				if (img.src && !img.src.startsWith('data:')) {
					try {
						const dataUrl = await this.imageToDataUrl(img.src);
						img.src = dataUrl;
					} catch (e) {
						// If we can't convert, use a placeholder or leave as is
						this.debug('warn', 'Could not convert image to data URL:', img.src);
					}
				}
			}

			// Also convert background images in inline styles
			const allElements = element.querySelectorAll('*');
			for (const el of allElements) {
				const style = el.getAttribute('style') || '';
				const bgMatch = style.match(/background-image:\s*url\(['"]?([^'")\s]+)['"]?\)/);
				if (bgMatch && bgMatch[1] && !bgMatch[1].startsWith('data:')) {
					try {
						const dataUrl = await this.imageToDataUrl(bgMatch[1]);
						const newStyle = style.replace(bgMatch[0], `background-image:url(${dataUrl})`);
						el.setAttribute('style', newStyle);
					} catch (e) {
						// Skip if can't convert
					}
				}
			}
		}

		/**
		 * Collect all styles from stylesheets
		 */
		async collectAllStyles(doc) {
			let cssText = '';

			for (const sheet of doc.styleSheets) {
				try {
					for (const rule of sheet.cssRules) {
						cssText += rule.cssText + '\n';
					}
				} catch (e) {
					// Cross-origin stylesheet, try to fetch it
					if (sheet.href) {
						try {
							const response = await fetch(sheet.href);
							const text = await response.text();
							cssText += text + '\n';
						} catch (fetchError) {
							this.debug('warn', 'Could not fetch stylesheet:', sheet.href);
						}
					}
				}
			}

			return cssText;
		}

		/**
		 * Capture iframe content using html2canvas injection
		 */
		async captureIframeWithHtml2Canvas(iframe, ctx, finalCanvas, width, height, scale) {
			const iframeDoc = iframe.contentDocument;
			const iframeWin = iframe.contentWindow;

			// Check if html2canvas is already in iframe, if not inject it
			if (typeof iframeWin.html2canvas !== 'function') {
				this.debug('debug', 'Injecting html2canvas into iframe...');

				const script = iframeDoc.createElement('script');
				script.src = this.config.assets_url + 'js/html2canvas.min.js';

				await new Promise((resolve, reject) => {
					script.onload = resolve;
					script.onerror = reject;
					iframeDoc.head.appendChild(script);
				});

				this.debug('debug', 'html2canvas injected successfully');
			}

			if (typeof iframeWin.html2canvas === 'function') {
				this.debug('debug', 'Running html2canvas inside iframe...');

				const iframeCanvas = await iframeWin.html2canvas(iframeDoc.documentElement, {
					useCORS: true,
					allowTaint: true,
					backgroundColor: '#ffffff',
					scale: scale,
					logging: false,
					width: width,
					height: height,
					scrollX: 0,
					scrollY: 0,
					windowWidth: width,
					windowHeight: height
				});

				// Check if canvas has content
				const tempCtx = iframeCanvas.getContext('2d');
				const imageData = tempCtx.getImageData(0, 0, Math.min(100, iframeCanvas.width), Math.min(100, iframeCanvas.height));
				let hasContent = false;
				for (let i = 0; i < imageData.data.length; i += 4) {
					if (imageData.data[i] !== 255 || imageData.data[i+1] !== 255 || imageData.data[i+2] !== 255) {
						hasContent = true;
						break;
					}
				}

				if (hasContent) {
					ctx.drawImage(iframeCanvas, 0, 0, finalCanvas.width, finalCanvas.height);
					this.debug('debug', 'Iframe content captured successfully via injected html2canvas');
					return true;
				}
			}

			return false;
		}

		/**
		 * Convert image URL to data URL
		 */
		async imageToDataUrl(url) {
			return new Promise((resolve, reject) => {
				const img = new Image();
				img.crossOrigin = 'anonymous';
				img.onload = () => {
					try {
						const canvas = document.createElement('canvas');
						canvas.width = img.naturalWidth;
						canvas.height = img.naturalHeight;
						const ctx = canvas.getContext('2d');
						ctx.drawImage(img, 0, 0);
						resolve(canvas.toDataURL('image/png'));
					} catch (e) {
						reject(e);
					}
				};
				img.onerror = reject;
				img.src = url;
				// Timeout after 3 seconds
				setTimeout(() => reject(new Error('Timeout')), 3000);
			});
		}

		/**
		 * Generate filename for downloaded heatmap image
		 * Format: {page-slug}-{type}-{device}-{YYYY-MM-DD}.png
		 */
		generateFilename() {
			// Get page slug from URL or use page ID
			let pageSlug = 'heatmap';
			if (this.config.page_url) {
				try {
					const url = new URL(this.config.page_url);
					pageSlug = url.pathname.replace(/\//g, '-').replace(/^-|-$/g, '') || 'home';
				} catch (e) {
					pageSlug = 'page-' + this.pageId;
				}
			} else {
				pageSlug = 'page-' + this.pageId;
			}

			// Sanitize slug (remove special chars, limit length)
			pageSlug = pageSlug.toLowerCase()
				.replace(/[^a-z0-9-]/g, '-')
				.replace(/-+/g, '-')
				.substring(0, 30);

			const type = this.currentType || 'click';
			const device = this.currentDevice || 'desktop';
			const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD

			return `${pageSlug}-${type}-${device}-${date}.png`;
		}

		/**
		 * Enable download button (called after heatmap is rendered)
		 */
		enableDownloadButton() {
			$('#download-heatmap').prop('disabled', false);
			this.debug('debug', 'Download button enabled');
		}

		/**
		 * Disable download button (called when heatmap is loading)
		 */
		disableDownloadButton() {
			$('#download-heatmap').prop('disabled', true);
			this.debug('debug', 'Download button disabled');
		}
	}

	// Initialize on document ready
	$(document).ready(function() {
		if (window.optiHeatmapDetail && window.optiHeatmapDetail.page_id) {
			new HeatmapDetailController();
		}
	});

})(jQuery);
