Search as the Map Moves Pro
A store finder that searches exactly what's on the map: pan or zoom and the list and pins update, with no page reload. An address box moves the map. The data comes from a search endpoint, so there's no JSON template to write.

Compare it with Live Map Search, which does the same with a hand-written JSON template on Lite.
1. The endpoint
In config/geomaps.php:
php
return [
'searchEndpoints' => [
'stores' => [
'field' => 'storeLocation',
'criteria' => ['section' => 'stores'],
],
],
];That's the whole back end: /actions/geomaps/search?endpoint=stores now returns stores with their distance, address and URL.
2. The page
twig
{% extends '_layout' %}
{% block content %}
<div class="finder">
<aside>
<h1>Find a store</h1>
<form class="search" id="place-form">
<label class="box">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input type="search" id="place" placeholder="Suburb or address" aria-label="Suburb or address">
</label>
<button class="btn">Go</button>
</form>
<p class="meta" id="meta" aria-live="polite">Move the map to search.</p>
<ol class="rows" id="results"></ol>
</aside>
<section><div id="map" style="height: 100%; border-radius: 14px; overflow: hidden"></div></section>
</div>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% set geo = craft.app.plugins.getPlugin('geomaps').settings %}
<script>
const endpoint = '/actions/geomaps/search?endpoint=stores';
const map = L.map('map').setView([-34.93, 138.6], 11);
L.tileLayer({{ geo.getTileUrl()|json_encode|raw }}, { attribution: {{ geo.getTileAttribution()|json_encode|raw }} }).addTo(map);
const pins = L.layerGroup().addTo(map);
const results = document.getElementById('results');
const meta = document.getElementById('meta');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const pin = (n) => L.divIcon({ className: '', iconSize: [36, 46], iconAnchor: [18, 46],
html: `<svg width="36" height="46" viewBox="0 0 36 46"><path d="M18 45s16-15.6 16-27.5A16 16 0 0 0 2 17.5C2 29.4 18 45 18 45z" fill="#5b67e0" stroke="#fff" stroke-width="2.5"/><text x="18" y="23" text-anchor="middle" font-family="Inter,Arial" font-weight="700" font-size="15" fill="#fff">${n}</text></svg>` });
let controller;
// Stores inside the visible map, nearest its centre first.
async function search() {
const b = map.getBounds();
const c = map.getCenter();
const params = new URLSearchParams({
within: [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].map((n) => n.toFixed(5)).join(','),
lat: c.lat.toFixed(5),
lng: c.lng.toFixed(5),
});
controller?.abort();
controller = new AbortController();
const { results: stores, total } = await (await fetch(`${endpoint}&${params}`, { signal: controller.signal })).json();
pins.clearLayers();
stores.forEach((s, i) => L.marker([s.location.lat, s.location.lng], { icon: pin(i + 1), title: s.title })
.bindPopup(`<strong>${esc(s.title)}</strong><br>${esc(s.location.address)}`).addTo(pins));
results.innerHTML = stores.map((s, i) => `
<li class="row"><span class="n">${i + 1}</span><h3><a href="${esc(s.url)}">${esc(s.title)}</a></h3>
<span class="d">${s.distance.toFixed(1)} km</span><p>${esc(s.location.address)}</p></li>`).join('');
meta.textContent = !total ? 'No stores here. Zoom out or move the map.'
: total > stores.length ? `Showing the ${stores.length} nearest of ${total} stores on the map` : `${total} store${total === 1 ? '' : 's'} on the map`;
}
// An address moves the map; the endpoint looks it up.
document.getElementById('place-form').addEventListener('submit', async (e) => {
e.preventDefault();
const q = document.getElementById('place').value.trim();
if (!q) return;
const { origin, message } = await (await fetch(`${endpoint}&limit=1&radius=100&location=${encodeURIComponent(q)}`)).json();
if (origin) map.setView([origin.lat, origin.lng], 13); else meta.textContent = message;
});
map.on('moveend', () => search().catch((e) => e.name === 'AbortError' || console.error(e)));
search();
</script>
{% endblock %}How it works:
- The visible map is sent as
within(south, west, north, east), with the map's centre aslatandlng, so stores come back nearest the centre first. Only the 20 nearest are drawn;totalsays how many are on the map. - The address box asks the endpoint for the place with
location, and moves the map to theoriginit returns. Moving the map then runs the normal search. - Moving the map quickly cancels the previous request, so results never arrive out of order.
- The tiles come from your map service settings, so the map matches your other maps.
Going further
- More results: add
&limit=50, or page through them with&offset=20. - Filters: add
relatedToor other params to the endpoint'scriteria, or set up a second endpoint, such asstores-with-parking. - Another site or app: add its address to the endpoint's
corsoption to call it from there.