PageSourceSearch

https://www.cedaroak.org.uk/wp-content/plugins/wp-google-maps/js/v8/atlas-major-live-preview.js?ver=10.1.10

js cedaroak.org.uk collected 2026-10-04 02:10:29 UTC 77,568 bytes, 2,003 lines download raw bytes

1/**
2 * @namespace WPGMZA
3 * @module AtlasMajorLivePreview
4 * @requires WPGMZA
5 *
6 * Live preview of frontend map components inside the Atlas Major map editor.
7 * Base plugin handles: Store Locator.
8 * Pro components are added by atlas-major-live-preview-pro.js via registerComponent().
9 */
10jQuery(function($) {
11
12	if(!document.querySelector('.wpgmza-atlas-major'))
13		return;
14
15	/* ========================================================
16	   ANCHOR MAP — mirrors ComponentAnchorControl PHP constants
17	   ======================================================== */
18	var ANCHOR_NAMES = {
19		0: 'top',
20		1: 'left',
21		2: 'center',
22		3: 'right',
23		4: 'bottom',
24		5: 'top_left',
25		6: 'top_right',
26		7: 'bottom_left',
27		8: 'bottom_right'
28	};
29	var ANCHOR_ABOVE = 9;
30	var ANCHOR_BELOW = 10;
31
32	/* ========================================================
33	   COMPONENT REGISTRY — base plugin only registers store locator.
34	   Pro adds its own via registerComponent().
35	   ======================================================== */
36	var COMPONENTS = {
37		'store-locator': {
38			enabledCheck: function(){ return $('input[name="store_locator_enabled"]').is(':checked'); },
39			anchorField: 'select[name="store_locator_component_anchor"]',
40			defaultAnchor: 0,
41			proOnly: false,
42			onInit: function(map, clone){
43				/* Set data-id so initStoreLocator() finds it */
44				clone.find('.wpgmza-store-locator').attr('data-id', map.id);
45				/* Call the real init */
46				map.initStoreLocator();
47				/* The map "init" event has already fired, so the filteringcomplete
48				   listener inside the StoreLocator constructor never bound.
49				   Bind it manually now. */
50				if(map.storeLocator && map.markerFilter){
51					map.markerFilter.on("filteringcomplete", function(event){
52						map.storeLocator.onFilteringComplete(event);
53					});
54				}
55			},
56			onTeardown: function(map){
57				if(map.storeLocator){
58					/* Destroy the search-radius circle before dropping the
59					 * storeLocator reference. Otherwise the next render
60					 * leaves the circle drawn on the leaflet/google map
61					 * with no live storeLocator owning it — visible but
62					 * orphaned, can't be cleared by reset/search. Mirrors
63					 * the cleanup in initStoreLocatorSettingsSync's
64					 * destroyAndRedrawCircle() — handles both classic
65					 * (removeCircle on the engine map) and modern
66					 * (canvas overlay needs explicit destroy) circle
67					 * subclasses. */
68					var circle = map.storeLocator._circle;
69					if(circle){
70						try { circle.setVisible(false); } catch(e){}
71						if(!(circle instanceof WPGMZA.ModernStoreLocatorCircle) && circle.map){
72							try { circle.map.removeCircle(circle); } catch(e){}
73						} else if(circle instanceof WPGMZA.ModernStoreLocatorCircle && typeof circle.destroy === 'function'){
74							try { circle.destroy(); } catch(e){}
75						}
76						map.storeLocator._circle = null;
77					}
78					map.storeLocator = null;
79				}
80			}
81		}
82	};
83
84	/* ========================================================
85	   CONSTRUCTOR
86	   ======================================================== */
87	WPGMZA.AtlasMajorLivePreview = function(){
88		var self = this;
89
90		this.map = WPGMZA.maps[0];
91		if(!this.map || !this.map.element) return;
92
93		this.mapElement = $(this.map.element);
94		this.templates = $('#wpgmza-live-preview-templates');
95		this.previewFrame = $('.am-preview-frame');
96		this.slotAbove = $('.am-preview-slot-above');
97		this.slotBelow = $('.am-preview-slot-below');
98		this.enabled = localStorage.getItem('wpgmza-live-preview') !== '0';
99		this._activeComponents = {};
100
101		if(!this.templates.length) return;
102
103		/* Create inner-stack scaffold */
104		this.stacks = {};
105		this.createInnerStacks();
106
107		/* Set viewport CSS variables */
108		this.setViewportVariables();
109		$(window).on('resize', function(){ self.setViewportVariables(); });
110
111		/* Toggle */
112		this.previewFrame.toggleClass('am-preview-off', !this.enabled);
113		this.initToggle();
114
115		/* Initial render */
116		this.render();
117
118		/* Bind reactivity */
119		this.bindEvents();
120
121		/* Store locator live settings sync */
122		this.initStoreLocatorSettingsSync();
123
124		/* Map init/start state sync (center, zoom, bounds, streetview) */
125		this.initMapStateSync();
126
127		/* Marker click/interaction behaviour sync */
128		this.initMarkerBehaviourSync();
129
130		/* Advanced: map controls & layers */
131		this.initControlsAndLayersSync();
132
133		/* Save reminder — show when any setting changes */
134		this.initSaveReminder();
135
136		/* Click outside to close color pickers */
137		$(document).on('mousedown', function(e){
138			if(!$(e.target).closest('.wpgmza-color-input-wrapper, .wpgmza-color-input-host').length){
139				$('.wpgmza-color-picker.active').removeClass('active');
140			}
141		});
142	}
143
144	/* ========================================================
145	   INNER-STACK SCAFFOLD
146	   ======================================================== */
147	/**
148	 * Register a newly-created inner-stack element so clicks/drags on
149	 * it don't propagate down to the underlying Leaflet map (which
150	 * would interpret the drag as a map pan). Mirrors the
151	 * `L.DomEvent.disableClickPropagation` calls in
152	 * leaflet-map.js:288-295 (which only run on inner-stacks present at
153	 * map-construction time — dynamic stacks created later by the live
154	 * preview need the same treatment manually).
155	 *
156	 * No-op for non-Leaflet engines (Google Maps, OpenLayers) — Google
157	 * Maps' own overlay layer handles event isolation; OpenLayers' map
158	 * surface only listens on its own canvas. Kept on the public
159	 * AtlasMajorLivePreview namespace so Pro can call it too.
160	 *
161	 * @param {jQuery|HTMLElement} stack — the inner-stack element.
162	 */
163	WPGMZA.AtlasMajorLivePreview.registerInnerStack = function(stack){
164		if(!stack) return;
165		if(typeof window.L === 'undefined' || !window.L.DomEvent) return;
166		var el = stack instanceof jQuery ? stack[0] : stack;
167		if(!el) return;
168		try {
169			L.DomEvent.disableClickPropagation(el);
170			L.DomEvent.disableScrollPropagation(el);
171			L.DomEvent.on(el, 'mousedown', L.DomEvent.stopPropagation);
172		} catch(e){ }
173	};
174
175	WPGMZA.AtlasMajorLivePreview.prototype.createInnerStacks = function(){
176		var mapEl = this.mapElement;
177
178		for(var code in ANCHOR_NAMES){
179			var name = ANCHOR_NAMES[code];
180			var existing = mapEl.children('.wpgmza-inner-stack.' + name);
181			if(existing.length){
182				this.stacks[code] = existing;
183			} else {
184				var stack = $('<div class="wpgmza-inner-stack ' + name + ' wpgmza-live-preview-stack"></div>');
185				mapEl.append(stack);
186				WPGMZA.AtlasMajorLivePreview.registerInnerStack(stack);
187				this.stacks[code] = stack;
188			}
189		}
190	}
191
192	/* ========================================================
193	   VIEWPORT CSS VARIABLES
194	   ======================================================== */
195	WPGMZA.AtlasMajorLivePreview.prototype.setViewportVariables = function(){
196		var el = this.mapElement[0];
197		if(!el) return;
198		var w = el.offsetWidth || 800;
199		var oMult = w < 760 ? 1 : (w < 960 ? 0.7 : 0.5);
200		var pMult = w < 760 ? 1 : (w < 960 ? 0.5 : 0.3);
201		el.style.setProperty('--wpgmza--viewport-overlays-max-width', (oMult * 100) + '%');
202		el.style.setProperty('--wpgmza--viewport-panels-max-width', (pMult * 100) + '%');
203		el.style.setProperty('--wpgmza--viewport-container-width', w + 'px');
204		el.style.setProperty('--wpgmza--viewport-container-height', (el.offsetHeight || 500) + 'px');
205	}
206
207	/* ========================================================
208	   TOGGLE
209	   ======================================================== */
210	WPGMZA.AtlasMajorLivePreview.prototype.initToggle = function(){
211		var self = this;
212		var checkbox = $('#am-live-preview-toggle');
213		checkbox.prop('checked', this.enabled);
214		checkbox.on('change', function(){
215			self.enabled = $(this).is(':checked');
216			self.previewFrame.toggleClass('am-preview-off', !self.enabled);
217			localStorage.setItem('wpgmza-live-preview', self.enabled ? '1' : '0');
218			self.render();
219
220			/* The map container's dimensions change when the mockup
221			 * frame is toggled (the mockup wraps it to ~1000px wide vs
222			 * full editor width in the off state). Leaflet / OpenLayers
223			 * cache viewport size and only recalculate on explicit
224			 * resize — without this the map tiles stay sized to the
225			 * old container and subsequent height changes look broken.
226			 * The CSS transition needs ~250ms to settle, so defer the
227			 * resize slightly. */
228			setTimeout(function(){
229				if(self.map && typeof self.map.onElementResized === 'function'){
230					self.map.onElementResized();
231				}
232				/* Leaflet-specific: force an invalidateSize as a belt-
233				 * and-suspenders for any engine that doesn't fully
234				 * resize from onElementResized. */
235				if(self.map && self.map.leafletMap && typeof self.map.leafletMap.invalidateSize === 'function'){
236					self.map.leafletMap.invalidateSize();
237				}
238			}, 300);
239		});
240	}
241
242	/* ========================================================
243	   STATE CHECKS
244	   ======================================================== */
245	WPGMZA.AtlasMajorLivePreview.prototype.isComponentEnabled = function(key){
246		var def = COMPONENTS[key];
247		if(!def) return false;
248		if(def.proOnly && (!WPGMZA.isProVersion || !WPGMZA.isProVersion()))
249			return false;
250		return def.enabledCheck();
251	}
252
253	WPGMZA.AtlasMajorLivePreview.prototype.getComponentAnchor = function(key){
254		var def = COMPONENTS[key];
255		if(!def) return 0;
256		var field = $(def.anchorField);
257		if(field.length){
258			var val = parseInt(field.val());
259			if(!isNaN(val)) return val;
260		}
261		return def.defaultAnchor;
262	}
263
264	/* ========================================================
265	   RENDER — places components and calls init methods
266	   ======================================================== */
267	WPGMZA.AtlasMajorLivePreview.prototype.render = function(){
268		var self = this;
269		var map = this.map;
270
271		/* Teardown all active components */
272		for(var activeKey in this._activeComponents){
273			this.teardownComponent(activeKey);
274		}
275
276		/* Clear DOM */
277		$('.wpgmza-live-preview-component').remove();
278		this.slotAbove.empty();
279		this.slotBelow.empty();
280
281		if(!this.enabled){
282			$(document.body).trigger('wpgmza_live_preview_rendered', [this]);
283			return;
284		}
285
286		for(var key in COMPONENTS){
287			if(!this.isComponentEnabled(key)) continue;
288
289			var anchor = this.getComponentAnchor(key);
290			var templateEl = this.templates.find('[data-preview-component="' + key + '"]');
291			if(!templateEl.length || !templateEl.children().length) continue;
292
293			var clone = templateEl.children().first().clone();
294			clone.addClass('wpgmza-live-preview-component');
295			clone.attr('data-live-preview-key', key);
296
297			/* Place in correct position */
298			var target;
299			if(anchor === ANCHOR_ABOVE){
300				this.slotAbove.append(clone);
301				target = this.slotAbove;
302			} else if(anchor === ANCHOR_BELOW){
303				this.slotBelow.append(clone);
304				target = this.slotBelow;
305			} else {
306				var stack = this.stacks[anchor] || this.stacks[0];
307				if(stack && stack.length){
308					stack.append(clone);
309					target = stack;
310				}
311			}
312
313			/* Ensure inner stacks with content are visible */
314			if(target && target.hasClass('wpgmza-live-preview-stack')){
315				target.css('display', 'flex');
316			}
317
318			/* Call component init */
319			var def = COMPONENTS[key];
320			if(def.onInit){
321				try {
322					def.onInit(map, clone);
323				} catch(e){
324					console.warn('AtlasMajorLivePreview: init failed for ' + key, e);
325				}
326			}
327
328			this._activeComponents[key] = true;
329		}
330
331		/* Hide empty stacks */
332		this.mapElement.find('.wpgmza-live-preview-stack').each(function(){
333			if(!$(this).children('.wpgmza-live-preview-component').length){
334				$(this).css('display', '');
335			}
336		});
337
338		/* Fire event for Pro to hook into */
339		$(document.body).trigger('wpgmza_live_preview_rendered', [this]);
340	}
341
342	/* ========================================================
343	   TEARDOWN — removes component and cleans up JS instance
344	   ======================================================== */
345	WPGMZA.AtlasMajorLivePreview.prototype.teardownComponent = function(key){
346		var def = COMPONENTS[key];
347		if(def && def.onTeardown){
348			try {
349				def.onTeardown(this.map);
350			} catch(e){
351				console.warn('AtlasMajorLivePreview: teardown failed for ' + key, e);
352			}
353		}
354		delete this._activeComponents[key];
355	}
356
357	/* ========================================================
358	   EVENT BINDING
359	   ======================================================== */
360	WPGMZA.AtlasMajorLivePreview.prototype.bindEvents = function(){
361		var self = this;
362		var timer = null;
363
364		function debouncedRender(){
365			clearTimeout(timer);
366			timer = setTimeout(function(){ self.render(); }, 200);
367		}
368
369		/* Watch all known component fields */
370		var selectors = [];
371		for(var key in COMPONENTS){
372			var def = COMPONENTS[key];
373			if(def.anchorField) selectors.push(def.anchorField);
374		}
375		/* Toggle checkboxes */
376		selectors.push('input[name="store_locator_enabled"]');
377
378		$(document.body).on('change', selectors.join(','), debouncedRender);
379
380		/* cmn-toggle labels */
381		$(document.body).on('click', 'label[for="store_locator_enabled"]', function(){
382			setTimeout(debouncedRender, 50);
383		});
384
385		/* Allow Pro to add more watched fields */
386		$(document.body).on('wpgmza_live_preview_watch_field', function(e, selector){
387			$(document.body).on('change', selector, debouncedRender);
388		});
389	}
390
391	/* ========================================================
392	   STORE LOCATOR SETTINGS SYNC
393	   Updates live-changeable settings without full rebuild.
394	   Structural changes trigger a full rebuild via render().
395	   ======================================================== */
396	WPGMZA.AtlasMajorLivePreview.prototype.initStoreLocatorSettingsSync = function(){
397		var self = this;
398		var map = this.map;
399
400		/* --- Circle settings (color, opacity, radius style) ---
401		   Sync value into map.settings, destroy cached circle,
402		   re-trigger filtering to redraw with new values */
403		/* Destroy circle and re-trigger filtering to recreate it */
404		function destroyAndRedrawCircle(){
405			if(map.storeLocator && map.storeLocator._circle){
406				var circle = map.storeLocator._circle;
407				circle.setVisible(false);
408				if(!(circle instanceof WPGMZA.ModernStoreLocatorCircle) && circle.map){
409					/* Classic circle — remove from map cleanly. */
410					try { circle.map.removeCircle(circle); } catch(e){}
411				} else if(circle instanceof WPGMZA.ModernStoreLocatorCircle && typeof circle.destroy === 'function'){
412					/* Modern circle is a canvas overlay — setVisible(false)
413					 * only hides, leaving the canvas attached to the DOM.
414					 * Without destroy(), a subsequent modern→modern cycle
415					 * (radar→classic→radar) stacks orphaned canvases which
416					 * block the new canvas's paint. Engine-specific
417					 * subclasses all implement destroy() to detach canvas
418					 * + unbind map events. */
419					try { circle.destroy(); } catch(e){}
420				}
421				map.storeLocator._circle = null;
422			}
423			if(map.storeLocator && map.storeLocator.state === WPGMZA.StoreLocator.STATE_APPLIED && map.storeLocator._center){
424				map.markerFilter.update({}, map.storeLocator);
425			}
426		}
427
428		/* Update modern circle color/draw without destroying */
429		function updateModernCircleColor(){
430			if(map.storeLocator && map.storeLocator._circle && map.storeLocator._circle instanceof WPGMZA.ModernStoreLocatorCircle){
431				map.storeLocator._circle.settings.color = map.storeLocator.circleStrokeColor;
432				if(typeof map.storeLocator._circle.draw === 'function'){
433					map.storeLocator._circle.draw();
434				}
435				return true;
436			}
437			return false;
438		}
439
440		/* Color fields — live update for modern circle, destroy+redraw for classic */
441		['sl_stroke_color', 'sl_fill_color'].forEach(function(name){
442			$(document.body).on('change input', '[name="' + name + '"]', function(){
443				map.settings[name] = $(this).val();
444				if(!updateModernCircleColor()){
445					destroyAndRedrawCircle();
446				}
447			});
448		});
449
450		/* Opacity fields — only affect classic circle */
451		['sl_stroke_opacity', 'sl_fill_opacity'].forEach(function(name){
452			$(document.body).on('change input', '[name="' + name + '"]', function(){
453				map.settings[name] = $(this).val();
454				destroyAndRedrawCircle();
455			});
456		});
457
458		/* Radius style switch — always needs full destroy+recreate (different circle class) */
459		$(document.body).on('change input', '[name="wpgmza_store_locator_radius_style"]', function(){
460			map.settings.wpgmza_store_locator_radius_style = $('[name="wpgmza_store_locator_radius_style"]:checked').val();
461			destroyAndRedrawCircle();
462		});
463
464		/* --- Live-updatable simple settings (just sync into map.settings) ---
465		 * Some fields (store_locator_button_style, store_locator_style)
466		 * are ALSO in rebuildFields below — the rebuildFields handler
467		 * triggers a render but doesn't sync map.settings. We include
468		 * them here too so map.settings has the new value by the time
469		 * render() finishes and wpgmza_live_preview_rendered fires
470		 * (post-render transforms like applyButtonStyleToPreview read
471		 * from map.settings). Both handlers fire on change; simpleFields
472		 * is bound first so the sync lands before render is scheduled. */
473		var simpleFields = [
474			'wpgmza_store_locator_bounce',
475			'store_locator_distance',
476			'store_locator_auto_area_max_zoom',
477			'store_locator_show_distance',
478			'wpgmza_store_locator_use_their_location',
479			'wpgmza_store_locator_hide_before_search',
480			'store_locator_nearby_searches',
481			'wpgmza_store_locator_restrict',
482			'store_locator_button_style',
483			'store_locator_style',
484			/* Info window settings — handled separately below */
485			'close_infowindow_on_map_click'
486		];
487
488		simpleFields.forEach(function(name){
489			$(document.body).on('change input', '[name="' + name + '"]', function(){
490				var el = $(this);
491				if(el.is(':checkbox')){
492					/* Unchecked = '' (falsy) NOT '0' — the string '0' is
493					 * truthy in JS so downstream `if(map.settings.foo)`
494					 * checks treat unchecked checkboxes as enabled. */
495					map.settings[name] = el.is(':checked') ? '1' : '';
496				} else if(el.is(':radio')){
497					map.settings[name] = $('[name="' + name + '"]:checked').val();
498				} else {
499					map.settings[name] = el.val();
500				}
501			});
502		});
503
504		/* --- Live text updates — modify the cloned DOM directly ---
505		   Fallbacks route through WPGMZA.localized_strings so translated
506		   copy shows when the user hasn't entered a custom override.
507		   Strings are registered in class.strings.php::getLocalizedStrings(). */
508		var LS = (WPGMZA && WPGMZA.localized_strings) || {};
509		var textFieldMap = {
510			'store_locator_query_string': function(val){
511				$('.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:');
512			},
513			'store_locator_location_placeholder': function(val){
514				$('.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');
515			},
516			'store_locator_name_string': function(val){
517				$('.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:');
518				$('.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');
519			},
520			'store_locator_not_found_message': function(val){
521				map.settings.store_locator_not_found_message = val;
522			},
523			'store_locator_default_address': function(val){
524				map.settings.store_locator_default_address = val;
525				$('.wpgmza-live-preview-component.wpgmza-store-locator input.wpgmza-address, .wpgmza-live-preview-component .wpgmza-store-locator input.wpgmza-address').val(val || '');
526			},
527			'wpgmza_store_locator_default_radius': function(val){
528				map.settings.wpgmza_store_locator_default_radius = val;
529				$('.wpgmza-live-preview-component.wpgmza-store-locator select.wpgmza-radius, .wpgmza-live-preview-component .wpgmza-store-locator select.wpgmza-radius').val(val);
530			}
531		};
532
533		for(var tfName in textFieldMap){
534			(function(name, handler){
535				$(document.body).on('change input', '[name="' + name + '"]', function(){
536					handler($(this).val());
537				});
538			})(tfName, textFieldMap[tfName]);
539		}
540
541		/* Re-apply every textFieldMap handler from the current form
542		 * value. Used after a full live-preview render() — toggling a
543		 * structural field (Enable Title Search, Enable Categories,
544		 * etc.) reclones the store locator template, which resets all
545		 * the user's customised labels/placeholders/default address/
546		 * default radius back to template defaults. Re-running each
547		 * handler against the current form state restores those
548		 * customisations on the freshly-cloned DOM. */
549		function reapplyTextFieldValues(){
550			for(var name in textFieldMap){
551				var $input = $('[name="' + name + '"]');
552				if(!$input.length) continue;
553				try {
554					textFieldMap[name]($input.val());
555				} catch(e){ }
556			}
557		}
558
559		/* Distance unit DOM sync — bring the SL DOM (suffix span +
560		 * radius option text) in line with the current form-state
561		 * unit. Idempotent: safe to call any time, including
562		 * immediately after a fresh template clone whose option
563		 * text was server-rendered with the SAVED unit baked in.
564		 *
565		 * The `oldSuffix` derivation reads from the live form value
566		 * (`isMiles ? 'km' : 'mi'`) and the regex only matches
567		 * options ending with that opposite suffix — so calling
568		 * this on a DOM that already matches the form is a no-op
569		 * (the regex fails to match, nothing changes).
570		 *
571		 * Localised strings aren't available client-side, so we
572		 * fall back to English — acceptable because this is the
573		 * admin-only live preview. */
574		function applyDistanceUnitToDOM(){
575			var isMiles = $('input[name="store_locator_distance"]').is(':checked');
576			var oldSuffix = isMiles ? 'km' : 'mi';
577			var newSuffix = isMiles ? 'mi' : 'km';
578			map.settings.store_locator_distance = isMiles ? '1' : '0';
579
580			/* Swap the suffix text. class.map-edit-page.php wraps the
581			 * suffix in <span class="wpgmza-distance-unit-suffix">. */
582			$('.wpgmza-distance-unit-suffix').text(newSuffix);
583
584			/* Each <option> in the live-preview store locator's radius
585			 * dropdown has its unit baked into the text ("10km",
586			 * "25km", etc.) because StoreLocator::populateRadiusSelect
587			 * writes `$radius . $suffix` at server render. Swap the
588			 * trailing unit on every option so the dropdown reflects
589			 * the current toggle state. The underlying `value`
590			 * attribute (just the number) is unchanged. */
591			$('.wpgmza-live-preview-component .wpgmza-radius option, .wpgmza-live-preview-component select.wpgmza-radius option').each(function(){
592				var $opt = $(this);
593				var text = $opt.text();
594				/* Only rewrite options that end with the old suffix,
595				 * so we don't double-suffix on repeat toggles. */
596				var re = new RegExp('(\\d+)\\s*' + oldSuffix + '\\s*$', 'i');
597				if(re.test(text)){
598					$opt.text(text.replace(re, '$1' + newSuffix));
599				}
600			});
601		}
602
603		/* User toggled the distance unit — full update including
604		 * circle redraw and info-window clear (in case "X miles
605		 * away" is currently showing). */
606		$(document.body).on('change', 'input[name="store_locator_distance"]', function(){
607			applyDistanceUnitToDOM();
608
609			/* Rebuild the circle so its radius recalculates under the
610			 * new unit. Modern circles redraw in-place via draw();
611			 * classic circles need a full destroy+recreate. */
612			if(!updateModernCircleColor()){
613				destroyAndRedrawCircle();
614			}
615
616			/* Info window "X miles/km away" text is cached in the open
617			 * popup — rebuild it so the unit flips too. info-window.js
618			 * reads map.settings.store_locator_distance live in
619			 * getContent, so reopening the popup regenerates the text
620			 * with the new unit. */
621			clearCachedInfoWindows();
622		});
623
624		/* Live preview re-rendered (most importantly: SL toggled
625		 * back on after being off). The freshly-cloned SL template
626		 * was server-rendered with the SAVED unit baked into the
627		 * radius option text + suffix span, so if the user changed
628		 * the unit while SL was disabled, the re-enabled template
629		 * would still display the saved unit. Re-applying the DOM
630		 * sync brings it in line with the current form value.
631		 *
632		 * Lightweight only — no circle redraw, no info-window
633		 * clear, since the re-init path handles those naturally
634		 * (no circle yet on a fresh re-enable, info windows already
635		 * cleared by the marker teardown/rebuild). Mirrors the
636		 * pattern used by `applyKeywordSearchVisibility` in
637		 * atlas-major-live-preview-pro.js. */
638		$(document.body).on('wpgmza_live_preview_rendered', function(){
639			applyDistanceUnitToDOM();
640		});
641
642		/* --- Info window settings — sync + clear cached info windows so new style applies --- */
643		var iwFields = ['wpgmza_iw_type', 'iw_primary_color', 'iw_accent_color', 'iw_text_color'];
644
645		function clearCachedInfoWindows(){
646			if(!map.markers) return;
647
648			/* Find which marker has an open info window */
649			var openMarker = null;
650			for(var i = 0; i < map.markers.length; i++){
651				var m = map.markers[i];
652				if(m.infoWindow && m.infoWindow.element && $(m.infoWindow.element).is(':visible')){
653					openMarker = m;
654				}
655			}
656			if(!openMarker && map.lastInteractedMarker && map.lastInteractedMarker.infoWindow){
657				openMarker = map.lastInteractedMarker;
658			}
659
660			/* Close and delete all cached info windows */
661			for(var i = 0; i < map.markers.length; i++){
662				if(map.markers[i].infoWindow){
663					try { map.markers[i].infoWindow.close(); } catch(e){}
664					delete map.markers[i].infoWindow;
665				}
666			}
667
668			/* Clear lastInteractedMarker so openInfoWindow doesn't try to close a deleted infoWindow */
669			map.lastInteractedMarker = null;
670
671			/* Reopen on the same marker with new style */
672			if(openMarker){
673				setTimeout(function(){
674					openMarker.openInfoWindow();
675				}, 100);
676			}
677		}
678
679		iwFields.forEach(function(name){
680			$(document.body).on('change input', '[name="' + name + '"]', function(){
681				var el = $(this);
682				if(el.is(':radio')){
683					map.settings[name] = $('[name="' + name + '"]:checked').val();
684				} else {
685					map.settings[name] = el.val();
686				}
687				clearCachedInfoWindows();
688			});
689		});
690
691		/* "Show distance from search" — toggles whether marker info windows
692		 * include "X miles/km away" after a store locator search.
693		 *
694		 * Two problems `simpleFields` above introduces that we fix here:
695		 *
696		 * 1. simpleFields writes a STRING '1'/'0' into map.settings. The
697		 *    info-window.js check `if(map.settings.store_locator_show_distance
698		 *    && ...)` is ALWAYS truthy for a non-empty string — the string
699		 *    '0' reads as true, leaving the distance visible after OFF.
700		 *    We overwrite with an integer 0/1 so the truthy check works.
701		 *
702		 * 2. simpleFields binds on `'change input'` — a checkbox click
703		 *    fires BOTH events. simpleFields thus writes the string value
704		 *    twice (once per event). If we only listen for 'change', our
705		 *    integer gets clobbered by the subsequent 'input' event
706		 *    writing the string back. We bind on both events too so our
707		 *    integer wins reliably.
708		 *
709		 * clearCachedInfoWindows is called once (on the 'change' event
710		 * only) so we close + reopen the info window exactly once per
711		 * user toggle — not twice per click. */
712		$(document.body).on('change input', 'input[name="store_locator_show_distance"]', function(e){
713			map.settings.store_locator_show_distance = $(this).is(':checked') ? 1 : 0;
714			if(e.type === 'change'){
715				clearCachedInfoWindows();
716			}
717		});
718
719		/* Enable Store Locator toggle — when turned OFF mid-search, tear
720		 * down the side-effects of the previous search: destroy the
721		 * circle, reset state to INITIAL, and refresh info windows so
722		 * they drop the "X miles/km away" line (the check in
723		 * info-window.js gates on storeLocator.state === STATE_APPLIED,
724		 * which no longer holds after the reset). Without this teardown
725		 * the circle stayed painted on the map and info windows kept
726		 * the stale distance text after the toggle flipped off. */
727		$(document.body).on('change', 'input[name="store_locator_enabled"]', function(){
728			if($(this).is(':checked')) return; // turning ON — render() handles it via bindEvents
729
730			if(map.storeLocator){
731				/* Destroy circle (same logic as destroyAndRedrawCircle
732				 * but without the follow-up redraw). */
733				if(map.storeLocator._circle){
734					var circle = map.storeLocator._circle;
735					circle.setVisible(false);
736					if(!(circle instanceof WPGMZA.ModernStoreLocatorCircle) && circle.map){
737						try { circle.map.removeCircle(circle); } catch(e){}
738					} else if(circle instanceof WPGMZA.ModernStoreLocatorCircle && typeof circle.destroy === 'function'){
739						try { circle.destroy(); } catch(e){}
740					}
741					map.storeLocator._circle = null;
742				}
743				/* Reset search state so info-window.js' distance check
744				 * (storeLocator.state === STATE_APPLIED) no longer
745				 * passes. We don't touch map.storeLocator._center /
746				 * _bounds — if the user toggles SL back on the state
747				 * goes back to INITIAL and they'll need to search
748				 * again, which matches the expected reset UX. */
749				map.storeLocator.state = WPGMZA.StoreLocator.STATE_INITIAL;
750			}
751
752			clearCachedInfoWindows();
753		});
754
755		/* --- Map width/height — apply to the map wrapper (NOT the mockup
756		 * page). Previously this wrote `max-width` onto
757		 * `.am-preview-page-inner`, which shrank the entire mockup
758		 * "website" frame instead of just the map. It also caused a
759		 * visual shift because .am-preview-page-inner has
760		 * `margin-left: max(70px, calc((100% - 1000px) / 2))` that
761		 * still assumes max-width: 1000px — once JS overwrote that
762		 * with 100%, the margin calc went off-grid. Now we target the
763		 * map wrapper, which sits inside the page-inner and is what
764		 * the frontend equivalent (.wpgmza_map inline style) also
765		 * sizes. */
766		function updateMapDimensions(){
767			var width = $('input[name="map_width"]').val();
768			var widthType = $('select[name="map_width_type"]').val() || '%';
769			var height = $('input[name="map_height"]').val();
770			var heightType = $('select[name="map_height_type"]').val() || 'px';
771
772			var mapWrapper = $('.am-preview-page-inner .map_wrapper');
773			var mapEl = $(map.element);
774
775			if(width && widthType){
776				mapWrapper.css({
777					'width': width + widthType,
778					'max-width': '100%' // so % values remain parent-bound
779				});
780			}
781			if(height && heightType){
782				mapWrapper.css({
783					'height': height + heightType,
784					'min-height': height + heightType
785				});
786				mapEl.css({
787					'height': height + heightType,
788					'min-height': height + heightType
789				});
790			}
791
792			/* Tell the map engine to resize to fit its new container.
793			 * onElementResized handles most engines; Leaflet needs an
794			 * explicit invalidateSize() because it caches viewport
795			 * dimensions and doesn't recalc unless told to. */
796			setTimeout(function(){
797				if(map.onElementResized){
798					map.onElementResized();
799				}
800				if(map.leafletMap && typeof map.leafletMap.invalidateSize === 'function'){
801					map.leafletMap.invalidateSize();
802				}
803				self.setViewportVariables();
804			}, 100);
805		}
806
807		$(document.body).on('change input', 'input[name="map_width"], select[name="map_width_type"], input[name="map_height"], select[name="map_height_type"]', function(){
808			updateMapDimensions();
809		});
810
811		/* Apply the saved width/height once on initial load too — the
812		 * change handler only fires on user interaction, so without
813		 * this initial call the preview shows the map at its CSS
814		 * default (100% width) regardless of the saved map_width value
815		 * (e.g. 500px). Defer slightly so the form values have been
816		 * populated by the upstream WPGMZA init. */
817		setTimeout(updateMapDimensions, 500);
818
819		/* Apply store_locator_button_style (SVG icons vs. text buttons)
820		 * to any currently-rendered live preview component. The hidden
821		 * template always holds the icon (SVG) version — we transform
822		 * to text buttons here when the setting calls for it. Mirrors
823		 * the PHP logic in class.store-locator.php that does the same
824		 * transformation at server render time. */
825		function applyButtonStyleToPreview(){
826			var style = (map.settings && map.settings.store_locator_button_style) || '';
827			$('.wpgmza-live-preview-component.wpgmza-store-locator, .wpgmza-live-preview-component .wpgmza-store-locator').each(function(){
828				var wrapper = this;
829				if(style === 'text'){
830					$(wrapper).find('svg[role="button"]').each(function(){
831						var svg = this;
832						var btn = document.createElement('button');
833						btn.textContent = svg.getAttribute('title') || '';
834						var attrs = ['class', 'aria-label'];
835						for(var i = 0; i < attrs.length; i++){
836							if(svg.hasAttribute(attrs[i])){
837								btn.setAttribute(attrs[i], svg.getAttribute(attrs[i]));
838							}
839						}
840						/* The PHP strips .wpgmza-aria-bridge on buttons */
841						btn.classList.remove('wpgmza-aria-bridge');
842						svg.parentNode.insertBefore(btn, svg.nextSibling);
843						svg.parentNode.removeChild(svg);
844					});
845				}
846				/* For 'icons' (default) we don't need to do anything — the
847				 * template clone already has SVG buttons. The reverse
848				 * transform (text → SVG) isn't possible without a
849				 * template copy, which is why we force the PHP template
850				 * render to always use icons. */
851			});
852		}
853
854		/* Re-apply button style after every live-preview render. The
855		 * render() flow clones the template (always icons) and replaces
856		 * existing components, so we need to re-transform to 'text'
857		 * after each render. Same reasoning for reapplyTextFieldValues:
858		 * a structural rebuild (Enable Title Search etc.) reclones the
859		 * store locator template, dropping any user-customised labels/
860		 * placeholders/defaults. Re-applying the textFieldMap handlers
861		 * from the current form state restores them. */
862		$(document.body).on('wpgmza_live_preview_rendered', function(){
863			applyButtonStyleToPreview();
864			reapplyTextFieldValues();
865		});
866
867		/* Also apply right now — the constructor calls render() BEFORE
868		 * initStoreLocatorSettingsSync binds listeners, so the initial
869		 * render's `wpgmza_live_preview_rendered` event fires before
870		 * we're listening. Running once here covers that initial pass;
871		 * subsequent renders go through the listener above. */
872		applyButtonStyleToPreview();
873
874		/* --- Structural changes — need full store locator rebuild --- */
875		var rebuildFields = [
876			'store_locator_name_search',
877			'store_locator_category',
878			'store_locator_search_area',
879			'store_locator_button_style',
880			'store_locator_style'
881		];
882
883		var rebuildTimer = null;
884		rebuildFields.forEach(function(name){
885			$(document.body).on('change', '[name="' + name + '"]', function(){
886				clearTimeout(rebuildTimer);
887				rebuildTimer = setTimeout(function(){ self.render(); }, 300);
888			});
889		});
890
891		/* Toggle labels for structural fields (need full rebuild) */
892		$(document.body).on('click',
893			'label[for="wpgmza_store_locator_name_search"], label[for="wpgmza_store_locator_category_enabled"]',
894			function(){
895				clearTimeout(rebuildTimer);
896				rebuildTimer = setTimeout(function(){ self.render(); }, 300);
897			}
898		);
899
900		/* Toggle labels for simple settings (just sync).
901		 *
902		 * NOTE: `store_locator_show_distance` was intentionally REMOVED
903		 * from this selector list. The dedicated change handler above
904		 * writes an INTEGER (0/1) so the truthy check in
905		 * info-window.js works correctly; this label-click handler
906		 * fires a 50ms-delayed STRING write ('0'/'1') that ran AFTER
907		 * change/input events and clobbered the integer with a string
908		 * (which reads as truthy — causing the distance line to stick
909		 * even when the toggle was off). Clicking a <label for="X">
910		 * natively toggles the checkbox and fires change/input events
911		 * on the input, so `simpleFields` already syncs — this
912		 * click-delayed write is redundant for all four toggles. */
913		$(document.body).on('click',
914			'label[for="wpgmza_store_locator_use_their_location"], label[for="wpgmza_store_locator_hide_before_search"], label[for="store_locator_nearby_searches"]',
915			function(){
916				var forId = $(this).attr('for');
917				var checkbox = $('#' + forId);
918				setTimeout(function(){
919					/* Unchecked = '' (falsy), NOT '0' (truthy string) */
920					map.settings[checkbox.attr('name')] = checkbox.is(':checked') ? '1' : '';
921				}, 50);
922			}
923		);
924	}
925
926	/* ========================================================
927	   MAP STATE SYNC
928	   Apply map-level settings (start position, zoom, bounds,
929	   streetview) directly to the map via existing APIs — no
930	   component rebuild needed.
931	   ======================================================== */
932	WPGMZA.AtlasMajorLivePreview.prototype.initMapStateSync = function(){
933		var self = this;
934		var map = this.map;
935
936		function readFloat(sel){
937			var v = parseFloat($(sel).val());
938			return isNaN(v) ? null : v;
939		}
940
941		function readInt(sel){
942			var v = parseInt($(sel).val());
943			return isNaN(v) ? null : v;
944		}
945
946		/* Note: map_start_lat/lng/zoom and wpgmza_start_location/zoom are
947		   implicit hidden fields — they auto-update as the user pans/zooms
948		   the map. No explicit watcher needed. */
949
950		/* --- Max/min zoom limits ---
951		   NOTE: the form field names are misleading. Per the UI labels:
952		     map_max_zoom  = "Maximum Zoom Out Level" → Google minZoom
953		     map_min_zoom  = "Maximum Zoom In Level"  → Google maxZoom */
954		function applyZoomLimits(){
955			var outLimit = readInt('input[name="map_max_zoom"]'); /* most zoomed out */
956			var inLimit  = readInt('input[name="map_min_zoom"]'); /* most zoomed in */
957
958			map.settings.map_max_zoom = outLimit;
959			map.settings.map_min_zoom = inLimit;
960
961			/* Engine-specific — Google Maps is the primary engine */
962			if(map.googleMap && typeof map.googleMap.setOptions === 'function'){
963				var opts = {};
964				if(outLimit !== null) opts.minZoom = outLimit;
965				if(inLimit !== null)  opts.maxZoom = inLimit;
966				map.googleMap.setOptions(opts);
967			}
968			/* Leaflet */
969			if(map.leafletMap){
970				if(outLimit !== null && typeof map.leafletMap.setMinZoom === 'function') map.leafletMap.setMinZoom(outLimit);
971				if(inLimit !== null  && typeof map.leafletMap.setMaxZoom === 'function') map.leafletMap.setMaxZoom(inLimit);
972			}
973		}
974
975		$(document.body).on('change input', 'input[name="map_max_zoom"], input[name="map_min_zoom"]', applyZoomLimits);
976
977		/* --- Start in streetview (Google Maps only) ---
978		 * The editor workflow (Atlas Novus behaviour) is: toggle ON → .streetview-starting-editor
979		 * fieldset fades in and WPGMZA.StreetViewEditor enters add mode. The user drags the
980		 * native Google pegman from the map's top-right control onto a location; position/
981		 * heading/pitch then populate. All of that is handled by Pro's ProMapEditPage +
982		 * StreetViewEditor (pro-map-edit-page.js + streetview-editor.js) bound to the single
983		 * admin map instance — the live preview shares that same map, so no extra wiring is
984		 * needed here. We only mirror the raw setting value onto map.settings.
985		 */
986		$(document.body).on('change', 'input[name="map_starts_in_streetview"]', function(){
987			var enabled = $(this).is(':checked');
988			map.settings.map_starts_in_streetview = enabled ? '1' : '0';
989
990			/* If the map currently has streetview open (e.g. loaded with this setting
991			   already enabled + a saved location) and the user toggles OFF, close it
992			   so the user isn't stuck looking at streetview. */
993			if(!enabled && typeof map.closeStreetView === 'function'){
994				map.closeStreetView();
995			}
996		});
997
998		/* --- Jump to nearest marker on init — trigger the effect on toggle --- */
999		$(document.body).on('change', 'input[name="jump_to_nearest_marker_on_initialization"]', function(){
1000			map.settings.jump_to_nearest_marker_on_initialization = $(this).is(':checked') ? '1' : '';
1001			if(!$(this).is(':checked')) return;
1002
1003			/* Find nearest marker to current center and pan to it */
1004			if(!map.markers || !map.markers.length) return;
1005			var center = map.getCenter();
1006			if(!center) return;
1007
1008			var nearest = null;
1009			var nearestDist = Infinity;
1010			for(var i = 0; i < map.markers.length; i++){
1011				var m = map.markers[i];
1012				if(!m.lat || !m.lng) continue;
1013				var dLat = m.lat - center.lat;
1014				var dLng = m.lng - center.lng;
1015				var dist = dLat*dLat + dLng*dLng;
1016				if(dist < nearestDist){
1017					nearestDist = dist;
1018					nearest = m;
1019				}
1020			}
1021			if(nearest && WPGMZA.LatLng){
1022				map.setCenter(new WPGMZA.LatLng({lat: nearest.lat, lng: nearest.lng}));
1023			}
1024		});
1025
1026		/* --- Fit bounds to markers --- */
1027		function fitBoundsToMarkers(){
1028			if(!map.markers || !map.markers.length) return;
1029			if(typeof map.fitBoundsToVisibleMarkers === 'function'){
1030				map.fitBoundsToVisibleMarkers();
1031				return;
1032			}
1033			/* Fallback */
1034			if(WPGMZA.LatLngBounds){
1035				var bounds = new WPGMZA.LatLngBounds();
1036				for(var i = 0; i < map.markers.length; i++){
1037					var m = map.markers[i];
1038					if(m.lat && m.lng) bounds.extend(new WPGMZA.LatLng({lat: m.lat, lng: m.lng}));
1039				}
1040				if(typeof map.fitBounds === 'function') map.fitBounds(bounds);
1041			}
1042		}
1043
1044		$(document.body).on('change', 'input[name="fit_maps_bounds_to_markers"]', function(){
1045			map.settings.fit_maps_bounds_to_markers = $(this).is(':checked') ? '1' : '';
1046			if($(this).is(':checked')) fitBoundsToMarkers();
1047		});
1048
1049		/* --- Fit bounds to markers after filtering --- */
1050		var fitOnFilterHandler = null;
1051		function setFitOnFilterHandler(enable){
1052			if(fitOnFilterHandler){
1053				$(document.body).off('filteringcomplete.wpgmza.fitOnFilter');
1054				fitOnFilterHandler = null;
1055			}
1056			if(enable){
1057				fitOnFilterHandler = function(event){
1058					if(event.map && event.map.id === map.id){
1059						fitBoundsToMarkers();
1060					}
1061				};
1062				$(document.body).on('filteringcomplete.wpgmza.fitOnFilter', fitOnFilterHandler);
1063			}
1064		}
1065
1066		$(document.body).on('change', 'input[name="fit_maps_bounds_to_markers_after_filtering"]', function(){
1067			var enabled = $(this).is(':checked');
1068			map.settings.fit_maps_bounds_to_markers_after_filtering = enabled ? '1' : '0';
1069			setFitOnFilterHandler(enabled);
1070		});
1071
1072		/* Initial state on load */
1073		if($('input[name="fit_maps_bounds_to_markers_after_filtering"]').is(':checked')){
1074			setFitOnFilterHandler(true);
1075		}
1076
1077		/* --- Map alignment ---
1078		 * Align the map within the live preview's mockup page.
1079		 * Values mirror the select options:
1080		 *   1 = Left (default), 2 = Center, 3 = Right, 4 = None
1081		 * We apply margin-* on the .map_wrapper so the map element
1082		 * sits in the requested position when its width is < 100%.
1083		 * For 100%-wide maps the alignment is visually a no-op. */
1084		function applyMapAlignment(){
1085			var v = parseInt($('select[name="wpgmza_map_align"]').val(), 10);
1086			var wrapper = $('.am-preview-page-inner .map_wrapper');
1087			if(!wrapper.length) return;
1088
1089			/* Reset alignment-related margins first so we don't stack */
1090			wrapper.css({ 'margin-left': '', 'margin-right': '' });
1091
1092			switch(v){
1093				case 2: /* Center */
1094					wrapper.css({ 'margin-left': 'auto', 'margin-right': 'auto' });
1095					break;
1096				case 3: /* Right */
1097					wrapper.css({ 'margin-left': 'auto', 'margin-right': '0' });
1098					break;
1099				case 4: /* None — no alignment, sits at default flow position */
1100				case 1: /* Left — default */
1101				default:
1102					wrapper.css({ 'margin-left': '0', 'margin-right': 'auto' });
1103					break;
1104			}
1105		}
1106
1107		$(document.body).on('change input', 'select[name="wpgmza_map_align"]', applyMapAlignment);
1108		/* Apply once on load so the saved alignment takes effect without
1109		 * requiring user interaction. Slight delay so the form value
1110		 * is populated. */
1111		setTimeout(applyMapAlignment, 500);
1112	}
1113
1114	/* ========================================================
1115	   MARKER BEHAVIOUR SYNC
1116	   Mirror marker click / info-window click-behaviour flags into
1117	   map.settings so Pro's runtime code (ProMarker.onClick,
1118	   ProMap.onMapClick) picks them up on the next interaction.
1119	   For enable_marker_labels, iterate existing markers and apply
1120	   setLabel() immediately so the change is visible without a reload.
1121	   ======================================================== */
1122	WPGMZA.AtlasMajorLivePreview.prototype.initMarkerBehaviourSync = function(){
1123		var map = WPGMZA.maps[0];
1124		if(!map) return;
1125
1126		/* --- Click marker opens link --- */
1127		$(document.body).on('change', 'input[name="click_open_link"]', function(){
1128			map.settings.click_open_link = $(this).is(':checked') ? 1 : 0;
1129		});
1130
1131		/* --- Zoom on marker click (+ zoom level from slider) ---
1132		 * The slider writes its value to input[name="wpgmza_zoom_on_marker_click_slider"];
1133		 * the existing zoom-slider UI fires change events on that input already. */
1134		$(document.body).on('change', 'input[name="wpgmza_zoom_on_marker_click"]', function(){
1135			map.settings.wpgmza_zoom_on_marker_click = $(this).is(':checked') ? 1 : 0;
1136		});
1137
1138		$(document.body).on('change input', 'input[name="wpgmza_zoom_on_marker_click_slider"]', function(){
1139			var v = parseInt($(this).val(), 10);
1140			if(!isNaN(v)) map.settings.wpgmza_zoom_on_marker_click_slider = v;
1141		});
1142
1143		/* close_infowindow_on_map_click is already synced by initStoreLocatorSettingsSync's
1144		   simpleFields array — no handler needed here. */
1145
1146		/* --- Marker labels ---
1147		 * ProMarker applies labels at onAdd/setVisible time using map.settings.enable_marker_labels
1148		 * and the marker's own title/subText. The label itself is a Pointlabel instance owned by
1149		 * the marker (see WPGMZA.Marker.prototype.setLabel), styled entirely from the
1150		 * map.settings.marker_label_* fields below. Iterate existing markers and refresh them
1151		 * live on any relevant setting change. */
1152		function refreshMarkerLabels(){
1153			if(!map.markers) return;
1154			for(var i = 0; i < map.markers.length; i++){
1155				var m = map.markers[i];
1156				if(typeof m.setLabel !== 'function') continue;
1157				if(map.settings.enable_marker_labels){
1158					m.setLabel();
1159				} else {
1160					m.setLabel(null);
1161				}
1162			}
1163		}
1164
1165		$(document.body).on('change', 'input[name="enable_marker_labels"]', function(){
1166			map.settings.enable_marker_labels = $(this).is(':checked') ? 1 : 0;
1167			refreshMarkerLabels();
1168		});
1169
1170		$(document.body).on('change', 'select[name="marker_label_style"]', function(){
1171			map.settings.marker_label_style = $(this).val();
1172			refreshMarkerLabels();
1173		});
1174
1175		$(document.body).on('change', 'input[name="marker_label_above_icons"]', function(){
1176			map.settings[$(this).attr('name')] = $(this).is(':checked') ? 1 : 0;
1177			refreshMarkerLabels();
1178		});
1179
1180		$(document.body).on('change', 'input[name="marker_label_hidden_by_default"]', function(){
1181			map.settings[$(this).attr('name')] = $(this).is(':checked') ? 1 : 0;
1182			refreshMarkerLabels();
1183		});
1184
1185		$(document.body).on('change', 'input[name="marker_label_click_opens_infowindow"]', function(){
1186			map.settings[$(this).attr('name')] = $(this).is(':checked') ? 1 : 0;
1187			refreshMarkerLabels();
1188		});
1189
1190		/* marker_label_icon is a single global icon URL (ImageInputSingle widget) - it
1191		 * fires a plain 'change' on its underlying text input when picked or reset. */
1192		$(document.body).on('change input', 'input[name="marker_label_font_size"], input[name="marker_label_offset_x"], input[name="marker_label_offset_y"], input[name="marker_label_icon"]', function(){
1193			map.settings[$(this).attr('name')] = $(this).val();
1194			refreshMarkerLabels();
1195		});
1196	}
1197
1198	/* ========================================================
1199	   CONTROLS & LAYERS SYNC (Map Settings → Advanced)
1200	   Live-apply layer toggles and scale control via the existing
1201	   engine methods. All of these are idempotent — calling them
1202	   with the same enable state is a no-op.
1203	   ======================================================== */
1204	WPGMZA.AtlasMajorLivePreview.prototype.initControlsAndLayersSync = function(){
1205		var map = WPGMZA.maps[0];
1206		if(!map) return;
1207
1208		function syncAndApply(name, method){
1209			$(document.body).on('change', 'input[name="' + name + '"]', function(){
1210				var enabled = $(this).is(':checked');
1211				map.settings[name] = enabled ? '1' : '0';
1212				if(typeof map[method] === 'function'){
1213					map[method](enabled);
1214				}
1215			});
1216		}
1217
1218		syncAndApply('bicycle',          'enableBicycleLayer');
1219		syncAndApply('traffic',          'enableTrafficLayer');
1220		syncAndApply('transport_layer',  'enablePublicTransportLayer');
1221		syncAndApply('enable_scale_control', 'enableScaleControl');
1222	}
1223
1224	/* ========================================================
1225	   SAVE REMINDER
1226	   ======================================================== */
1227	WPGMZA.AtlasMajorLivePreview.prototype.initSaveReminder = function(){
1228		var self = this;
1229		var shown = false;
1230
1231		var fadeTimer = null;
1232		var frontendOnlyFadeTimer = null;
1233
1234		/* Settings whose changes do NOT affect the live preview. When these
1235		   change, show a "frontend only" notice instead of the save reminder. */
1236		var FRONTEND_ONLY_SETTINGS = [
1237			'only_load_markers_within_viewport',
1238			'lazyload_info_window_content',
1239			/* Mobile overrides — preview frame is fixed-size desktop,
1240			   these only take effect on a real mobile viewport. */
1241			'zoom_level_mobile_override_enabled',
1242			'zoom_level_mobile_override',
1243			'map_dimensions_mobile_override_enabled',
1244			'map_mobile_width',
1245			'map_mobile_width_type',
1246			'map_mobile_height',
1247			'map_mobile_height_type',
1248			/* Advanced tab — frontend-only behaviours */
1249			'disable_lightbox_images',   /* takes effect on marker image click on frontend */
1250			'use_Raw_Jpeg_Coordinates',  /* admin-side JPEG exif reader, no live preview effect */
1251			/* Marker Listing — click-triggered effects only */
1252			'marker_listing_nudge_marker_center',
1253			'marker_listing_nudge_x',
1254			'marker_listing_nudge_y',
1255			'zoom_level_on_marker_listing_override',
1256			'zoom_level_on_marker_listing_click',
1257			'marker_listing_disable_zoom',
1258			/* Directions — fires only when user runs directions */
1259			'directions_behaviour',
1260			'force_google_directions_app',
1261			/* UGM / VGM — all settings only affect the frontend visitor-
1262			   facing submission form (rendered via shortcode on public
1263			   pages). None has a live preview effect inside the editor. */
1264			'wpgmza_ugm_enbaled',
1265			'wpgmza_ugm_access',
1266			'wpgmza_ugm_desc_enabled',
1267			'wpgmza_ugm_category_enbaled',
1268			'wpgmza_ugm_upload_images',
1269			'wpgmza_ugm_link_enabled',
1270			'zoom_after_ugm_submission',
1271			'wpgmza_ugm_form_header',
1272			'wpgmza_ugm_form_title',
1273			'wpgmza_ugm_form_title_ph',
1274			'wpgmza_ugm_form_address',
1275			'wpgmza_ugm_form_address_ph',
1276			'wpgmza_ugm_form_address_help',
1277			'wpgmza_ugm_form_desc',
1278			'wpgmza_ugm_form_link',
1279			'wpgmza_ugm_form_link_ph',
1280			'wpgmza_ugm_form_image',
1281			'wpgmza_ugm_form_category'
1282		];
1283
1284		function showReminder(){
1285			if(shown) return;
1286			shown = true;
1287
1288			if(self.enabled){
1289				$('.am-save-reminder-preview').show();
1290				$('.am-save-reminder-toolbar').hide();
1291			} else {
1292				$('.am-save-reminder-toolbar').show();
1293				$('.am-save-reminder-preview').hide();
1294			}
1295
1296			/* Fade out after 8 seconds */
1297			clearTimeout(fadeTimer);
1298			fadeTimer = setTimeout(function(){
1299				$('.am-save-reminder').fadeOut(400, function(){
1300					shown = false;
1301				});
1302			}, 8000);
1303		}
1304
1305		function showFrontendOnlyNotice(){
1306			if(self.enabled){
1307				$('.am-frontend-only-notice-preview').stop(true, true).show();
1308				$('.am-frontend-only-notice-toolbar').hide();
1309			} else {
1310				$('.am-frontend-only-notice-toolbar').stop(true, true).show();
1311				$('.am-frontend-only-notice-preview').hide();
1312			}
1313
1314			clearTimeout(frontendOnlyFadeTimer);
1315			frontendOnlyFadeTimer = setTimeout(function(){
1316				$('.am-frontend-only-notice').fadeOut(400);
1317			}, 8000);
1318		}
1319
1320		function isFrontendOnlySetting(el){
1321			var name = $(el).attr('name');
1322			if(!name) return false;
1323			return FRONTEND_ONLY_SETTINGS.indexOf(name) !== -1;
1324		}
1325
1326		/* Listen for ANY form change inside the sidebar */
1327		$('.wpgmza-editor .sidebar').on('change input', 'input, select, textarea', function(){
1328			if(isFrontendOnlySetting(this)){
1329				showFrontendOnlyNotice();
1330			} else {
1331				showReminder();
1332			}
1333		});
1334
1335		/* Also catch cmn-toggle label clicks */
1336		$('.wpgmza-editor .sidebar').on('click', 'label[for]', function(){
1337			var targetId = $(this).attr('for');
1338			var targetEl = document.getElementById(targetId);
1339			if(targetEl && FRONTEND_ONLY_SETTINGS.indexOf($(targetEl).attr('name')) !== -1){
1340				setTimeout(showFrontendOnlyNotice, 50);
1341			} else {
1342				setTimeout(showReminder, 50);
1343			}
1344		});
1345
1346		/* Map move/zoom triggers save reminder */
1347		if(self.map){
1348			self.map.on('dragend', function(){ showReminder(); });
1349			self.map.on('zoomchanged', function(){ showReminder(); });
1350		}
1351
1352		/* Update visibility when preview is toggled */
1353		$('#am-live-preview-toggle').on('change', function(){
1354			if(shown){
1355				if(self.enabled){
1356					$('.am-save-reminder-preview').show();
1357					$('.am-save-reminder-toolbar').hide();
1358				} else {
1359					$('.am-save-reminder-toolbar').show();
1360					$('.am-save-reminder-preview').hide();
1361				}
1362			}
1363		});
1364	}
1365
1366	/* ========================================================
1367	   PUBLIC API — for Pro to register components
1368	   ======================================================== */
1369	WPGMZA.AtlasMajorLivePreview.prototype.registerComponent = function(key, config){
1370		COMPONENTS[key] = config;
1371
1372		/* Watch the enabled field and anchor field */
1373		var self = this;
1374		if(config.anchorField){
1375			$(document.body).trigger('wpgmza_live_preview_watch_field', [config.anchorField]);
1376		}
1377	}
1378
1379	WPGMZA.AtlasMajorLivePreview.prototype.getTemplatesContainer = function(){
1380		return this.templates;
1381	}
1382
1383	WPGMZA.AtlasMajorLivePreview.prototype.getMap = function(){
1384		return this.map;
1385	}
1386
1387	/* ========================================================
1388	   LIVE TILESET SWAP — Themes > Tileset card click applies
1389	   the new tileset to the live preview map without needing a
1390	   save. Map editor under Atlas Major previously showed only
1391	   "Save map to apply tileset!" notification; this hooks the
1392	   click and reloads the leaflet/maplibre tile layer in place.
1393
1394	   Pulls config from the public tileservers.json (same source
1395	   the PHP-side `TileServers::getList()` reads). One-shot
1396	   fetch, cached on `WPGMZA.atlasMajorTileServers` so repeat
1397	   clicks don't re-hit the network.
1398
1399	   Engine guard: only runs on Leaflet-family engines (Google
1400	   Maps tilesets are managed by Google's own type selector,
1401	   not the OpenFreeMap/OSM tileset cards in this panel).
1402	   ======================================================== */
1403	jQuery(function($){
1404		var tilesetCache = null;
1405
1406		function fetchTileServers(callback){
1407			if(WPGMZA.atlasMajorTileServers){
1408				callback(WPGMZA.atlasMajorTileServers);
1409				return;
1410			}
1411			if(tilesetCache){
1412				callback(tilesetCache);
1413				return;
1414			}
1415			$.ajax({
1416				url: WPGMZA.pluginDirURL + 'js/tileservers.json',
1417				dataType: 'json',
1418				cache: true,
1419				success: function(data){
1420					/* Stamp slug into each entry, mirroring the PHP
1421					   getList() behaviour. */
1422					for(var slug in data){
1423						if(data[slug] && typeof data[slug] === 'object'){
1424							data[slug].slug = slug;
1425						}
1426					}
1427					tilesetCache = data;
1428					WPGMZA.atlasMajorTileServers = data;
1429					callback(data);
1430				},
1431				error: function(){
1432					/* Silent fail — tileset just won't update live; the
1433					   pre-existing "Save map to apply" notification
1434					   already covers the fallback path. */
1435				}
1436			});
1437		}
1438
1439		/* CRS is locked at leaflet map construction (leaflet-map.js:29-38
1440		   sets it from `customTileMode` at init only). Trying to swap
1441		   the layer type (custom image overlay <-> XYZ tiles) into a
1442		   map whose CRS doesn't match the new layer renders the map
1443		   grey — XYZ tiles request URLs in Web Mercator coords that
1444		   don't translate inside L.CRS.Simple, and L.imageOverlay
1445		   bounds get interpreted as lat/lng inside EPSG3857. The
1446		   extended Simple CRS preserves `infinite: true`; EPSG3857
1447		   doesn't set it — that's our reliable signal. */
1448		function mapHasSimpleCRS(map){
1449			return !!(map && map.leafletMap && map.leafletMap.options.crs && map.leafletMap.options.crs.infinite);
1450		}
1451
1452		/* Reload the leaflet/maplibre tile layer in place using the
1453		   current map.settings values. Used by both the tileset card
1454		   click handler and the custom-image field handlers — anything
1455		   that changes a tile-layer-relevant setting calls this to
1456		   visualise the change without a save. */
1457		function reloadLiveTileLayer(){
1458			if(!WPGMZA.atlasMajorLivePreview) return;
1459			var lp = WPGMZA.atlasMajorLivePreview;
1460			var map = lp.getMap && lp.getMap();
1461			if(!map || !map.leafletMap) return; /* Leaflet-only path */
1462			if(typeof map.getTileLayers !== 'function') return;
1463
1464			/* CRS-compat guard: only proceed when the map's locked CRS
1465			   is appropriate for the layer we're about to add. The
1466			   modal that fires on custom_tile_enabled toggle already
1467			   tells the user a save+reload is needed — a fresh map
1468			   construction will reset the CRS to match. Skipping the
1469			   reload here keeps the visual stable (last good state)
1470			   instead of going grey/blank. */
1471			var wantSimple = !!(map.settings && map.settings.custom_tile_enabled);
1472			if(mapHasSimpleCRS(map) !== wantSimple) return;
1473
1474			if(map.tileLayers && map.tileLayers.length){
1475				map.tileLayers.forEach(function(layer){
1476					try { map.leafletMap.removeLayer(layer); } catch(e){}
1477				});
1478			}
1479
1480			try {
1481				map.tileLayers = map.getTileLayers();
1482				map.tileLayers.forEach(function(layer){
1483					layer.addTo(map.leafletMap);
1484				});
1485
1486				/* Deliberately NOT calling fitBounds(customTileBounds)
1487				   here. The custom-image branch of getTileLayers sets
1488				   customTileBounds to `[[0,0],[height,width]]` which is
1489				   only valid in L.CRS.Simple — but the map was already
1490				   constructed in EPSG3857 (Mercator) and CRS is locked
1491				   at construction. Calling fitBounds against those
1492				   bounds in Mercator pans the map to invalid lat/lng,
1493				   which onCenterChanged writes into map_start_lat /
1494				   map_start_lng form fields. Saving then sends garbage
1495				   coords to the REST endpoint. The custom image
1496				   overlay visually requires save+reload to display
1497				   correctly anyway (so the map can be reconstructed
1498				   with CRS.Simple), so live preview just no-ops the
1499				   view shift here — the toggle still applies cleanly
1500				   and the save path stays valid. */
1501			} catch(e){
1502				/* If something throws, fall back silently. */
1503			}
1504		}
1505
1506		function applyTilesetToLivePreview(slug){
1507			if(!WPGMZA.atlasMajorLivePreview) return;
1508			var lp = WPGMZA.atlasMajorLivePreview;
1509			var map = lp.getMap && lp.getMap();
1510			if(!map) return;
1511
1512			fetchTileServers(function(servers){
1513				var config = servers[slug];
1514				if(!config){
1515					/* Empty slug = "Global Default" — fall back to whatever
1516					   the engine's default tile server URL is. */
1517					if(!slug){
1518						config = WPGMZA.tileServer || null;
1519					}
1520					if(!config) return;
1521				}
1522
1523				/* Update the in-memory settings so configureTileServer
1524				   picks up the new override. tile_server_override is
1525				   the slug; tile_server_override_config is the hydrated
1526				   definition that getTileLayers reads via
1527				   configureTileServer (map-settings.js:472-482). */
1528				map.settings.tile_server_override = slug || '';
1529				map.settings.tile_server_override_config = config;
1530
1531				/* For zerocost engine the URL also lives on the global
1532				   settings under tile_server_url_leaflet_zerocost. Keep
1533				   it in sync so getTileLayers' switch/case path returns
1534				   the right base URL. */
1535				if(WPGMZA.settings && WPGMZA.settings.engine === 'leaflet-zerocost' && config.url){
1536					WPGMZA.settings.tile_server_url_leaflet_zerocost = config.url;
1537				}
1538
1539				reloadLiveTileLayer();
1540			});
1541		}
1542
1543		/* Bind the tileset card click. Body-delegated so it survives
1544		   the panel re-renders that SidebarGroupings can do. */
1545		$(document.body).on('click', '#wpgmza-tileset-panel .tileset-option', function(){
1546			if(!document.querySelector('.wpgmza-atlas-major .am-preview-frame')) return;
1547			var slug = $(this).find('input[name="tile_server_override"]').val();
1548			applyTilesetToLivePreview(slug);
1549		});
1550
1551		/* Custom Image overlay — Themes > Custom Image tab.
1552		   `configureTileServer` (map-settings.js:644-676) flips into
1553		   `type: 'custom'` mode when `custom_tile_enabled` is true and
1554		   `custom_tile_image` is set. getTileLayers then renders an
1555		   L.imageOverlay over the explicit bounds derived from
1556		   width/height. We mirror each form field into map.settings
1557		   and reload the tile layer so the image appears immediately.
1558
1559		   Toggling OFF reverts to the active tileset (via the same
1560		   reload path — when custom_tile_enabled=false, configureTileServer
1561		   skips the custom branch and returns the regular tile config).
1562		   Field debounced so dragging width/height inputs doesn't
1563		   spam reloads on every keystroke. */
1564		var customTileTimer = null;
1565		function syncCustomTileFromForm(){
1566			if(!WPGMZA.atlasMajorLivePreview) return;
1567			var lp = WPGMZA.atlasMajorLivePreview;
1568			var map = lp.getMap && lp.getMap();
1569			if(!map || !map.settings) return;
1570
1571			map.settings.custom_tile_enabled = $('input[name="custom_tile_enabled"]').is(':checked') ? 1 : 0;
1572			map.settings.custom_tile_image = $('input[name="custom_tile_image"]').val() || '';
1573			map.settings.custom_tile_image_width = $('input[name="custom_tile_image_width"]').val() || '';
1574			map.settings.custom_tile_image_height = $('input[name="custom_tile_image_height"]').val() || '';
1575			map.settings.custom_tile_image_attribution = $('input[name="custom_tile_image_attribution"]').val() || '';
1576
1577			/* getTileLayers' custom branch only ever sets customTileMode
1578			   and customTileBounds — never resets them. If the user
1579			   toggles custom_tile_enabled off, those flags would stay
1580			   true from the previous enable, leaking custom-tile state
1581			   into subsequent renders (and into the saved form payload
1582			   where leaflet-map.js reads them at construction). Reset
1583			   them explicitly here whenever the toggle is off. */
1584			if(!map.settings.custom_tile_enabled){
1585				map.customTileMode = false;
1586				map.customTileBounds = null;
1587				map.customTileDimensions = null;
1588
1589				/* If the saved map_start_lat / map_start_lng came from a
1590				   previous custom-tile session they're in L.CRS.Simple
1591				   pixel coords (e.g., 400, 600 for an 800x600 image),
1592				   not lat/lng. Saving those would break the map on
1593				   next reload (leaflet-map.js constructs in EPSG3857
1594				   without the customTileMode bounds-check at line
1595				   40-44). Detect out-of-range values and reset to the
1596				   plugin defaults from class.map.php:195-200 +
1597				   class.database.php:334 (zoom 4). The setView call
1598				   triggers MapEditPage.onCenterChanged which writes
1599				   the new values into map_start_lat / map_start_lng /
1600				   map_start_zoom inputs. */
1601				var lat = parseFloat($('input[name="map_start_lat"]').val());
1602				var lng = parseFloat($('input[name="map_start_lng"]').val());
1603				var coordsInvalid = isNaN(lat) || isNaN(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180;
1604
1605				if(coordsInvalid && map.leafletMap){
1606					var defaultLat = 36.778261;
1607					var defaultLng = -119.4179323999;
1608					var defaultZoom = 4;
1609
1610					try {
1611						map.leafletMap.setView([defaultLat, defaultLng], defaultZoom);
1612					} catch(e){}
1613
1614					/* Force-write in case setView's moveend doesn't fire
1615					   in time before a save click. */
1616					$('input[name="map_start_lat"]').val(defaultLat).trigger('change');
1617					$('input[name="map_start_lng"]').val(defaultLng).trigger('change');
1618					$('input[name="map_start_zoom"]').val(defaultZoom).trigger('change');
1619				}
1620			}
1621		}
1622
1623		function applyCustomTileLive(){
1624			clearTimeout(customTileTimer);
1625			customTileTimer = setTimeout(function(){
1626				syncCustomTileFromForm();
1627				reloadLiveTileLayer();
1628			}, 200);
1629		}
1630
1631		/* Modal that intercepts custom-image enable/disable. Because
1632		   the leaflet CRS is locked at map construction
1633		   (leaflet-map.js:29-38), live preview can't visually swap
1634		   between image overlay (CRS.Simple) and XYZ tiles (EPSG3857)
1635		   without rebuilding the entire engine. Instead, we ask the
1636		   user to confirm a save+reload — clicking the savemap submit
1637		   button POSTs the form and the page reloads with the new
1638		   CRS. Cancel reverts the toggle so the form state stays
1639		   consistent with what's on screen. */
1640		var customTileModal = null;
1641		var customTilePreviousState = null;
1642
1643		/* Full-screen blocking overlay shown while the editor saves and
1644		   reloads. Inline-styled so we don't have to ship CSS through
1645		   the build pipeline for a one-off element. z-index sits above
1646		   modals (99999) and Leaflet's panes. The reload typically
1647		   takes <2s; the overlay is removed automatically when the
1648		   page navigates. */
1649		function showLivePreviewReloadingMask(){
1650			if(document.getElementById('wpgmza-live-preview-reloading-mask')) return;
1651
1652			var mask = document.createElement('div');
1653			mask.id = 'wpgmza-live-preview-reloading-mask';
1654			mask.style.cssText = [
1655				'position:fixed',
1656				'top:0',
1657				'left:0',
1658				'width:100vw',
1659				'height:100vh',
1660				'background:rgba(0,0,0,0.55)',
1661				'z-index:999999',
1662				'display:flex',
1663				'align-items:center',
1664				'justify-content:center',
1665				'cursor:wait',
1666				'font-family:"Plus Jakarta Sans",system-ui,sans-serif'
1667			].join(';');
1668
1669			var inner = document.createElement('div');
1670			inner.style.cssText = [
1671				'background:#fff',
1672				'color:#1f2937',
1673				'padding:20px 28px',
1674				'border-radius:10px',
1675				'font-size:14px',
1676				'font-weight:600',
1677				'box-shadow:0 8px 32px rgba(0,0,0,0.25)',
1678				'display:flex',
1679				'align-items:center',
1680				'gap:12px'
1681			].join(';');
1682
1683			var spinner = document.createElement('div');
1684			spinner.style.cssText = [
1685				'width:18px',
1686				'height:18px',
1687				'border:2px solid #e5e7eb',
1688				'border-top-color:#e8473f',
1689				'border-radius:50%',
1690				'animation:wpgmza-mask-spin 0.7s linear infinite'
1691			].join(';');
1692
1693			/* Inject the keyframes once. Scoped via the unique id so
1694			   we don't collide with anything else. */
1695			if(!document.getElementById('wpgmza-mask-keyframes')){
1696				var style = document.createElement('style');
1697				style.id = 'wpgmza-mask-keyframes';
1698				style.textContent = '@keyframes wpgmza-mask-spin{to{transform:rotate(360deg)}}';
1699				document.head.appendChild(style);
1700			}
1701
1702			inner.appendChild(spinner);
1703			inner.appendChild(document.createTextNode('Saving and reloading editor…'));
1704			mask.appendChild(inner);
1705			document.body.appendChild(mask);
1706		}
1707
1708		function buildCustomTileModal(){
1709			/* Must inject inside the .wpgmza-atlas-major wrapper —
1710			   the generic-modal CSS in input.css (~line 2548) is
1711			   scoped under that selector, so appending to <body>
1712			   leaves the modal unstyled and stuck in document flow
1713			   below the page. */
1714			var $host = $('.wpgmza-atlas-major').first();
1715			if(!$host.length) $host = $('body');
1716
1717			var html = ''
1718				+ '<div class="wpgmza-generic-modal wpgmza-custom-tile-modal" data-type="custom_tile_save_reload">'
1719				+   '<div class="wpgmza-generic-modal-inner">'
1720				+     '<div class="wpgmza-generic-modal-title">'
1721				+       'Save and reload required'
1722				+     '</div>'
1723				+     '<div class="wpgmza-generic-modal-content">'
1724				+       'Disabling or enabling this feature requires the map to be saved and the editor reloaded. Click <strong>Save &amp; Reload</strong> to apply this change now, or <strong>Cancel</strong> to revert.'
1725				+     '</div>'
1726				+     '<div class="wpgmza-generic-modal-actions">'
1727				+       '<div class="wpgmza-row">'
1728				+         '<div class="wpgmza-col">'
1729				+           '<div class="wpgmza-button" data-action="cancel"><span>Cancel</span></div>'
1730				+           '<div class="wpgmza-button" data-action="complete"><span>Save &amp; Reload</span></div>'
1731				+         '</div>'
1732				+       '</div>'
1733				+     '</div>'
1734				+   '</div>'
1735				+ '</div>';
1736			$host.append(html);
1737
1738			return WPGMZA.GenericModal.createInstance(
1739				$host.find('.wpgmza-custom-tile-modal').get(0),
1740				/* complete -> save+reload */
1741				function(){
1742					/* Atlas Major's autosave hijacks form submit and
1743					   saves via REST instead of POST, so a plain
1744					   submit-button click only saves — the page
1745					   never reloads. Set the reloadAfterSave flag
1746					   on the autosave so its REST success handler
1747					   triggers location.reload() (atlas-major-
1748					   autosave.js, success branch). */
1749					if(WPGMZA.atlasMajorAutoSave){
1750						WPGMZA.atlasMajorAutoSave.reloadAfterSave = true;
1751					}
1752
1753					/* Cover the editor with a non-interactive overlay
1754					   so the user can't fire other actions during the
1755					   save+reload window. */
1756					showLivePreviewReloadingMask();
1757
1758					/* Trigger the same submit input the visible Save
1759					   Map label points at — keeps the save flow
1760					   identical to a manual click. */
1761					var $btn = $('input[name="wpgmza_savemap"]');
1762					if($btn.length){
1763						$btn.trigger('click');
1764					}
1765				},
1766				/* cancel -> revert checkbox */
1767				function(){
1768					if(customTilePreviousState !== null){
1769						$('input[name="custom_tile_enabled"]')
1770							.prop('checked', customTilePreviousState)
1771							.trigger('change.revert');
1772					}
1773				}
1774			);
1775		}
1776
1777		function showCustomTileSaveReloadModal(){
1778			if(!customTileModal){
1779				customTileModal = buildCustomTileModal();
1780			}
1781			customTileModal.show();
1782		}
1783
1784		$(document.body).on('change',
1785			'input[name="custom_tile_enabled"]',
1786			function(event){
1787				if(!document.querySelector('.wpgmza-atlas-major .am-preview-frame')) return;
1788
1789				/* Skip when the change is our own revert — namespaced
1790				   `change.revert` lets us put the checkbox back without
1791				   re-firing the modal. */
1792				if(event && event.namespace === 'revert') return;
1793
1794				/* Stash the inverse of the new state so cancel can
1795				   revert. The change event fires AFTER the checkbox
1796				   value flipped, so previous = !current. */
1797				customTilePreviousState = !$(this).is(':checked');
1798
1799				applyCustomTileLive();
1800				showCustomTileSaveReloadModal();
1801			}
1802		);
1803
1804		$(document.body).on('change',
1805			'input[name="custom_tile_image"]',
1806			function(){
1807				if(!document.querySelector('.wpgmza-atlas-major .am-preview-frame')) return;
1808				applyCustomTileLive();
1809			}
1810		);
1811		$(document.body).on('change input',
1812			'input[name="custom_tile_image_width"], input[name="custom_tile_image_height"], input[name="custom_tile_image_attribution"]',
1813			function(){
1814				if(!document.querySelector('.wpgmza-atlas-major .am-preview-frame')) return;
1815				applyCustomTileLive();
1816			}
1817		);
1818	});
1819
1820	/* ========================================================
1821	   BOOTSTRAP
1822	   ======================================================== */
1823	function tryInitLivePreview(){
1824		if(WPGMZA.atlasMajorLivePreview) return;
1825		if(WPGMZA.maps && WPGMZA.maps[0] && document.querySelector('.am-preview-frame')){
1826			WPGMZA.atlasMajorLivePreview = new WPGMZA.AtlasMajorLivePreview();
1827		}
1828	}
1829
1830	$(document.body).on('wpgmza_map_edit_page_created', function(){
1831		setTimeout(tryInitLivePreview, 1200);
1832	});
1833
1834	/* Fallback poll */
1835	var pollCount = 0;
1836	var pollInterval = setInterval(function(){
1837		pollCount++;
1838		if(WPGMZA.atlasMajorLivePreview || pollCount > 30){
1839			clearInterval(pollInterval);
1840			return;
1841		}
1842		tryInitLivePreview();
1843	}, 1000);
1844
1845
1846	/* ========================================================
1847	   COLOR PICKER REPARENT (Live Preview mode)
1848	   --------------------------------------------------------
1849	   Lives in this file (rather than a standalone module) so
1850	   it inherits a known-good loading position in the base
1851	   combined bundle. A previous attempt as its own file got
1852	   misrouted into the Pro bundle by the build CLI, throwing
1853	   WPGMZA-is-not-defined and breaking the whole Pro bundle.
1854	   --------------------------------------------------------
1855	   Why this exists: the color picker is appended to
1856	   `.map_wrapper` via data-container, ending up nested in
1857	   the map engine's DOM. The map engine applies transforms
1858	   on inner layers, which hijack `position: fixed`
1859	   (containing block becomes the transformed ancestor
1860	   instead of the viewport). Plus `.content` and
1861	   `.am-preview-page` both clip overflow. CSS-only fixes
1862	   can't escape both.
1863	   --------------------------------------------------------
1864	   When LP is on: detach picker from map DOM, append to a
1865	   body-level host wrapper, position via getBoundingClientRect
1866	   relative to the trigger swatch. Host carries
1867	   `.wpgmza-color-input-host` so the existing picker CSS
1868	   (sizing, palette layout, .active display toggle) still
1869	   applies. When LP is off (or toggled off mid-session):
1870	   restore picker to original parent on next open so the
1871	   base `left: 10px inside .map_wrapper` rule applies. */
1872	if(document.querySelector('.wpgmza-atlas-major') && WPGMZA.currentPage === 'map-edit'){
1873		var $lpColorHost = null;
1874
1875		var ensureLpColorHost = function(){
1876			if(!$lpColorHost){
1877				$lpColorHost = $('<div class="wpgmza-color-input-host wpgmza-atlas-major am-lp-color-host"></div>');
1878				$lpColorHost.css({
1879					position: 'fixed',
1880					zIndex: 100000,
1881					width: '200px'
1882				});
1883				$('body').append($lpColorHost);
1884			}
1885			return $lpColorHost;
1886		};
1887
1888		var repositionLpColorHostNear = function(triggerEl){
1889			var host = ensureLpColorHost();
1890			var rect = triggerEl.getBoundingClientRect();
1891			var $picker = host.find('.wpgmza-color-picker').first();
1892			var pickerHeight = ($picker.length && $picker[0].offsetHeight) || 370;
1893			var vh = window.innerHeight;
1894			var vw = window.innerWidth;
1895
1896			/* Default placement: below the trigger swatch, aligned
1897			   to its left edge. Standard popover behaviour. */
1898			var topPos = rect.bottom + 4;
1899			var leftPos = rect.left;
1900
1901			/* Flip above the trigger if there's no room below. */
1902			if(topPos + pickerHeight > vh - 10){
1903				var aboveTop = rect.top - pickerHeight - 4;
1904				if(aboveTop >= 10){
1905					topPos = aboveTop;
1906				} else {
1907					topPos = Math.max(10, vh - pickerHeight - 10);
1908				}
1909			}
1910
1911			/* Clamp horizontally so the 200px-wide picker can't
1912			   render off-screen. */
1913			if(leftPos + 200 > vw - 10) leftPos = vw - 210;
1914			if(leftPos < 10) leftPos = 10;
1915
1916			host.css({
1917				top: topPos + 'px',
1918				left: leftPos + 'px'
1919			});
1920		};
1921
1922		var restoreLpPicker = function($picker){
1923			var origParent = $picker.data('wpgmza-orig-parent');
1924			if(origParent && document.body.contains(origParent)){
1925				$(origParent).append($picker);
1926			}
1927		};
1928
1929		/* Track the currently-open ColorInput instance so the
1930		   outside-click handler below can close it on any tab /
1931		   panel click that would otherwise leave the picker
1932		   stranded. color-input.js binds its own body-click
1933		   autoClose listener, but tab navigation in Atlas Major
1934		   (sidebar groupings) sometimes stops propagation, so the
1935		   picker's own listener doesn't fire and the picker stays
1936		   visible — and because the host gets re-evaluated against
1937		   a stale/hidden trigger, it can shoot to viewport-top-left.
1938		   This defensive document-level handler ensures the picker
1939		   closes whenever the user clicks anywhere outside the
1940		   picker and its trigger swatch. */
1941		var lpActiveColorInstance = null;
1942
1943		$(document.body).on('colorpicker.open.wpgmza', function(event){
1944			if(!event.instance || !event.instance.picker) return;
1945
1946			lpActiveColorInstance = event.instance;
1947
1948			var $picker = $(event.instance.picker);
1949			var trigger = event.instance.preview;
1950			if(!trigger || !trigger.length) return;
1951
1952			var lpActive = !!document.querySelector('.am-preview-frame:not(.am-preview-off)');
1953			var inHost = $picker.closest('.am-lp-color-host').length > 0;
1954
1955			if(lpActive){
1956				if(!inHost){
1957					if(!$picker.data('wpgmza-orig-parent')){
1958						$picker.data('wpgmza-orig-parent', $picker.parent()[0]);
1959					}
1960					ensureLpColorHost().append($picker);
1961				}
1962				repositionLpColorHostNear(trigger[0]);
1963			} else if(inHost){
1964				/* LP toggled off between open events — return picker
1965				   to original parent so the base `left: 10px inside
1966				   .map_wrapper` CSS rule applies again. */
1967				restoreLpPicker($picker);
1968			}
1969		});
1970
1971		/* Defensive outside-click handler — runs on mousedown
1972		   (earlier than click) so we close the picker even if the
1973		   downstream click handler (e.g. a sidebar tab switch)
1974		   later stops propagation and prevents color-input.js's
1975		   own body listener from firing.
1976		   Bind once on document; uses lpActiveColorInstance tracked
1977		   above to know which instance to close. Safe to fire even
1978		   if the picker's own listener also closes it — onTogglePicker
1979		   is gated on `state.open` so the second call is a no-op. */
1980		$(document).on('mousedown.amlpcolor', function(e){
1981			if(!lpActiveColorInstance) return;
1982			if(!lpActiveColorInstance.state || !lpActiveColorInstance.state.open) return;
1983
1984			var $target = $(e.target);
1985			/* Clicks inside the body-level host (where the picker
1986			   itself lives once reparented) should NOT close. */
1987			if($target.closest('.am-lp-color-host').length) return;
1988			/* Clicks on any color preview / swatch should NOT close
1989			   either — the preview's own click handler manages the
1990			   toggle and stopPropagation. */
1991			if($target.closest('.wpgmza-color-preview').length) return;
1992			/* Clicks inside the picker (if not reparented, e.g.
1993			   LP off) should also be ignored. */
1994			if($target.closest('.wpgmza-color-picker').length) return;
1995
1996			try {
1997				lpActiveColorInstance.onTogglePicker();
1998			} catch(e){}
1999			lpActiveColorInstance = null;
2000		});
2001	}
2002
2003});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.