| 1 |
/** |
| 2 |
* Optimole Srcset Detection Module |
| 3 |
* Detects images without data-opt-src (lazyload) and calculates missing srcset variations |
| 4 |
* for different device types and viewport widths |
| 5 |
*/ |
| 6 |
|
| 7 |
import { optmlLogger } from './logger.js'; |
| 8 |
import { optmlDevice } from './device.js'; |
| 9 |
|
| 10 |
/** |
| 11 |
* Srcset detection and calculation utilities |
| 12 |
*/ |
| 13 |
export const optmlSrcsetDetector = { |
| 14 |
/** |
| 15 |
* Common device breakpoints for responsive images |
| 16 |
* Based on popular CSS frameworks and real-world usage patterns |
| 17 |
*/ |
| 18 |
DEVICE_BREAKPOINTS: { |
| 19 |
// Mobile breakpoints |
| 20 |
MOBILE_SMALL: 320, // Small mobile devices |
| 21 |
MOBILE_MEDIUM: 375, // Medium mobile devices |
| 22 |
MOBILE_LARGE: 425, // Large mobile devices |
| 23 |
|
| 24 |
// Tablet breakpoints |
| 25 |
TABLET_SMALL: 768, // Small tablets |
| 26 |
TABLET_LARGE: 1024, // Large tablets |
| 27 |
|
| 28 |
// Desktop breakpoints |
| 29 |
DESKTOP_SMALL: 1200, // Small desktop |
| 30 |
DESKTOP_MEDIUM: 1440, // Medium desktop |
| 31 |
DESKTOP_LARGE: 1920, // Large desktop |
| 32 |
DESKTOP_XL: 2560 // Extra large desktop |
| 33 |
}, |
| 34 |
|
| 35 |
/** |
| 36 |
* Device pixel ratio multipliers for high-DPI displays |
| 37 |
*/ |
| 38 |
DPR_MULTIPLIERS: [1, 2], |
| 39 |
|
| 40 |
/** |
| 41 |
* Configuration for srcset generation |
| 42 |
*/ |
| 43 |
CONFIG: { |
| 44 |
// Minimum image size to consider (in pixels) |
| 45 |
MIN_SIZE: 200, |
| 46 |
|
| 47 |
// Maximum number of srcset variations per image |
| 48 |
MAX_VARIATIONS: 8, |
| 49 |
|
| 50 |
// Tolerance for existing srcset sizes (in pixels) |
| 51 |
SIZE_TOLERANCE: 50 |
| 52 |
}, |
| 53 |
|
| 54 |
/** |
| 55 |
* Configure srcset generation settings |
| 56 |
* @param {Object} config - Configuration options |
| 57 |
* @param {number} config.minSize - Minimum image size to consider (default: 200) |
| 58 |
* @param {number} config.maxVariations - Maximum srcset variations per image (default: 8) |
| 59 |
* @param {number} config.sizeTolerance - Tolerance for existing sizes (default: 50) |
| 60 |
*/ |
| 61 |
configure: function(config) { |
| 62 |
if (config.minSize) this.CONFIG.MIN_SIZE = config.minSize; |
| 63 |
if (config.maxVariations) this.CONFIG.MAX_VARIATIONS = config.maxVariations; |
| 64 |
if (config.sizeTolerance) this.CONFIG.SIZE_TOLERANCE = config.sizeTolerance; |
| 65 |
|
| 66 |
optmlLogger.info('Srcset detector configured:', this.CONFIG); |
| 67 |
}, |
| 68 |
|
| 69 |
/** |
| 70 |
* Wait for images to load and get their natural dimensions |
| 71 |
* @param {NodeList} images - Collection of image elements |
| 72 |
* @returns {Promise} Promise that resolves when all images are loaded |
| 73 |
*/ |
| 74 |
_waitForImagesToLoad: function(images) { |
| 75 |
const imagePromises = Array.from(images).map(img => { |
| 76 |
return new Promise((resolve) => { |
| 77 |
// If image is already loaded |
| 78 |
if (img.complete && img.naturalWidth > 0) { |
| 79 |
resolve(img); |
| 80 |
return; |
| 81 |
} |
| 82 |
|
| 83 |
// Wait for image to load |
| 84 |
const onLoad = () => { |
| 85 |
img.removeEventListener('load', onLoad); |
| 86 |
img.removeEventListener('error', onError); |
| 87 |
resolve(img); |
| 88 |
}; |
| 89 |
|
| 90 |
const onError = () => { |
| 91 |
img.removeEventListener('load', onLoad); |
| 92 |
img.removeEventListener('error', onError); |
| 93 |
optmlLogger.warn('Image failed to load:', img.src); |
| 94 |
resolve(img); // Still resolve to continue processing |
| 95 |
}; |
| 96 |
|
| 97 |
img.addEventListener('load', onLoad); |
| 98 |
img.addEventListener('error', onError); |
| 99 |
|
| 100 |
// Timeout after 5 seconds |
| 101 |
setTimeout(() => { |
| 102 |
img.removeEventListener('load', onLoad); |
| 103 |
img.removeEventListener('error', onError); |
| 104 |
optmlLogger.warn('Image load timeout:', img.src); |
| 105 |
resolve(img); |
| 106 |
}, 5000); |
| 107 |
}); |
| 108 |
}); |
| 109 |
|
| 110 |
return Promise.all(imagePromises); |
| 111 |
}, |
| 112 |
|
| 113 |
/** |
| 114 |
* Detect all Optimole images that are NOT using lazyload (no data-opt-src) |
| 115 |
* and calculate missing srcset variations |
| 116 |
* @returns {Promise<Object>} Promise that resolves to object with srcset data and crop status |
| 117 |
*/ |
| 118 |
detectMissingSrcsets: async function() { |
| 119 |
const missingSrcsetData = {}; |
| 120 |
const cropStatusData = {}; |
| 121 |
|
| 122 |
// Find all Optimole images |
| 123 |
const optimoleImages = document.querySelectorAll('img[data-opt-id]'); |
| 124 |
|
| 125 |
if (optimoleImages.length === 0) { |
| 126 |
optmlLogger.info('No Optimole images found for srcset analysis'); |
| 127 |
return { srcset: missingSrcsetData, crop: cropStatusData }; |
| 128 |
} |
| 129 |
|
| 130 |
optmlLogger.info(`Found ${optimoleImages.length} Optimole images, waiting for them to load...`); |
| 131 |
|
| 132 |
// Wait for all images to load to get accurate natural dimensions |
| 133 |
const loadedImages = await this._waitForImagesToLoad(optimoleImages); |
| 134 |
|
| 135 |
optmlLogger.info(`Loaded ${loadedImages.length} images, analyzing srcset requirements...`); |
| 136 |
|
| 137 |
loadedImages.forEach(img => { |
| 138 |
try { |
| 139 |
const imageId = parseInt(img.getAttribute('data-opt-id'), 10); |
| 140 |
if (isNaN(imageId)) return; |
| 141 |
|
| 142 |
// Improved skip logic: |
| 143 |
// - Include if image doesn't have data-opt-src |
| 144 |
// - Include if image has data-opt-src but also has data-opt-lazy-loaded |
| 145 |
// - Skip otherwise |
| 146 |
const hasOptSrc = img.hasAttribute('data-opt-src'); |
| 147 |
const hasLazyLoaded = img.hasAttribute('data-opt-lazy-loaded'); |
| 148 |
|
| 149 |
const shouldInclude = !hasOptSrc || (hasOptSrc && hasLazyLoaded); |
| 150 |
|
| 151 |
if (shouldInclude) { |
| 152 |
const reason = !hasOptSrc ? |
| 153 |
'not using lazyload' : |
| 154 |
'lazyload completed (has data-opt-lazy-loaded)'; |
| 155 |
optmlLogger.info(`Image ${imageId} ${reason}, analyzing srcset requirements`); |
| 156 |
|
| 157 |
// Analyze the image and calculate required srcset variations |
| 158 |
const analysisResult = this._analyzeSrcsetRequirements(img, imageId); |
| 159 |
|
| 160 |
if (analysisResult && analysisResult.srcset && analysisResult.srcset.length > 0) { |
| 161 |
missingSrcsetData[imageId] = analysisResult.srcset; |
| 162 |
cropStatusData[imageId] = analysisResult.requiresCropping; |
| 163 |
} |
| 164 |
} |
| 165 |
} catch (error) { |
| 166 |
optmlLogger.error('Error analyzing image for srcset:', img, error); |
| 167 |
} |
| 168 |
}); |
| 169 |
|
| 170 |
optmlLogger.info('Images requiring srcset variations:', Object.keys(missingSrcsetData).length); |
| 171 |
optmlLogger.info('Images with crop status:', Object.keys(cropStatusData).length); |
| 172 |
return { srcset: missingSrcsetData, crop: cropStatusData }; |
| 173 |
}, |
| 174 |
|
| 175 |
/** |
| 176 |
* Analyze an image and determine what srcset variations are needed |
| 177 |
* @private |
| 178 |
* @param {HTMLImageElement} img - Image element to analyze |
| 179 |
* @param {number} imageId - Optimole image ID |
| 180 |
* @returns {Object|null} Srcset requirements data or null |
| 181 |
*/ |
| 182 |
_analyzeSrcsetRequirements: function(img, imageId) { |
| 183 |
// Get current image dimensions |
| 184 |
const currentWidth = img.offsetWidth || img.clientWidth; |
| 185 |
const currentHeight = img.offsetHeight || img.clientHeight; |
| 186 |
const naturalWidth = img.naturalWidth || 0; |
| 187 |
const naturalHeight = img.naturalHeight || 0; |
| 188 |
|
| 189 |
// Skip if we can't determine dimensions |
| 190 |
if (!currentWidth || !currentHeight || !naturalWidth || !naturalHeight) { |
| 191 |
optmlLogger.warn(`Skipping image ${imageId}: insufficient dimension data`); |
| 192 |
return null; |
| 193 |
} |
| 194 |
|
| 195 |
optmlLogger.info(`Analyzing image ${imageId}:`, { |
| 196 |
current: `${currentWidth}x${currentHeight}`, |
| 197 |
natural: `${naturalWidth}x${naturalHeight}` |
| 198 |
}); |
| 199 |
|
| 200 |
// Calculate aspect ratios |
| 201 |
const naturalAspectRatio = naturalWidth / naturalHeight; |
| 202 |
const currentAspectRatio = currentWidth / currentHeight; |
| 203 |
|
| 204 |
// Determine if image requires cropping based on object-fit and aspect ratio difference |
| 205 |
const aspectRatioDifference = Math.abs(naturalAspectRatio - currentAspectRatio); |
| 206 |
const requiresCropping = this._requiresCropping(img, aspectRatioDifference, naturalAspectRatio, currentAspectRatio); |
| 207 |
|
| 208 |
// Get current device type |
| 209 |
const currentDeviceType = optmlDevice.getDeviceType(); |
| 210 |
|
| 211 |
// Calculate required sizes for different breakpoints |
| 212 |
// Use current aspect ratio for srcset generation to match the rendered dimensions |
| 213 |
const aspectRatioForSizing = requiresCropping ? currentAspectRatio : naturalAspectRatio; |
| 214 |
const requiredSizes = this._calculateRequiredSizes( |
| 215 |
currentWidth, |
| 216 |
currentHeight, |
| 217 |
aspectRatioForSizing, |
| 218 |
currentDeviceType, |
| 219 |
naturalWidth, |
| 220 |
naturalHeight |
| 221 |
); |
| 222 |
|
| 223 |
// Check if current image has existing srcset |
| 224 |
const existingSrcset = img.getAttribute('srcset'); |
| 225 |
const existingSizes = this._parseExistingSrcset(existingSrcset); |
| 226 |
|
| 227 |
// Determine missing sizes |
| 228 |
const missingSizes = this._findMissingSizes(requiredSizes, existingSizes); |
| 229 |
|
| 230 |
if (missingSizes.length === 0) { |
| 231 |
optmlLogger.info(`Image ${imageId} already has adequate srcset coverage`); |
| 232 |
return null; |
| 233 |
} |
| 234 |
|
| 235 |
// Log full analysis for debugging |
| 236 |
optmlLogger.info(`Image ${imageId} srcset analysis:`, { |
| 237 |
currentSize: { w: currentWidth, h: currentHeight }, |
| 238 |
naturalSize: { w: naturalWidth, h: naturalHeight }, |
| 239 |
requiredSizes: requiredSizes, |
| 240 |
naturalAspectRatio: Math.round(naturalAspectRatio * 1000) / 1000, |
| 241 |
currentAspectRatio: Math.round(currentAspectRatio * 1000) / 1000, |
| 242 |
aspectRatioDifference: Math.round(aspectRatioDifference * 1000) / 1000, |
| 243 |
requiresCropping: requiresCropping, |
| 244 |
aspectRatioForSizing: Math.round(aspectRatioForSizing * 1000) / 1000, |
| 245 |
deviceType: currentDeviceType, |
| 246 |
missingSizes: missingSizes, |
| 247 |
existingSrcset: existingSrcset || null |
| 248 |
}); |
| 249 |
|
| 250 |
// Additional debug logging for aspect ratio analysis |
| 251 |
optmlLogger.info(`[Optimole Debug] Image ${imageId} aspect ratio analysis:`, { |
| 252 |
natural: `${naturalWidth}x${naturalHeight} (${Math.round(naturalAspectRatio * 1000) / 1000}:1)`, |
| 253 |
current: `${currentWidth}x${currentHeight} (${Math.round(currentAspectRatio * 1000) / 1000}:1)`, |
| 254 |
difference: Math.round(aspectRatioDifference * 1000) / 1000, |
| 255 |
requiresCropping: requiresCropping, |
| 256 |
aspectRatioForSizing: Math.round(aspectRatioForSizing * 1000) / 1000, |
| 257 |
reason: requiresCropping ? 'Aspect ratio significantly different' : 'Aspect ratios match within tolerance' |
| 258 |
}); |
| 259 |
|
| 260 |
// Return both srcset data and crop status separately |
| 261 |
return { |
| 262 |
srcset: missingSizes.map(size => ({ |
| 263 |
w: size.w, |
| 264 |
h: size.h, |
| 265 |
d: size.dpr, // dpr -> d |
| 266 |
s: size.descriptor, // descriptor -> s (srcset) |
| 267 |
b: size.breakpoint // breakpoint -> b |
| 268 |
})), |
| 269 |
requiresCropping: requiresCropping |
| 270 |
}; |
| 271 |
}, |
| 272 |
|
| 273 |
/** |
| 274 |
* Determine if an image requires cropping based on object-fit and aspect ratio differences |
| 275 |
* Requires BOTH object-fit: cover AND significant aspect ratio difference to return true |
| 276 |
* @private |
| 277 |
* @param {HTMLImageElement} img - Image element to analyze |
| 278 |
* @param {number} aspectRatioDifference - Absolute difference between natural and current aspect ratios |
| 279 |
* @param {number} naturalAspectRatio - Natural image aspect ratio |
| 280 |
* @param {number} currentAspectRatio - Current displayed aspect ratio |
| 281 |
* @returns {boolean} True if the image requires cropping (both conditions must be true) |
| 282 |
*/ |
| 283 |
_requiresCropping: function(img, aspectRatioDifference, naturalAspectRatio, currentAspectRatio) { |
| 284 |
// First check if the image has object-fit: cover |
| 285 |
let hasObjectFitCover = false; |
| 286 |
try { |
| 287 |
const computedStyle = window.getComputedStyle(img); |
| 288 |
hasObjectFitCover = computedStyle.objectFit === 'cover'; |
| 289 |
if (hasObjectFitCover) { |
| 290 |
optmlLogger.info(`Image has object-fit: cover`); |
| 291 |
} |
| 292 |
} catch (error) { |
| 293 |
optmlLogger.warn('Could not get computed style for object-fit check:', error); |
| 294 |
} |
| 295 |
|
| 296 |
// Define thresholds for determining when cropping is needed |
| 297 |
const ASPECT_RATIO_TOLERANCE = 0.05; // 5% tolerance for minor differences |
| 298 |
const SIGNIFICANT_DIFFERENCE_THRESHOLD = 0.15; // 15% for significant differences |
| 299 |
|
| 300 |
// If the difference is very small, no cropping needed |
| 301 |
if (aspectRatioDifference <= ASPECT_RATIO_TOLERANCE) { |
| 302 |
return false; |
| 303 |
} |
| 304 |
|
| 305 |
// Check aspect ratio conditions |
| 306 |
let aspectRatioRequiresCropping = false; |
| 307 |
|
| 308 |
// If the difference is significant, definitely needs cropping |
| 309 |
if (aspectRatioDifference >= SIGNIFICANT_DIFFERENCE_THRESHOLD) { |
| 310 |
aspectRatioRequiresCropping = true; |
| 311 |
} else { |
| 312 |
// For moderate differences, check if the current aspect ratio is significantly different |
| 313 |
// from the natural one (indicating intentional resizing that would require cropping) |
| 314 |
const ratioChange = Math.abs(currentAspectRatio - naturalAspectRatio) / naturalAspectRatio; |
| 315 |
|
| 316 |
// If the current aspect ratio is more than 10% different from natural, likely needs cropping |
| 317 |
aspectRatioRequiresCropping = ratioChange > 0.1; |
| 318 |
} |
| 319 |
|
| 320 |
// Both object-fit: cover AND aspect ratio difference must be true to require cropping |
| 321 |
const requiresCropping = hasObjectFitCover && aspectRatioRequiresCropping; |
| 322 |
|
| 323 |
if (requiresCropping) { |
| 324 |
optmlLogger.info(`Image requires cropping: object-fit cover=${hasObjectFitCover}, aspect ratio=${aspectRatioRequiresCropping}`); |
| 325 |
} |
| 326 |
|
| 327 |
return requiresCropping; |
| 328 |
}, |
| 329 |
|
| 330 |
/** |
| 331 |
* Calculate required image sizes for different breakpoints and DPR |
| 332 |
* @private |
| 333 |
* @param {number} currentWidth - Current displayed width |
| 334 |
* @param {number} currentHeight - Current displayed height |
| 335 |
* @param {number} aspectRatio - Image aspect ratio |
| 336 |
* @param {number} currentDeviceType - Current device type |
| 337 |
* @param {number} naturalWidth - Natural image width |
| 338 |
* @param {number} naturalHeight - Natural image height |
| 339 |
* @returns {Array} Array of required size objects |
| 340 |
*/ |
| 341 |
_calculateRequiredSizes: function(currentWidth, currentHeight, aspectRatio, currentDeviceType, naturalWidth, naturalHeight) { |
| 342 |
const requiredSizes = []; |
| 343 |
|
| 344 |
// Generate responsive sizes based on common viewport widths and typical image usage |
| 345 |
const responsiveSizes = this._generateResponsiveSizes(currentWidth, currentHeight, aspectRatio, naturalWidth, naturalHeight); |
| 346 |
|
| 347 |
// Add all responsive sizes |
| 348 |
responsiveSizes.forEach(size => { |
| 349 |
requiredSizes.push(size); |
| 350 |
}); |
| 351 |
|
| 352 |
// Remove duplicates and sort by width |
| 353 |
const uniqueSizes = this._removeDuplicateSizes(requiredSizes); |
| 354 |
const sortedSizes = uniqueSizes.sort((a, b) => a.w - b.w); |
| 355 |
|
| 356 |
// Apply MAX_VARIATIONS limit to the final result with smart selection |
| 357 |
if (sortedSizes.length > this.CONFIG.MAX_VARIATIONS) { |
| 358 |
optmlLogger.info(`Limiting srcset variations from ${sortedSizes.length} to ${this.CONFIG.MAX_VARIATIONS}`); |
| 359 |
return this._selectBestVariations(sortedSizes, this.CONFIG.MAX_VARIATIONS); |
| 360 |
} |
| 361 |
|
| 362 |
return sortedSizes; |
| 363 |
}, |
| 364 |
|
| 365 |
/** |
| 366 |
* Parse existing srcset attribute to understand what sizes are already available |
| 367 |
* @private |
| 368 |
* @param {string|null} srcset - Existing srcset attribute value |
| 369 |
* @returns {Array} Array of existing size descriptors |
| 370 |
*/ |
| 371 |
_parseExistingSrcset: function(srcset) { |
| 372 |
if (!srcset) return []; |
| 373 |
|
| 374 |
const existingSizes = []; |
| 375 |
const srcsetEntries = srcset.split(',').map(entry => entry.trim()); |
| 376 |
|
| 377 |
srcsetEntries.forEach(entry => { |
| 378 |
const parts = entry.split(/\s+/); |
| 379 |
if (parts.length >= 2) { |
| 380 |
const descriptor = parts[parts.length - 1]; |
| 381 |
|
| 382 |
// Parse width descriptor (e.g., "800w") |
| 383 |
if (descriptor.endsWith('w')) { |
| 384 |
const width = parseInt(descriptor.slice(0, -1), 10); |
| 385 |
if (!isNaN(width)) { |
| 386 |
existingSizes.push({ |
| 387 |
w: width, |
| 388 |
descriptor: descriptor, |
| 389 |
url: parts.slice(0, -1).join(' ') |
| 390 |
}); |
| 391 |
} |
| 392 |
} |
| 393 |
} |
| 394 |
}); |
| 395 |
|
| 396 |
return existingSizes; |
| 397 |
}, |
| 398 |
|
| 399 |
/** |
| 400 |
* Generate responsive sizes based on the current image ratio |
| 401 |
* Device-aware approach: generates sizes for different viewports while maintaining the current ratio |
| 402 |
* @private |
| 403 |
* @param {number} currentWidth - Current displayed width |
| 404 |
* @param {number} currentHeight - Current displayed height |
| 405 |
* @param {number} aspectRatio - Image aspect ratio |
| 406 |
* @param {number} naturalWidth - Natural image width |
| 407 |
* @param {number} naturalHeight - Natural image height |
| 408 |
* @returns {Array} Array of responsive size objects |
| 409 |
*/ |
| 410 |
_generateResponsiveSizes: function(currentWidth, currentHeight, aspectRatio, naturalWidth, naturalHeight) { |
| 411 |
const sizes = []; |
| 412 |
|
| 413 |
// Calculate the current ratio: what percentage of viewport width does this image occupy? |
| 414 |
const viewportWidth = window.innerWidth; |
| 415 |
const currentRatio = Math.min(currentWidth / viewportWidth, 1.0); |
| 416 |
|
| 417 |
optmlLogger.info(`Generating srcset for current ratio: ${Math.round(currentRatio * 100)}% of viewport`); |
| 418 |
|
| 419 |
// Use configured device breakpoints |
| 420 |
const breakpoints = Object.entries(this.DEVICE_BREAKPOINTS).map(([key, viewport]) => ({ |
| 421 |
viewport, |
| 422 |
label: key.toLowerCase().replace('_', '-'), |
| 423 |
dpr: this.DPR_MULTIPLIERS |
| 424 |
})); |
| 425 |
|
| 426 |
// Generate sizes for each viewport using the current ratio |
| 427 |
breakpoints.forEach(bp => { |
| 428 |
const baseWidth = Math.round(bp.viewport * currentRatio); |
| 429 |
|
| 430 |
// Skip if the calculated width is too small or too large |
| 431 |
if (baseWidth < this.CONFIG.MIN_SIZE || baseWidth > naturalWidth * 1.2) { |
| 432 |
return; |
| 433 |
} |
| 434 |
|
| 435 |
// Generate for each DPR |
| 436 |
bp.dpr.forEach(dprValue => { |
| 437 |
const targetWidth = Math.round(baseWidth * dprValue); |
| 438 |
const targetHeight = Math.round(targetWidth / aspectRatio); |
| 439 |
|
| 440 |
// Don't generate 1x DPR variations larger than natural image size |
| 441 |
// But allow 2x DPR variations for retina displays |
| 442 |
// Fixes: srcset capped at profiling container width (issue #1030) |
| 443 |
if (dprValue === 1 && targetWidth > naturalWidth) { |
| 444 |
return; |
| 445 |
} |
| 446 |
|
| 447 |
// Only include if within reasonable bounds |
| 448 |
if (this._isValidSize(targetWidth, targetHeight, naturalWidth, naturalHeight) && |
| 449 |
targetWidth >= this.CONFIG.MIN_SIZE) { |
| 450 |
|
| 451 |
sizes.push({ |
| 452 |
w: targetWidth, |
| 453 |
h: targetHeight, |
| 454 |
dpr: dprValue, |
| 455 |
breakpoint: bp.viewport, |
| 456 |
descriptor: `${targetWidth}w`, |
| 457 |
source: 'responsive', |
| 458 |
category: `${bp.label}-${Math.round(currentRatio * 100)}`, |
| 459 |
label: `${bp.label}-${targetWidth}w${dprValue > 1 ? `-${dprValue}x` : ''}` |
| 460 |
}); |
| 461 |
} |
| 462 |
}); |
| 463 |
}); |
| 464 |
|
| 465 |
// Add current size if not already covered |
| 466 |
const currentExists = sizes.some(size => |
| 467 |
Math.abs(size.w - currentWidth) <= this.CONFIG.SIZE_TOLERANCE |
| 468 |
); |
| 469 |
|
| 470 |
if (!currentExists && this._isValidSize(currentWidth, currentHeight, naturalWidth, naturalHeight)) { |
| 471 |
sizes.push({ |
| 472 |
w: currentWidth, |
| 473 |
h: currentHeight, |
| 474 |
dpr: 1, |
| 475 |
breakpoint: window.innerWidth, |
| 476 |
descriptor: `${currentWidth}w`, |
| 477 |
source: 'current', |
| 478 |
category: 'current', |
| 479 |
label: 'current-size' |
| 480 |
}); |
| 481 |
} |
| 482 |
|
| 483 |
return sizes; |
| 484 |
}, |
| 485 |
|
| 486 |
/** |
| 487 |
* Check if a size is valid for srcset generation |
| 488 |
* @private |
| 489 |
* @param {number} width - Target width |
| 490 |
* @param {number} height - Target height |
| 491 |
* @param {number} naturalWidth - Natural image width |
| 492 |
* @param {number} naturalHeight - Natural image height |
| 493 |
* @returns {boolean} True if size is valid |
| 494 |
*/ |
| 495 |
_isValidSize: function(width, height, naturalWidth, naturalHeight) { |
| 496 |
return width >= this.CONFIG.MIN_SIZE && |
| 497 |
height >= this.CONFIG.MIN_SIZE && |
| 498 |
width <= naturalWidth && |
| 499 |
height <= naturalHeight; |
| 500 |
}, |
| 501 |
|
| 502 |
/** |
| 503 |
* Find missing sizes by comparing required vs existing |
| 504 |
* @private |
| 505 |
* @param {Array} requiredSizes - Required size variations |
| 506 |
* @param {Array} existingSizes - Existing size variations |
| 507 |
* @returns {Array} Array of missing size variations |
| 508 |
*/ |
| 509 |
_findMissingSizes: function(requiredSizes, existingSizes) { |
| 510 |
const existingWidths = new Set(existingSizes.map(size => size.w)); |
| 511 |
const tolerance = this.CONFIG.SIZE_TOLERANCE; |
| 512 |
|
| 513 |
return requiredSizes.filter(required => { |
| 514 |
// Check if we already have a size within tolerance |
| 515 |
const hasCloseMatch = Array.from(existingWidths).some(existingWidth => |
| 516 |
Math.abs(existingWidth - required.w) <= tolerance |
| 517 |
); |
| 518 |
|
| 519 |
return !hasCloseMatch; |
| 520 |
}); |
| 521 |
}, |
| 522 |
|
| 523 |
/** |
| 524 |
* Select the best variations when limiting the number of srcset sizes |
| 525 |
* Prioritizes 1x variants and ensures good coverage across the size range |
| 526 |
* @private |
| 527 |
* @param {Array} sortedSizes - Array of size objects sorted by width |
| 528 |
* @param {number} maxVariations - Maximum number of variations to select |
| 529 |
* @returns {Array} Array of selected size variations |
| 530 |
*/ |
| 531 |
_selectBestVariations: function(sortedSizes, maxVariations) { |
| 532 |
if (sortedSizes.length <= maxVariations) { |
| 533 |
return sortedSizes; |
| 534 |
} |
| 535 |
|
| 536 |
// Separate 1x and 2x variants |
| 537 |
const dpr1Sizes = sortedSizes.filter(size => size.dpr === 1); |
| 538 |
const dpr2Sizes = sortedSizes.filter(size => size.dpr === 2); |
| 539 |
|
| 540 |
const selected = []; |
| 541 |
|
| 542 |
// Strategy: Prioritize 1x variants, then add strategic 2x variants |
| 543 |
const target1xCount = Math.min(dpr1Sizes.length, Math.ceil(maxVariations * 0.75)); // 75% should be 1x |
| 544 |
const target2xCount = maxVariations - target1xCount; |
| 545 |
|
| 546 |
// Select 1x variants with even distribution |
| 547 |
if (dpr1Sizes.length > 0) { |
| 548 |
if (dpr1Sizes.length <= target1xCount) { |
| 549 |
// Include all 1x variants |
| 550 |
selected.push(...dpr1Sizes); |
| 551 |
} else { |
| 552 |
// Distribute 1x variants evenly |
| 553 |
const step = (dpr1Sizes.length - 1) / (target1xCount - 1); |
| 554 |
for (let i = 0; i < target1xCount; i++) { |
| 555 |
const index = Math.round(i * step); |
| 556 |
selected.push(dpr1Sizes[index]); |
| 557 |
} |
| 558 |
} |
| 559 |
} |
| 560 |
|
| 561 |
// Select 2x variants strategically (prefer middle sizes for 2x) |
| 562 |
if (target2xCount > 0 && dpr2Sizes.length > 0) { |
| 563 |
if (dpr2Sizes.length <= target2xCount) { |
| 564 |
// Include all 2x variants |
| 565 |
selected.push(...dpr2Sizes); |
| 566 |
} else { |
| 567 |
// Select middle-range 2x variants (most useful for retina displays) |
| 568 |
const sortedDpr2 = dpr2Sizes.sort((a, b) => a.w - b.w); |
| 569 |
const middleStart = Math.floor(sortedDpr2.length / 4); |
| 570 |
const middleEnd = Math.ceil(sortedDpr2.length * 3 / 4); |
| 571 |
const middleRange = sortedDpr2.slice(middleStart, middleEnd); |
| 572 |
|
| 573 |
const step = Math.max(1, Math.floor(middleRange.length / target2xCount)); |
| 574 |
for (let i = 0; i < target2xCount && i * step < middleRange.length; i++) { |
| 575 |
selected.push(middleRange[i * step]); |
| 576 |
} |
| 577 |
} |
| 578 |
} |
| 579 |
|
| 580 |
// Remove duplicates and sort by width |
| 581 |
const uniqueSelected = selected.filter((size, index, arr) => |
| 582 |
arr.findIndex(s => s.w === size.w) === index |
| 583 |
); |
| 584 |
|
| 585 |
return uniqueSelected.sort((a, b) => a.w - b.w); |
| 586 |
}, |
| 587 |
|
| 588 |
/** |
| 589 |
* Remove duplicate sizes from the required sizes array |
| 590 |
* @private |
| 591 |
* @param {Array} sizes - Array of size objects |
| 592 |
* @returns {Array} Array with duplicates removed |
| 593 |
*/ |
| 594 |
_removeDuplicateSizes: function(sizes) { |
| 595 |
const seen = new Set(); |
| 596 |
return sizes.filter(size => { |
| 597 |
const key = `${size.w}x${size.h}`; |
| 598 |
if (seen.has(key)) { |
| 599 |
return false; |
| 600 |
} |
| 601 |
seen.add(key); |
| 602 |
return true; |
| 603 |
}); |
| 604 |
}, |
| 605 |
|
| 606 |
/** |
| 607 |
* Get a summary of srcset analysis for all images |
| 608 |
* @returns {Object} Summary statistics |
| 609 |
*/ |
| 610 |
getSrcsetAnalysisSummary: function() { |
| 611 |
const allImages = document.querySelectorAll('img[data-opt-id]'); |
| 612 |
|
| 613 |
// Count images based on new logic |
| 614 |
let includedImages = 0; |
| 615 |
let skippedImages = 0; |
| 616 |
let lazyLoadedImages = 0; |
| 617 |
let nonLazyloadImages = 0; |
| 618 |
|
| 619 |
allImages.forEach(img => { |
| 620 |
const hasOptSrc = img.hasAttribute('data-opt-src'); |
| 621 |
const hasLazyLoaded = img.hasAttribute('data-opt-lazy-loaded'); |
| 622 |
const shouldInclude = !hasOptSrc || (hasOptSrc && hasLazyLoaded); |
| 623 |
|
| 624 |
if (shouldInclude) { |
| 625 |
includedImages++; |
| 626 |
if (!hasOptSrc) { |
| 627 |
nonLazyloadImages++; |
| 628 |
} else if (hasLazyLoaded) { |
| 629 |
lazyLoadedImages++; |
| 630 |
} |
| 631 |
} else { |
| 632 |
skippedImages++; |
| 633 |
} |
| 634 |
}); |
| 635 |
|
| 636 |
return { |
| 637 |
totalOptimoleImages: allImages.length, |
| 638 |
includedImages: includedImages, |
| 639 |
skippedImages: skippedImages, |
| 640 |
nonLazyloadImages: nonLazyloadImages, |
| 641 |
lazyLoadedImages: lazyLoadedImages, |
| 642 |
currentDeviceType: optmlDevice.getDeviceType(), |
| 643 |
viewportSize: { |
| 644 |
w: window.innerWidth, |
| 645 |
h: window.innerHeight |
| 646 |
} |
| 647 |
}; |
| 648 |
} |
| 649 |
}; |
| 650 |
|