Render MVF v2 with Mapkit JS
Mappedin Venue Format (MVF) v2 is currently in an beta state while Mappedin perfects a new design. Breaking changes may occur and this page will be updated to reflect this.
A GeoJSON renderer is required to display MVF data. For a full breakdown of the Mappedin Venue Format v2 (MVFv2) bundle, read the MVF Data Model. This guide will demonstrate how to get started using MapKit JS, Apple's JavaScript library for rendering maps. A screenshot of the result is shown below. Jump straight to the End Result to try out the live interactive map.
Project Setup
- Start a new vanilla Vite project using TypeScript.
yarn create vite mappedin-mvf-guide
From the setup menu, select Vanilla
, then TypeScript
.
cd mappedin-mvf-guide
- Install deck.gl and related packages.
yarn add @types/geojson jszip geojson @turf/buffer @types/adm-zip
Update main.ts, style.css
Open the project in your editor. In the src
directory, create or update the main.ts
file and replace the contents with the code block below. Create a utils.ts
and mapkit.d.ts
file in the src
directory copy the code below into these files. The rest of this guide will break down and explain the code.
import { Feature } from "geojson";
import JSZip from "jszip";
import "./styles.css";
import {
downloadAndProcessVenue,
getAccessToken,
hexToRGB,
loadFileFromZip,
} from "./utils";
//The Mappedin Map ID to load.
const MAP_ID = "64ef49e662fd90fe020bee61";
// See Trial API key Terms and Conditions
// https://developer.mappedin.com/docs/demo-keys-and-maps
const MAPPEDIN_KEY = "mik_yeBk0Vf0nNJtpesfu560e07e5";
const MAPPEDIN_SECRET =
"mis_2g9ST8ZcSFb5R9fPnsvYhrX3RyRwPtDGbMGweCYKEq385431022";
// Replace this with your Mapkit JWT token. This token is whitelisted for this CodeSandbox only.
const MAPKIT_TOKEN =
"eyJraWQiOiI5WDY5NFc5Qjg2IiwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJpc3MiOiJINkMyOTI0UUE1IiwiaWF0IjoxNzM3NTU4MDU3LCJvcmlnaW4iOiJjcWZxOXYuY3NiLmFwcCJ9.iKt6rUFnqXOe4dOML3vesdPmPwKpWetx3tpMeLEQidZ4stkKpXSkPwKixw5sDmoi2B_eSENAq0sun7jVSIYCyg";
// Initialize MapKit JS with your JWT token.
mapkit.init({
authorizationCallback: function (done) {
done(MAPKIT_TOKEN);
},
});
const ELEVATION = 0; // The floor elevation to be displayed.
// Helper function to create MapKit overlays from GeoJSON features
function createGeoJSONOverlay(
features: Feature[],
style: {
strokeColor: string;
fillColor: string;
lineWidth: number;
}
): mapkit.Overlay[] {
return features
.map((feature) => {
if (feature.geometry.type === "Polygon") {
const coordinates = feature.geometry.coordinates[0].map(
(coord) => new mapkit.Coordinate(coord[1], coord[0])
);
const polygon = new mapkit.PolygonOverlay(coordinates);
polygon.style = new mapkit.Style({
strokeColor: style.strokeColor,
fillColor: style.fillColor,
lineWidth: style.lineWidth,
strokeOpacity: 1.0,
fillOpacity: 0.95,
});
return polygon;
} else if (feature.geometry.type === "LineString") {
const coordinates = feature.geometry.coordinates.map(
(coord) => new mapkit.Coordinate(coord[1], coord[0])
);
const polyline = new mapkit.PolylineOverlay(coordinates);
polyline.style = new mapkit.Style({
strokeColor: style.strokeColor,
lineWidth: style.lineWidth * 2,
strokeOpacity: 1.0,
});
return polyline;
}
// Return null for unsupported geometry types
console.warn("Unsupported geometry type:", feature.geometry.type);
return null;
})
.filter((overlay) => overlay !== null) as mapkit.Overlay[];
}
// Initialize the visualization with the GoeJSON files in the MVFv2 zip.
async function initVisualization(zip: JSZip) {
// Load the essential MVF data files from zip
const manifestData = await loadFileFromZip(zip, "manifest.geojson");
const styles = await loadFileFromZip(zip, "styles.json");
const floorData = await loadFileFromZip(zip, "floor.geojson");
// Extract the features array from the FeatureCollection
const mapData = floorData.features.map((feature) => feature.properties);
// Get the current map for the set elevation
const floorId = mapData.find((f) => f.elevation === ELEVATION).id;
// Get geometry data by floor ID
const spaceData = await loadFileFromZip(zip, `space/${floorId}.geojson`);
const obstructionData = await loadFileFromZip(
zip,
`obstruction/${floorId}.geojson`
);
// Initialize MapKit JS map
const map = new mapkit.Map("map", {
center: new mapkit.Coordinate(
manifestData.features[0].geometry.coordinates[1],
manifestData.features[0].geometry.coordinates[0]
),
cameraZoomRange: new mapkit.CameraZoomRange(15, 500),
showsMapTypeControl: true,
showsZoomControl: true,
});
// Set map features visibility
map.showsScale = mapkit.FeatureVisibility.Visible;
// Set initial zoom level using camera distance
map.setCameraDistanceAnimated(150);
// Create overlay styles for each layer
const roomStyles = styles["Rooms"];
const roomOverlays = createGeoJSONOverlay(
spaceData.features.filter((f: Feature) =>
roomStyles.polygons.includes(f.properties!.id)
),
{
strokeColor: "transparent",
fillColor: hexToRGB(roomStyles.color),
lineWidth: 0,
}
);
const hallwayStyles = styles["Hallways"];
const hallwayOverlays = createGeoJSONOverlay(
spaceData.features.filter((f: Feature) =>
hallwayStyles.polygons.includes(f.properties!.id)
),
{
strokeColor: "transparent",
fillColor: hexToRGB(hallwayStyles.color),
lineWidth: 0,
}
);
// Modify the wall styles to be more visible.
const wallStyles = styles["Walls"];
const wallOverlays = createGeoJSONOverlay(
obstructionData.features.filter((f: Feature) =>
wallStyles.lineStrings.includes(f.properties!.id)
),
{
strokeColor: "#b2b2b2",
lineWidth: wallStyles.width * 1.5,
fillColor: "transparent",
}
);
// Modify the exterior wall styles to be more visible.
const exteriorWallStyles = styles["ExteriorWalls"];
const exteriorWallOverlays = createGeoJSONOverlay(
obstructionData.features.filter((f: Feature) =>
exteriorWallStyles.lineStrings.includes(f.properties!.id)
),
{
strokeColor: "#acacac",
lineWidth: exteriorWallStyles.width * 2,
fillColor: "transparent",
}
);
const deskStyles = styles["Desks"];
const deskOverlays = createGeoJSONOverlay(
obstructionData.features.filter((f: Feature) =>
deskStyles.polygons.includes(f.properties!.id)
),
{
strokeColor: "transparent",
fillColor: hexToRGB(deskStyles.color),
lineWidth: 0,
}
);
// Add all overlays to the map
roomOverlays.forEach((overlay) => map.addOverlay(overlay));
hallwayOverlays.forEach((overlay) => map.addOverlay(overlay));
wallOverlays.forEach((overlay) => map.addOverlay(overlay));
exteriorWallOverlays.forEach((overlay) => map.addOverlay(overlay));
deskOverlays.forEach((overlay) => map.addOverlay(overlay));
}
// Execute everything
getAccessToken(MAPPEDIN_KEY, MAPPEDIN_SECRET)
.then(async (token) => {
//Download the MVF Bundle.
const zip = await downloadAndProcessVenue(token, MAP_ID);
// Initialize visualization with the zip contents.
await initVisualization(zip);
})
.catch((error) => console.error("Error:", error));
import JSZip from "jszip";
interface TokenResponse {
access_token: string;
expires_in: number;
}
interface VenueResponse {
url: string;
updated_at: string;
}
// Use the MappedIn API key & secret to get an access token.
export async function getAccessToken(
key: String,
secret: String
): Promise<string> {
// See Trial API key Terms and Conditions
// https://developer.mappedin.com/docs/demo-keys-and-maps
const response = await fetch(
"https://app.mappedin.com/api/v1/api-key/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: key,
secret: secret,
}),
}
);
const data: TokenResponse = await response.json();
return data.access_token;
}
// Load a file from a zip archive.
export async function loadFileFromZip(zip: JSZip, path: string) {
const file = zip.file(path);
if (!file) throw new Error(`File not found in zip: ${path}`);
const content = await file.async("text");
return JSON.parse(content);
}
// Download the MVFv2 zip file from the MappedIn API and process it.
export async function downloadAndProcessVenue(
accessToken: string,
mapId: string
): Promise<JSZip> {
const venueResponse = await fetch(
`https://app.mappedin.com/api/venue/${mapId}/mvf`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
const venueData: VenueResponse = await venueResponse.json();
// Download the zip file
const zipResponse = await fetch(venueData.url);
const zipBuffer = await zipResponse.arrayBuffer();
// Process zip contents in memory
const zip = new JSZip();
await zip.loadAsync(zipBuffer);
return zip;
}
declare var mapkit: any;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #3178c6aa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
Get an Access Token
The Mappedin API requires an access token to download the MVF bundle. This guide will use the Demo API key to get an access token from the API Key REST endpoint.
// Use the MappedIn API key & secret to get an access token.
export async function getAccessToken(
key: String,
secret: String
): Promise<string> {
// See Trial API key Terms and Conditions
// https://developer.mappedin.com/docs/demo-keys-and-maps
const response = await fetch(
"https://app.mappedin.com/api/v1/api-key/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: key,
secret: secret,
}),
}
);
const data: TokenResponse = await response.json();
return data.access_token;
}
Download the MVF Bundle
The Get Venue MVF endpoint is called with the venue/map ID. The response contains a URL to the MVFv2 bundle, which is downloaded, loaded into memory, and passed to the initVisualization
function.
// Download the MVFv2 zip file from the MappedIn API and process it.
export async function downloadAndProcessVenue(
accessToken: string,
mapId: string
): Promise<JSZip> {
const venueResponse = await fetch(
`https://app.mappedin.com/api/venue/${mapId}/mvf`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
const venueData: VenueResponse = await venueResponse.json();
// Download the zip file
const zipResponse = await fetch(venueData.url);
const zipBuffer = await zipResponse.arrayBuffer();
// Process zip contents in memory
const zip = new JSZip();
await zip.loadAsync(zipBuffer);
return zip;
}
Loading the Data
The GeoJSON data for an MVF is split into multiple parts, often separated by floor ID. The structure of this bundle is explained in the MVF v2 Data Model. the loadFileFromZip
function is used to load the GeoJSON files from the zip archive and return them as JSON objects.
// Load a file from a zip archive.
export async function loadFileFromZip(zip: JSZip, path: string) {
const file = zip.file(path);
if (!file) throw new Error(`File not found in zip: ${path}`);
const content = await file.async("text");
return JSON.parse(content);
}
For a basic 2D or 3D rendering, the following datasets are needed:
Some data - like Space and Obstruction - is divided by floor ID. Using the desired elevation, the floor ID can be pulled from the Floor GeoJSON file. Then, in the fetch specify which floor ID file to load.
// Load the essential MVF data files from zip
const manifestData = await loadFileFromZip(zip, "manifest.geojson");
const styles = await loadFileFromZip(zip, "styles.json");
const floorData = await loadFileFromZip(zip, "floor.geojson");
// Extract the features array from the FeatureCollection
const mapData = floorData.features.map((feature) => feature.properties);
// Get the current map for the set elevation
const floorId = mapData.find((f) => f.elevation === ELEVATION).id;
// Get geometry data by floor ID
const spaceData = await loadFileFromZip(zip, `space/${floorId}.geojson`);
const obstructionData = await loadFileFromZip(
zip,
`obstruction/${floorId}.geojson`
);
Initialize the Mapkit JS Map
The initVisualization
function is called with the zip contents. The first step is to initialize the Mapkit JS map, which provides the outdoor map view and will be used to render the GeoJSON data from the MVF bundle. The Manifest GeoJSON contains the center coordinate of the indoor map, which is used to set the MapKit JS center
property.
// Initialize MapKit JS map
const map = new mapkit.Map("map", {
center: new mapkit.Coordinate(
manifestData.features[0].geometry.coordinates[1],
manifestData.features[0].geometry.coordinates[0]
),
cameraZoomRange: new mapkit.CameraZoomRange(15, 500),
showsMapTypeControl: true,
showsZoomControl: true,
});
// Set map features visibility
map.showsScale = mapkit.FeatureVisibility.Visible;
// Set initial zoom level using camera distance
map.setCameraDistanceAnimated(75);
Mapkit JS uses a Style
object to define the appearance of the map. The hexToRGB
function is used to convert the hex color code from the Styles JSON to an RGBA value.
export function hexToRGB(hex: string): string {
const rgb = hex.match(/[0-9a-f]{2}/g)!.map((x) => parseInt(x, 16));
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.95)`;
}
Rendering the Geometry
The createGeoJSONOverlay
function is used to create Mapkit overlays from the GeoJSON features. This function is used for both the Space and Obstruction datasets. It accepts an array of GeoJSON features and a style object, and returns an array of Mapkit overlays.
// Helper function to create MapKit overlays from GeoJSON features
function createGeoJSONOverlay(
features: Feature[],
style: {
strokeColor: string;
fillColor: string;
lineWidth: number;
}
): mapkit.Overlay[] {
return features
.map((feature) => {
if (feature.geometry.type === "Polygon") {
const coordinates = feature.geometry.coordinates[0].map(
(coord) => new mapkit.Coordinate(coord[1], coord[0])
);
const polygon = new mapkit.PolygonOverlay(coordinates);
polygon.style = new mapkit.Style({
strokeColor: style.strokeColor,
fillColor: style.fillColor,
lineWidth: style.lineWidth,
strokeOpacity: 1.0,
fillOpacity: 0.95,
});
return polygon;
} else if (feature.geometry.type === "LineString") {
const coordinates = feature.geometry.coordinates.map(
(coord) => new mapkit.Coordinate(coord[1], coord[0])
);
const polyline = new mapkit.PolylineOverlay(coordinates);
polyline.style = new mapkit.Style({
strokeColor: style.strokeColor,
lineWidth: style.lineWidth * 2,
strokeOpacity: 1.0,
});
return polyline;
}
// Return null for unsupported geometry types
console.warn("Unsupported geometry type:", feature.geometry.type);
return null;
})
.filter((overlay) => overlay !== null) as mapkit.Overlay[];
}
The Space and Obstruction datasets contain the geometry of the map, but they don't contain any properties which specify how they should be rendered. The MVF comes with a Styles JSON file for this purpose.
Open styles.json
in an editor to preview some of the style sets available. The key names are not guaranteed, but are generally English descriptors such as Rooms
or Walls
. Each style has a list of either polygons
or lineStrings
which belong to it.
Create Mapkit Overlays for rooms using the createGeoJSONOverlay
function described above. To select only the rooms from the Space GeoJSON, filter the dataset by polygons included in the Rooms
Styles. Since these are polygons, apply the color to fillColor
in Mapkit JS.
const roomStyles = styles["Rooms"];
const roomOverlays = createGeoJSONOverlay(
spaceData.features.filter((f: Feature) =>
roomStyles.polygons.includes(f.properties!.id)
),
{
strokeColor: "transparent",
fillColor: hexToRGB(roomStyles.color),
lineWidth: 0,
}
);
Similarly, the same process is done for Hallways.
const hallwayStyles = styles["Hallways"];
const hallwayOverlays = createGeoJSONOverlay(
spaceData.features.filter((f: Feature) =>
hallwayStyles.polygons.includes(f.properties!.id)
),
{
strokeColor: "transparent",
fillColor: hexToRGB(hallwayStyles.color),
lineWidth: 0,
}
);
There are 3 main Styles for Obstruction GeoJSON data, Walls
, ExteriorWalls
, and Desks
. Note that Walls and ExteriorWalls are lineStrings
and Desks are polygons
. The style of walls is modified to make them more visible. In this example the strokeColor
is set to a gray shade and the lineWidth
thickness is increased.
// Modify the wall styles to be more visible.
const wallStyles = styles["Walls"];
const wallOverlays = createGeoJSONOverlay(
obstructionData.features.filter((f: Feature) =>
wallStyles.lineStrings.includes(f.properties!.id)
),
{
strokeColor: "#b2b2b2",
lineWidth: wallStyles.width * 1.5,
fillColor: "transparent",
}
);
// Modify the exterior wall styles to be more visible.
const exteriorWallStyles = styles["ExteriorWalls"];
const exteriorWallOverlays = createGeoJSONOverlay(
obstructionData.features.filter((f: Feature) =>
exteriorWallStyles.lineStrings.includes(f.properties!.id)
),
{
strokeColor: "#acacac",
lineWidth: exteriorWallStyles.width * 2,
fillColor: "transparent",
}
);
const deskStyles = styles["Desks"];
const deskOverlays = createGeoJSONOverlay(
obstructionData.features.filter((f: Feature) =>
deskStyles.polygons.includes(f.properties!.id)
),
{
strokeColor: "transparent",
fillColor: hexToRGB(deskStyles.color),
lineWidth: 0,
}
);
Finally, add all of the overlays to the map. List the geometry layers in the order that they stack vertically. For example, the Desks and Walls should be a layer above the Rooms and Hallways.
// Add all overlays to the map
roomOverlays.forEach((overlay) => map.addOverlay(overlay));
hallwayOverlays.forEach((overlay) => map.addOverlay(overlay));
wallOverlays.forEach((overlay) => map.addOverlay(overlay));
exteriorWallOverlays.forEach((overlay) => map.addOverlay(overlay));
deskOverlays.forEach((overlay) => map.addOverlay(overlay));
End Result
The end result should be a 2D render of the MVF v2 in DeckGL. The following CodeSandbox implements this guide with the Dev Office Demo Map.