Add sequential image loader with viewport prioritization

- Load property card images 2 at a time instead of all at once
- Prioritize images in viewport, then by distance from viewport
- Single execution guard prevents concurrent loading runs
- Gracefully handles DOM removal (cleared grid aborts pending loads)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-17 02:45:10 -06:00
parent dfad0f57e6
commit 51a5c3e166
2 changed files with 119 additions and 46 deletions
File diff suppressed because one or more lines are too long
@@ -1735,7 +1735,7 @@
if (pageData) pageData.state = 'populated'; if (pageData) pageData.state = 'populated';
if (typeof CardImageLoader !== 'undefined') { if (typeof CardImageLoader !== 'undefined') {
CardImageLoader.loadVisibleImages(); CardImageLoader.process();
} }
} }
} else { } else {
@@ -1810,7 +1810,7 @@
$page.attr('data-state', 'populated'); $page.attr('data-state', 'populated');
if (typeof CardImageLoader !== 'undefined') { if (typeof CardImageLoader !== 'undefined') {
CardImageLoader.loadVisibleImages(); CardImageLoader.process();
} }
} }
} }
@@ -1870,65 +1870,138 @@
}; };
/** /**
* Property Card Image Loader * Sequential Image Loader
* Lazy loads background images with loading spinner * Loads background images 2 at a time, prioritizing viewport
*/ */
var CardImageLoader = { var CardImageLoader = {
_isRunning: false,
_activeLoads: 0,
MAX_PARALLEL: 2,
init: function() { init: function() {
this.loadVisibleImages(); this.process();
this.bindEvents();
}, },
bindEvents: function() { /**
var self = this; * Start processing images sequentially
* Only one execution runs at a time
// Observe DOM changes for AJAX-loaded content */
if (typeof MutationObserver !== 'undefined') { process: function() {
var observer = new MutationObserver(function(mutations) { if (this._isRunning) return;
self.loadVisibleImages(); this._isRunning = true;
}); this._activeLoads = 0;
var resultsContainer = document.getElementById('property-results'); this._processNext();
if (resultsContainer) {
observer.observe(resultsContainer, { childList: true, subtree: true });
}
var gridContainer = document.getElementById('property-results-grid');
if (gridContainer) {
observer.observe(gridContainer, { childList: true, subtree: true });
}
}
}, },
loadVisibleImages: function() { /**
var self = this; * Get the next element to load, prioritized by viewport proximity
* Returns element in viewport first, then by distance from viewport
*/
_getNextElement: function() {
var $elements = $('.property-card-image[data-bg]');
if (!$elements.length) return null;
// Load all images in DOM immediately - no viewport check var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
$('.property-card-image.is-loading[data-bg]').each(function() { var viewportTop = scrollTop;
self.loadImage($(this)); var viewportBottom = scrollTop + window.innerHeight;
var inViewport = [];
var outOfViewport = [];
$elements.each(function() {
var rect = this.getBoundingClientRect();
var elementTop = rect.top + scrollTop;
var elementBottom = elementTop + rect.height;
// Check if element is in viewport
if (elementBottom >= viewportTop && elementTop <= viewportBottom) {
// In viewport - distance is 0, but sort by position (top first)
inViewport.push({
el: this,
position: elementTop
});
} else {
// Out of viewport - calculate distance
var distance;
if (elementTop > viewportBottom) {
distance = elementTop - viewportBottom;
} else {
distance = viewportTop - elementBottom;
}
outOfViewport.push({
el: this,
distance: distance
});
}
}); });
// Sort in-viewport by position (top to bottom)
inViewport.sort(function(a, b) {
return a.position - b.position;
});
// Sort out-of-viewport by distance (closest first)
outOfViewport.sort(function(a, b) {
return a.distance - b.distance;
});
// Return first in-viewport, or closest out-of-viewport
if (inViewport.length) return inViewport[0].el;
if (outOfViewport.length) return outOfViewport[0].el;
return null;
}, },
loadImage: function($el) { /**
var bgUrl = $el.data('bg'); * Process next image(s) up to MAX_PARALLEL
if (!bgUrl) return; */
_processNext: function() {
var self = this;
// Remove from loading queue immediately to prevent double-load // Fill up to MAX_PARALLEL slots
$el.removeClass('is-loading').removeAttr('data-bg'); while (this._activeLoads < this.MAX_PARALLEL) {
var el = this._getNextElement();
if (!el) {
// No more elements to process
if (this._activeLoads === 0) {
this._isRunning = false;
}
return;
}
// Create image to preload var $el = $(el);
var img = new Image(); var bgUrl = $el.data('bg');
img.onload = function() { // Remove data-bg immediately to prevent double-processing
$el.css('background-image', 'url(' + bgUrl + ')'); $el.removeAttr('data-bg').removeClass('is-loading');
$el.addClass('is-loaded');
};
img.onerror = function() { this._activeLoads++;
// On error, show placeholder
$el.addClass('is-loaded');
$el.removeClass('has-image');
};
img.src = bgUrl; // Use detached Image to preload
(function($element, url) {
var img = new Image();
img.onload = function() {
// Only set if element still exists in DOM
if ($.contains(document, $element[0])) {
$element.css('background-image', 'url("' + url + '")');
$element.addClass('is-loaded');
}
self._activeLoads--;
self._processNext();
};
img.onerror = function() {
// On error, mark as loaded but remove has-image
if ($.contains(document, $element[0])) {
$element.addClass('is-loaded').removeClass('has-image');
}
self._activeLoads--;
self._processNext();
};
img.src = url;
})($el, bgUrl);
}
} }
}; };