8. Import and Upload IMDF Venues

The following tutorial has two parts and it will use the UNL SDK to:

  1. Import a particular venue that was uploaded in studio;
  2. Upload a zip archive that contains a valid IMDF venue;

The indoor mapping data format (IMDF) documentation can be found here.

Import IMDF Venue from Studio#

Add a new option in the action sheet component (ActionSheet.js) inside the <div> element, that will trigger this flow.

<button id="import-venue-button" class="action-sheet-button">
Import IMDF venue from Studio
</button>

In the unlApi.js file, add a new function that calls the UNL SDK method to retrieve the imdf features of the venue.

export const getImdfFeatures = (projectId, venueId, includedFeatureTypes) => {
return unlApi.venuesApi.getImdfFeatures(
projectId,
venueId,
includedFeatureTypes
);
};

The function above accepts three parameters: the id of the project where the venue was uploaded, id of the venue and the array of requested feature types.

This tutorial, exemplifies the rendering of the following feature types of the IMDF venue:

So the next step is to implement the functions that will add these features to the map. We will include the venueId in the ids of the sources and layers so that we can reuse these functions to render multiple venues.

  1. First, implement the function that will render a venue marker at the venue coordinates:
const addVenueMarker = (
map,
venueFeatureCollection,
levelFeatureCollection
) => {
const venueId = venueFeatureCollection.venueId;
const venueCoordinates =
venueFeatureCollection.geojson.features[0].properties.display_point
.coordinates;
const venueName = Object.values(
venueFeatureCollection.geojson.features[0].properties.name
)[0];
if (map.getSource(`venueFeature_${venueId}`)) {
return;
}
const venueGroundLevel = levelFeatureCollection.geojson.features.findIndex(
(feature) => feature.properties.ordinal === 0
);
const groundLevelId =
levelFeatureCollection.geojson.features[venueGroundLevel].id;
map.addSource(`venueFeature_${venueId}`, {
type: "geojson",
data: {
type: "Feature",
geometry: {
type: "Point",
coordinates: venueCoordinates,
},
properties: {
name: venueName,
venueId,
venueGroundLevel,
groundLevelId,
levelsShortNames: levelFeatureCollection.geojson.features.map(
(level) => Object.values(level.properties.short_name)[0]
),
levelsOrdinals: levelFeatureCollection.geojson.features.map((level) => {
return { id: level.id, ordinal: level.properties.ordinal };
}),
},
},
});
map.addLayer(
{
id: `venueFeature_${venueId}`,
type: "symbol",
source: `venueFeature_${venueId}`,
layout: {
"icon-image": "default_marker_icon",
"icon-size": 0.5,
"icon-offset": [0, -40],
"text-font": ["Fira GO Regular"],
"text-field": venueName,
"text-size": 14,
"text-anchor": "bottom",
"text-offset": [0, -3.5],
"icon-allow-overlap": true,
"text-allow-overlap": true,
},
},
"gridLines"
);
};
  1. Next, implement the function that will add the level source and layers to the map. There are two layers defined: one for rendering the border and the other one to render the fill of the level:
const addLevel = (map, levelFeatureCollection) => {
const venueId = levelFeatureCollection.venueId;
if (map.getSource(`levelFeature_${venueId}`)) {
return;
}
map.addSource(`levelFeature_${venueId}`, {
type: "geojson",
data: {
...levelFeatureCollection.geojson,
features: levelFeatureCollection.geojson.features.map((feature) => {
return {
...feature,
properties: {
...feature.properties,
id: feature.id,
venueId: venueId,
},
};
}),
},
});
map.addLayer(
{
id: `levelFeature_Line_${venueId}`,
type: "line",
source: `levelFeature_${venueId}`,
paint: {
"line-color": "#191C28",
},
},
`venueFeature_${venueId}`
);
map.addLayer(
{
id: `levelFeature_Fill_${venueId}`,
type: "fill",
source: `levelFeature_${venueId}`,
paint: {
"fill-color": "#F4F3E1",
"fill-opacity": 1,
},
},
`levelFeature_Line_${venueId}`
);
};
  1. In case of the units, apart from the border and fill, we will also add a symbol layer responsible for rendering the marker of the unit:
const addUnit = (map, unitFeatureCollection) => {
const venueId = unitFeatureCollection.venueId;
if (map.getSource(`unitFeature_${venueId}`)) {
return;
}
map.addSource(`unitFeature_${venueId}`, {
type: "geojson",
data: {
...unitFeatureCollection.geojson,
features: unitFeatureCollection.geojson.features.map((feature) => {
return {
...feature,
properties: {
...feature.properties,
id: feature.id,
venueId: venueId,
},
};
}),
},
});
map.addLayer(
{
id: `unitFeature_Symbol_${venueId}`,
type: "symbol",
source: `unitFeature_${venueId}`,
layout: {
"icon-image": "venue_unit_icon",
"icon-size": 0.5,
"icon-offset": [0, -10],
"text-font": ["Fira GO Regular"],
"text-field": ["get", "en", ["get", "name"]],
"text-size": 14,
"text-anchor": "bottom",
"text-offset": [0, -1.8],
},
paint: {
"text-color": "#5F608C",
},
},
`venueFeature_${venueId}`
);
map.addLayer(
{
id: `unitFeature_Line_${venueId}`,
type: "line",
source: `unitFeature_${venueId}`,
paint: {
"line-color": "#191C28",
},
},
`unitFeature_Symbol_${venueId}`
);
map.addLayer(
{
id: `unitFeature_Fill_${venueId}`,
type: "fill",
source: `unitFeature_${venueId}`,
paint: {
"fill-color": "#F1F1F1",
},
},
`unitFeature_Line_${venueId}`
);
};
  1. Finally, add the opening source and layer. Basically, an opening represents a line that marks the entrance point of a unit:
const addOpening = (map, openingFeatureCollection) => {
const venueId = openingFeatureCollection.venueId;
if (map.getSource(`openingFeature_${venueId}`)) {
return;
}
map.addSource(`openingFeature_${venueId}`, {
type: "geojson",
data: openingFeatureCollection.geojson,
});
map.addLayer(
{
id: `openingFeature_Line_${venueId}`,
type: "line",
source: `openingFeature_${venueId}`,
paint: {
"line-color": "#F3F2E9",
"line-width": 5,
},
},
`unitFeature_Symbol_${venueId}`
);
};

Now is the time to call the functions implemented above. It gets the map as a parameter and is also responsible to call the map animation in order to move the map to the newly imported venue. renderVenue also gets a parameter named imdfFeature which is the object returned by the sdk. We can add a function named getVenueRenderedFeatures that will return the venue, level, unit and opening feature collections from imdfFeature:

const getVenueRenderedFeatures = (imdfFeatures) => {
const venueFeatureCollection = imdfFeatures.find(
(feature) => feature.type === "venue"
);
const levelFeatureCollection = imdfFeatures.find(
(feature) => feature.type === "level"
);
const unitFeatureCollection = imdfFeatures.find(
(feature) => feature.type === "unit"
);
const openingFeatureCollection = imdfFeatures.find(
(feature) => feature.type === "opening"
);
return {
venueFeatureCollection,
levelFeatureCollection,
unitFeatureCollection,
openingFeatureCollection,
};
};
export const renderVenue = (map, imdfFeatures) => {
const {
venueFeatureCollection,
levelFeatureCollection,
unitFeatureCollection,
openingFeatureCollection,
} = getVenueRenderedFeatures(imdfFeatures);
const venueCoordinates =
venueFeatureCollection.geojson.features[0].properties.display_point
.coordinates;
addVenueMarker(map, venueFeatureCollection, levelFeatureCollection);
addLevel(map, levelFeatureCollection);
addUnit(map, unitFeatureCollection);
addOpening(map, openingFeatureCollection);
map.flyTo({ center: venueCoordinates, zoom: 14 });
};

To wrap things up, add the function which calls the getImdfFeatures function from unlApi.js and, once the venue is fetched, trigger the rendering of the IMDF features:

export const importVenueFromStudio = async (map) => {
const projectId = config.PROJECT_ID;
const venueId = config.IMPORTED_VENUE_ID;
const includedFeatureTypes = ["venue", "level", "unit", "opening"];
const imdfFeatures = await getImdfFeatures(
projectId,
venueId,
includedFeatureTypes
);
renderVenue(map, imdfFeatures);
};

This function will be triggered by clicking the button that was added in the menu at the beginning of the tutorial:

document.getElementById("import-venue-button").addEventListener("click", () => {
importVenueFromStudio(map);
});

To complete this part, we have to add the venue id in the config.js file. It will be used to call the UNL SDK method to retrieve the venue features. You can copy this id from the properties panel of a venue, in studio

Replace YOUR-IMPORTED-VENUE-ID with valid a value:

export default {
MAPBOX_TOKEN: "YOUR-MAPBOX-TOKEN",
HERE_MAPS_API_KEY: "YOUR-HERE-MAPS-API-KEY",
UNL_API_KEY: "YOUR-UNL-API-KEY",
PROJECT_ID: "YOUR-PROJECT-ID",
IMPORTED_VENUE_ID: "YOUR-IMPORTED-VENUE-ID",
};

By this time, we should have the venue rendered:

As you can see, the map looks rather messy and the reason behind this is that all the levels, along with the containing units, are render simultaneously. In order to filter the components and show one level at time, we have to continue with the implementation of the level selector.

Implement the Level Selector#

In order to implement the level selector, lets add a div in the index.html and style it in the styles.css file:

<div id="level-selector" class="level-selector"></div>
.level-selector {
position: fixed;
z-index: 1;
top: 20px;
right: 20px;
}

Next, we have the LevelSelector component which basically render a fab to display the name of each level, and use a different style to distinguish the selected one:

const LevelSelector = (venueId, levelNames, venueGroundLevel, onLevelClick) => {
const root = document.createElement("div");
root.id = `level-selector-container-${venueId}`;
levelNames.map((levelName, index) => {
const levelFab = document.createElement("div");
const levelNameText = document.createTextNode(levelName);
levelFab.appendChild(levelNameText);
levelFab.addEventListener("click", () => {
root.childNodes.forEach((childNode) =>
childNode.classList.remove("selected-fab")
);
levelFab.classList.add("selected-fab");
onLevelClick(index);
});
levelFab.className = "fab";
if (index == venueGroundLevel) {
levelFab.classList.add("selected-fab");
}
root.appendChild(levelFab);
});
return root;
};
export default LevelSelector;

Whenever the map moves, detect the rendered venue and re-initialize the level selector with the new level names.

export const updateLevelSelector = (map) => {
const renderedFeatures = map.queryRenderedFeatures(map);
const venueFeature = renderedFeatures.find((feature) =>
feature.source.startsWith("venueFeature_")
);
if (venueFeature) {
const properties = venueFeature.properties;
if (
!document.getElementById(`level-selector-container-${properties.venueId}`)
) {
displayLevelSelector(
map,
JSON.parse(properties.levelsShortNames),
JSON.parse(properties.levelsOrdinals),
properties.venueGroundLevel,
properties.venueId
);
handleLevelSelected(
map,
properties.venueGroundLevel,
properties.groundLevelId,
properties.venueId
);
}
}
};
const displayLevelSelector = (
map,
levelsShortNames,
levelsOrdinals,
venueGroundLevel,
venueId
) => {
document.getElementById("level-selector").innerHTML = "";
document.getElementById("level-selector").appendChild(
LevelSelector(venueId, levelsShortNames, venueGroundLevel, (index) => {
const selectedVenueOrdinal = index - venueGroundLevel;
const selectedLevelId = levelsOrdinals.find(
(level) => level.ordinal === selectedVenueOrdinal
).id;
handleLevelSelected(map, venueGroundLevel, selectedLevelId, venueId);
})
);
};

Next, lets write the handleLevelSelected function to filter the layers based on newly the selected level:

export const handleLevelSelected = (map, index, selectedLevelId, venueId) => {
const unitsFilter = [
"all",
["==", ["get", "level_id"], selectedLevelId],
["!=", ["get", "category"], "walkway"],
];
const levelsFilter = ["==", "ordinal", index];
map.setFilter(`levelFeature_Fill_${venueId}`, levelsFilter);
map.setFilter(`levelFeature_Line_${venueId}`, levelsFilter);
map.setFilter(`unitFeature_Fill_${venueId}`, unitsFilter);
map.setFilter(`unitFeature_Symbol_${venueId}`, unitsFilter);
map.setFilter(`unitFeature_Line_${venueId}`, unitsFilter);
map.setFilter(`openingFeature_Line_${venueId}`, unitsFilter);
};

And call the updateLevelSelector function when the moveend event of map:

map.on("moveend", () => {
updateGridLines(map);
updateLevelSelector(map);
});

Upload an IMDF Venue#

Next, we will add the option to upload a zip archive which contains a valid IMDF venue. First, add the button to trigger this flow in the ActionSheet.js component:

<button id="upload-venue-button" class="action-sheet-button">
Upload IMDF venue
</button>

In order to make the file selection possible, we also need to add an input element in the index.html file. We can restrict the accepted files to zip archives:

<input
id="venue-uploader"
type="file"
accept="zip,application/octet-stream,application/zip,application/x-zip,application/x-zip-compressed"
/>

In the unlApi.js file, add the call to the sdk for uploading the imdf archive:

export const uploadImdfArchive = (projectId, imdfArchive) => {
return unlApi.venuesApi.uploadImdfArchive(projectId, imdfArchive);
};

Write the function which opens the file selector and calls the uploadImdfArchvie function to upload the zip file. Once the file is selected, simply call the renderVenue that was implemented in the first part of the tutorial to display the venue and animate the map to that location.

export const uploadImdfVenue = async (map) => {
const projectId = config.PROJECT_ID;
const input = document.getElementById("venue-uploader");
input.onchange = async (e) => {
if (e.target.files && e.target.files[0]) {
const uploadResult = await uploadImdfArchive(
projectId,
e.target.files[0]
);
if (uploadResult && uploadResult.id) {
const includedFeatureTypes = ["venue", "level", "unit", "opening"];
const imdfFeatures = await getImdfFeatures(
projectId,
uploadResult.id,
includedFeatureTypes
);
renderVenue(map, imdfFeatures);
}
}
};
input.click();
};

Finally, add the event listener to trigger the upload:

document.getElementById("upload-venue-button").addEventListener("click", () => {
uploadImdfVenue(map);
});