Files
michaelschiemer/resources/js/modules/api-manager/ObserverManager.js
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

491 lines
17 KiB
JavaScript

// modules/api-manager/ObserverManager.js
import { Logger } from '../../core/logger.js';
/**
* Observer APIs Manager - Intersection, Resize, Mutation, Performance Observers
*/
export class ObserverManager {
constructor(config = {}) {
this.config = config;
this.activeObservers = new Map();
this.observerInstances = new Map();
Logger.info('[ObserverManager] Initialized with support:', {
intersection: 'IntersectionObserver' in window,
resize: 'ResizeObserver' in window,
mutation: 'MutationObserver' in window,
performance: 'PerformanceObserver' in window
});
}
/**
* Intersection Observer - Viewport intersection detection
*/
intersection(elements, callback, options = {}) {
if (!('IntersectionObserver' in window)) {
Logger.warn('[ObserverManager] IntersectionObserver not supported');
return this.createFallbackObserver('intersection', elements, callback);
}
const defaultOptions = {
root: null,
rootMargin: '50px',
threshold: [0, 0.1, 0.5, 1.0],
...options
};
const observerId = this.generateId('intersection');
const observer = new IntersectionObserver((entries, obs) => {
const processedEntries = entries.map(entry => ({
element: entry.target,
isIntersecting: entry.isIntersecting,
intersectionRatio: entry.intersectionRatio,
boundingClientRect: entry.boundingClientRect,
rootBounds: entry.rootBounds,
intersectionRect: entry.intersectionRect,
time: entry.time,
// Enhanced data
visibility: this.calculateVisibility(entry),
direction: this.getScrollDirection(entry),
position: this.getElementPosition(entry)
}));
callback(processedEntries, obs);
}, defaultOptions);
// Observe elements
const elementList = Array.isArray(elements) ? elements : [elements];
elementList.forEach(el => {
if (el instanceof Element) observer.observe(el);
});
this.observerInstances.set(observerId, observer);
this.activeObservers.set(observerId, {
type: 'intersection',
elements: elementList,
callback,
options: defaultOptions
});
Logger.info(`[ObserverManager] IntersectionObserver created: ${observerId}`);
return {
id: observerId,
observer,
unobserve: (element) => observer.unobserve(element),
disconnect: () => this.disconnect(observerId),
updateThreshold: (threshold) => this.updateIntersectionThreshold(observerId, threshold)
};
}
/**
* Resize Observer - Element resize detection
*/
resize(elements, callback, options = {}) {
if (!('ResizeObserver' in window)) {
Logger.warn('[ObserverManager] ResizeObserver not supported');
return this.createFallbackObserver('resize', elements, callback);
}
const observerId = this.generateId('resize');
const observer = new ResizeObserver((entries) => {
const processedEntries = entries.map(entry => ({
element: entry.target,
contentRect: entry.contentRect,
borderBoxSize: entry.borderBoxSize,
contentBoxSize: entry.contentBoxSize,
devicePixelContentBoxSize: entry.devicePixelContentBoxSize,
// Enhanced data
dimensions: {
width: entry.contentRect.width,
height: entry.contentRect.height,
aspectRatio: entry.contentRect.width / entry.contentRect.height
},
deltaSize: this.calculateDeltaSize(entry),
breakpoint: this.detectBreakpoint(entry.contentRect.width)
}));
callback(processedEntries);
});
const elementList = Array.isArray(elements) ? elements : [elements];
elementList.forEach(el => {
if (el instanceof Element) observer.observe(el);
});
this.observerInstances.set(observerId, observer);
this.activeObservers.set(observerId, {
type: 'resize',
elements: elementList,
callback,
options
});
Logger.info(`[ObserverManager] ResizeObserver created: ${observerId}`);
return {
id: observerId,
observer,
unobserve: (element) => observer.unobserve(element),
disconnect: () => this.disconnect(observerId)
};
}
/**
* Mutation Observer - DOM change detection
*/
mutation(target, callback, options = {}) {
if (!('MutationObserver' in window)) {
Logger.warn('[ObserverManager] MutationObserver not supported');
return null;
}
const defaultOptions = {
childList: true,
attributes: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true,
...options
};
const observerId = this.generateId('mutation');
const observer = new MutationObserver((mutations) => {
const processedMutations = mutations.map(mutation => ({
type: mutation.type,
target: mutation.target,
addedNodes: Array.from(mutation.addedNodes),
removedNodes: Array.from(mutation.removedNodes),
attributeName: mutation.attributeName,
attributeNamespace: mutation.attributeNamespace,
oldValue: mutation.oldValue,
// Enhanced data
summary: this.summarizeMutation(mutation),
impact: this.assessMutationImpact(mutation)
}));
callback(processedMutations);
});
observer.observe(target, defaultOptions);
this.observerInstances.set(observerId, observer);
this.activeObservers.set(observerId, {
type: 'mutation',
target,
callback,
options: defaultOptions
});
Logger.info(`[ObserverManager] MutationObserver created: ${observerId}`);
return {
id: observerId,
observer,
disconnect: () => this.disconnect(observerId),
takeRecords: () => observer.takeRecords()
};
}
/**
* Performance Observer - Performance metrics monitoring
*/
performance(callback, options = {}) {
if (!('PerformanceObserver' in window)) {
Logger.warn('[ObserverManager] PerformanceObserver not supported');
return null;
}
const defaultOptions = {
entryTypes: ['measure', 'navigation', 'paint', 'largest-contentful-paint'],
buffered: true,
...options
};
const observerId = this.generateId('performance');
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries().map(entry => ({
name: entry.name,
entryType: entry.entryType,
startTime: entry.startTime,
duration: entry.duration,
// Enhanced data based on entry type
details: this.enhancePerformanceEntry(entry),
timestamp: Date.now()
}));
callback(entries);
});
observer.observe(defaultOptions);
this.observerInstances.set(observerId, observer);
this.activeObservers.set(observerId, {
type: 'performance',
callback,
options: defaultOptions
});
Logger.info(`[ObserverManager] PerformanceObserver created: ${observerId}`);
return {
id: observerId,
observer,
disconnect: () => this.disconnect(observerId),
takeRecords: () => observer.takeRecords()
};
}
/**
* Lazy Loading Helper - Uses IntersectionObserver
*/
lazyLoad(selector = 'img[data-src], iframe[data-src]', options = {}) {
const elements = document.querySelectorAll(selector);
return this.intersection(elements, (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.element;
// Load image or iframe
if (element.dataset.src) {
element.src = element.dataset.src;
delete element.dataset.src;
}
// Load srcset if available
if (element.dataset.srcset) {
element.srcset = element.dataset.srcset;
delete element.dataset.srcset;
}
// Add loaded class
element.classList.add('loaded');
// Stop observing this element
entry.observer.unobserve(element);
Logger.info('[ObserverManager] Lazy loaded:', element.src);
}
});
}, {
rootMargin: '100px',
...options
});
}
/**
* Scroll Trigger Helper - Uses IntersectionObserver
*/
scrollTrigger(elements, callback, options = {}) {
return this.intersection(elements, (entries) => {
entries.forEach(entry => {
const triggerData = {
element: entry.element,
progress: entry.intersectionRatio,
isVisible: entry.isIntersecting,
direction: entry.direction,
position: entry.position
};
callback(triggerData);
});
}, {
threshold: this.createThresholdArray(options.steps || 10),
...options
});
}
/**
* Viewport Detection Helper
*/
viewport(callback, options = {}) {
const viewportElement = document.createElement('div');
viewportElement.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
visibility: hidden;
`;
document.body.appendChild(viewportElement);
return this.resize([viewportElement], (entries) => {
const viewport = entries[0];
callback({
width: viewport.dimensions.width,
height: viewport.dimensions.height,
aspectRatio: viewport.dimensions.aspectRatio,
orientation: viewport.dimensions.width > viewport.dimensions.height ? 'landscape' : 'portrait',
breakpoint: viewport.breakpoint
});
}, options);
}
// Helper Methods
generateId(type) {
return `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
disconnect(observerId) {
const observer = this.observerInstances.get(observerId);
if (observer) {
observer.disconnect();
this.observerInstances.delete(observerId);
this.activeObservers.delete(observerId);
Logger.info(`[ObserverManager] Observer disconnected: ${observerId}`);
}
}
disconnectAll() {
this.observerInstances.forEach((observer, id) => {
observer.disconnect();
});
this.observerInstances.clear();
this.activeObservers.clear();
Logger.info('[ObserverManager] All observers disconnected');
}
calculateVisibility(entry) {
if (!entry.isIntersecting) return 0;
const visibleArea = entry.intersectionRect.width * entry.intersectionRect.height;
const totalArea = entry.boundingClientRect.width * entry.boundingClientRect.height;
return totalArea > 0 ? Math.round((visibleArea / totalArea) * 100) : 0;
}
getScrollDirection(entry) {
// This would need to store previous positions to determine direction
// For now, return based on intersection ratio change
return entry.intersectionRatio > 0.5 ? 'down' : 'up';
}
getElementPosition(entry) {
const rect = entry.boundingClientRect;
const viewportHeight = window.innerHeight;
if (rect.top < 0 && rect.bottom > 0) return 'entering-top';
if (rect.top < viewportHeight && rect.bottom > viewportHeight) return 'entering-bottom';
if (rect.top >= 0 && rect.bottom <= viewportHeight) return 'visible';
return 'hidden';
}
calculateDeltaSize(entry) {
// Would need to store previous sizes to calculate delta
return { width: 0, height: 0 };
}
detectBreakpoint(width) {
if (width < 576) return 'xs';
if (width < 768) return 'sm';
if (width < 992) return 'md';
if (width < 1200) return 'lg';
return 'xl';
}
summarizeMutation(mutation) {
return `${mutation.type} on ${mutation.target.tagName}`;
}
assessMutationImpact(mutation) {
// Simple impact assessment
if (mutation.type === 'childList') {
return mutation.addedNodes.length + mutation.removedNodes.length > 5 ? 'high' : 'low';
}
return 'medium';
}
enhancePerformanceEntry(entry) {
const details = { raw: entry };
switch (entry.entryType) {
case 'navigation':
details.loadTime = entry.loadEventEnd - entry.navigationStart;
details.domContentLoaded = entry.domContentLoadedEventEnd - entry.navigationStart;
break;
case 'paint':
details.paintType = entry.name;
break;
case 'largest-contentful-paint':
details.element = entry.element;
details.url = entry.url;
break;
}
return details;
}
createThresholdArray(steps) {
const thresholds = [];
for (let i = 0; i <= steps; i++) {
thresholds.push(i / steps);
}
return thresholds;
}
createFallbackObserver(type, elements, callback) {
Logger.warn(`[ObserverManager] Creating fallback for ${type}Observer`);
// Simple polling fallback
const fallbackId = this.generateId(`fallback_${type}`);
let intervalId;
switch (type) {
case 'intersection':
intervalId = setInterval(() => {
const elementList = Array.isArray(elements) ? elements : [elements];
const entries = elementList.map(el => ({
element: el,
isIntersecting: this.isElementInViewport(el),
intersectionRatio: this.calculateIntersectionRatio(el)
}));
callback(entries);
}, 100);
break;
}
return {
id: fallbackId,
disconnect: () => {
if (intervalId) clearInterval(intervalId);
}
};
}
isElementInViewport(el) {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
);
}
calculateIntersectionRatio(el) {
const rect = el.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const visibleHeight = Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0);
const visibleWidth = Math.min(rect.right, viewportWidth) - Math.max(rect.left, 0);
if (visibleHeight <= 0 || visibleWidth <= 0) return 0;
const visibleArea = visibleHeight * visibleWidth;
const totalArea = rect.height * rect.width;
return totalArea > 0 ? visibleArea / totalArea : 0;
}
getActiveObservers() {
return Array.from(this.activeObservers.entries()).map(([id, data]) => ({
id,
...data
}));
}
}