How to Find Places Inside the Current Map View
- API or service
- Places API + map library
- Task
- Visible map bounds → places shown on map
- Examples
- Difficulty
- Intermediate
- Time
- 10 min
A user is exploring a map and needs to see places that belong to a selected category within the currently visible area. Read the map's bounding box, convert it to a Places API rectangle filter, and refresh the displayed GeoJSON after the map stops moving.
The request is the same for every map library. Only extracting the visible bounds and updating the map layer are library-specific.
The code samples intentionally show the simplest implementation: request the exact viewport after moveend and replace the displayed features. For a smoother production experience, request a buffered rectangle larger than the visible map and reuse those results while the user makes small movements. Start a new request only after the viewport moves beyond a chosen delta—for example, 25% of its width or height—approaches the edge of the buffered area, changes zoom materially, or changes category or filters.
Buffered and overlapping searches can return the same place more than once. Merge cached results by properties.place_id before updating the map layer.
Refresh places with MapLibre GL
This example assumes map is an initialized MapLibre GL map and getPlacesInBounds() is imported from the shared helper. It stores places in one GeoJSON source and replaces the source data after each moveend event.
import { getPlacesInBounds } from "./get-places-in-bounds.js";
const apiKey = "YOUR_API_KEY";
const category = "catering.restaurant";
const sourceId = "visible-places";
let requestController;
async function refreshPlaces() {
const source = map.getSource(sourceId);
if (!source) return;
// Avoid dense, low-value searches while the map is zoomed far out.
if (map.getZoom() < 13) {
source.setData({ type: "FeatureCollection", features: [] });
return;
}
// Cancel a response that belongs to an older map position.
requestController?.abort();
requestController = new AbortController();
const bounds = map.getBounds();
try {
const places = await getPlacesInBounds({
west: bounds.getWest(),
south: bounds.getSouth(),
east: bounds.getEast(),
north: bounds.getNorth()
}, {
category,
apiKey,
signal: requestController.signal
});
source.setData(places);
} catch (error) {
if (error.name !== "AbortError") console.error(error);
}
}
map.on("load", () => {
map.addSource(sourceId, {
type: "geojson",
data: { type: "FeatureCollection", features: [] }
});
map.addLayer({
id: sourceId,
type: "circle",
source: sourceId,
paint: {
"circle-radius": 6,
"circle-color": "#ff3d7f",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1
}
});
refreshPlaces();
});
map.on("moveend", refreshPlaces);
Use moveend, not the continuously firing move event, to avoid sending requests for intermediate frames. Remove the listener and abort the active request when the map component is destroyed.
Refresh places with Leaflet
This example assumes map is an initialized Leaflet map and reuses the shared getPlacesInBounds() helper. L.geoJSON() converts each returned GeoJSON point into a circle marker.
import { getPlacesInBounds } from "./get-places-in-bounds.js";
const apiKey = "YOUR_API_KEY";
const category = "catering.restaurant";
let requestController;
const placesLayer = L.geoJSON(null, {
pointToLayer: (_feature, latlng) => L.circleMarker(latlng, {
radius: 6,
color: "#ffffff",
weight: 1,
fillColor: "#ff3d7f",
fillOpacity: 1
})
}).addTo(map);
async function refreshPlaces() {
if (map.getZoom() < 13) {
placesLayer.clearLayers();
return;
}
// Ensure a slow response cannot replace results for newer bounds.
requestController?.abort();
requestController = new AbortController();
const bounds = map.getBounds();
try {
const places = await getPlacesInBounds({
west: bounds.getWest(),
south: bounds.getSouth(),
east: bounds.getEast(),
north: bounds.getNorth()
}, {
category,
apiKey,
signal: requestController.signal
});
placesLayer.clearLayers().addData(places);
} catch (error) {
if (error.name !== "AbortError") console.error(error);
}
}
map.on("moveend", refreshPlaces);
refreshPlaces();
Call map.off("moveend", refreshPlaces) and abort the active controller when removing the map component.
Refresh places with OpenLayers
This example assumes map is an initialized OpenLayers map. OpenLayers extents use the map view's projection, so transform the extent to longitude and latitude before building the Places API rectangle.
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import { transformExtent } from "ol/proj.js";
import { getPlacesInBounds } from "./get-places-in-bounds.js";
const apiKey = "YOUR_API_KEY";
const category = "catering.restaurant";
const geojson = new GeoJSON();
const placesSource = new VectorSource();
const placesLayer = new VectorLayer({ source: placesSource });
let requestController;
map.addLayer(placesLayer);
async function refreshPlaces() {
if (map.getView().getZoom() < 13) {
placesSource.clear();
return;
}
requestController?.abort();
requestController = new AbortController();
const projection = map.getView().getProjection();
const extent = map.getView().calculateExtent(map.getSize());
const [west, south, east, north] = transformExtent(
extent,
projection,
"EPSG:4326"
);
try {
const places = await getPlacesInBounds({
west,
south,
east,
north
}, {
category,
apiKey,
signal: requestController.signal
});
// Convert GeoJSON longitude/latitude coordinates to the map projection.
const features = geojson.readFeatures(places, {
featureProjection: projection
});
placesSource.clear();
placesSource.addFeatures(features);
} catch (error) {
if (error.name !== "AbortError") console.error(error);
}
}
map.on("moveend", refreshPlaces);
refreshPlaces();
Unregister the moveend listener, abort the request, and remove the vector layer when the map component is disposed.
Prepare map-based place search for production
- Treat the examples as a starting point: They demonstrate the map-library integration, but a production search also needs buffering, cache management, deduplication, loading and error states, pagination, and request limits.
- Request beyond the viewport: Expand the visible bounds by roughly 25–50% to create a result buffer. This lets users make small pans without waiting for another request.
- Define a refresh delta: Keep the bounds used by the last successful request. A practical starting point is to refresh when the centre moves by more than 25% of the viewport width or height. Also refresh when the viewport leaves the buffered bounds, the zoom changes materially, or the category or conditions change.
- Deduplicate by place ID: Overlapping buffered rectangles and consecutive pages can contain the same place. Store features by
properties.place_idbefore combining them. - Choose a minimum zoom: A world-, country-, or region-scale viewport is too broad for marker discovery. Clear the layer or ask the user to zoom in before searching.
- Request after movement stops: Use the map library's
moveendevent. Add a short debounce if other controls can trigger several updates together. - Cancel stale requests: Abort the previous request when bounds, category, or filters change.
- Limit visual density: Request only the number of places that can be understood on the map. Use clustering when many markers may overlap.
- Handle the antimeridian: A viewport that crosses longitude ±180° cannot be represented by one normal west-to-east rectangle. Split it into two rectangle requests.
- Avoid unnecessary refreshes: Cache the last category and bounds, and skip a request when the effective search has not changed.
- Treat results as a page: A visible area can contain more matches than
limit. Offer an explicit “load more” action or narrow the area rather than silently suggesting that the first page is exhaustive. - Protect browser API keys: Restrict the key to the application's allowed origins in Geoapify MyProjects.