Store Locator with Draw Area Controls using REST API Queries as Data Source
This guide shows you how to add drawing area controls to a customized store locator using REST API queries. The REST API used returns responses containing the store locations data in JSON format. The store locations are processed to GeoJSON format before being added to the map using the geojson.js library.
The finished example will look like this:
Store locations are loaded dynamically from the server only for the region of the map which is visible. When drawing is enabled, the stores are loaded only for the region inside the drawn area.
When to use
- The store location database is large, deployed on a server, and only a small part is downloaded to the client on demand basis.
- The data set is changing frequently.
What is needed
- Magic Lane API key token
- Web server (an example is provided)
- REST API for a database containing the store locations
Setup
Get your Magic Lane API key token: if you do not have a token, see the Getting Started guide.
This project needs a web server. If you do not have access to a web server, you can easily install a local web server, see the Installing a Local Web Server guide. In this project we use a local web server.
Store Locator using REST API Queries
To see how to create a store locator like the example above check out the guide Store Locator using REST API Queries as Data Source or download the files needed for this step:
Changing Map Initialization Options
We have changed the map style, startup location and zoom level using options available from gem.core.App.initAppScreen
:
- JavaScript
// Start by setting your token from https://developer.magiclane.com/api/projects
const defaultAppScreen = gem.core.App.initAppScreen({
container: 'map-canvas',
zoom: 10,
center: [40.431404, -3.680445], // Madrid
style: './Printemps.style'
});
To see how to create your own custom map style check out the guide Create a Map Style
Download Printemps Map StyleChanging the Markers and Markers Groups Style
To change the markers style you can use the options available in the control class gem.control.CustomQueryAddedDataControl
and set your custom style rules:
- JavaScript
- HTML
const dataQueryControl = new gem.control.CustomQueryAddedDataControl('' /* default icon */, '' /* icon filter */, {
marker: {
highlightClass: 'highlight-store-marker',
cssClass: 'marker-circle',
width: 12,
height: 12,
hoverWidth: 17,
hoverHeight: 17,
markerPos: 'center'
},
...
markerGrouping: {
maxLevel: 14,
style: gem.control.MarkersGroupStyleType.custom,
markerGroupFunction: function (groupsize) {
const customGroupDiv = document.createElement('div');
customGroupDiv.classList.add('marker-circle', 'marker-group', 'disable-pointer-events');
const textDiv = customGroupDiv.appendChild(document.createElement('div'));
textDiv.className = 'marker-group-text';
const textGroupSize = textDiv.appendChild(document.createElement('span'));
textGroupSize.textContent = groupsize;
return customGroupDiv;
}
}
...
});
<style>
.marker-circle {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
background-color: #04aa6d;
position: absolute;
-webkit-box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
cursor: pointer;
}
.marker-group {
width: 14px;
height: 14px;
background-color: #2e7d32;
}
.marker-group-text {
display: inline-block;
position: relative;
border-radius: 50%;
max-width: 30px;
min-width: 18px;
min-height: 10px;
text-align: center;
font-size: 0.7rem;
padding: 0.1rem 0rem 0.1rem 0rem;
top: 4px;
left: 50%;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: white;
-webkit-box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
}
.highlight-store-marker {
filter: hue-rotate(80deg) contrast(1.5);
}
.disable-pointer-events {
pointer-events: none;
cursor: pointer;
}
...
</style>
Adding Draw Area Control
- JavaScript
- HTML
const dataQueryControl = new gem.control.CustomQueryAddedDataControl('' /* default icon */, '' /* icon filter */, {
marker: {
...
}, {
markersFeedFunction: async function (mapCenterLon, mapCenterlat, mapLonMin, mapLatMin, mapLonMax, mapLatMax, filters, signal) {
...
},
markersAreaSelectionFunction: async function (area, filters, signal) {
const strUrlParams = filtersToParams(filters);
const strUrlAreaParams = areaCoordsToParams(area);
const dataFetchUrl = 'https://acq1.ro.m71os.services.generalmagic.com/storelocator/query_external?' + strUrlAreaParams + strUrlParams;
return fetchGeoJSONData(dataFetchUrl, { method: 'GET', signal: signal })
.then(response => { return response; })
.catch((e) => { return ''; });
}
});
defaultAppScreen.addControl(dataQueryControl);
const drawAreaControl = new gem.control.DrawAreaOnMapControl(dataQueryControl, {
button: {
parentContainer: 'draw-button-container'
}
});
defaultAppScreen.addControl(drawAreaControl);
function areaCoordsToParams(area) {
let strParams = 'polygon=';
area.forEach((elem, index) => {
strParams += elem.longitude + ',' + elem.latitude;
if (index < area.length - 1)
strParams += ',';
});
const strUrlParams = encodeURI(strParams);
return strUrlParams;
}
function fetchGeoJSONData(url, options) {
return fetch(url, options)
.then(response => response.json())
.then(responseJson => {
return jsonToGeoJSON(responseJson);
})
.catch((e) => {
return '';
});
}
function jsonToGeoJSON(jsonData) {
jsonData.forEach(element => {
const propertiesJson = JSON.parse(element.properties);
for (const [key, value] of Object.entries(propertiesJson.properties)) {
element[key] = value;
}
delete element.properties;
});
const GeoJSONdata = GeoJSON.parse(jsonData, { Point: ['st_y', 'st_x'] });
return GeoJSONdata;
}
The control class gem.control.DrawAreaOnMapControl
is used to add a draw area control over the interactive map.
The start/stop drawing button is appended to the parent element specified in the option button.parentContainer
.
Using the option markersAreaSelectionFunction
from data source control class gem.control.CustomQueryAddedDataControl
you specify how to handle the data source REST API requests in the case of an area selection.
In our example, the function areaCoordsToParams()
is used to transform the drawn area contour coordinates into the URL parameters needed by the REST API.
You can customize the draw area control using the options available for the button style and text, dialog style and text, the draw area stroke and fill, as well as other options.
<style>
...
.draw-button-container {
position: absolute;
left: 27%;
top: 8px;
}
</style>
<body>
<div id="store-locator" style="width: 100%; height: 100%">
<div id="stores-list" class="menu-list-container" style="width: 25%; height: 100%; position: absolute"></div>
<div id="map-canvas" style="width: 75%; left: 25%; height: 100%; position: absolute; overflow: hidden"></div>
<div id="draw-button-container" class="draw-button-container"></div>
<div id="filters-container" class="filters-container">
<div id="filter-languages" class="filter-languages"></div>
<div id="filter-limit" class="filter-limit"></div>
</div>
</div>
...
</body>
The div
with id draw-button-container
is the parent element for the draw button element.
Complete example code
- JavaScript
- HTML
// Start by setting your token from https://developer.magiclane.com/api/projects
if (gem.core.App.token === undefined) gem.core.App.token = '';
const defaultAppScreen = gem.core.App.initAppScreen({
container: 'map-canvas',
zoom: 10,
center: [40.431404, -3.680445], // Madrid
style: './Printemps.style'
});
const dataQueryControl = new gem.control.CustomQueryAddedDataControl('' /* default icon */, '' /* icon filter */, {
marker: {
highlightClass: 'highlight-store-marker',
cssClass: 'marker-circle',
width: 12,
height: 12,
hoverWidth: 17,
hoverHeight: 17,
markerPos: 'center'
},
markerBubble: {
title: ['name'],
image: ['preview']
},
markerGrouping: {
maxLevel: 14,
style: gem.control.MarkersGroupStyleType.custom,
markerGroupFunction: function (groupsize) {
const customGroupDiv = document.createElement('div');
customGroupDiv.classList.add('marker-circle', 'marker-group', 'disable-pointer-events');
const textDiv = customGroupDiv.appendChild(document.createElement('div'));
textDiv.className = 'marker-group-text';
const textGroupSize = textDiv.appendChild(document.createElement('span'));
textGroupSize.textContent = groupsize;
return customGroupDiv;
}
}
}, {
markersFeedFunction: async function (mapCenterLon, mapCenterlat, mapLonMin, mapLatMin, mapLonMax, mapLatMax, filters, signal) {
const strUrlParams = filtersToParams(filters);
const dataFetchUrl = 'https://acq1.ro.m71os.services.generalmagic.com/storelocator/query_external?' +
'polygon=' + mapLonMin + ',' + mapLatMin + ',' + mapLonMax + ',' + mapLatMin + ',' + mapLonMin + ',' + mapLatMax + ',' + mapLonMax + ',' + mapLatMax + strUrlParams;
return fetchGeoJSONData(dataFetchUrl, { method: 'GET', signal: signal })
.then(response => { return response; })
.catch((e) => { return ''; });
},
markersAreaSelectionFunction: async function (area, filters, signal) {
const strUrlParams = filtersToParams(filters);
const strUrlAreaParams = areaCoordsToParams(area);
const dataFetchUrl = 'https://acq1.ro.m71os.services.generalmagic.com/storelocator/query_external?' + strUrlAreaParams + strUrlParams;
return fetchGeoJSONData(dataFetchUrl, { method: 'GET', signal: signal })
.then(response => { return response; })
.catch((e) => { return ''; });
}
});
defaultAppScreen.addControl(dataQueryControl);
const drawAreaControl = new gem.control.DrawAreaOnMapControl(dataQueryControl, {
button: {
parentContainer: 'draw-button-container'
}
});
defaultAppScreen.addControl(drawAreaControl);
const radioLimitFilter = new gem.control.CategoryFilterControl({
parentContainer: 'filter-limit',
name: 'byLimit',
title: 'Filter by Limit',
categories: [
{ label: '100 items', filter: { key: 'limit', value: '100' } },
{ label: '500 items', filter: { key: 'limit', value: '500' } },
{ label: '1000 items', filter: { key: 'limit', value: '1000' } }
]
}, dataQueryControl);
const checkboxesLanguageFilterControl = new gem.control.CategoryFilterControl({
parentContainer: 'filter-languages',
name: 'byLanguage',
style: 'checkboxes',
title: 'Filter by Language',
categories: [
{
label: 'All', filter: { key: 'langs', value: '' }, children:
[
{ label: 'English', filter: { key: 'langs', value: 'en' } },
{ label: 'Dutch', filter: { key: 'langs', value: 'nl' } },
{ label: 'Finnish', filter: { key: 'langs', value: 'fi' } },
{ label: 'Romanian', filter: { key: 'langs', value: 'ro' } },
{ label: 'Spanish', filter: { key: 'langs', value: 'es' } },
{ label: 'German', filter: { key: 'langs', value: 'de' } },
{ label: 'Hungarian', filter: { key: 'langs', value: 'hu' } }
]
}
]
}, dataQueryControl);
defaultAppScreen.addControl(checkboxesLanguageFilterControl);
defaultAppScreen.addControl(radioLimitFilter);
const searchControl = new gem.control.SearchControl({
highlightOptions: {
contourColor: { r: 0, g: 255, b: 0, a: 0 }
},
searchPreferences: {
exactMatch: true,
maximumMatches: 3,
addressSearch: true,
mapPoisSearch: true,
setCursorReferencePoint: true
}
});
defaultAppScreen.addControl(searchControl);
const listUIControl = new gem.control.ListControl({
sourceControl: dataQueryControl,
container: 'stores-list',
displayCount: true,
flyToItemAltitude: 300,
menuName: 'REST API data source',
titleProperties: ['name'],
detailsProperties: ['language'],
imageProperty: ['preview']
});
defaultAppScreen.addControl(listUIControl);
function filtersToParams(filters) {
let strParams = '';
for (const [name, filterValues] of filters.entries()) {
if (name === 'byLimit') {
strParams += '&' + filterValues[0].key + '=' + filterValues[0].value;
}
else if (name === 'byLanguage') {
if (!filterValues.length) {
strParams += '&langs=all';
continue;
}
strParams += '&' + filterValues[0].key + '=';
for (let i = 0; i < filterValues.length; i++) {
strParams += filterValues[i].value + ',';
}
}
}
if (!filters.has('byLimit')) {
strParams += '&limit=100';
}
if (!filters.has('byLanguage')) {
strParams += '&langs=all';
}
const strUrlParams = encodeURI(strParams);
return strUrlParams;
}
function areaCoordsToParams(area) {
let strParams = 'polygon=';
area.forEach((elem, index) => {
strParams += elem.longitude + ',' + elem.latitude;
if (index < area.length - 1)
strParams += ',';
});
const strUrlParams = encodeURI(strParams);
return strUrlParams;
}
function fetchGeoJSONData(url, options) {
return fetch(url, options)
.then(response => response.json())
.then(responseJson => {
return jsonToGeoJSON(responseJson);
})
.catch((e) => {
return '';
});
}
function jsonToGeoJSON(jsonData) {
jsonData.forEach(element => {
const propertiesJson = JSON.parse(element.properties);
for (const [key, value] of Object.entries(propertiesJson.properties)) {
element[key] = value;
}
delete element.properties;
});
const GeoJSONdata = GeoJSON.parse(jsonData, { Point: ['st_y', 'st_x'] });
return GeoJSONdata;
}
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no, shrink-to-fit=no" />
<title>Store Locator Draw Area control - MagicLane Maps SDK for JavaScript</title>
<link rel="stylesheet" type="text/css" href="https://www.magiclane.com/sdk/js/gem.css">
<link rel="stylesheet" href="/fonts/webfonts.css" type="text/css" media="all" />
<style>
.marker-circle {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
background-color: #04aa6d;
position: absolute;
-webkit-box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
cursor: pointer;
}
.marker-group {
width: 14px;
height: 14px;
background-color: #2e7d32;
}
.marker-group-text {
display: inline-block;
position: relative;
border-radius: 50%;
max-width: 30px;
min-width: 18px;
min-height: 10px;
text-align: center;
font-size: 0.7rem;
padding: 0.1rem 0rem 0.1rem 0rem;
top: 4px;
left: 50%;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: white;
-webkit-box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
box-shadow: 0px 5px 15px rgb(0 0 0 / 15%);
}
.highlight-store-marker {
filter: hue-rotate(80deg) contrast(1.5);
}
.disable-pointer-events {
pointer-events: none;
cursor: pointer;
}
.filters-container {
position: absolute;
width: auto;
max-width: 300px;
right: 0px;
top: 50px;
margin: 8px;
z-index: 9;
font-size: 0.75rem;
background-color: white;
box-shadow: 0 2px 4px rgb(0 0 0 / 20%), 0 -1px 0 rgb(0 0 0 / 2%);
}
.draw-button-container {
position: absolute;
left: 27%;
top: 8px;
}
</style>
</head>
<body>
<div id="store-locator" style="width: 100%; height: 100%">
<div id="stores-list" class="menu-list-container" style="width: 25%; height: 100%; position: absolute"></div>
<div id="map-canvas" style="width: 75%; left: 25%; height: 100%; position: absolute; overflow: hidden"></div>
<div id="draw-button-container" class="draw-button-container"></div>
<div id="filters-container" class="filters-container">
<div id="filter-languages" class="filter-languages"></div>
<div id="filter-limit" class="filter-limit"></div>
</div>
</div>
<script src="https://www.magiclane.com/sdk/js/gemapi.js"></script>
<script type="text/javascript" src="token.js"></script>
<script type="text/javascript" src="geojson.min.js"></script>
<script type="text/javascript" src="storeRESTQueryDrawArea.js"></script>
</body>
</html>
Files
Source code for this example:
Right-click on the links and select Save As.
For transforming JSON data into GeoJSON we are using geojson.js
JavaScript Examples
Maps SDK for JavaScript Examples can be downloaded or cloned with Git