/** * @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