/** * ThrillWiki HTMX Maps Integration - Dynamic Map Updates via HTMX * * This module handles HTMX events for map updates, manages loading states * during API calls, updates map content based on HTMX responses, and provides * error handling for failed requests */ class HTMXMapIntegration { constructor(options = {}) { this.options = { mapInstance: null, filterInstance: null, defaultTarget: '#map-container', loadingClass: 'htmx-loading', errorClass: 'htmx-error', successClass: 'htmx-success', loadingTimeout: 30000, // 30 seconds retryAttempts: 3, retryDelay: 1000, ...options }; this.loadingElements = new Set(); this.activeRequests = new Map(); this.requestQueue = []; this.retryCount = new Map(); this.init(); } /** * Initialize HTMX integration */ init() { if (typeof htmx === 'undefined') { console.warn('HTMX not found, map integration disabled'); return; } this.setupEventHandlers(); this.setupCustomEvents(); this.setupErrorHandling(); this.enhanceExistingElements(); } /** * Setup HTMX event handlers */ setupEventHandlers() { // Before request - show loading states document.addEventListener('htmx:beforeRequest', (e) => { this.handleBeforeRequest(e); }); // After request - handle response and update maps document.addEventListener('htmx:afterRequest', (e) => { this.handleAfterRequest(e); }); // Response error - handle failed requests document.addEventListener('htmx:responseError', (e) => { this.handleResponseError(e); }); // Send error - handle network errors document.addEventListener('htmx:sendError', (e) => { this.handleSendError(e); }); // Timeout - handle request timeouts document.addEventListener('htmx:timeout', (e) => { this.handleTimeout(e); }); // Before swap - prepare for content updates document.addEventListener('htmx:beforeSwap', (e) => { this.handleBeforeSwap(e); }); // After swap - update maps with new content document.addEventListener('htmx:afterSwap', (e) => { this.handleAfterSwap(e); }); // Config request - modify requests before sending document.addEventListener('htmx:configRequest', (e) => { this.handleConfigRequest(e); }); } /** * Setup custom map-specific events */ setupCustomEvents() { // Custom event for map data updates document.addEventListener('map:dataUpdate', (e) => { this.handleMapDataUpdate(e); }); // Custom event for filter changes document.addEventListener('filter:changed', (e) => { this.handleFilterChange(e); }); // Custom event for search updates document.addEventListener('search:results', (e) => { this.handleSearchResults(e); }); } /** * Setup global error handling */ setupErrorHandling() { // Global error handler window.addEventListener('error', (e) => { if (e.filename && e.filename.includes('htmx')) { console.error('HTMX error:', e.error); this.showErrorMessage('An error occurred while updating the map'); } }); // Unhandled promise rejection handler window.addEventListener('unhandledrejection', (e) => { if (e.reason && e.reason.toString().includes('htmx')) { console.error('HTMX promise rejection:', e.reason); this.showErrorMessage('Failed to complete map request'); } }); } /** * Enhance existing elements with HTMX map functionality */ enhanceExistingElements() { // Add map-specific attributes to filter forms const filterForms = document.querySelectorAll('[data-map-filter]'); filterForms.forEach(form => { if (!form.hasAttribute('hx-get')) { form.setAttribute('hx-get', form.getAttribute('data-map-filter')); form.setAttribute('hx-trigger', 'change, submit'); form.setAttribute('hx-target', '#map-container'); form.setAttribute('hx-swap', 'none'); } }); // Add map update attributes to search inputs const searchInputs = document.querySelectorAll('[data-map-search]'); searchInputs.forEach(input => { if (!input.hasAttribute('hx-get')) { input.setAttribute('hx-get', input.getAttribute('data-map-search')); input.setAttribute('hx-trigger', 'input changed delay:500ms'); input.setAttribute('hx-target', '#search-results'); } }); } /** * Handle before request event */ handleBeforeRequest(e) { const element = e.target; const requestId = this.generateRequestId(); // Store request information this.activeRequests.set(requestId, { element: element, startTime: Date.now(), url: e.detail.requestConfig.path }); // Show loading state this.showLoadingState(element, true); // Add request ID to detail for tracking e.detail.requestId = requestId; // Set timeout setTimeout(() => { if (this.activeRequests.has(requestId)) { this.handleTimeout({ detail: { requestId } }); } }, this.options.loadingTimeout); console.log('HTMX request started:', e.detail.requestConfig.path); } /** * Handle after request event */ handleAfterRequest(e) { const requestId = e.detail.requestId; const request = this.activeRequests.get(requestId); if (request) { const duration = Date.now() - request.startTime; console.log(`HTMX request completed in ${duration}ms:`, request.url); this.activeRequests.delete(requestId); this.showLoadingState(request.element, false); } if (e.detail.successful) { this.handleSuccessfulResponse(e); } else { this.handleFailedResponse(e); } } /** * Handle successful response */ handleSuccessfulResponse(e) { const element = e.target; // Add success class temporarily element.classList.add(this.options.successClass); setTimeout(() => { element.classList.remove(this.options.successClass); }, 2000); // Reset retry count this.retryCount.delete(element); // Check if this is a map-related request if (this.isMapRequest(e)) { this.updateMapFromResponse(e); } } /** * Handle failed response */ handleFailedResponse(e) { const element = e.target; // Add error class element.classList.add(this.options.errorClass); setTimeout(() => { element.classList.remove(this.options.errorClass); }, 5000); // Check if we should retry if (this.shouldRetry(element)) { this.scheduleRetry(element, e.detail); } else { this.showErrorMessage('Failed to update map data'); } } /** * Handle response error */ handleResponseError(e) { console.error('HTMX response error:', e.detail); const element = e.target; const status = e.detail.xhr.status; let message = 'An error occurred while updating the map'; switch (status) { case 400: message = 'Invalid request parameters'; break; case 401: message = 'Authentication required'; break; case 403: message = 'Access denied'; break; case 404: message = 'Map data not found'; break; case 429: message = 'Too many requests. Please wait a moment.'; break; case 500: message = 'Server error. Please try again later.'; break; } this.showErrorMessage(message); this.showLoadingState(element, false); } /** * Handle send error */ handleSendError(e) { console.error('HTMX send error:', e.detail); this.showErrorMessage('Network error. Please check your connection.'); this.showLoadingState(e.target, false); } /** * Handle timeout */ handleTimeout(e) { console.warn('HTMX request timeout'); if (e.detail.requestId) { const request = this.activeRequests.get(e.detail.requestId); if (request) { this.showLoadingState(request.element, false); this.activeRequests.delete(e.detail.requestId); } } this.showErrorMessage('Request timed out. Please try again.'); } /** * Handle before swap */ handleBeforeSwap(e) { // Prepare map for content update if (this.isMapRequest(e)) { console.log('Preparing map for content swap'); } } /** * Handle after swap */ handleAfterSwap(e) { // Re-initialize any new HTMX elements this.enhanceExistingElements(); // Update maps if needed if (this.isMapRequest(e)) { this.reinitializeMapComponents(); } } /** * Handle config request */ handleConfigRequest(e) { const config = e.detail; // Add CSRF token if available const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); if (csrfToken && (config.verb === 'post' || config.verb === 'put' || config.verb === 'patch')) { config.headers['X-CSRFToken'] = csrfToken.value; } // Add map-specific headers if (this.isMapRequest(e)) { config.headers['X-Map-Request'] = 'true'; // Add current map bounds if available if (this.options.mapInstance) { const bounds = this.options.mapInstance.getBounds(); if (bounds) { config.headers['X-Map-Bounds'] = JSON.stringify({ north: bounds.getNorth(), south: bounds.getSouth(), east: bounds.getEast(), west: bounds.getWest(), zoom: this.options.mapInstance.getZoom() }); } } } } /** * Handle map data updates */ handleMapDataUpdate(e) { if (this.options.mapInstance) { const data = e.detail; this.options.mapInstance.updateMarkers(data); } } /** * Handle filter changes */ handleFilterChange(e) { if (this.options.filterInstance) { const filters = e.detail; // Trigger HTMX request for filter update const filterForm = document.getElementById('map-filters'); if (filterForm && filterForm.hasAttribute('hx-get')) { htmx.trigger(filterForm, 'change'); } } } /** * Handle search results */ handleSearchResults(e) { const results = e.detail; // Update map with search results if applicable if (results.locations && this.options.mapInstance) { this.options.mapInstance.updateMarkers({ locations: results.locations }); } } /** * Show/hide loading state */ showLoadingState(element, show) { if (show) { element.classList.add(this.options.loadingClass); this.loadingElements.add(element); // Show loading indicators const indicators = element.querySelectorAll('.htmx-indicator'); indicators.forEach(indicator => { indicator.style.display = 'block'; }); // Disable form elements const inputs = element.querySelectorAll('input, button, select'); inputs.forEach(input => { input.disabled = true; }); } else { element.classList.remove(this.options.loadingClass); this.loadingElements.delete(element); // Hide loading indicators const indicators = element.querySelectorAll('.htmx-indicator'); indicators.forEach(indicator => { indicator.style.display = 'none'; }); // Re-enable form elements const inputs = element.querySelectorAll('input, button, select'); inputs.forEach(input => { input.disabled = false; }); } } /** * Check if request is map-related */ isMapRequest(e) { const element = e.target; const url = e.detail.requestConfig ? e.detail.requestConfig.path : ''; return element.hasAttribute('data-map-filter') || element.hasAttribute('data-map-search') || element.closest('[data-map-target]') || url.includes('/api/map/') || url.includes('/maps/'); } /** * Update map from HTMX response */ updateMapFromResponse(e) { if (!this.options.mapInstance) return; try { // Try to extract map data from response const responseText = e.detail.xhr.responseText; // If response is JSON, update map directly try { const data = JSON.parse(responseText); if (data.status === 'success' && data.data) { this.options.mapInstance.updateMarkers(data.data); } } catch (jsonError) { // If not JSON, look for data attributes in HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = responseText; const mapData = tempDiv.querySelector('[data-map-data]'); if (mapData) { const data = JSON.parse(mapData.getAttribute('data-map-data')); this.options.mapInstance.updateMarkers(data); } } } catch (error) { console.error('Failed to update map from response:', error); } } /** * Check if element should be retried */ shouldRetry(element) { const retryCount = this.retryCount.get(element) || 0; return retryCount < this.options.retryAttempts; } /** * Schedule retry for failed request */ scheduleRetry(element, detail) { const retryCount = (this.retryCount.get(element) || 0) + 1; this.retryCount.set(element, retryCount); const delay = this.options.retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff setTimeout(() => { console.log(`Retrying HTMX request (attempt ${retryCount})`); htmx.trigger(element, 'retry'); }, delay); } /** * Show error message to user */ showErrorMessage(message) { // Create or update error message element let errorEl = document.getElementById('htmx-error-message'); if (!errorEl) { errorEl = document.createElement('div'); errorEl.id = 'htmx-error-message'; errorEl.className = 'htmx-error-message'; // Insert at top of page document.body.insertBefore(errorEl, document.body.firstChild); } errorEl.innerHTML = `