/**
* Find Training Map - Google Maps Integration
*
* Handles Google Maps initialization, markers, clustering,
* and interaction for the Find Training page.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
(function($) {
'use strict';
// Namespace for the module
window.HVACTrainingMap = {
// Map instance
map: null,
// Marker collections
trainerMarkers: [],
venueMarkers: [],
// MarkerClusterer instance
markerClusterer: null,
// Info window instance (reused)
infoWindow: null,
// Current data
trainers: [],
venues: [],
// Visible trainers (filtered by map bounds)
visibleTrainers: [],
// Configuration
config: {
mapElementId: 'hvac-training-map',
defaultCenter: { lat: 39.8283, lng: -98.5795 },
defaultZoom: 4,
clusterZoom: 8
},
/**
* Initialize the map
*/
init: function() {
const self = this;
// Check if API key is configured
if (typeof hvacFindTraining === 'undefined' || !hvacFindTraining.api_key_configured) {
console.warn('Google Maps API key not configured');
// Still load trainer directory data
this.loadTrainerDirectory();
return;
}
// Check if Google Maps is loaded
if (typeof google === 'undefined' || typeof google.maps === 'undefined') {
console.error('Google Maps API not loaded');
this.showMapError('Google Maps failed to load. Please refresh the page.');
// Still load trainer directory data
this.loadTrainerDirectory();
return;
}
// Override config with localized data
if (hvacFindTraining.map_center) {
this.config.defaultCenter = hvacFindTraining.map_center;
}
if (hvacFindTraining.default_zoom) {
this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom);
}
// Create the map
this.createMap();
// Create info window
this.infoWindow = new google.maps.InfoWindow();
// Load initial data
this.loadMapData();
// Bind events
this.bindEvents();
// Initialize responsive features
this.handleWindowResize();
this.initSidebarToggle();
},
/**
* Load just the trainer directory (no map)
*/
loadTrainerDirectory: function() {
const self = this;
$.ajax({
url: hvacFindTraining.ajax_url,
type: 'POST',
data: {
action: 'hvac_get_training_map_data',
nonce: hvacFindTraining.nonce
},
success: function(response) {
if (response.success && response.data) {
self.trainers = response.data.trainers || [];
self.updateTrainerGrid(self.trainers);
self.updateCounts(self.trainers.length, 0);
}
},
error: function() {
self.hideLoading();
}
});
},
/**
* Create the Google Map instance
*/
createMap: function() {
const mapElement = document.getElementById(this.config.mapElementId);
if (!mapElement) {
console.error('Map element not found');
return;
}
// Remove loading indicator
mapElement.innerHTML = '';
// Create map with options
this.map = new google.maps.Map(mapElement, {
center: this.config.defaultCenter,
zoom: this.config.defaultZoom,
mapTypeControl: true,
mapTypeControlOptions: {
position: google.maps.ControlPosition.TOP_RIGHT
},
streetViewControl: false,
fullscreenControl: true,
zoomControl: true,
zoomControlOptions: {
position: google.maps.ControlPosition.RIGHT_CENTER
},
styles: this.getMapStyles()
});
// Close info window on map click
this.map.addListener('click', () => {
this.infoWindow.close();
});
// Sync sidebar with map viewport on pan/zoom
const self = this;
this.map.addListener('idle', function() {
self.syncSidebarWithViewport();
});
},
/**
* Get custom map styles
*/
getMapStyles: function() {
return [
{
featureType: 'poi',
elementType: 'labels',
stylers: [{ visibility: 'off' }]
},
{
featureType: 'transit',
elementType: 'labels',
stylers: [{ visibility: 'off' }]
}
];
},
/**
* Load map data via AJAX
*/
loadMapData: function(filters) {
const self = this;
const data = {
action: 'hvac_get_training_map_data',
nonce: hvacFindTraining.nonce
};
// Add filters if provided
if (filters) {
Object.assign(data, filters);
}
$.ajax({
url: hvacFindTraining.ajax_url,
type: 'POST',
data: data,
beforeSend: function() {
self.showLoading();
},
success: function(response) {
if (response.success) {
self.trainers = response.data.trainers || [];
self.venues = response.data.venues || [];
self.visibleTrainers = self.trainers.slice(); // Initially all trainers are "visible"
self.updateMarkers();
self.updateCounts(self.trainers.length);
self.updateTrainerGrid();
// Note: syncSidebarWithViewport will be called by map 'idle' event
// to filter trainers to current viewport
} else {
self.showMapError(response.data?.message || 'Failed to load data');
}
},
error: function() {
self.showMapError('Network error. Please try again.');
},
complete: function() {
self.hideLoading();
}
});
},
/**
* Update markers on the map
*/
updateMarkers: function() {
// Clear existing markers
this.clearMarkers();
// Check toggle states
const showTrainers = $('#hvac-show-trainers').is(':checked');
const showVenues = $('#hvac-show-venues').is(':checked');
// Add trainer markers
if (showTrainers && this.trainers.length > 0) {
this.trainers.forEach(trainer => {
if (trainer.lat && trainer.lng) {
this.addTrainerMarker(trainer);
}
});
}
// Add venue markers
if (showVenues && this.venues.length > 0) {
this.venues.forEach(venue => {
if (venue.lat && venue.lng) {
this.addVenueMarker(venue);
}
});
}
// Initialize clustering
this.initClustering();
// Fit bounds if we have markers
this.fitBounds();
},
/**
* Add a trainer marker
*/
addTrainerMarker: function(trainer) {
const self = this;
// Create marker with custom icon
const marker = new google.maps.Marker({
position: { lat: trainer.lat, lng: trainer.lng },
map: this.map,
title: trainer.name,
icon: this.getTrainerIcon(),
optimized: false // Required for reliable hover events
});
// Store trainer data on marker
marker.trainerData = trainer;
marker.markerType = 'trainer';
// Add hover listener to show info window preview
marker.addListener('mouseover', function() {
self.showTrainerInfoWindow(this);
});
// Add click listener (also shows info window, for touch devices)
marker.addListener('click', function() {
self.showTrainerInfoWindow(this);
});
this.trainerMarkers.push(marker);
},
/**
* Add a venue marker
*/
addVenueMarker: function(venue) {
const self = this;
// Create marker with custom icon
const marker = new google.maps.Marker({
position: { lat: venue.lat, lng: venue.lng },
map: this.map,
title: venue.name,
icon: this.getVenueIcon(),
optimized: false // Required for reliable hover events
});
// Store venue data on marker
marker.venueData = venue;
marker.markerType = 'venue';
// Add hover listener to show info window preview
marker.addListener('mouseover', function() {
self.showVenueInfoWindow(this);
});
// Add click listener (also shows info window, for touch devices)
marker.addListener('click', function() {
self.showVenueInfoWindow(this);
});
this.venueMarkers.push(marker);
},
/**
* Get trainer marker icon
*/
getTrainerIcon: function() {
// Use custom icon if available, otherwise use SVG circle
if (hvacFindTraining.marker_icons?.trainer) {
return {
url: hvacFindTraining.marker_icons.trainer,
scaledSize: new google.maps.Size(32, 32)
};
}
// SVG circle marker (teal)
return {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#00b3a4',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 2,
scale: 10
};
},
/**
* Get venue marker icon
*/
getVenueIcon: function() {
// Use custom icon if available, otherwise use SVG marker
if (hvacFindTraining.marker_icons?.venue) {
return {
url: hvacFindTraining.marker_icons.venue,
scaledSize: new google.maps.Size(32, 32)
};
}
// SVG marker (orange)
return {
path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW,
fillColor: '#f5a623',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 2,
scale: 6
};
},
/**
* Initialize marker clustering
*/
initClustering: function() {
// Clear existing clusterer
if (this.markerClusterer) {
this.markerClusterer.clearMarkers();
}
// Combine all markers
const allMarkers = [...this.trainerMarkers, ...this.venueMarkers];
if (allMarkers.length === 0) {
return;
}
// Check if MarkerClusterer is available
if (typeof markerClusterer !== 'undefined' && markerClusterer.MarkerClusterer) {
this.markerClusterer = new markerClusterer.MarkerClusterer({
map: this.map,
markers: allMarkers,
algorithmOptions: {
maxZoom: this.config.clusterZoom
}
});
}
},
/**
* Clear all markers from the map
*/
clearMarkers: function() {
// Clear trainer markers
this.trainerMarkers.forEach(marker => marker.setMap(null));
this.trainerMarkers = [];
// Clear venue markers
this.venueMarkers.forEach(marker => marker.setMap(null));
this.venueMarkers = [];
// Clear clusterer
if (this.markerClusterer) {
this.markerClusterer.clearMarkers();
}
},
/**
* Fit map bounds to show all markers
*/
fitBounds: function() {
const allMarkers = [...this.trainerMarkers, ...this.venueMarkers];
if (allMarkers.length === 0) {
// Reset to default view
this.map.setCenter(this.config.defaultCenter);
this.map.setZoom(this.config.defaultZoom);
return;
}
if (allMarkers.length === 1) {
// Single marker - center on it
this.map.setCenter(allMarkers[0].getPosition());
this.map.setZoom(10);
return;
}
// Calculate bounds
const bounds = new google.maps.LatLngBounds();
allMarkers.forEach(marker => {
bounds.extend(marker.getPosition());
});
this.map.fitBounds(bounds, { padding: 50 });
},
/**
* Show trainer info window
*/
showTrainerInfoWindow: function(marker) {
const self = this;
const trainer = marker.trainerData;
// Create DOM elements safely to avoid XSS
const container = document.createElement('div');
container.className = 'hvac-info-window';
const title = document.createElement('div');
title.className = 'hvac-info-window-title';
title.textContent = trainer.name;
container.appendChild(title);
const location = document.createElement('div');
location.className = 'hvac-info-window-location';
location.textContent = (trainer.city || '') + ', ' + (trainer.state || '');
container.appendChild(location);
if (trainer.certification) {
const certBadge = document.createElement('span');
certBadge.className = 'hvac-info-window-cert';
certBadge.textContent = trainer.certification;
container.appendChild(certBadge);
}
const button = document.createElement('button');
button.className = 'hvac-info-window-btn';
button.textContent = 'View Profile';
button.addEventListener('click', function() {
self.openTrainerModal(trainer.profile_id);
});
container.appendChild(button);
this.infoWindow.setContent(container);
this.infoWindow.open(this.map, marker);
},
/**
* Show venue info window
*/
showVenueInfoWindow: function(marker) {
const self = this;
const venue = marker.venueData;
const eventsText = venue.upcoming_events > 0
? venue.upcoming_events + ' upcoming event' + (venue.upcoming_events > 1 ? 's' : '')
: 'No upcoming events';
// Create DOM elements safely to avoid XSS
const container = document.createElement('div');
container.className = 'hvac-info-window-venue';
const title = document.createElement('div');
title.className = 'hvac-info-window-title';
title.textContent = venue.name;
container.appendChild(title);
const address = document.createElement('div');
address.className = 'hvac-info-window-address';
address.innerHTML = this.escapeHtml(venue.address || '') + '
' +
this.escapeHtml(venue.city || '') + ', ' + this.escapeHtml(venue.state || '');
container.appendChild(address);
const eventsCount = document.createElement('div');
eventsCount.className = 'hvac-info-window-events-count';
eventsCount.textContent = eventsText;
container.appendChild(eventsCount);
const button = document.createElement('button');
button.className = 'hvac-info-window-btn';
button.textContent = 'View Details';
button.addEventListener('click', function() {
self.openVenueModal(venue.id);
});
container.appendChild(button);
this.infoWindow.setContent(container);
this.infoWindow.open(this.map, marker);
},
/**
* Open trainer profile modal
*/
openTrainerModal: function(profileId) {
const self = this;
const $modal = $('#hvac-trainer-modal');
const $body = $modal.find('.hvac-modal-body');
const $loading = $modal.find('.hvac-modal-loading');
// Show modal with loading
$modal.show();
$loading.show();
$body.hide();
// Close info window
this.infoWindow.close();
// Fetch profile data
$.ajax({
url: hvacFindTraining.ajax_url,
type: 'POST',
data: {
action: 'hvac_get_trainer_profile_modal',
nonce: hvacFindTraining.nonce,
profile_id: profileId
},
success: function(response) {
if (response.success) {
$body.html(response.data.html);
$loading.hide();
$body.show();
self.bindContactForm();
} else {
$body.html('
Failed to load profile.
'); $loading.hide(); $body.show(); } }, error: function() { $body.html('Network error. Please try again.
'); $loading.hide(); $body.show(); } }); }, /** * Open venue details modal */ openVenueModal: function(venueId) { const self = this; const $modal = $('#hvac-venue-modal'); // Close info window this.infoWindow.close(); // Fetch venue data $.ajax({ url: hvacFindTraining.ajax_url, type: 'POST', data: { action: 'hvac_get_venue_info', nonce: hvacFindTraining.nonce, venue_id: venueId }, success: function(response) { if (response.success) { const venue = response.data.venue; self.populateVenueModal(venue); $modal.show(); } } }); }, /** * Populate venue modal with data */ populateVenueModal: function(venue) { const self = this; const $modal = $('#hvac-venue-modal'); // Title $modal.find('#venue-modal-title').text(venue.name); // Address const addressParts = [venue.address, venue.city, venue.state, venue.zip].filter(Boolean); $modal.find('.hvac-venue-address').text(addressParts.join(', ')); // Phone if (venue.phone) { $modal.find('.hvac-venue-phone').html('Phone: ' + this.escapeHtml(venue.phone)).show(); } else { $modal.find('.hvac-venue-phone').hide(); } // Capacity if (venue.capacity) { $modal.find('.hvac-venue-capacity').html('Capacity: ' + this.escapeHtml(venue.capacity)).show(); } else { $modal.find('.hvac-venue-capacity').hide(); } // Description if (venue.description) { $modal.find('.hvac-venue-description').html(venue.description).show(); } else { $modal.find('.hvac-venue-description').hide(); } // Equipment badges const $equipmentSection = $modal.find('.hvac-venue-equipment'); const $equipmentBadges = $modal.find('.hvac-equipment-badges'); $equipmentBadges.empty(); if (venue.equipment && venue.equipment.length > 0) { venue.equipment.forEach(item => { $equipmentBadges.append(`${this.escapeHtml(item)}`); }); $equipmentSection.show(); } else { $equipmentSection.hide(); } // Amenities badges const $amenitiesSection = $modal.find('.hvac-venue-amenities'); const $amenitiesBadges = $modal.find('.hvac-amenities-badges'); $amenitiesBadges.empty(); if (venue.amenities && venue.amenities.length > 0) { venue.amenities.forEach(item => { $amenitiesBadges.append(`${this.escapeHtml(item)}`); }); $amenitiesSection.show(); } else { $amenitiesSection.hide(); } // Events list const $eventsList = $modal.find('.hvac-venue-events-list'); const $noEvents = $modal.find('.hvac-venue-no-events'); $eventsList.empty(); if (venue.events && venue.events.length > 0) { venue.events.forEach(event => { $eventsList.append(`' + message + '
${this.escapeHtml(message)}