/** * Cache manager for efficient tile management and retrieval in layers. * Implements a singleton pattern for centralized cache control across the application. * Handles tile loading, prefetching, and memory management with rate limiting capabilities. * * @class */ class Cache { /** * Private static instance for singleton pattern * @type {Cache} */ static #instance; /** * List of layers being managed * @type {Array} */ #layers = []; /** * Total cache capacity in bytes * @type {number} */ #capacity; /** * Current amount of GPU RAM used * @type {number} */ #size = 0; /** * Current number of active HTTP requests * @type {number} */ #requested = 0; /** * Maximum concurrent HTTP requests * @type {number} */ #maxRequest; /** * Maximum requests per second (0 for unlimited) * @type {number} */ #maxRequestsRate; /** * Timeout for rate limiting * @type {number|null} */ #requestRateTimeout = null; /** * Timestamp of last request for rate limiting * @type {number} */ #lastRequestTimestamp; /** * Maximum size of prefetched tiles in bytes * @type {number} */ #maxPrefetch; /** * Current amount of prefetched GPU RAM * @type {number} */ #prefetched = 0; /** * Creates or returns the existing Cache instance. * @param {Object} [options] - Configuration options for the cache * @param {number} [options.capacity=536870912] - Total cache capacity in bytes (default: 512MB) * @param {number} [options.maxRequest=6] - Maximum concurrent HTTP requests * @param {number} [options.maxRequestsRate=0] - Maximum requests per second (0 for unlimited) * @param {number} [options.maxPrefetch=8388608] - Maximum prefetch size in bytes (default: 8MB) * @returns {Cache} The singleton Cache instance */ constructor(options = {}) { if (Cache.#instance) { return Cache.#instance; } const defaults = { capacity: 512 * (1 << 20), maxRequest: 6, maxRequestsRate: 0, maxPrefetch: 8 * (1 << 20), }; const config = { ...defaults, ...options }; this.#capacity = config.capacity; this.#maxRequest = config.maxRequest; this.#maxRequestsRate = config.maxRequestsRate; this.#maxPrefetch = config.maxPrefetch; this.#lastRequestTimestamp = performance.now(); Cache.#instance = this; } /** * Gets the singleton instance with optional configuration update. * @param {Object} [options] - Configuration options to update * @returns {Cache} The singleton Cache instance * @static */ static getInstance(options) { if (!Cache.#instance) { new Cache(options); } else if (options) { const instance = Cache.#instance; if (options.capacity !== undefined) instance.#capacity = options.capacity; if (options.maxRequest !== undefined) instance.#maxRequest = options.maxRequest; if (options.maxRequestsRate !== undefined) instance.#maxRequestsRate = options.maxRequestsRate; if (options.maxPrefetch !== undefined) instance.#maxPrefetch = options.maxPrefetch; } return Cache.#instance; } /** * Registers a layer's tiles as candidates for downloading and initiates the update process. * @param {Layer} layer - The layer whose tiles should be considered for caching */ setCandidates(layer) { if (!this.#layers.includes(layer)) { this.#layers.push(layer); } Promise.resolve().then(() => this.update()); } /** * Checks if the cache is currently rate limited based on request count and timing. * @returns {boolean} True if rate limited, false otherwise */ #isRateLimited() { if (this.#requested >= this.#maxRequest) { return true; } if (this.#maxRequestsRate === 0) { return false; } const now = performance.now(); const period = 1000 / this.#maxRequestsRate; const timeSinceLastRequest = now - this.#lastRequestTimestamp; if (timeSinceLastRequest > period) { return false; } if (!this.#requestRateTimeout) { this.#requestRateTimeout = setTimeout(() => { this.#requestRateTimeout = null; this.update(); }, period - timeSinceLastRequest + 10); } return true; } /** * Updates the cache state by processing the download queue while respecting capacity and rate limits. */ update() { if (this.#isRateLimited()) { return; } const best = this.#findBestCandidate(); if (!best) { return; } while (this.#size > this.#capacity) { const worst = this.#findWorstTile(); if (!worst) { console.warn("Cache management issue: No tiles available for removal"); break; } if (worst.tile.time < best.tile.time) { this.#dropTile(worst.layer, worst.tile); } else { return; } } best.layer.queue.shift(); this.#lastRequestTimestamp = performance.now(); this.#loadTile(best.layer, best.tile); } /** * Identifies the highest priority tile that should be downloaded next. * @returns {Object|null} Object containing the best candidate layer and tile, or null if none found */ #findBestCandidate() { let best = null; for (const layer of this.#layers) { while (layer.queue.length > 0 && layer.tiles.has(layer.queue[0].index)) { layer.queue.shift(); } if (!layer.queue.length) { continue; } const tile = layer.queue[0]; if (!best || tile.time > best.tile.time + 1.0 || tile.priority > best.tile.priority) { best = { layer, tile }; } } return best; } /** * Identifies the lowest priority tile that should be removed from cache if space is needed. * @returns {Object|null} Object containing the worst candidate layer and tile, or null if none found */ #findWorstTile() { let worst = null; for (const layer of this.#layers) { for (const tile of layer.tiles.values()) { if (tile.missing !== 0) { continue; } if (!worst || tile.time < worst.tile.time || (tile.time === worst.tile.time && tile.priority < worst.tile.priority)) { worst = { layer, tile }; } } } return worst; } /** * Initiates the loading of a tile for a specific layer. * @param {Layer} layer - The layer the tile belongs to * @param {Object} tile - The tile to be loaded */ #loadTile(layer, tile) { this.#requested++; (async () => { try { await layer.loadTile(tile, (size) => { this.#size += size; this.#requested--; this.update(); }); } catch (error) { console.error("Error loading tile:", error); this.#requested--; this.update(); } })(); } /** * Removes a tile from the cache and updates the cache size. * @param {Layer} layer - The layer the tile belongs to * @param {Object} tile - The tile to be removed */ #dropTile(layer, tile) { this.#size -= tile.size; layer.dropTile(tile); } /** * Removes all tiles associated with a specific layer from the cache. * @param {Layer} layer - The layer whose tiles should be flushed */ flushLayer(layer) { if (!this.#layers.includes(layer)) { return; } for (const tile of layer.tiles.values()) { this.#dropTile(layer, tile); } } /** * Gets current cache statistics. * @returns {Object} Current cache statistics */ getStats() { return { capacity: this.#capacity, used: this.#size, usedPercentage: (this.#size / this.#capacity) * 100, activeRequests: this.#requested, layers: this.#layers.length }; } } export { Cache };