Skip to main content
GuidesAPI ReferenceExamples

Store Locator using REST API Queries as Data Source

Estimated reading time: 11 minutes

This guide shows you how to build a store locator using REST API queries that return 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:

See the example fullscreen

Store locations are loaded dynamically from the server only for the region of the map which is visible.

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.

info

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.

Adding stores data

 // 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',
center: [48.8562, 2.3516, 10000] // Paris
});

const dataQueryControl = new gem.control.CustomQueryAddedDataControl('' /* default icon */, '' /* icon filter */, {
markerBubble: {
title: ['name'],
image: ['preview']
},
markerGrouping: {
maxLevel: 14,
style: gem.control.MarkersGroupStyleType.default
}
}, {
markersFeedFunction: async function (mapCenterLon, mapCenterlat, mapLonMin, mapLatMin, mapLonMax, mapLatMax, filters, signal) {
const dataFetchUrl = 'https://acq1.ro.m71os.services.generalmagic.com/storelocator/query_external?' +
'polygon=' + mapLonMin + ',' + mapLatMin + ',' + mapLonMax + ',' + mapLatMin + ',' + mapLonMin + ',' + mapLatMax + ',' + mapLonMax + ',' + mapLatMax + '&limit=1000&langs=all';

return fetch(dataFetchUrl, { method: 'GET', signal: signal })
.then(response => response.json())
.then(responseJson => {
return jsonToGeoJSON(responseJson);
})
.catch((e) => {
return '';
});
}
});
defaultAppScreen.addControl(dataQueryControl);

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 map control is initialized using gem.core.App.initAppScreen. The map is rendered in the div container with the id map-canvas. The map is centered on Paris, France using coordinate latitude, longitude and altitude.

The control class gem.control.CustomQueryAddedDataControl is used to add data loaded dynamically from the server through the option markersFeedFunction. Before being added to the map the JSON response is processed into GeoJSON data using the function jsonToGeoJSON().

To add free-form text search functionality simply add the following JavaScript code:

 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);

The places free text search control is initialized using the control class gem.control.SearchControl. The functionality and appearance can be customized using the class options.

Adding stores list

 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);

The list element is initialized using the control class gem.control.ListControl. The list is connected to the data source control using option sourceControl and is appended to the parent element div using the option container.

The list is customized using the option populateItemFunction and custom style rules are defined in the <style> section of the HTML file for brevity of the example.

Adding filters control

To add filters for your data you can follow the boilerplate code below:

 const dataQueryControl = new gem.control.CustomQueryAddedDataControl('' /* default icon */, '' /* icon filter */, {
markerBubble: {
title: ['name'],
image: ['preview']
},
markerGrouping: {
maxLevel: 14,
style: gem.control.MarkersGroupStyleType.default
}
}, {
markersFeedFunction: async function (mapCenterLon, mapCenterlat, mapLonMin, mapLatMin, mapLonMax, mapLatMax, filters, signal) {
const strUrlParams = filtersToUrlParams(filters);

const dataFetchUrl = 'https://acq1.ro.m71os.services.generalmagic.com/storelocator/query_external?' +
'polygon=' + mapLonMin + ',' + mapLatMin + ',' + mapLonMax + ',' + mapLatMin + ',' + mapLonMin + ',' + mapLatMax + ',' + mapLonMax + ',' + mapLatMax + strUrlParams;

return fetch(dataFetchUrl, { method: 'GET', signal: signal })
.then(response => response.json())
.then(responseJson => {
return jsonToGeoJSON(responseJson);
})
.catch((e) => {
return '';
});
}
});
defaultAppScreen.addControl(dataQueryControl);

const radioLimitFilter = new gem.control.CategoryFilterControl({
parentContainer: 'filter-category-limit',
name: 'byLimit',
title: '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-category-language',
name: 'byLanguage',
style: 'checkboxes',
title: 'Filter by Languages',
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);

function filtersToUrlParams(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;
}

The filter elements are initialized using the control class gem.control.CategoryFilterControl.

The first filter radioLimitFilter is used for limiting the number of results shown on the map and is using the radio style to display the available options.
The second filter checkboxesLanguageFilterControl is used for filtering the data based on the language of the items and is using the checkboxes style to display the options.
The function filtersToUrlParams() is used to transform the received filters map into URL parameters for the REST API of the database. The URL parameters are then used in the data source request with the option markersFeedFunction from the data source control variable dataQueryControl.

Complete example code

 // 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',
center: [48.8562, 2.3516, 10000] // Paris
});

const dataQueryControl = new gem.control.CustomQueryAddedDataControl('' /* default icon */, '' /* icon filter */, {
markerBubble: {
title: ['name'],
image: ['preview']
},
markerGrouping: {
maxLevel: 14,
style: gem.control.MarkersGroupStyleType.default
}
}, {
markersFeedFunction: async function (mapCenterLon, mapCenterlat, mapLonMin, mapLatMin, mapLonMax, mapLatMax, filters, signal) {
const strUrlParams = filtersToUrlParams(filters);

const dataFetchUrl = 'https://acq1.ro.m71os.services.generalmagic.com/storelocator/query_external?' +
'polygon=' + mapLonMin + ',' + mapLatMin + ',' + mapLonMax + ',' + mapLatMin + ',' + mapLonMin + ',' + mapLatMax + ',' + mapLonMax + ',' + mapLatMax + strUrlParams;

return fetch(dataFetchUrl, { method: 'GET', signal: signal })
.then(response => response.json())
.then(responseJson => {
return jsonToGeoJSON(responseJson);
})
.catch((e) => {
return '';
});
}
});
defaultAppScreen.addControl(dataQueryControl);

const radioLimitFilter = new gem.control.CategoryFilterControl({
parentContainer: 'filter-category-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-category-language',
name: 'byLanguage',
style: 'checkboxes',
title: 'Filter by Languages',
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 filtersToUrlParams(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 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;
}

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.

See Store Locator using GeoJSON File as Data Source with Custom Controls for an example with GeoJSON data loaded from a file and using custom controls.

See Store Locator using a GeoJSON File as Data Source for an example with GeoJSON data loaded from a file and rendered on the map.

See Store Locator using Studio Defined Data Source for an example with GeoJSON data uploaded to the map studio and rendered on the map.

JavaScript Examples

Maps SDK for JavaScript Examples can be downloaded or cloned with Git.