/**
* @namespace WPGMZA
* @module AtlasMajorLivePreview
* @requires WPGMZA
*
* Live preview of frontend map components inside the Atlas Major map editor.
* Base plugin handles: Store Locator.
* Pro components are added by atlas-major-live-preview-pro.js via registerComponent().
*/
jQuery(function($) {
if(!document.querySelector('.wpgmza-atlas-major'))
return;
/* ========================================================
ANCHOR MAP — mirrors ComponentAnchorControl PHP constants
======================================================== */
var ANCHOR_NAMES = {
0: 'top',
1: 'left',
2: 'center',
3: 'right',
4: 'bottom',
5: 'top_left',
6: 'top_right',
7: 'bottom_left',
8: 'bottom_right'
};
var ANCHOR_ABOVE = 9;
var ANCHOR_BELOW = 10;
/* ========================================================
COMPONENT REGISTRY — base plugin only registers store locator.
Pro adds its own via registerComponent().
======================================================== */
var COMPONENTS = {
'store-locator': {
enabledCheck: function(){ return $('input[name="store_locator_enabled"]').is(':checked'); },
anchorField: 'select[name="store_locator_component_anchor"]',
defaultAnchor: 0,
proOnly: false,
onInit: function(map, clone){
/* Set data-id so initStoreLocator() finds it */
clone.find('.wpgmza-store-locator').attr('data-id', map.id);
/* Call the real init */
map.initStoreLocator();
/* The map "init" event has already fired, so the filteringcomplete
listener inside the StoreLocator constructor never bound.
Bind it manually now. */
if(map.storeLocator && map.markerFilter){
map.markerFilter.on("filteringcomplete", function(event){
map.storeLocator.onFilteringComplete(event);
});
}
},
onTeardown: function(map){
if(map.storeLocator){
/* Destroy the search-radius circle before dropping the
* storeLocator reference. Otherwise the next render
* leaves the circle drawn on the leaflet/google map
* with no live storeLocator owning it — visible but
* orphaned, can't be cleared by reset/search. Mirrors
* the cleanup in initStoreLocatorSettingsSync's
* destroyAndRedrawCircle() — handles both classic
* (removeCircle on the engine map) and modern
* (canvas overlay needs explicit destroy) circle
* subclasses. */
var circle = map.storeLocator._circle;
if(circle){
try { circle.setVisible(false); } catch(e){}
if(!(circle instanceof WPGMZA.ModernStoreLocatorCircle) && circle.map){
try { circle.map.removeCircle(circle); } catch(e){}
} else if(circle instanceof WPGMZA.ModernStoreLocatorCircle && typeof circle.destroy === 'function'){
try { circle.destroy(); } catch(e){}
}
map.storeLocator._circle = null;
}
map.storeLocator = null;
}
}
}
};
/* ========================================================
CONSTRUCTOR
======================================================== */
WPGMZA.AtlasMajorLivePreview = function(){
var self = this;
this.map = WPGMZA.maps[0];
if(!this.map || !this.map.element) return;
this.mapElement = $(this.map.element);
this.templates = $('#wpgmza-live-preview-templates');
this.previewFrame = $('.am-preview-frame');
this.slotAbove = $('.am-preview-slot-above');
this.slotBelow = $('.am-preview-slot-below');
this.enabled = localStorage.getItem('wpgmza-live-preview') !== '0';
this._activeComponents = {};
if(!this.templates.length) return;
/* Create inner-stack scaffold */
this.stacks = {};
this.createInnerStacks();
/* Set viewport CSS variables */
this.setViewportVariables();
$(window).on('resize', function(){ self.setViewportVariables(); });
/* Toggle */
this.previewFrame.toggleClass('am-preview-off', !this.enabled);
this.initToggle();
/* Initial render */
this.render();
/* Bind reactivity */
this.bindEvents();
/* Store locator live settings sync */
this.initStoreLocatorSettingsSync();
/* Map init/start state sync (center, zoom, bounds, streetview) */
this.initMapStateSync();
/* Marker click/interaction behaviour sync */
this.initMarkerBehaviourSync();
/* Advanced: map controls & layers */
this.initControlsAndLayersSync();
/* Save reminder — show when any setting changes */
this.initSaveReminder();
/* Click outside to close color pickers */
$(document).on('mousedown', function(e){
if(!$(e.target).closest('.wpgmza-color-input-wrapper, .wpgmza-color-input-host').length){
$('.wpgmza-color-picker.active').removeClass('active');
}
});
}
/* ========================================================
INNER-STACK SCAFFOLD
======================================================== */
/**
* Register a newly-created inner-stack element so clicks/drags on
* it don't propagate down to the underlying Leaflet map (which
* would interpret the drag as a map pan). Mirrors the
* `L.DomEvent.disableClickPropagation` calls in
* leaflet-map.js:288-295 (which only run on inner-stacks present at
* map-construction time — dynamic stacks created later by the live
* preview need the same treatment manually).
*
* No-op for non-Leaflet engines (Google Maps, OpenLayers) — Google
* Maps' own overlay layer handles event isolation; OpenLayers' map
* surface only listens on its own canvas. Kept on the public
* AtlasMajorLivePreview namespace so Pro can call it too.
*
* @param {jQuery|HTMLElement} stack — the inner-stack element.
*/
WPGMZA.AtlasMajorLivePreview.registerInnerStack = function(stack){
if(!stack) return;
if(typeof window.L === 'undefined' || !window.L.DomEvent) return;
var el = stack instanceof jQuery ? stack[0] : stack;
if(!el) return;
try {
L.DomEvent.disableClickPropagation(el);
L.DomEvent.disableScrollPropagation(el);
L.DomEvent.on(el, 'mousedown', L.DomEvent.stopPropagation);
} catch(e){ }
};
WPGMZA.AtlasMajorLivePreview.prototype.createInnerStacks = function(){
var mapEl = this.mapElement;
for(var code in ANCHOR_NAMES){
var name = ANCHOR_NAMES[code];
var existing = mapEl.children('.wpgmza-inner-stack.' + name);
if(existing.length){
this.stacks[code] = existing;
} else {
var stack = $('
');
mapEl.append(stack);
WPGMZA.AtlasMajorLivePreview.registerInnerStack(stack);
this.stacks[code] = stack;
}
}
}
/* ========================================================
VIEWPORT CSS VARIABLES
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.setViewportVariables = function(){
var el = this.mapElement[0];
if(!el) return;
var w = el.offsetWidth || 800;
var oMult = w < 760 ? 1 : (w < 960 ? 0.7 : 0.5);
var pMult = w < 760 ? 1 : (w < 960 ? 0.5 : 0.3);
el.style.setProperty('--wpgmza--viewport-overlays-max-width', (oMult * 100) + '%');
el.style.setProperty('--wpgmza--viewport-panels-max-width', (pMult * 100) + '%');
el.style.setProperty('--wpgmza--viewport-container-width', w + 'px');
el.style.setProperty('--wpgmza--viewport-container-height', (el.offsetHeight || 500) + 'px');
}
/* ========================================================
TOGGLE
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.initToggle = function(){
var self = this;
var checkbox = $('#am-live-preview-toggle');
checkbox.prop('checked', this.enabled);
checkbox.on('change', function(){
self.enabled = $(this).is(':checked');
self.previewFrame.toggleClass('am-preview-off', !self.enabled);
localStorage.setItem('wpgmza-live-preview', self.enabled ? '1' : '0');
self.render();
/* The map container's dimensions change when the mockup
* frame is toggled (the mockup wraps it to ~1000px wide vs
* full editor width in the off state). Leaflet / OpenLayers
* cache viewport size and only recalculate on explicit
* resize — without this the map tiles stay sized to the
* old container and subsequent height changes look broken.
* The CSS transition needs ~250ms to settle, so defer the
* resize slightly. */
setTimeout(function(){
if(self.map && typeof self.map.onElementResized === 'function'){
self.map.onElementResized();
}
/* Leaflet-specific: force an invalidateSize as a belt-
* and-suspenders for any engine that doesn't fully
* resize from onElementResized. */
if(self.map && self.map.leafletMap && typeof self.map.leafletMap.invalidateSize === 'function'){
self.map.leafletMap.invalidateSize();
}
}, 300);
});
}
/* ========================================================
STATE CHECKS
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.isComponentEnabled = function(key){
var def = COMPONENTS[key];
if(!def) return false;
if(def.proOnly && (!WPGMZA.isProVersion || !WPGMZA.isProVersion()))
return false;
return def.enabledCheck();
}
WPGMZA.AtlasMajorLivePreview.prototype.getComponentAnchor = function(key){
var def = COMPONENTS[key];
if(!def) return 0;
var field = $(def.anchorField);
if(field.length){
var val = parseInt(field.val());
if(!isNaN(val)) return val;
}
return def.defaultAnchor;
}
/* ========================================================
RENDER — places components and calls init methods
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.render = function(){
var self = this;
var map = this.map;
/* Teardown all active components */
for(var activeKey in this._activeComponents){
this.teardownComponent(activeKey);
}
/* Clear DOM */
$('.wpgmza-live-preview-component').remove();
this.slotAbove.empty();
this.slotBelow.empty();
if(!this.enabled){
$(document.body).trigger('wpgmza_live_preview_rendered', [this]);
return;
}
for(var key in COMPONENTS){
if(!this.isComponentEnabled(key)) continue;
var anchor = this.getComponentAnchor(key);
var templateEl = this.templates.find('[data-preview-component="' + key + '"]');
if(!templateEl.length || !templateEl.children().length) continue;
var clone = templateEl.children().first().clone();
clone.addClass('wpgmza-live-preview-component');
clone.attr('data-live-preview-key', key);
/* Place in correct position */
var target;
if(anchor === ANCHOR_ABOVE){
this.slotAbove.append(clone);
target = this.slotAbove;
} else if(anchor === ANCHOR_BELOW){
this.slotBelow.append(clone);
target = this.slotBelow;
} else {
var stack = this.stacks[anchor] || this.stacks[0];
if(stack && stack.length){
stack.append(clone);
target = stack;
}
}
/* Ensure inner stacks with content are visible */
if(target && target.hasClass('wpgmza-live-preview-stack')){
target.css('display', 'flex');
}
/* Call component init */
var def = COMPONENTS[key];
if(def.onInit){
try {
def.onInit(map, clone);
} catch(e){
console.warn('AtlasMajorLivePreview: init failed for ' + key, e);
}
}
this._activeComponents[key] = true;
}
/* Hide empty stacks */
this.mapElement.find('.wpgmza-live-preview-stack').each(function(){
if(!$(this).children('.wpgmza-live-preview-component').length){
$(this).css('display', '');
}
});
/* Fire event for Pro to hook into */
$(document.body).trigger('wpgmza_live_preview_rendered', [this]);
}
/* ========================================================
TEARDOWN — removes component and cleans up JS instance
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.teardownComponent = function(key){
var def = COMPONENTS[key];
if(def && def.onTeardown){
try {
def.onTeardown(this.map);
} catch(e){
console.warn('AtlasMajorLivePreview: teardown failed for ' + key, e);
}
}
delete this._activeComponents[key];
}
/* ========================================================
EVENT BINDING
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.bindEvents = function(){
var self = this;
var timer = null;
function debouncedRender(){
clearTimeout(timer);
timer = setTimeout(function(){ self.render(); }, 200);
}
/* Watch all known component fields */
var selectors = [];
for(var key in COMPONENTS){
var def = COMPONENTS[key];
if(def.anchorField) selectors.push(def.anchorField);
}
/* Toggle checkboxes */
selectors.push('input[name="store_locator_enabled"]');
$(document.body).on('change', selectors.join(','), debouncedRender);
/* cmn-toggle labels */
$(document.body).on('click', 'label[for="store_locator_enabled"]', function(){
setTimeout(debouncedRender, 50);
});
/* Allow Pro to add more watched fields */
$(document.body).on('wpgmza_live_preview_watch_field', function(e, selector){
$(document.body).on('change', selector, debouncedRender);
});
}
/* ========================================================
STORE LOCATOR SETTINGS SYNC
Updates live-changeable settings without full rebuild.
Structural changes trigger a full rebuild via render().
======================================================== */
WPGMZA.AtlasMajorLivePreview.prototype.initStoreLocatorSettingsSync = function(){
var self = this;
var map = this.map;
/* --- Circle settings (color, opacity, radius style) ---
Sync value into map.settings, destroy cached circle,
re-trigger filtering to redraw with new values */
/* Destroy circle and re-trigger filtering to recreate it */
function destroyAndRedrawCircle(){
if(map.storeLocator && map.storeLocator._circle){
var circle = map.storeLocator._circle;
circle.setVisible(false);
if(!(circle instanceof WPGMZA.ModernStoreLocatorCircle) && circle.map){
/* Classic circle — remove from map cleanly. */
try { circle.map.removeCircle(circle); } catch(e){}
} else if(circle instanceof WPGMZA.ModernStoreLocatorCircle && typeof circle.destroy === 'function'){
/* Modern circle is a canvas overlay — setVisible(false)
* only hides, leaving the canvas attached to the DOM.
* Without destroy(), a subsequent modern→modern cycle
* (radar→classic→radar) stacks orphaned canvases which
* block the new canvas's paint. Engine-specific
* subclasses all implement destroy() to detach canvas
* + unbind map events. */
try { circle.destroy(); } catch(e){}
}
map.storeLocator._circle = null;
}
if(map.storeLocator && map.storeLocator.state === WPGMZA.StoreLocator.STATE_APPLIED && map.storeLocator._center){
map.markerFilter.update({}, map.storeLocator);
}
}
/* Update modern circle color/draw without destroying */
function updateModernCircleColor(){
if(map.storeLocator && map.storeLocator._circle && map.storeLocator._circle instanceof WPGMZA.ModernStoreLocatorCircle){
map.storeLocator._circle.settings.color = map.storeLocator.circleStrokeColor;
if(typeof map.storeLocator._circle.draw === 'function'){
map.storeLocator._circle.draw();
}
return true;
}
return false;
}
/* Color fields — live update for modern circle, destroy+redraw for classic */
['sl_stroke_color', 'sl_fill_color'].forEach(function(name){
$(document.body).on('change input', '[name="' + name + '"]', function(){
map.settings[name] = $(this).val();
if(!updateModernCircleColor()){
destroyAndRedrawCircle();
}
});
});
/* Opacity fields — only affect classic circle */
['sl_stroke_opacity', 'sl_fill_opacity'].forEach(function(name){
$(document.body).on('change input', '[name="' + name + '"]', function(){
map.settings[name] = $(this).val();
destroyAndRedrawCircle();
});
});
/* Radius style switch — always needs full destroy+recreate (different circle class) */
$(document.body).on('change input', '[name="wpgmza_store_locator_radius_style"]', function(){
map.settings.wpgmza_store_locator_radius_style = $('[name="wpgmza_store_locator_radius_style"]:checked').val();
destroyAndRedrawCircle();
});
/* --- Live-updatable simple settings (just sync into map.settings) ---
* Some fields (store_locator_button_style, store_locator_style)
* are ALSO in rebuildFields below — the rebuildFields handler
* triggers a render but doesn't sync map.settings. We include
* them here too so map.settings has the new value by the time
* render() finishes and wpgmza_live_preview_rendered fires
* (post-render transforms like applyButtonStyleToPreview read
* from map.settings). Both handlers fire on change; simpleFields
* is bound first so the sync lands before render is scheduled. */
var simpleFields = [
'wpgmza_store_locator_bounce',
'store_locator_distance',
'store_locator_auto_area_max_zoom',
'store_locator_show_distance',
'wpgmza_store_locator_use_their_location',
'wpgmza_store_locator_hide_before_search',
'store_locator_nearby_searches',
'wpgmza_store_locator_restrict',
'store_locator_button_style',
'store_locator_style',
/* Info window settings — handled separately below */
'close_infowindow_on_map_click'
];
simpleFields.forEach(function(name){
$(document.body).on('change input', '[name="' + name + '"]', function(){
var el = $(this);
if(el.is(':checkbox')){
/* Unchecked = '' (falsy) NOT '0' — the string '0' is
* truthy in JS so downstream `if(map.settings.foo)`
* checks treat unchecked checkboxes as enabled. */
map.settings[name] = el.is(':checked') ? '1' : '';
} else if(el.is(':radio')){
map.settings[name] = $('[name="' + name + '"]:checked').val();
} else {
map.settings[name] = el.val();
}
});
});
/* --- Live text updates — modify the cloned DOM directly ---
Fallbacks route through WPGMZA.localized_strings so translated
copy shows when the user hasn't entered a custom override.
Strings are registered in class.strings.php::getLocalizedStrings(). */
var LS = (WPGMZA && WPGMZA.localized_strings) || {};
var textFieldMap = {
'store_locator_query_string': function(val){
$('.wpgmza-live-preview-component.wpgmza-store-locator label.wpgmza-address, .wpgmza-live-preview-component .wpgmza-store-locator label.wpgmza-address').text(val || LS.atlas_major_sl_address_label || 'ZIP / Address:');
},
'store_locator_location_placeholder': function(val){
$('.wpgmza-live-preview-component.wpgmza-store-locator input.wpgmza-address, .wpgmza-live-preview-component .wpgmza-store-locator input.wpgmza-address').attr('placeholder', val || LS.autcomplete_placeholder || 'Enter a location');
},
'store_locator_name_string': function(val){
$('.wpgmza-live-preview-component.wpgmza-store-locator label.wpgmza-keywords, .wpgmza-live-preview-component .wpgmza-store-locator label.wpgmza-keywords').text(val || LS.atlas_major_sl_keywords_label || 'Title / Description:');
$('.wpgmza-live-preview-component.wpgmza-store-locator input.wpgmza-keywords, .wpgmza-live-preview-component .wpgmza-store-locator input.wpgmza-keywords').attr('placeholder', val || LS.atlas_major_sl_keywords_placeholder || 'Enter a title');
},
'store_locator_not_found_message': function(val){
map.settings.store_locator_not_found_message = val;
},
'store_locator_default_address': function(val){
map.settings.store_locator_default_address = val;
$('.wpgmza-live-preview-component.wpgmza-store-locator input.wpgmza-address, .wpgmza-live-preview-component .wpgmza-store-locator input.wpgmza-address').val(val || '');
},
'wpgmza_store_locator_default_radius': function(val){
map.settings.wpgmza_store_locator_default_radius = val;
$('.wpgmza-live-preview-component.wpgmza-store-locator select.wpgmza-radius, .wpgmza-live-preview-component .wpgmza-store-locator select.wpgmza-radius').val(val);
}
};
for(var tfName in textFieldMap){
(function(name, handler){
$(document.body).on('change input', '[name="' + name + '"]', function(){
handler($(this).val());
});
})(tfName, textFieldMap[tfName]);
}
/* Re-apply every textFieldMap handler from the current form
* value. Used after a full live-preview render() — toggling a
* structural field (Enable Title Search, Enable Categories,
* etc.) reclones the store locator template, which resets all
* the user's customised labels/placeholders/default address/
* default radius back to template defaults. Re-running each
* handler against the current form state restores those
* customisations on the freshly-cloned DOM. */
function reapplyTextFieldValues(){
for(var name in textFieldMap){
var $input = $('[name="' + name + '"]');
if(!$input.length) continue;
try {
textFieldMap[name]($input.val());
} catch(e){ }
}
}
/* Distance unit DOM sync — bring the SL DOM (suffix span +
* radius option text) in line with the current form-state
* unit. Idempotent: safe to call any time, including
* immediately after a fresh template clone whose option
* text was server-rendered with the SAVED unit baked in.
*
* The `oldSuffix` derivation reads from the live form value
* (`isMiles ? 'km' : 'mi'`) and the regex only matches
* options ending with that opposite suffix — so calling
* this on a DOM that already matches the form is a no-op
* (the regex fails to match, nothing changes).
*
* Localised strings aren't available client-side, so we
* fall back to English — acceptable because this is the
* admin-only live preview. */
function applyDistanceUnitToDOM(){
var isMiles = $('input[name="store_locator_distance"]').is(':checked');
var oldSuffix = isMiles ? 'km' : 'mi';
var newSuffix = isMiles ? 'mi' : 'km';
map.settings.store_locator_distance = isMiles ? '1' : '0';
/* Swap the suffix text. class.map-edit-page.php wraps the
* suffix in . */
$('.wpgmza-distance-unit-suffix').text(newSuffix);
/* Each