Render MVF v2 with MapKit
Mappedin Venue Format (MVF) v2 is currently in an alpha 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 Mappedin Venue Format v2 (MVFv2) data. For a full breakdown of the MVFv2 bundle, read the MVF Data Model. This guide will demonstrate how to get started rendering MVFv2 data with MapKit, Apple's native mapping library.
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.
struct TokenResponse: Codable {
let accessToken: String
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
}
}
// See Demo API key Terms and Conditions
// https://developer.mappedin.com/docs/demo-keys-and-maps
private let apiKey = "mik_yeBk0Vf0nNJtpesfu560e07e5"
private let apiSecret = "mis_2g9ST8ZcSFb5R9fPnsvYhrX3RyRwPtDGbMGweCYKEq385431022"
private func getAccessToken() async throws -> String {
let url = URL(string: "https://app.mappedin.com/api/v1/api-key/token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"key": apiKey,
"secret": apiSecret
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
return response.accessToken
}
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 and extracted.
struct VenueResponse: Codable {
let url: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case url
case updatedAt = "updated_at"
}
}
private func downloadAndProcessVenue(accessToken: String) async throws {
let url = URL(string: "https://app.mappedin.com/api/venue/660c0c3aae0596d87766f2da/mvf")!
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
let venueResponse = try JSONDecoder().decode(VenueResponse.self, from: data)
// Download zip file
let zipUrl = URL(string: venueResponse.url)!
let (zipData, _) = try await URLSession.shared.data(from: zipUrl)
// Save zip data temporarily and process
let tempDir = FileManager.default.temporaryDirectory
let zipPath = tempDir.appendingPathComponent("venue.zip")
try zipData.write(to: zipPath)
try await processZipFile(at: zipPath)
}
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 processZipFile
and loadFileFromZip
functions are used to load the GeoJSON files from the zip archive and return them as JSON objects.
private func processZipFile(at path: URL) async throws {
guard let archive = Archive(url: path, accessMode: .read) else {
throw NSError(domain: "VenueError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to read ZIP archive"])
}
// Load manifest
let manifestData = try await loadFileFromZip(archive: archive, path: "manifest.geojson")
let stylesData = try await loadFileFromZip(archive: archive, path: "styles.json")
let floorData = try await loadFileFromZip(archive: archive, path: "floor.geojson")
let floorJson = try JSONSerialization.jsonObject(with: floorData) as! [String: Any]
let floorFeatures = (floorJson["features"] as! [[String: Any]])
var floor: [String: Any] = [:]
var floorId: String = ""
for feature in floorFeatures {
let properties = feature["properties"] as! [String: Any]
if let floorElevation = properties["elevation"] as? Int {
if floorElevation == elevation {
floor = feature
floorId = properties["id"] as! String
break;
}
}
}
let spaceData = try await loadFileFromZip(archive: archive, path: "space/\(floorId).geojson")
let obstructionData = try await loadFileFromZip(archive: archive, path: "obstruction/\(floorId).geojson")
// Process the data and set up visualization
try await initVisualization(
manifest: manifestData,
styles: stylesData,
floorData: floorData,
spaceData: spaceData,
obstructionData: obstructionData,
archive: archive
)
}
private func loadFileFromZip(archive: Archive, path: String) async throws -> Data {
guard let entry = archive[path] else {
throw NSError(domain: "ZipError", code: 2, userInfo: [NSLocalizedDescriptionKey: "File not found in zip: \(path)"])
}
var data = Data()
_ = try archive.extract(entry) { chunk in
data.append(chunk)
}
return data
}
Create the Geometry
The GoeJSON geometry of spaces is added to the map as an overlay. The floor of each space is added as an MKPolygon
or MKPolyline
. Annotations are added as MKPointAnnotation
. Obstructions represent things like walls and furniture, which must be navigated around. Similar to the spaces, obstructions are added to the map as an overlay using MKPolyline
and MKPolygon
. Annotations are once again added to the map as MKPointAnnotation
.
The manifest.geojson
file contains the center coordinate of the map. This is used as the center point for the map.
private func initVisualization(manifest: Data, styles: Data, floorData: Data, spaceData: Data, obstructionData: Data, archive: Archive) async throws {
// Visualization setup.
let manifestJson = try JSONSerialization.jsonObject(with: manifest) as! [String: Any]
let features = (manifestJson["features"] as! [[String: Any]])[0]
let geometry = features["geometry"] as! [String: Any]
let coordinates = geometry["coordinates"] as! [Double]
// Center the map on the venue.
let coordinate = CLLocationCoordinate2D(latitude: coordinates[1], longitude: coordinates[0])
centerMap(on: coordinate, with: 100)
// Styles
if let dictionary = try? JSONSerialization.jsonObject(with: styles, options: []) as? [String: Any] {
self.styles = dictionary
}
let decoder = MKGeoJSONDecoder()
// Spaces
if let features = try? decoder.decode(spaceData) as? [MKGeoJSONFeature] {
for feature in features {
if let featureData = feature.properties, let properties = try? JSONSerialization.jsonObject(with: featureData, options: []) as? [String: Any] {
if let point = feature.geometry[0] as? MKPointAnnotation {
point.title = properties["externalId"] as? String
mapView.addAnnotation(point)
} else if let polyline = feature.geometry[0] as? MKPolyline {
mapView.addOverlay(polyline)
} else if let polygon = feature.geometry[0] as? MKPolygon {
polygon.title = properties["id"] as? String
mapView.addOverlay(polygon)
}
}
}
}
// Obstructions
if let features = try? decoder.decode(obstructionData) as? [MKGeoJSONFeature] {
for feature in features {
if let featureData = feature.properties, let properties = try? JSONSerialization.jsonObject(with: featureData, options: []) as? [String: Any] {
if let point = feature.geometry[0] as? MKPointAnnotation {
point.title = properties["externalId"] as? String
mapView.addAnnotation(point)
} else if let polyline = feature.geometry[0] as? MKPolyline {
mapView.addOverlay(polyline)
} else if let polygon = feature.geometry[0] as? MKPolygon {
polygon.title = properties["id"] as? String
mapView.addOverlay(polygon)
}
}
}
}
}
Apply Styles
The MVFv2 bundle contains a styles.json
file that contains key-value pairs of styles. The key is a system-generated string and can be any value. The value is an object containing properties depending on the GeoJSON type. getColorForId
is used to get the color for a given geometry ID.
var styles: [String: Any] = [:]
//Get the color for a given geometry ID.
func getColorForId(_ id: String) -> String {
for key in styles.keys {
if let style = styles[key] as? [String: Any] {
if let polygons = style["polygons"] as? [String], polygons.contains(id) {
if let colorHex = style["color"] as? String {
return colorHex
}
}
}
}
return "#f5f5f5"
}
Color Conversion
The UIColor
extension is used to set the color of the geometry. The hex
color is converted to a UIColor
object using the UIColor(hex:)
initializer.
extension UIColor {
convenience init(hex: String) {
var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
// Handle the case where the hex string starts with '#' or '0x'
if hexString.hasPrefix("#") {
hexString.remove(at: hexString.startIndex)
} else if hexString.hasPrefix("0x") {
hexString.removeSubrange(hexString.startIndex..<hexString.index(hexString.startIndex, offsetBy: 2))
}
// Convert hex string to RGB
var rgb: UInt64 = 0
Scanner(string: hexString).scanHexInt64(&rgb)
let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
let blue = CGFloat(rgb & 0x0000FF) / 255.0
self.init(red: red, green: green, blue: blue, alpha: 1.0)
}
}
Render the Map
The geometry is rendered using the MKOverlayRenderer
class. The fillColor
is set to the color of the space or obstruction, which is specified in the styles.json
file.
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let polygon = overlay as? MKPolygon {
let renderer = MKPolygonRenderer(polygon: polygon)
var color = "#f5f5f5"
if let id = polygon.title {
color = getColorForId(id)
}
print(color)
renderer.fillColor = UIColor(hex: color);
return renderer
}
else if let line = overlay as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: line)
// The default color of walls is white, which may not be visible on the map.
// This example uses a light gray color.
renderer.strokeColor = UIColor(hex:"#dddddd")
renderer.lineWidth = 2.0
return renderer
}
return MKOverlayRenderer(overlay: overlay)
}
Complete Example.
A complete example can be found below. This contains all of the code that has been described above.
import UIKit
import MapKit
import ZIPFoundation
import CoreLocation
class ViewController: UIViewController, MKMapViewDelegate {
@IBOutlet weak var mapView: MKMapView!
var styles: [String: Any] = [:]
struct TokenResponse: Codable {
let accessToken: String
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
}
}
struct VenueResponse: Codable {
let url: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case url
case updatedAt = "updated_at"
}
}
// See Demo API key Terms and Conditions
// https://developer.mappedin.com/docs/demo-keys-and-maps
private let elevation: Int = 0 // The floor elevation to be displayed.
private let apiKey = "mik_yeBk0Vf0nNJtpesfu560e07e5"
private let apiSecret = "mis_2g9ST8ZcSFb5R9fPnsvYhrX3RyRwPtDGbMGweCYKEq385431022"
override func viewDidLoad() {
super.viewDidLoad()
mapView.delegate = self
Task {
do {
let token = try await getAccessToken()
try await downloadAndProcessVenue(accessToken: token)
} catch {
print("Error: \(error)")
}
}
}
func getColorForId(_ id: String) -> String {
for key in styles.keys {
if let style = styles[key] as? [String: Any] {
if let polygons = style["polygons"] as? [String], polygons.contains(id) {
if let colorHex = style["color"] as? String {
return colorHex
}
}
}
}
return "#f5f5f5"
}
// MKMapViewDelegate method to provide a renderer for overlays
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let polygon = overlay as? MKPolygon {
let renderer = MKPolygonRenderer(polygon: polygon)
var color = "#f5f5f5"
if let id = polygon.title {
color = getColorForId(id)
}
print(color)
renderer.fillColor = UIColor(hex: color);
return renderer
}
else if let line = overlay as? MKPolyline {
let renderer = MKPolylineRenderer(polyline: line)
// The default color of walls is white, which may not be visible on the map.
// This example uses a light gray color.
renderer.strokeColor = UIColor(hex:"#dddddd")
renderer.lineWidth = 2.0
return renderer
}
return MKOverlayRenderer(overlay: overlay)
}
// Center the map on the venue.
func centerMap(on coordinate: CLLocationCoordinate2D, with zoomLevel: CLLocationDistance) {
let region = MKCoordinateRegion(
center: coordinate,
latitudinalMeters: zoomLevel,
longitudinalMeters: zoomLevel
)
mapView.setRegion(region, animated: true)
}
private func getAccessToken() async throws -> String {
let url = URL(string: "https://app.mappedin.com/api/v1/api-key/token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"key": apiKey,
"secret": apiSecret
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
return response.accessToken
}
private func downloadAndProcessVenue(accessToken: String) async throws {
let url = URL(string: "https://app.mappedin.com/api/venue/660c0c3aae0596d87766f2da/mvf")!
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
let venueResponse = try JSONDecoder().decode(VenueResponse.self, from: data)
// Download zip file
let zipUrl = URL(string: venueResponse.url)!
let (zipData, _) = try await URLSession.shared.data(from: zipUrl)
// Save zip data temporarily and process
let tempDir = FileManager.default.temporaryDirectory
let zipPath = tempDir.appendingPathComponent("venue.zip")
try zipData.write(to: zipPath)
try await processZipFile(at: zipPath)
}
private func processZipFile(at path: URL) async throws {
guard let archive = Archive(url: path, accessMode: .read) else {
throw NSError(domain: "VenueError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to read ZIP archive"])
}
// Load manifest
let manifestData = try await loadFileFromZip(archive: archive, path: "manifest.geojson")
let stylesData = try await loadFileFromZip(archive: archive, path: "styles.json")
let floorData = try await loadFileFromZip(archive: archive, path: "floor.geojson")
let floorJson = try JSONSerialization.jsonObject(with: floorData) as! [String: Any]
let floorFeatures = (floorJson["features"] as! [[String: Any]])
var floor: [String: Any] = [:]
var floorId: String = ""
for feature in floorFeatures {
let properties = feature["properties"] as! [String: Any]
if let floorElevation = properties["elevation"] as? Int {
if floorElevation == elevation {
floor = feature
floorId = properties["id"] as! String
break;
}
}
}
let spaceData = try await loadFileFromZip(archive: archive, path: "space/\(floorId).geojson")
let obstructionData = try await loadFileFromZip(archive: archive, path: "obstruction/\(floorId).geojson")
// Process the data and set up visualization
try await initVisualization(
manifest: manifestData,
styles: stylesData,
floorData: floorData,
spaceData: spaceData,
obstructionData: obstructionData,
archive: archive
)
}
private func loadFileFromZip(archive: Archive, path: String) async throws -> Data {
guard let entry = archive[path] else {
throw NSError(domain: "ZipError", code: 2, userInfo: [NSLocalizedDescriptionKey: "File not found in zip: \(path)"])
}
var data = Data()
_ = try archive.extract(entry) { chunk in
data.append(chunk)
}
return data
}
private func initVisualization(manifest: Data, styles: Data, floorData: Data, spaceData: Data, obstructionData: Data, archive: Archive) async throws {
// Visualization logic
let manifestJson = try JSONSerialization.jsonObject(with: manifest) as! [String: Any]
let features = (manifestJson["features"] as! [[String: Any]])[0]
let geometry = features["geometry"] as! [String: Any]
let coordinates = geometry["coordinates"] as! [Double]
// Set up your map view here with the coordinates
let coordinate = CLLocationCoordinate2D(latitude: coordinates[1], longitude: coordinates[0])
centerMap(on: coordinate, with: 100)
// Styles
if let dictionary = try? JSONSerialization.jsonObject(with: styles, options: []) as? [String: Any] {
self.styles = dictionary
}
let decoder = MKGeoJSONDecoder()
// Spaces
if let features = try? decoder.decode(spaceData) as? [MKGeoJSONFeature] {
for feature in features {
if let featureData = feature.properties, let properties = try? JSONSerialization.jsonObject(with: featureData, options: []) as? [String: Any] {
if let point = feature.geometry[0] as? MKPointAnnotation {
point.title = properties["externalId"] as? String
mapView.addAnnotation(point)
} else if let polyline = feature.geometry[0] as? MKPolyline {
mapView.addOverlay(polyline)
} else if let polygon = feature.geometry[0] as? MKPolygon {
polygon.title = properties["id"] as? String
mapView.addOverlay(polygon)
}
}
}
}
// Obstructions
if let features = try? decoder.decode(obstructionData) as? [MKGeoJSONFeature] {
for feature in features {
if let featureData = feature.properties, let properties = try? JSONSerialization.jsonObject(with: featureData, options: []) as? [String: Any] {
if let point = feature.geometry[0] as? MKPointAnnotation {
point.title = properties["externalId"] as? String
mapView.addAnnotation(point)
} else if let polyline = feature.geometry[0] as? MKPolyline {
mapView.addOverlay(polyline)
} else if let polygon = feature.geometry[0] as? MKPolygon {
polygon.title = properties["id"] as? String
mapView.addOverlay(polygon)
}
}
}
}
}
}
extension UIColor {
convenience init(hex: String) {
var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
// Handle the case where the hex string starts with '#' or '0x'
if hexString.hasPrefix("#") {
hexString.remove(at: hexString.startIndex)
} else if hexString.hasPrefix("0x") {
hexString.removeSubrange(hexString.startIndex..<hexString.index(hexString.startIndex, offsetBy: 2))
}
// Convert hex string to RGB
var rgb: UInt64 = 0
Scanner(string: hexString).scanHexInt64(&rgb)
let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
let blue = CGFloat(rgb & 0x0000FF) / 255.0
self.init(red: red, green: green, blue: blue, alpha: 1.0)
}
}