`, removing it from the DOM. The map ref then points to nothing and the map cannot recover.
### Solution:
Keep the container div always mounted and use an overlay for error messages:
```jsx
// ❌ Wrong — replaces the container div
if (error) return
Something went wrong
;
// ✅ Correct — overlay on top of the container
return (
<>
{error &&
Something went wrong
}
>
);
```
---
## 🔴 Coordinates Not Displaying Correctly
### Symptoms:
- Marker appears in wrong location
- Map centered on wrong place
### Cause:
Mixing up longitude and latitude order
### Solution:
**MapMetrics uses [longitude, latitude] order:**
```javascript
// ❌ WRONG - [latitude, longitude]
center: [40.7128, -74.0060] // This is backwards!
// ✅ CORRECT - [longitude, latitude]
center: [-74.0060, 40.7128] // Longitude first!
// ✅ For API requests, use object notation:
{
"lat": 40.7128,
"lon": -74.0060
}
```
**Remember:**
- **Map/SDK**: `[longitude, latitude]` (array)
- **REST API**: `{lat: number, lon: number}` (object)
---
## 🔴 Geocoding Returns Empty Results
### Symptoms:
- API returns `[]` or no features
- Can't find addresses
### Common Causes:
**1. Missing Token**
```javascript
// ❌ WRONG
fetch('https://gateway.mapmetrics-atlas.net/forward-geocode/?text=Paris')
// ✅ CORRECT
fetch('https://gateway.mapmetrics-atlas.net/forward-geocode/?token=YOUR_TOKEN&text=Paris')
```
**2. Text Not URL Encoded**
```javascript
// ❌ WRONG - Spaces not encoded
const url = `...?text=New York City`
// ✅ CORRECT - Use encodeURIComponent
const text = encodeURIComponent('New York City');
const url = `...?text=${text}`
```
**3. Too Specific Query**
```javascript
// ❌ Might not find
"1234 Some Random Street That Doesn't Exist"
// ✅ Better - Start broad
"Paris, France"
"New York"
```
---
## 🔴 Geocoding Returns 200 But No Results
This is specifically about the **v2 geocoder** (`/v2/autocomplete/`,
`/v2/retrieve/`, `/v2/retrieve-batch/`, `/v2/forward-geocode/`,
`/v2/reverse-geocode/`). Almost every v2 misuse below answers with
**HTTP 200** and an empty or unexpected result set rather than an error —
if a v2 request "isn't finding anything," check this list before assuming
the data is missing.
### 1. Using `text` instead of `q`
v2 endpoints take **`q`**, not `text` (the v1 parameter name).
```bash
# ❌ WRONG - v1 parameter name on a v2 endpoint
curl "https://gateway.mapmetrics-atlas.net/v2/autocomplete/?text=Nieuwezijds%20Voorburgwal%20147&token=YOUR_API_KEY"
# → HTTP 200, {"results": [], "q": ""}
# ✅ CORRECT
curl "https://gateway.mapmetrics-atlas.net/v2/autocomplete/?q=Nieuwezijds%20Voorburgwal%20147&token=YOUR_API_KEY"
```
### 2. Missing `format=pelias` on forward/reverse geocode
Without `format=pelias`, `/v2/forward-geocode/` and
`/v2/reverse-geocode/` don't error — they silently return a **different,
flat response shape** instead of the documented GeoJSON
`FeatureCollection`. Code written against the `FeatureCollection` shape
then reads `features[0]` as `undefined`.
```bash
# ❌ WRONG - format omitted, returns the flat shape
curl "https://gateway.mapmetrics-atlas.net/v2/forward-geocode/?q=Nieuwezijds%20Voorburgwal%20147&country=nl&token=YOUR_API_KEY"
# ✅ CORRECT
curl "https://gateway.mapmetrics-atlas.net/v2/forward-geocode/?q=Nieuwezijds%20Voorburgwal%20147&country=nl&format=pelias&token=YOUR_API_KEY"
```
### 3. `retrieve-batch` called with repeated params instead of one `items` array
`/v2/retrieve-batch/` takes exactly **one** parameter, `items`, holding a
URL-encoded JSON array. Repeated params (`ord=`, `ords=`, `ids=`) return
HTTP 200 with `count: 0` — no error.
```bash
# ❌ WRONG - repeated params, silently returns count: 0
curl "https://gateway.mapmetrics-atlas.net/v2/retrieve-batch/?ord=128371&ord=128372&token=YOUR_API_KEY"
# ✅ CORRECT - one items param, URL-encoded JSON array
curl "https://gateway.mapmetrics-atlas.net/v2/retrieve-batch/?items=%5B%7B%22country%22%3A%22nl%22%2C%22layer%22%3A%22address%22%2C%22ord%22%3A128371%7D%5D&token=YOUR_API_KEY"
```
### 4. Retrieving by `id` instead of `ord`
`/v2/retrieve/` is keyed on **`ord`**, the handle returned by
autocomplete — not `id`. Passing `id` 404s on every layer.
```bash
# ❌ WRONG - id instead of ord
curl "https://gateway.mapmetrics-atlas.net/v2/retrieve/?country=nl&layer=address&id=osm:ext:7f3a9c1d2e4b5a6f&token=YOUR_API_KEY"
# → 404
# ✅ CORRECT - ord from the autocomplete suggestion
curl "https://gateway.mapmetrics-atlas.net/v2/retrieve/?country=nl&layer=address&ord=128371&token=YOUR_API_KEY"
```
### 5. A `country` filter that excludes the result
A `country` param that doesn't match the actual country of the result
filters it out entirely — this returns 200 with an empty or short result
set, with no indication the filter was the cause.
```bash
# ❌ WRONG - country=de excludes a Dutch address, empty results
curl "https://gateway.mapmetrics-atlas.net/v2/forward-geocode/?q=Nieuwezijds%20Voorburgwal%20147&country=de&format=pelias&token=YOUR_API_KEY"
# ✅ CORRECT - country matches where the address actually is
curl "https://gateway.mapmetrics-atlas.net/v2/forward-geocode/?q=Nieuwezijds%20Voorburgwal%20147&country=nl&format=pelias&token=YOUR_API_KEY"
```
See also [API Keys & Security](./api-keys.md) for auth-related 401/403s,
which look different from these silent-empty-result cases.
---
## 🔴 CORS Errors
### Symptoms:
```
Access to fetch at '...' from origin 'http://localhost' has been blocked by CORS policy
```
### Cause:
Browser security - usually only in development
### Solution:
**For Development:**
Use a local server, not `file://`
```bash
# Use any local server
python -m http.server 8000
# or
npx serve .
# or
npm run dev
```
**For Production:**
Deploy to a proper domain (Vercel, Netlify, etc.)
---
## 🔴 NPM Package Import Errors
### Symptoms:
```
Module not found: Can't resolve '@mapmetrics/mapmetrics-gl'
```
### Solution:
**1. Install the CORRECT package:**
```bash
# ✅ CORRECT - MapMetrics package
npm install @mapmetrics/mapmetrics-gl
# ❌ WRONG - This is MapLibre, NOT MapMetrics!
npm install maplibre-gl
```
**2. Import correctly:**
```javascript
// ✅ CORRECT
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
// ❌ WRONG
import maplibregl from 'maplibre-gl'; // Wrong package!
```
**3. If using TypeScript, you may need:**
```typescript
declare module '@mapmetrics/mapmetrics-gl';
```
---
## 🔴 Using Wrong Package (maplibre-gl vs @mapmetrics/mapmetrics-gl)
### Symptoms:
- Map doesn't load even with valid token
- Authentication errors with MapMetrics style URLs
- Missing MapMetrics-specific features
### Cause:
Using `maplibre-gl` (the base library) instead of `@mapmetrics/mapmetrics-gl` (MapMetrics custom fork)
### Solution:
**Uninstall wrong package and install correct one:**
```bash
# Remove wrong package
npm uninstall maplibre-gl
# Install correct package
npm install @mapmetrics/mapmetrics-gl
```
**Update imports:**
```javascript
// ❌ Before (WRONG)
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
// ✅ After (CORRECT)
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
```
**Update all references:**
```javascript
// ❌ WRONG
const map = new maplibregl.Map({...});
const marker = new maplibregl.Marker();
// ✅ CORRECT
const map = new mapmetricsgl.Map({...});
const marker = new mapmetricsgl.Marker();
```
**Why this matters:**
- MapMetrics GL is a **custom fork** of MapLibre with proprietary features
- MapMetrics style URLs **only work** with the MapMetrics package
- The packages are **NOT interchangeable**
---
## 🔴 Directions API: No Route Found
### Symptoms:
```json
{
"error": "No route found"
}
```
### Common Causes:
**1. Unreachable Locations**
```javascript
// Locations on different continents with no ferry routes
locations: [
{ lat: 40.7128, lon: -74.0060 }, // New York
{ lat: 51.5074, lon: -0.1278 } // London - needs ferry!
]
```
**2. Invalid Costing Model**
```javascript
// ❌ Wrong costing for the route
costing: "bicycle" // But route includes highways
// ✅ Use appropriate costing
costing: "auto"
```
**3. Coordinates Reversed**
```javascript
// ❌ WRONG - lat/lon format in REST API
locations: [
{ lon: -74.0060, lat: 40.7128 } // Backwards!
]
// ✅ CORRECT - lat comes first in objects
locations: [
{ lat: 40.7128, lon: -74.0060 }
]
```
---
## 🆘 Still Having Issues?
### Checklist:
- [ ] **Do you have a valid token?** Get one at [portal.mapmetrics.org](https://portal.mapmetrics.org/)
- [ ] **Is the token in the URL?** Check for `?token=` or `&token=`
- [ ] **Is your map container styled?** Add `height: 500px` to CSS
- [ ] **Are coordinates in correct order?** `[longitude, latitude]` for maps
- [ ] **Check browser console** for error messages
- [ ] **Is JavaScript loading?** Check Network tab in DevTools
- [ ] **Using HTTPS in production?** Some features require secure context
### Get Help:
- 📖 [Documentation](/)
- 💬 [Discord Community](https://discord.com/invite/uRXQRfbb7d)
- 📧 Support: Contact via portal
---
## 🎓 Prevention Tips
### Before You Start:
1. ✅ Sign up at [portal.mapmetrics.org](https://portal.mapmetrics.org/)
2. ✅ Get your style URL (includes token)
3. ✅ Test with simple example first
4. ✅ Check browser console for errors
5. ✅ Read relevant example docs
### While Developing:
1. ✅ Always check token is present
2. ✅ Use browser DevTools to debug
3. ✅ Test API calls with curl first
4. ✅ Verify coordinates are correct
5. ✅ Keep documentation handy
---
**Most issues are solved by:**
1. Getting a valid API token
2. Adding it to your requests
3. Setting proper container height
**99% of "map not loading" issues = missing/invalid token!** 🔑
---
# Directions Api
https://docs.mapatlas.xyz/overview/directions/directions
# Directions Api
Routing Direction Service (a.k.a. turn-by-turn), is an open-source routing service that lets you integrate routing and navigation into a web or mobile application.
The **`/directions`** endpoint calculates a route between two points (origin → destination).
use your token in Query Param:
```http
POST /directions/?token=YOUR_TOKEN
```
## Example Request
```json
{ "locations": [ { "lat": "42.358528", "lon": "-83.271400" }, { "lat": "42.996613", "lon": "-78.749855" } ], "costing": "auto" ,"id":"my_work_route"}
```
There is an option to name your route request. You can do this by appending the following to your request &id=. The id is returned with the response so a user could match to the corresponding request.
| Parameter | Location |Required| Description |
| --------------| ----------- |--------| ------------------------------------------------------------------------------------------ |
| **token** | Query Param |✅ Yes | API authentication token. Required, otherwise request will fail with `401 Token required`. |
| **locations** | bodyjson |✅ Yes | Latitude,Longitude of the starting point to the destination point, "locations":[ { "lat": "42.358528", "lon": "-83.271400" }, { "lat": "42.996613", "lon": "-78.749855" } ], |
| **costing** | bodyjson |✅ Yes | "auto","bicycle","bus","truck","taxi","pedestrian" |
# Costing models
directions routing service uses dynamic, run-time costing to generate the route path. The route request must include the name of the costing model and can include optional parameters available for the chosen costing model.
| Costing model | Description |
|---------------|-------------|
| **auto** | Standard costing for driving routes by car, motorcycle, truck, and so on that obeys automobile driving rules, such as access and turn restrictions. `auto` provides a short time path (though not guaranteed to be shortest time) and uses intersection costing to minimize turns and maneuvers or road name changes. Routes also tend to favor highways and higher classification roads, such as motorways and trunks. |
| **bicycle** | Standard costing for travel by bicycle, with a slight preference for using cycleways or roads with bicycle lanes. Bicycle routes follow regular roads when needed, but avoid roads without bicycle access. |
| **bus** | Standard costing for bus routes. Bus costing inherits the auto costing behaviors, but checks for bus access on the roads. |
| **truck** | Standard costing for trucks. Truck costing inherits the auto costing behaviors, but checks for truck access, width and height restrictions, and weight limits on the roads. |
| **taxi** | Standard costing for taxi routes. Taxi costing inherits the auto costing behaviors, but checks for taxi lane access on the roads and favors those roads. |
| **motor_scooter** | Standard costing for travel by motor scooter or moped. By default, `motor_scooter` costing will avoid higher class roads unless the country overrides allows motor scooters on these roads. Motor scooter routes follow regular roads when needed, but avoid roads without motor_scooter, moped, or mofa access. |
| **pedestrian** | Standard walking route that excludes roads without pedestrian access. In general, pedestrian routes are shortest distance with the following exceptions: walkways and footpaths are slightly favored, while steps or stairs and alleys are slightly avoided. |
## Trip legs and maneuvers
A `trip` contains one or more `legs`. For *n* number of `break` locations, there are *n-1* legs.
`Through` locations do not create separate legs.
Each leg of the trip includes a summary, which is comprised of the same information as a trip summary but applied to the single leg of the trip. It also includes a `shape`, which is an encoded polyline of the route path (with 6 digits decimal precision), and a list of `maneuvers` as a JSON array.
For more about decoding route shapes, see these [code examples](#).
If `elevation_interval` is specified, each leg of the trip will return `elevation` along the route as a JSON array. The `elevation_interval` is also returned.
Units for both `elevation` and `elevation_interval` are either meters or feet based on the input units specified.
---
## Each maneuver includes
| Maneuver item | Description |
|----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **type** | Type of maneuver. See below for a list. |
| **instruction** | Written maneuver instruction. Describes the maneuver, such as `"Turn right onto Main Street"`. |
| **verbal_transition_alert_instruction** | Text suitable for use as a verbal alert in a navigation application. The transition alert instruction will prepare the user for the forthcoming transition.
Example: `"Turn right onto North Prince Street"`. |
| **verbal_pre_transition_instruction** | Text suitable for use as a verbal message immediately prior to the maneuver transition.
Example: `"Turn right onto North Prince Street, U.S. 2 22"`. |
| **verbal_post_transition_instruction** | Text suitable for use as a verbal message immediately after the maneuver transition.
Example: `"Continue on U.S. 2 22 for 3.9 miles"`. |
| **street_names** | List of street names that are consistent along the entire nonobvious maneuver. |
| **begin_street_names** | When present, these are the street names at the beginning (transition point) of the nonobvious maneuver (if they are different than the names that are consistent along the entire nonobvious maneuver). |
| **time** | Estimated time along the maneuver in seconds. |
| **length** | Maneuver length in the units specified. |
| **begin_shape_index** | Index into the list of shape points for the start of the maneuver. |
| **end_shape_index** | Index into the list of shape points for the end of the maneuver. |
| **toll** | `true` if the maneuver has any toll, or portions of the maneuver are subject to a toll. |
| **highway** | `true` if a highway is encountered on this maneuver. |
| **rough** | `true` if the maneuver is unpaved or rough pavement, or has any portions that have rough pavement. |
| **gate** | `true` if a gate is encountered on this maneuver. |
| **ferry** | `true` if a ferry is encountered on this maneuver. |
| **sign** | Contains the interchange guide information at a road junction associated with this maneuver. See below for details. |
| **roundabout_exit_count** | The spoke to exit roundabout after entering. |
| **depart_instruction** | Written depart time instruction. Typically used with a transit maneuver, such as `"Depart: 8:04 AM from 8 St - NYU"`. |
| **verbal_depart_instruction** | Text suitable for use as a verbal depart time instruction. Typically used with a transit maneuver, such as `"Depart at 8:04 AM from 8 St - NYU"`. |
| **arrive_instruction** | Written arrive time instruction. Typically used with a transit maneuver, such as `"Arrive: 8:10 AM at 34 St - Herald Sq"`. |
| **verbal_arrive_instruction** | Text suitable for use as a verbal arrive time instruction. Typically used with a transit maneuver, such as `"Arrive at 8:10 AM at 34 St - Herald Sq"`. |
| **transit_info** | Contains the attributes that describe a specific transit route. See below for details. |
| **verbal_multi_cue** | `true` if the `verbal_pre_transition_instruction` has been appended with the verbal instruction of the next maneuver. |
| **travel_mode** | Travel mode:
• `"drive"`
• `"pedestrian"`
• `"bicycle"`
• `"transit"` |
| **travel_type** | Travel type for drive:
• `"car"`
• `"motorcycle"`
• `"motor_scooter"`
• `"truck"`
• `"bus"`
Travel type for pedestrian:
• `"foot"`
• `"wheelchair"`
Travel type for bicycle:
• `"road"`
• `"hybrid"`
• `"cross"`
• `"mountain"`
Travel type for transit:
• Tram or light rail = `"tram"`
• Metro or subway = `"metro"`
• Rail = `"rail"`
• Bus = `"bus"`
• Ferry = `"ferry"`
• Cable car = `"cable_car"`
• Gondola = `"gondola"`
• Funicular = `"funicular"` |
| **bss_maneuver_type** | Used when `travel_mode` is `bikeshare`. Describes bike share maneuver. The default value is `"NoneAction"`.
Options:
• `"NoneAction"`
• `"RentBikeAtBikeShare"`
• `"ReturnBikeAtBikeShare"` |
| **bearing_before** | The clockwise angle from true north to the direction of travel immediately before the maneuver. |
| **bearing_after** | The clockwise angle from true north to the direction of travel immediately after the maneuver. |
| **lanes** | An array describing lane-level guidance. Used when `turn_lanes` is enabled. See below for details. |
## Directions Options
Directions options should be specified at the top level of the JSON object.
| Options | Description |
|--------------------|-------------|
| **units** | Distance units for output. Allowable unit types are miles (or mi) and kilometers (or km). If no unit type is specified, the units default to kilometers. |
| **language** | The language of the narration instructions based on the [IETF BCP 47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) language tag string. If no language is specified or the specified language is unsupported, United States-based English (en-US) is used. [Currently supported language list](https://valhalla.readthedocs.io/en/latest/api/languages/). |
| **directions_type** | An enum with 3 values:
- `none` indicating no maneuvers or instructions should be returned.
- `maneuvers` indicating that only maneuvers be returned.
- `instructions` indicating that maneuvers with instructions should be returned (this is the default if not specified). |
| **format** | Four options are available:
- `json` is default Valhalla routing directions JSON format.
- `gpx` returns the route as a GPX (GPS exchange format) XML track.
- `osrm` creates an OSRM compatible route directions JSON.
- `pbf` formats the result using protocol buffers. |
| **shape_format** | If `"format": "osrm"` is set: Specifies the optional format for the path shape of each connection. One of `polyline6` (default), `polyline5`, `geojson` or `no_shape`. |
| **banner_instructions** | If the format is `osrm`, this boolean indicates if each step should have the additional `bannerInstructions` attribute, which can be displayed in some navigation system SDKs. |
| **voice_instructions** | If the format is `osrm`, this boolean indicates if each step should have the additional `voiceInstructions` attribute, which can be heard in some navigation system SDKs. |
| **alternates** | A number denoting how many alternate routes should be provided. There may be no alternates or less alternates than the user specifies. Alternates are not yet supported on multipoint routes (that is, routes with more than 2 locations). They are also not supported on time dependent routes. |
---
## Example with language
```json
{"locations":[{"lat":40.730930,"lon":-73.991379},{"lat":40.749706,"lon":-73.991562}],"format":"osrm","costing":"bus","banner_instructions":true,"voice_instructions":true,"language":"es-ES"}
```
---
## Costing Options
You can customize route preferences using `costing_options` for different vehicle types. These options allow you to control route behavior such as avoiding tolls or highways.
### Using Tolls and Highways
For the `auto` costing model, you can specify whether to use or avoid tolls and highways:
- `use_tolls`: `1` (use tolls) or `0` (avoid tolls)
- `use_highways`: `1` (use highways) or `0` (avoid highways)
### Example with Tolls and Highways
```json
{
"locations": [
{"lat": "24.938833", "lon": "67.089141"},
{"lat": "25.385833", "lon": "68.367643"}
],
"costing": "auto",
"units": "km",
"alternates": 3,
"id": "route_request",
"directions_options": {
"alternates": 3
},
"costing_options": {
"auto": {
"use_tolls": 1,
"use_highways": 1
}
}
}
```
In this example:
- `"use_tolls": 1` means the route **will include** toll roads
- `"use_highways": 1` means the route **will include** highways
- Set to `0` to **avoid** tolls or highways respectively
# Supported Language Tags
| Language tag | Language alias | Description |
|---------------|----------------|------------------------|
| **bg-BG** | bg | Bulgarian (Bulgaria) |
| **ca-ES** | ca | Catalan (Spain) |
| **cs-CZ** | cs | Czech (Czech Republic) |
| **da-DK** | da | Danish (Denmark) |
| **de-DE** | de | German (Germany) |
| **el-GR** | el | Greek (Greece) |
| **en-GB** | en | English (United Kingdom) |
| **en-US-x-pirate** | en-x-pirate | English (United States) Pirate |
| **en-US** | en | English (United States) |
| **es-ES** | es | Spanish (Spain) |
| **et-EE** | et | Estonian (Estonia) |
| **fi-FI** | fi | Finnish (Finland) |
| **fr-FR** | fr | French (France) |
| **hi-IN** | hi | Hindi (India) |
| **hu-HU** | hu | Hungarian (Hungary) |
| **it-IT** | it | Italian (Italy) |
| **ja-JP** | ja | Japanese (Japan) |
| **nb-NO** | nb | Bokmål (Norway) |
| **nl-NL** | nl | Dutch (Netherlands) |
| **pl-PL** | pl | Polish (Poland) |
| **pt-BR** | pt | Portuguese (Brazil) |
| **pt-PT** | pt | Portuguese (Portugal) |
| **ro-RO** | ro | Romanian (Romania) |
| **ru-RU** | ru | Russian (Russia) |
| **sk-SK** | sk | Slovak (Slovakia) |
| **sl-SI** | sl | Slovenian (Slovenia) |
| **sv-SE** | sv | Swedish (Sweden) |
| **tr-TR** | tr | Turkish (Turkey) |
| **uk-UA** | uk | Ukrainian (Ukraine) |
## Outputs of a route
The route results are returned as a `trip`. This is a JSON object that contains details about the trip, including locations, a summary with basic information about the entire trip, and a list of `legs`.
| Trip item | Description |
|--------------|-----------------------------------------------------------------------------|
| `status` | Status code. |
| `status_messa` | Status message. |
| `units` | The specified units of length are returned, either kilometers or miles. |
| `language` | The language of the narration instructions. If the user specified a language in the directions options and the specified language was supported - this returned value will be equal to the specified value. Otherwise, this value will be the default (en-US) language. |
| `locations` | Location information is returned in the same form as it is entered with additional fields to indicate the side of the street. |
| `warnings` (optional) | This array may contain warning objects informing about deprecated request parameters, clamped values etc. |
---
## Example Response
```json
{
"trip": {
"locations": [
{
"type": "break",
"lat": 42.358528,
"lon": -83.271400,
"original_index": 0
},
{
"type": "break",
"lat": 42.996613,
"lon": -78.749855,
"original_index": 1
}
],
"legs": [
{
"maneuvers": [
{
"type": 2,
"instruction": "Drive northeast on Appleton Avenue.",
"verbal_pre_transition_instruction": "Drive northeast on Appleton Avenue for 1.2 miles.",
"street_names": ["Appleton Avenue"],
"time": 142.5,
"length": 1.932,
"cost": 142.5,
"begin_shape_index": 0,
"end_shape_index": 23,
"highway": false,
"toll": false,
"rough": false,
"gate": false,
"ferry": false,
"travel_mode": "drive",
"travel_type": "car"
},
{
"type": 15,
"instruction": "Turn right onto Main Street.",
"verbal_pre_transition_instruction": "Turn right onto Main Street.",
"street_names": ["Main Street"],
"time": 45.2,
"length": 0.542,
"cost": 45.2,
"begin_shape_index": 23,
"end_shape_index": 35,
"highway": false,
"toll": false,
"rough": false,
"gate": false,
"ferry": false,
"travel_mode": "drive",
"travel_type": "car"
},
{
"type": 4,
"instruction": "You have arrived at your destination.",
"verbal_pre_transition_instruction": "You have arrived at your destination.",
"time": 0,
"length": 0,
"cost": 0,
"begin_shape_index": 35,
"end_shape_index": 35,
"travel_mode": "drive",
"travel_type": "car"
}
],
"summary": {
"has_time_restrictions": false,
"has_toll": false,
"has_highway": true,
"has_ferry": false,
"min_lat": 42.358421,
"min_lon": -83.271523,
"max_lat": 42.997234,
"max_lon": -78.749234,
"time": 18734.567,
"length": 453.211,
"cost": 18734.567
},
"shape": "gysalAlg~j}ClFhBnHdCrKrDfKjDdK~CjGtB|@b@n@TdG`Cd@VzEnBb@l@\\Vh@d@b@n@f@d@nRzQpCvCNPPRNNFHHJFDDDJLVTFJTVFDx@z@d@j@\\^r@r@`@`@NPNPJLJJLJHHHJDFJHb@b@j@h@nBnBlBnBvBvBpBpBxBxBvBvBjBjB^`@Z\\TTNR^\\~@z@hDfDbArATVrBnBlAfAbEhEXXt@v@dCdCZ\\vBvB`@`@RPLJJJ~ArAXVlEjEdDfDVVHHJHRRPPNNrFrFbBbBfLjLhBlBp@n@~D`EtCxCv@t@~@`AxIbJzD~DnLrLbDdDTVFHn@l@bBfBdDdDdAhAfC~BrBnBXV^l@d@\\l@dArBv@rBnA|Bd@x@PZHJp@rAnAtB`BjCjBxChCbEfApB|GjLxBtDl@pA^v@`BdDlBfDrBvDHNvExH\\n@z@bBrBhDxBzDNTjAtBpC~EbBvCdAnBjAjBbDbFpA`C^r@hAnBnB~Cl@dA|@xAlFbJfAnBtBhDvFlJv@tAfCxDpD~FZj@dApB|E|HfBzC^p@tDhGtAnClBpDvA~BhAlBx@~A`AjBlBnDx@|AnCfFzGjMf@bAfChFp@tAZ~@h@v@pB|D|@bBzBbEjHzMfBxCz@zAvDbHNTpCtF`BfCfCdE~C|FpBnDn@~@|BfE`D`HdAxBlAnC"
}
],
"summary": {
"has_time_restrictions": false,
"has_toll": false,
"has_highway": true,
"has_ferry": false,
"min_lat": 42.358421,
"min_lon": -83.271523,
"max_lat": 42.997234,
"max_lon": -78.749234,
"time": 18734.567,
"length": 453.211,
"cost": 18734.567
},
"status_message": "Found route between points",
"status": 0,
"units": "kilometers",
"language": "en-US"
}
}
```
---
# Elevation API
https://docs.mapatlas.xyz/overview/directions/elevation
# Elevation API
Elevation lookup service provides digital elevation model (DEM) data as the result of a query. The elevation service data has many applications when combined with other routing and navigation data, including computing the steepness of roads and paths or generating an elevation profile chart along a route.
## Inputs of the elevation service
An elevation service request takes the form of json={}, where the JSON inputs inside the {} includes location information.
# Use Shape list for input location
The elevation request run locally takes the form of json={}, where the JSON inputs inside the {} are described below.
A shape request must include a latitude and longitude in decimal degrees, and the locations are visited in the order specified. The input coordinates can come from many input sources, such as a GPS location, a point or a click on a map, a geocoding service, and so on.
These parameters are available for shape.
# Shape Parameters
| Parameter | Description |
|-----------|------------------------------------|
| `lat` | Latitude of the location in degrees |
| `lon` | Longitude of the location in degrees |
| `token` | token parameter is required /elevation/?token=YOUR TOKEN
---
## Example 1: Request with `range` and `shape`
```json
{"range":true,"shape":[{"lat":40.712431,"lon":-76.504916},{"lat":40.712275,"lon":-76.605259},{"lat":40.712122,"lon":-76.805694},{"lat":40.722431,"lon":-76.884916},{"lat":40.812275,"lon":-76.905259},{"lat":40.912122,"lon":-76.965694}]}
```
## Example 2:
```json
{"shape":[{"lat":40.712433,"lon":-76.504913},{"lat":40.712276,"lon":-76.605263},{"lat":40.712124,"lon":-76.805695},{"lat":40.722431,"lon":-76.884918},{"lat":40.812275,"lon":-76.905258},{"lat":40.912121,"lon":-76.965691}],"range_height":[[0,307],[8467,272],[25380,204],[32162,204],[42309,180],[54533,198]]}
```
## Example 3:
```json
{"range":true,"shape":[{"lat":40.712431,"lon":-76.504916},{"lat":40.712275,"lon":-76.605259},{"lat":40.712122,"lon":-76.805694},{"lat":40.722431,"lon":-76.884916},{"lat":40.812275,"lon":-76.905259},{"lat":40.912122,"lon":-76.965694}]}
```
---
## Example Response
```json
{
"shape": [
{"lat": 40.712431, "lon": -76.504916},
{"lat": 40.712275, "lon": -76.605259},
{"lat": 40.712122, "lon": -76.805694},
{"lat": 40.722431, "lon": -76.884916},
{"lat": 40.812275, "lon": -76.905259},
{"lat": 40.912122, "lon": -76.965694}
],
"range_height": [
[0, 307],
[8467, 272],
[25380, 204],
[32162, 204],
[42309, 180],
[54533, 198]
],
"height": [307, 272, 204, 204, 180, 198]
}
```
**Response Fields:**
- `shape`: Array of lat/lon coordinates that were queried
- `range_height`: Array of `[distance, elevation]` pairs where distance is in meters from the start point
- `height`: Simple array of elevation values in meters corresponding to each coordinate in the shape
---
# Isochrones Maps
https://docs.mapatlas.xyz/overview/directions/isochrone
# Isochrones Maps
The ability to return these amazing structures called isochrones. What's an isochrone? The word is a combination of two greek roots iso meaning equal and chrono meaning time. So indeed, an isochrone is a structure representing equal time. In our case it's a line that represents constant travel time about a given location. One can think of isochrone maps as somewhat similar to the familiar topographic maps except that instead of lines of constant height, lines are of constant travel time are depicted. For this reason other terms common in topography apply such as contours or isolines.
This is an example of 15, 30, 45 and 60 minute bicycle isochrones centered in Lancaster, PA.
# Inputs of the Isochrone Service
An isochrone request run locally takes the form of json={}, where the JSON inputs inside the {} includes an array of at least one location
For example, you can use the isochrone service to find out where you can travel within a 15-minute walk from your office building. The API request for this uses isochrone? as the request action, pedestrian costing, and a single contour for a 15-minute time interval. The response is GeoJSON, which you can display on a map to visualize where you might be able to walk.
```json
{"locations":[{"lat":40.744014,"lon":-73.990508}],"costing":"pedestrian","contours":[{"time":15.0,"color":"ff0000"}]}
```
# Parameters & bodyjson
| Parameter | Description |
|-----------|------------------------------------|
| `lat` | Latitude of the location in degrees |
| `lon` | Longitude of the location in degrees |
| `token` | token parameter is required /elevation/?token=YOUR TOKEN
---
# Output of Isochrone Service
In the service response, the isochrone contours are returned as GeoJSON, which can be integrated into mapping applications.
The isochrone service returns contours as GeoJSON line or polygon features for the requested intervals (depending on the value of the polygons request parameter). These contours are calculated using a two dimensional grid. If the format request parameter is set to geotiff, the underlying grid data is returned directly instead of the contours derived from it. It will return one band for each requested metric (i.e. one for time and one for distance). If an isochrone request has been named using the optional &id= input, then the id is returned as a name property for the feature collection within the GeoJSON response. A metric attribute lets you know whether it's a distance or time contour. A warnings array may also be included. This array may contain warning objects informing about deprecated request parameters, clamped values etc.
# Other request bodyjson content
| Parameter | Description |
|--------------|-----------------------------------------------------------------------------|
| `date_time` | The local date and time at the location.
- `type`
- `0` - Current departure time.
- `1` - Specified departure time.
- `2` - Specified arrival time. Note: This is not yet implemented for `multimodal`.
- `value` - the date and time specified in ISO 8601 format (YYYY-MM-DDThh:mm) in the local time zone of departure or arrival. For example, "2016-07-03T08:06". |
| `contours` | A JSON array of contour objects with the time in minutes or distance in kilometers and color to use for each isochrone contour. You can specify up to four contours (by default).
- `time` - A floating point value specifying the time in minutes for the contour.
- `distance` - A floating point value specifying the distance in kilometers for the contour.
- `color` - The color for the output of the contour. Specify it as a Hex value, but without the `#`, such as `"ff0000"` for red. If no color is specified, the isochrone service will assign a default color to the output.
You can only specify one metric per contour, i.e. time or distance. |
| `polygons` | A Boolean value to determine whether to return geojson polygons or linestrings as the contours. The default is `false`, which returns lines; when `true`, polygons are returned. Note: When polygons is `true`, a feature's geometry type can be either `Polygon` or `MultiPolygon`, depending on the number of exterior rings formed for a given interval. |
| `denoise` | A floating point value from 0 to 1 (default of 1) which can be used to remove smaller contours. A value of 1 will only return the largest contour for a given time value. A value of 0.5 drops any contours that are less than half the area of the largest contour in the set of contours for that same time value. |
| `generalize` | A floating point value in meters used as the tolerance for Douglas-Peucker generalization. Note: Generalization of contours can lead to self-intersections, as well as intersections of adjacent contours. |
| `show_locations` | A boolean indicating whether the input locations should be returned as MultiPoint features; one feature for the exact input coordinates and one feature for the coordinates of the network node it snapped to. Default false. |
---
## Example Response (GeoJSON Polygon)
```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-73.990508, 40.744014],
[-73.989234, 40.745123],
[-73.987654, 40.746234],
[-73.986123, 40.747345],
[-73.985432, 40.748456],
[-73.984321, 40.749567],
[-73.983210, 40.750678],
[-73.990508, 40.744014]
]
]
},
"properties": {
"fill": "#ff0000",
"fillOpacity": 0.33,
"fill-opacity": 0.33,
"fillColor": "#ff0000",
"color": "#ff0000",
"contour": 0,
"opacity": 0.33,
"metric": "time",
"value": 15
}
}
],
"bbox": [-73.995234, 40.738765, -73.983210, 40.750678]
}
```
## Example Response (GeoJSON LineString - polygons: false)
```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[-73.990508, 40.744014],
[-73.989234, 40.745123],
[-73.987654, 40.746234],
[-73.986123, 40.747345],
[-73.985432, 40.748456],
[-73.984321, 40.749567],
[-73.983210, 40.750678],
[-73.990508, 40.744014]
]
},
"properties": {
"fill": "#ff0000",
"fillOpacity": 0.33,
"fill-opacity": 0.33,
"fillColor": "#ff0000",
"color": "#ff0000",
"contour": 0,
"opacity": 0.33,
"metric": "time",
"value": 15
}
}
],
"bbox": [-73.995234, 40.738765, -73.983210, 40.750678]
}
```
---
# Map Matching
https://docs.mapatlas.xyz/overview/directions/mapMatch
# Map Matching
Map Matching service, you can match coordinates, such as GPS locations, to roads and paths that have been mapped in OpenStreetMap. By doing this, you can turn a path into a route with narrative instructions and also get the attribute values from that matched line.
# Map Matching API (`/map-matching/`)
The **`/map-matching`** API is used to match noisy GPS traces onto the road network, reconstructing the most probable route.
---
## Example Request
```http
GET /map-matching/?token=YOUR_API_TOKEN
```
```json
{
"shape": [
{ "lat": 39.983841, "lon": -76.735741, "type": "break" },
{ "lat": 39.983704, "lon": -76.735298, "type": "via" },
{ "lat": 39.983578, "lon": -76.734848, "type": "via" },
{ "lat": 39.983551, "lon": -76.734253, "type": "break" },
{ "lat": 39.983555, "lon": -76.734116, "type": "via" },
{ "lat": 39.983589, "lon": -76.733315, "type": "via" },
{ "lat": 39.983719, "lon": -76.732445, "type": "via" },
{ "lat": 39.983818, "lon": -76.731712, "type": "via" },
{ "lat": 39.983776, "lon": -76.731506, "type": "via" },
{ "lat": 39.983696, "lon": -76.731369, "type": "break" }
],
"costing": "auto",
"shape_match": "map_snap"
}
```
| Parameter | Location | Required | Description |
| ---------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| **token** | Query Param | ✅ Yes | API authentication token. Required, otherwise request will fail with `401 Token required`. |
| **shape** | Body JSON | ✅ Yes | Array of GPS coordinates with at least **2 points**. Each point must have `lat`, `lon`, and a `type` (`break` or `via`). |
| **costing** | Body JSON | ✅ Yes | Travel mode. Options: `auto`, `pedestrian`, `bicycle`, `bus`,`motor_scooter`. |
| **shape\_match** | Body JSON | ❌ No | Controls the map matching behavior. Common values: `map_snap` (default) Options: `walk_or_snap`, `map_snap`, `edge_walk`|
## Shape Match Options details
`shape_match` is an optional string input parameter. It allows some control of the matching algorithm based on the type of input.
| shape_match type | Description |
|-------------------|-----------------------------------------------------------------------------|
| edge_walk | Indicates an edge walking algorithm can be used. This algorithm requires nearly exact shape matching, so it should only be used when the shape is from a prior Valhalla route. |
| map_snap | Indicates that a map-matching algorithm should be used because the input shape might not closely match Valhalla edges. This algorithm is more expensive. |
| walk_or_snap | Also the default option. This will try edge walking and if this does not succeed, it will fall back and use map matching. |
## Attribute filters (trace_attributes only)
The trace_attributes action allows you to apply filters to include or exclude specific attribute filter keys in your response. These filters are optional and can be added to the action string inside of the filters object.
```
// Edge filter Keys
edge.names
edge.length # can also set source/target_percent_along
edge.speed
edge.speeds_faded
edge.speeds_non_faded
edge.road_class
edge.begin_heading
edge.end_heading
edge.begin_shape_index
edge.end_shape_index
edge.traversability
edge.use
edge.toll
edge.unpaved
edge.tunnel
edge.bridge
edge.roundabout
edge.internal_intersection
edge.drive_on_right
edge.surface
edge.sign.exit_number
edge.sign.exit_branch
edge.sign.exit_toward
edge.sign.exit_name
edge.travel_mode
edge.vehicle_type
edge.pedestrian_type
edge.bicycle_type
edge.transit_type
edge.id
edge.indoor
edge.way_id
edge.weighted_grade
edge.max_upward_grade
edge.max_downward_grade
edge.mean_elevation
edge.lane_count
edge.cycle_lane
edge.bicycle_network
edge.sac_scale
edge.shoulder
edge.sidewalk
edge.density
edge.speed_limit
edge.truck_speed
edge.truck_route
edge.country_crossing
edge.forward
edge.traffic_signal
// Node filter keys
node.intersecting_edge.begin_heading
node.intersecting_edge.from_edge_name_consistency
node.intersecting_edge.to_edge_name_consistency
node.intersecting_edge.driveability
node.intersecting_edge.cyclability
node.intersecting_edge.walkability
node.intersecting_edge.use
node.intersecting_edge.road_class
node.intersecting_edge.lane_count
node.elapsed_time
node.admin_index
node.type
node.traffic_signal
node.fork
node.time_zone
// Other filter keys
osm_changeset
shape
admin.country_code
admin.country_text
admin.state_code
admin.state_text
matched.point
matched.type
matched.edge_index
matched.begin_route_discontinuity
matched.end_route_discontinuity
matched.distance_along_edge
matched.distance_from_trace_point
```
## Edge items include:
| Item | Description |
|-----------------|-----------------------------------------------------------------------------|
| names | List of names |
| source | The start of an edge's matched percentage of its length (0, range if edge was fully matched; end of an edge's matched percentage of its length (0, range if edge was fully matched); we set value to percentage of its length in units specified (default is kilometers). If percentage is unavailable, we set value to -1. |
| target | The end of an edge's matched percentage of its length (0, range if edge was fully matched); we set value to percentage of its length in units specified (default is kilometers). If percentage is unavailable, we set value to -1. |
| length | The retrieved edge's length in the units specified (default is kilometers). If unavailable, we represent the partially matched length, otherwise full length percent. |
| speed | Edge's specified speed. The default is kilometers per hour. |
| from.type | Describes how the shape was assigned to the edge. Either it’s inferred from road class (default value) or set by the user. |
| speed.faded | Contains all speed values that are faded in the current time (if available). The current value fades with the flow rates when they are available. |
| speed.non_faded | Contains all flow rates when speed is available that the edge is not the units specified (default flow rates are in kilometers per hour). All flows are available when speed is available. |
| road_class | Road class value. |
| begin.heading | The direction at the beginning of the edge. The units are degrees from north in a clockwise direction. |
| end.heading | The direction at the end of the edge. The units are degrees from north in a clockwise direction. |
| begin_shape_index | Index of the list of shape points for the start of the edge. |
| end_shape_index | Index of the list of shape points for the end of the edge. |
| traversability | Traversability value, if available. |
| use | Use value. |
| toll | Toll value. |
| unpaved | Unpaved value. |
| tunnel | Tunnel value. |
| bridge | Bridge value. |
| roundabout | Roundabout value. |
| internal_intersection | Internal intersection value. |
| drive_on_right | Drive on right value. |
| surface | Surface value. |
| sign.exit_number | Sign exit number value. |
| sign.exit_branch | Sign exit branch value. |
| sign.exit_toward | Sign exit toward value. |
| sign.exit_name | Sign exit name value. |
| travel_mode | Travel mode value. |
| vehicle_type | Vehicle type value. |
| pedestrian_type | Pedestrian type value. |
| bicycle_type | Bicycle type value. |
| transit_type | Transit type value. |
| id | ID value. |
| indoor | Indoor value. |
| way_id | Way ID value. |
| weighted_grade | Weighted grade value. |
| max_upward_grade | Maximum upward grade value. |
| max_downward_grade | Maximum downward grade value. |
| mean_elevation | Mean elevation value. |
| lane_count | Lane count value. |
| cycle_lane | Cycle lane value. |
| bicycle_network | Bicycle network value. |
| sac_scale | SAC scale value. |
| shoulder | Shoulder value. |
| sidewalk | Sidewalk value. |
| density | Density value. |
| speed_limit | Speed limit value. |
| truck_speed | Truck speed value. |
| truck_route | Truck route value. |
| country_crossing | Country crossing value. |
| forward | Forward value. |
| traffic_signal | Traffic signal value. |
## Outputs of trace_attributes
The `trace_attributes` results contains a list of edges and, optionally, the following items: `osm_changeset`, list of `admins`, `shape`, `matched_points`, and `units`.
| Result item | Description |
|-----------------|--------------------------------------------------|
| edges | List of edges associated with input shape. See the list of edge items for details. |
| osm_changeset | Identifier of the OpenStreetMap base data version. |
| admins | List of the administrative codes and names. See the list of admin items for details. |
| shape | The encoded polyline of the matched path. |
| matched_points | List of match results when using the `map_snap` shape match algorithm. There is a one-to-one correspondence with the input set of latitude, longitude coordinates and this list of match results. See the list of matched point items for details. |
| units | The specified units with the request, in either kilometers or miles. |
| warnings | A warnings array. This array may contain descriptive text about notices of deprecated request parameters, clamped values etc. |
## trace_attributes with shape parameter
```json
{
"shape": [
{ "lat": 39.983841, "lon": -76.735741, "type": "break" },
{ "lat": 39.983704, "lon": -76.735298, "type": "via" },
{ "lat": 39.983578, "lon": -76.734848, "type": "via" },
{ "lat": 39.983551, "lon": -76.734253, "type": "break" },
{ "lat": 39.983555, "lon": -76.734116, "type": "via" },
{ "lat": 39.983589, "lon": -76.733315, "type": "via" },
{ "lat": 39.983719, "lon": -76.732445, "type": "via" },
{ "lat": 39.983818, "lon": -76.731712, "type": "via" },
{ "lat": 39.983776, "lon": -76.731506, "type": "via" },
{ "lat": 39.983696, "lon": -76.731369, "type": "break" }
],
"costing": "motor_scooter",
"shape_match": "walk_or_snap",
"filters": {
"attributes": [
"edge.names",
"edge.id",
"edge.weighted_grade",
"edge.speed"
],
"action": "include"
}
}
```
## trace_attributes with encoded polyline parameter
```json
{"shape":[{"lat":39.983841,"lon":-76.735741,"type":"break"},{"lat":39.983704,"lon":-76.735298,"type":"via"},{"lat":39.983578,"lon":-76.734848,"type":"via"},{"lat":39.983551,"lon":-76.734253,"type":"break"},{"lat":39.983555,"lon":-76.734116,"type":"via"},{"lat":39.983589,"lon":-76.733315,"type":"via"},{"lat":39.983719,"lon":-76.732445,"type":"via"},{"lat":39.983818,"lon":-76.731712,"type":"via"},{"lat":39.983776,"lon":-76.731506,"type":"via"},{"lat":39.983696,"lon":-76.731369,"type":"break"}],"encoded_polyline":"_grbgAh~{nhF?lBAzBFvBHxBEtBKdB?fB@dBZdBb@hBh@jBb@x@\\|@x@pB\\x@v@hBl@nBPbCXtBn@|@z@ZbAEbAa@~@q@z@QhA]pAUpAVhAPlAWtASpAAdA[dASdAQhAIlARjANnAZhAf@n@`A?lB^nCRbA\\xB`@vBf@tBTbCFbARzBZvBThBRnBNrBP`CHbCF`CNdCb@vBX`ARlAJfADhA@dAFdAP`AR`Ah@hBd@bBl@rBV|B?vB]tBCvBBhAF`CFnBXtAVxAVpAVtAb@|AZ`Bd@~BJfA@fAHdADhADhABjAGzAInAAjAB|BNbCR|BTjBZtB`@lBh@lB\\|Bl@rBXtBN`Al@g@t@?nAA~AKvACvAAlAMdAU`Ac@hAShAI`AJ`AIdAi@bAu@|@k@p@]p@a@bAc@z@g@~@Ot@Bz@f@X`BFtBXdCLbAf@zBh@fBb@xAb@nATjAKjAW`BI|AEpAHjAPdAAfAGdAFjAv@p@XlAVnA?~A?jAInAPtAVxAXnAf@tBDpBJpBXhBJfBDpAZ|Ax@pAz@h@~@lA|@bAnAd@hAj@tAR~AKxAc@xAShA]hAIdAAjA]~A[v@BhB?dBSv@Ct@CvAI~@Oz@Pv@dAz@lAj@~A^`B^|AXvAVpAXdBh@~Ap@fCh@hB\\zBN`Aj@xBFdA@jALbAPbAJdAHdAJbAHbAHfAJhALbA\\lBTvBAdC@bC@jCKjASbC?`CM`CDpB\\xAj@tB\\fA\\bAVfAJdAJbAXz@L|BO`AOdCDdA@~B\\z@l@v@l@v@l@r@j@t@b@x@b@r@z@jBVfCJdAJdANbCPfCF|BRhBS~BS`AYbAe@~BQdA","shape_match":"map_snap","costing":"pedestrian","directions_options":{"units":"miles"}}
```
## trace_attributes with include attribute filter
```json
{
"shape":[{"lat":39.983841,"lon":-76.735741},{"lat":39.983704,"lon":-76.735298},{"lat":39.983578,"lon":-76.734848},{"lat":39.983551,"lon":-76.734253},{"lat":39.983555,"lon":-76.734116},{"lat":39.983589,"lon":-76.733315},{"lat":39.983719,"lon":-76.732445},{"lat":39.983818,"lon":-76.731712},{"lat":39.983776,"lon":-76.731506},{"lat":39.983696,"lon":-76.731369}],"costing":"auto","shape_match":"walk_or_snap","filters":{"attributes":["edge.names","edge.id", "edge.weighted_grade","edge.speed"],"action":"include"}
}
```
## trace_attributes with exclude attribute filter
```json
{"shape":[{"lat":39.983841,"lon":-76.735741},{"lat":39.983704,"lon":-76.735298},{"lat":39.983578,"lon":-76.734848},{"lat":39.983551,"lon":-76.734253},{"lat":39.983555,"lon":-76.734116},{"lat":39.983589,"lon":-76.733315},{"lat":39.983719,"lon":-76.732445},{"lat":39.983818,"lon":-76.731712},{"lat":39.983776,"lon":-76.731506},{"lat":39.983696,"lon":-76.731369}],"costing":"auto","shape_match":"walk_or_snap","filters":{"attributes":["edge.names","edge.begin_shape_index","edge.end_shape_index","shape"],"action":"exclude"}}
```
---
# Matrix Service
https://docs.mapatlas.xyz/overview/directions/matrix
# Matrix Service
The time distance matrix service takes a sources and targets to list locations. This allows you to set the source (origin) locations separately from the target (destination) locations. The set of origins may be disjoint (not overlapping) with the set of destinations. In other words, the target locations do not have to include any locations from source locations. The time-distance matrix can return a row matrix, a column matrix, or a general matrix of computed time and distance, depending on your input for the sources and targets parameters. The general case is a row ordered matrix with the time and distance from each source location to each target location. A row vector is considered a one_to_many time-distance matrix where there is one source location and multiple target locations. The time and distance from the source location to all target locations is returned. A column matrix represents a many_to_one time-distance matrix where there are many sources and one target. Another special case is when the source location list is the same as the target location list. Here, a diagonal (square matrix with [0,0.00] on the diagonal elements) matrix is returned. The is special case is often used as the input to optimized routing problems.
## Required Parameters
- **Query Param**:
- `token` → Required /matrix/?token=YOUR_TOKEN (Authorization, otherwise `401 Token required` error will occur)
- **Body JSON**:
- `sources` → Required (List of source coordinates)
- `targets` → Optional (List of target coordinates, defaults to sources if omitted)
- `costing` → Required (Travel mode, e.g. `pedestrian`, `auto`, `bicycle`)
---
## Examples (Copy & Paste)
## One-to-Many
```json
{ "sources": [ { "lat": 40.744014, "lon": -73.990508 } ], "targets": [ { "lat": 40.744014, "lon": -73.990508 }, { "lat": 40.739735, "lon": -73.979713 }, { "lat": 40.752522, "lon": -73.985015 }, { "lat": 40.750117, "lon": -73.983704 }, { "lat": 40.750552, "lon": -73.993519 } ], "costing": "pedestrian" }
```
## Many-to-One
```json
{ "sources": [ { "lat": 40.744014, "lon": -73.990508 }, { "lat": 40.739735, "lon": -73.979713 }, { "lat": 40.752522, "lon": -73.985015 }, { "lat": 40.750117, "lon": -73.983704 }, { "lat": 40.750552, "lon": -73.993519 } ], "targets": [ { "lat": 40.750552, "lon": -73.993519 } ], "costing": "pedestrian" }
```
## Many-to-Many
```json
{ "sources": [ { "lat": 40.744014, "lon": -73.990508 }, { "lat": 40.739735, "lon": -73.979713 }, { "lat": 40.752522, "lon": -73.985015 }, { "lat": 40.750117, "lon": -73.983704 }, { "lat": 40.750552, "lon": -73.993519 } ], "targets": [ { "lat": 40.744014, "lon": -73.990508 }, { "lat": 40.739735, "lon": -73.979713 }, { "lat": 40.752522, "lon": -73.985015 }, { "lat": 40.750117, "lon": -73.983704 }, { "lat": 40.750552, "lon": -73.993519 } ], "costing": "pedestrian" }
```
# Costing parameter
The Time-Distance Matrix service uses the auto, bicycle, pedestrian and bikeshare and other costing models available in the route service. Exception: multimodal costing is not supported for the time-distance matrix service at this time.
## Other Request Option
| Option | Description |
|-----------------|-------------|
| **verbose** | If `true`, it will output a flat list of objects for `distances` & `durations` explicitly specifying the source & target indices. If `false`, will return more compact, nested row-major `distances` & `durations` arrays and not echo `sources` and `targets`.
**Default:** `true`. |
| **shape_format** | Specifies the optional format for the path shape of each connection. One of `polyline6`, `polyline5`, `geojson`, or `no_shape` (default). |
---
## Outputs of the matrix service
Depending on the `verbose` (default: `true`) request parameter, the result of the Time-Distance Matrix service is different. In both (`"verbose": true` and `"verbose": false`) cases, these parameters are present:
| Item | Description |
|--------------|-----------------------------------------------------------------------------|
| `algorithm` | The algorithm used to compute the results. Can be `"timedistancematrix"`, `"costmatrix"`, or `"timedistancebssmatrix"`. |
| `units` | Distance units for output. Allowable unit types are `"miles"` and `"kilometers"`. If no unit type is specified in the input, the units default to `"kilometers"`. |
| `warnings` (optional) | This array may contain warning objects informing about deprecated request parameters, clamped values etc. |
# Verbose mode ("verbose": true)
The following parameters are only present in "verbose": true mode:
| Item | Description |
|-----------------|--------------------------------------------------|
| sources | The sources passed to the request. |
| targets | The targets passed to the request. |
| sources_to_targets | An array of time and distance between the sources and the targets. The array is row-ordered, meaning the time and distance from the first location to all others forms the first row of the array, followed by the time and distance from the second source location to all target locations, etc. The Object contained in the arrays contains the following fields:
distance: The computed distance between each set of points. Distance will always be 0.00 for the first element of the time-distance array for one_to_many, the last element in a many_to_one, and the first and last elements of a many_to_many.time: The computed time between each set of points. Time will always be 0 for the first element of the time-distance array for one_to_many, the last element in a many_to_one, and the first and last elements of a many_to_many.to_index: The destination index into the locations array.from_index: The origin index into the locations array. When a user will arrive at/depart from this location. See the part above where we explain how time dependent matrices work for further context. Note: If the time is above the setting
max_time_dep_distance_matrix this is skipped. |
## Concise mode ("verbose": false)
| Item | Description |
|-----------------|--------------------------------------------------|
| sources_to_target | Returns an object with durations and distances as row-ordered contents of the values above. |
---
## Example Response (Verbose Mode)
```json
{
"sources": [
{"lat": 40.744014, "lon": -73.990508},
{"lat": 40.739735, "lon": -73.979713}
],
"targets": [
{"lat": 40.752522, "lon": -73.985015},
{"lat": 40.750117, "lon": -73.983704},
{"lat": 40.750552, "lon": -73.993519}
],
"sources_to_targets": [
[
{
"distance": 1.342,
"time": 294,
"to_index": 0,
"from_index": 0
},
{
"distance": 0.987,
"time": 215,
"to_index": 1,
"from_index": 0
},
{
"distance": 0.532,
"time": 142,
"to_index": 2,
"from_index": 0
}
],
[
{
"distance": 2.134,
"time": 412,
"to_index": 0,
"from_index": 1
},
{
"distance": 1.876,
"time": 387,
"to_index": 1,
"from_index": 1
},
{
"distance": 2.245,
"time": 456,
"to_index": 2,
"from_index": 1
}
]
],
"units": "kilometers",
"algorithm": "timedistancematrix"
}
```
## Example Response (Concise Mode - verbose: false)
```json
{
"sources_to_targets": {
"durations": [
[294, 215, 142],
[412, 387, 456]
],
"distances": [
[1.342, 0.987, 0.532],
[2.134, 1.876, 2.245]
]
},
"units": "kilometers",
"algorithm": "timedistancematrix"
}
```
---
# Optimization
https://docs.mapatlas.xyz/overview/directions/optimization
# Optimization
The Optimized Route service provides a quick computation of time and distance between a set of location sources and location targets and returns them in an optimized route order, along with the shape.
# Inputs of the optimized route
The optimized route request run locally takes the form of json={}, where the JSON inputs inside the {} includes location information (at least four locations), as well as the name and options for the costing model
Here is an example of an Optimized Route scenario:
Given a list of cities and the distances and times between each pair, a salesperson wants to visit each city one time by taking the most optimized route and end at a destination (either return to origin or a different destination)
```json
{ "locations": [ { "lat": 40.042072, "lon": -76.306572 }, { "lat": 39.992115, "lon": -76.781559 }, { "lat": 39.984519, "lon": -76.695600 }, { "lat": 39.996586, "lon": -76.769028 }, { "lat": 39.984322, "lon": -76.706672 } ], "costing": "auto", "units": "miles" }
```
| Parameter | Location | Required | Description |
| ------------- | ----------- | -------- | --------------------------------------------------------------------------------------- |
| **token** | Query Param | ✅ Yes | API authentication token. Required, otherwise the API will return `401 Token required`. |
| **locations** | Body JSON | ✅ Yes | Array of coordinates (`lat`, `lon`). **At least 2 locations must be provided**. |
| **costing** | Body JSON | ✅ Yes | Travel mode. Options: `auto`, `pedestrian`, `bicycle`. |
| **units** | Body JSON | ❌ No | Distance units. Options: `miles` (default) or `kilometers`. |
# Outputs of the optimized route service
These are the results of a request to the Optimized Route service.
| Item | Description |
|-----------------|-----------------------------------------------------------------------------|
| optimized_route | Returns an optimized route path from point 'a' to point 'n'. Given a list of locations, an optimized route with stops at each intermediate location exactly one time, always starting at the first location in the list and ending at the last location. |
| locations | The specified array of lat/lngs from the input request. The first and last locations in the array will remain the same as the input request. The intermediate locations may be returned reordered in the response. Due to the reordering of the intermediate locations, an `original_index` is also part of the `locations` object within the response. This is an identifier of the location index that will allow a user to easily correlate input locations with output locations. |
| units | Distance units for output. Allowable unit types are mi (miles) and km (kilometers). If no unit type is specified, the units default to kilometers. |
| warnings (optional) | This array may contain warning objects informing about deprecated request parameters, clamped values etc. |
---
## Example Response
```json
{
"optimized_route": [
{
"trip": {
"locations": [
{
"type": "break",
"lat": 40.042072,
"lon": -76.306572,
"original_index": 0
},
{
"type": "break",
"lat": 39.984519,
"lon": -76.695600,
"original_index": 2
},
{
"type": "break",
"lat": 39.984322,
"lon": -76.706672,
"original_index": 4
},
{
"type": "break",
"lat": 39.996586,
"lon": -76.769028,
"original_index": 3
},
{
"type": "break",
"lat": 39.992115,
"lon": -76.781559,
"original_index": 1
}
],
"legs": [
{
"summary": {
"time": 2156.785,
"length": 41.812,
"cost": 2156.785
},
"shape": "encoded_polyline_string_here"
}
],
"summary": {
"time": 2156.785,
"length": 41.812,
"cost": 2156.785
},
"status_message": "Found route between points",
"status": 0,
"units": "miles",
"language": "en-US"
}
}
],
"units": "miles"
}
```
**Note:** The `locations` array shows the optimized order of stops. Use the `original_index` field to map back to your input locations.
---
# Autocomplete Endpoint
https://docs.mapatlas.xyz/overview/geocoder/autocomplete
# Autocomplete Endpoint
::: danger Deprecated — do not use for new development
This is the **v1** autocomplete endpoint. It is still served and existing
integrations keep working, but it is no longer the recommended API and will
not receive new features.
**For anything new, use [v2](/overview/geocoder/v2/autocomplete).** v2 adds session-based billing
(materially cheaper for as-you-type search), richer results, and a
[suggest → retrieve](/overview/geocoder/v2/retrieve) flow.
The easiest path is one of the
[geocoding SDKs](/overview/sdk/geocoding/), which handle sessions, debouncing
and error typing for you.
:::
The **Autocomplete API** provides instant place and address suggestions
based on partial user input. It is typically used in search boxes where
results update in real time.
## How it Works
- Send a **partial query** (`text`) such as `"YMCA"`.
- The API returns a **list of matching places** (venues, streets, cities, etc.)
with coordinates and metadata.
- You can restrict search by location using **boundary parameters**.
- **Important:** You must include a valid `token` parameter in the request.
If missing or invalid, the API will return `401 Token required`.
## Endpoint
```
GET https://gateway.mapmetrics-atlas.net/autocomplete/
```
## Parameters
# Search API Parameters
| Parameter | Type | Req | Example | Description |
|---------------------------|--------------------------|-----|---------------------|-----------------------------------------------------------------------------|
| `text` | string | ✅ | `Union Square` | The search query text. |
| `token` | string (auth token) | ✅ | `abcd1234` | Required authentication token, otherwise request returns `401 Token required`. |
| `focus.point.lat` | float | ❌ | `48.581755` | Latitude for search focus. |
| `focus.point.lon` | float | ❌ | `7.745843` | Longitude for search focus. |
| `boundary.rect.min_lon` | float | ❌ | `139.2794` | Minimum longitude of bounding box. |
| `boundary.rect.max_lon` | float | ❌ | `140.1471` | Maximum longitude of bounding box. |
| `boundary.rect.min_lat` | float | ❌ | `35.53308` | Minimum latitude of bounding box. |
| `boundary.rect.max_lat` | float | ❌ | `35.81346` | Maximum latitude of bounding box. |
| `boundary.circle.lat` | float | ❌ | `43.818156` | Circle boundary center latitude. |
| `boundary.circle.lon` | float | ❌ | `-79.186484` | Circle boundary center longitude. |
| `boundary.circle.radius` | float | ❌ | `35` | Circle radius (in kilometers). |
| `sources` | string (comma-separated) | ❌ | `openstreetmap,wof` | Data sources to include. |
| `layers` | string (comma-separated) | ❌ | `address,venue` | Feature layers to include. |
| `boundary.country` | string (comma-separated) | ❌ | `GBR,FRA` | ISO country codes to limit search. |
| `boundary.gid` | Pelias `gid` | ❌ | `whosonfirst:locality:101748355` | Restrict results to a specific boundary by gid. |
| `size` | integer | ❌ | `20` | Number of results to return. |
## Example
```bash
GET https://gateway.mapmetrics-atlas.net/autocomplete/?text=YMCA&boundary.circle.lon=-79.186484&boundary.circle.lat=43.818156&boundary.circle.radius=35&token=YOUR_API_KEY
```
## Example Response
```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-78.861037, 43.900526]
},
"properties": {
"name": "Durham Family YMCA",
"country": "Canada",
"region": "Ontario",
"locality": "Oshawa",
"label": "Durham Family YMCA, Oshawa, ON, Canada"
}
}
]
}
```
---
# Forward Geocode
https://docs.mapatlas.xyz/overview/geocoder/forwardgeocode
# Forward Geocode
::: danger Deprecated — do not use for new development
This is the **v1** forward geocoding endpoint. It is still served and existing
integrations keep working, but it is no longer the recommended API and will
not receive new features.
**For anything new, use [v2](/overview/geocoder/v2/forwardgeocode).** v2 adds session-based billing
(materially cheaper for as-you-type search), richer results, and a
[suggest → retrieve](/overview/geocoder/v2/retrieve) flow.
The easiest path is one of the
[geocoding SDKs](/overview/sdk/geocoding/), which handle sessions, debouncing
and error typing for you.
:::
Geocoding is the process of matching an address or other text to its corresponding geographic coordinates.
## Search the world
In the simplest search, you can provide only one parameter, the text you want to match in any part of the location details. To do this, build a query where the text parameter is set to the item you want to find.
For example, if you want to find a YMCA facility, here's what you'd need to append to the base URL of the service.
# Note the parameter values are set as follows:
| parameter | value |
|-----------|-------|
|`token` |*string|
| text | YMCA |
In the example above, you will find the name of each matched locations in a property named `'label'`. The top 10 labels returned at the time of writing were:
- YMCA, Bargoed Community, United Kingdom
- YMCA, Nunspeet, Gelderland
- YMCA, Belleville, IL
- YMCA, Forest City, IA
- YMCA, Fargo, ND
- YMCA, Taipei, Taipei City
- YMCA, Orpington, Greater London
- YMCA, Frisco, TX
- YMCA, Jefferson, OH
- YMCA, Belleville, IL
Spelling matters, but not capitalization. You can type `ymca`, `YMCA`, or even `yMcA`. See for yourself by comparing the results of the earlier search to the following:
[/forward-geocode/?token=YOUR_TOKEN&text=ymcA](https://gateway.mapmetrics-atlas.net/forward-geocode/?token=YOUR_TOKEN&text=ymcA)
Note that the results are spread out throughout the world because you have not given your current location or provided any other geographic context in which to search.
## Set the number of results returned
By default, returns up to 10 results. If you want a different number, set the size parameter to the desired number. This example shows returning only the first result.
| parameter | value |
|-----------|-------|
|`token` |*string|
| text | YMCA |
| size | 1 |
If you want 25 results, you can build the query where size is 25.
## Narrow your search
If you are looking for places in a particular region, or country, or only want to look in the immediate vicinity of a user with a known location, you can narrow your search to an area. There are different ways of including a region in your query. we supports three types: country, rectangle, and circle.
Sometimes your work might require that all the search results be from a particular country or a list of countries. To do this, you can set the boundary.country parameter value to a comma separated list of alpha-2 or alpha-3 ISO-3166 country code.
Now, you want to search for YMCA again, but this time only in Great Britain. To do this, you will need to know that the alpha-3 code for Great Britain is GBR and set the parameters like this:
[/forward-geocode/?token=YOUR_TOKEN&text=YMCA&boundary.country=GBR](https://gateway.mapmetrics-atlas.net/forward-geocode/?token=YOUR_TOKEN&text=YMCA&boundary.country=GBR)
| parameter | value |
| :--- | :--- |
| `token` |*string|
| `text` | YMCA |
| `boundary.country` | GBR |
Note that all the results are within Great Britain:
* YMCA, Bargoed Community, United Kingdom
* YMCA, Orpington, Greater London
* YMCA, Erdington, West Midlands
* YMCA, Malvern CP, United Kingdom
* YMCA, Ancoats, Greater Manchester
* YMCA, Carmarthen Community, United Kingdom
* YMCA, Halebank, Cheshire
* YMCA, Brightlingsea CP, United Kingdom
* YMCA, Lenton Abbey, Nottinghamshire
* YMCA, Old Clee, Lincolnshire
If you try the same search request with different country codes, the results change to show YMCA locations within this region.
Results in the United States:
* YMCA, Belleville, IL
* YMCA, Forest City, IA
* YMCA, Fargo, ND
* YMCA, Frisco, TX
* YMCA, Jefferson, OH
* YMCA, Belleville, IL
* YMCA, Chapel Hill, NC
* YMCA, West Lampeter, PA
* YMCA, Bremerton, WA
* YMCA, Westerly, RI
## Search within a rectangular region
To specify the boundary using a rectangle, you need latitude, longitude coordinates for two diagonals of the bounding box (the minimum and the maximum latitude, longitude).
For example, to find a YMCA within the state of Texas, you can set the `boundary.rect.*` parameter to values representing the bounding box around Texas: min_lon=-106.65 min_lat=25.84 max_lon=-93.51 max_lat=36.5
[/forward-geocode/?token=YOUR_TOKEN&text=YMCA&boundary.rect.min_lat=25.84&boundary.rect.min_lon=-106.65&boundary.rect.max_lat=36.5&boundary.rect.max_lon=-93.51]
| parameter | value |
| :--- | :--- |
| `token` |*string|
| `text` | YMCA |
| `boundary.rect.min_lat` | 25.84 |
| `boundary.rect.min_lon` | -106.65 |
| `boundary.rect.max_lat` | 36.5 |
| `boundary.rect.max_lon` | -93.51 |
* YMCA, Austin, TX
* YMCA, Frisco, TX
* Y.M.C.A, Fort Worth, TX
* YMCA, Rockwall, TX
* YMCA, Missouri City, TX
* YMCA, Northshore, TX
* YMCA, Austin, TX
* YMCA, Tulsa, OK
* YMCA, Los Alamos, NM
* YMCA, Tulsa, OK
## Search within a circular region
Sometimes you don't have a rectangle to work with, but rather you have a point on earth—for example, your location coordinates—and a maximum distance within which acceptable results can be located.
In this example, you want to find all YMCA locations within a 35-kilometer radius of a location in Ontario, Canada. This time, you can use the `boundary.circle.*` parameter group, where `boundary.circle.lat` and `boundary.circle.lon` is your location in Ontario and `boundary.circle.radius` is the acceptable distance from that location. Note that the `boundary.circle.radius` parameter is always specified in kilometers.
[/forward-geocode/?token=YOUR_TOKEN&text=YMCA&boundary.circle.lat=43.818156&boundary.circle.lon=-79.186484&boundary.circle.radius=35]
| parameter | value |
| :--- | :--- |
| `token` |*string|
| `text` | YMCA |
| `boundary.circle.lat` | 43.818156 |
| `boundary.circle.lon` | -79.186484 |
| `boundary.circle.radius` | 35 |
You can see the results have fewer than the standard 10 items because there are not that many YMCA locations in the specified radius:
* YMCA, Toronto, Ontario
* YMCA, Markham, Ontario
* YMCA, Toronto, Ontario
* Metro Central YMCA, Toronto, Ontario
* Pinnacle Jr YMCA, Toronto, Ontario
* Cooper Koo Family Cherry Street YMCA Centre, Toronto, Ontario
## Search within a parent administrative area
forward-geocode has a powerful understanding of relationships between places. In particular, it has a concept called the administrative hierarchy: each record in our map is listed as belonging to a parent neighbourhood, city, region, country, and other regions. This has many uses, including filtering. The map global id (gid) of any record can be used with the boundary.gid filter to return only records with a given parent.
For example, finding YMCAs in [Oklahoma](https://en.wikipedia.org/wiki/Oklahoma) with only a bounding box would be challenging: the bounding box would include much of nearby Texas, possibly leading to incorrect results.
With `boundary.gid`, this query can return accurate results.
[/v1/search?text=YMCA&__boundary.gid=whosonfirst:region:85688585__](http://pelias.github.io/compare/#/v1/search%3Fboundary.gid=whosonfirst:region:85688585&text=ymca)
* YMCA, Stillwater, OK, USA
* YMCA, Edmond, OK, USA
* YMCA, Guymon, OK, USA
* YMCA, Grove, OK, USA
* YMCA, Midwest City, OK, USA
* YMCA, Shawnee, OK, USA
* YMCA, Owasso, OK, USA
* YMCA, Tulsa, OK, USA
* YMCA, The Village, OK, USA
* YMCA, Broken Arrow, OK, USA

By specifying a `focus.point`, results will be sorted in part by their proximity to the given coordinate. All else being equal, results closest to the point will show up higher. However, unlike a `boundary.circle` query, important results far from the given coordinate may still be returned. This allows.
To find YMCAs again, but this time near a specific coordinate location (representing the Sydney Opera House) in Sydney, Australia, use `focus.point`.
[/forward-geocode/?token=YOUR_TOKEN&text=YMCA&focus.point.lat=-33.856680&focus.point.lon=151.215281]
| parameter | value |
| :--- | :--- |
| `token`| *string |
| `text` | YMCA |
| `focus.point.lat` | -33.856680 |
| `focus.point.lon` | 151.215281 |
Looking at the results, you can see that the few locations closer to this location show up at the top of the list, sorted by distance. You also still get back a significant amount of remote locations, for a well balanced mix. Because you provided a focus point, we can compute distance from that point for each resulting feature.
* YMCA, Redfern, New South Wales [distance: 3.836]
* YMCA, St Ives (NSW), New South Wales [distance: 14.844]
* YMCA, Epping (NSW), New South Wales [distance: 16.583]
* YMCA, Revesby, New South Wales [distance: 21.335]
* YMCA, Kochâang, South Gyeongsang [distance: 8071.436]
* YMCA, Center, IN [distance: 14882.675]
* YMCA, Lake Villa, IL [distance: 14847.667]
* YMCA, Onondaga, NY [distance: 15818.08]
* YMCA, 's-Gravenhage, Zuid-Holland [distance: 16688.292]
* YMCA, Loughborough, United Kingdom [distance: 16978.367]
## Prioritize around a point
| parameter | value |
| :--- | :--- |
| `token`| *string |
| `text` | YMCA |
| `focus.point.lat` | -33.856680 |
| `focus.point.lon` | 151.215281 |
| `boundary.country` | AUS |
The results below look different from the ones you saw before with only a focus point specified. These results are all from within Australia. You'll note the closest results show up at the top of the list, which is helped by the focus parameter.
* YMCA, Redfern, New South Wales [distance: 3.836]
* YMCA, St Ives (NSW), New South Wales [distance: 14.844]
* YMCA, Epping (NSW), New South Wales [distance: 16.583]
* YMCA, Revesby, New South Wales [distance: 21.335]
* YMCA, Larrakeyah, Northern Territory [distance: 3144.296]
* YMCA, Kepnock, Queensland [distance: 1001.657]
* YMCA, Kings Meadows, Tasmania [distance: 917.144]
* YMCA, Katherine East, Northern Territory [distance: 2873.376]
* YMCA, Sadadeen, Northern Territory [distance: 2026.731]
* YMCA, Ararat, Victoria [distance: 841.022]
## Prioritize within a circular region
| parameter | value |
| :--- | :--- |
| `token`| *string |
| `text` | YMCA |
| `focus.point.lat` | -33.856680 |
| `focus.point.lon` | 151.215281 |
| `boundary.circle.lat` | -33.856680 |
| `boundary.circle.lon` | 151.215281 |
| `boundary.circle.radius` | 50 |
Looking at these results, they are all less than 50 kilometers away from the focus point:
* YMCA, Redfern, New South Wales [distance: 3.836]
* YMCA, St Ives (NSW), New South Wales [distance: 14.844]
* YMCA, Epping (NSW), New South Wales [distance: 16.583]
* YMCA, Revesby, New South Wales [distance: 21.335]
* Caringbah YMCA, Caringbah, New South Wales [distance: 22.543]
* YMCA building, Loftus, New South Wales [distance: 25.756]
## Filter by data source
The search examples so far have returned a mix of results from all the data sources available to Pelias. Here are the sources being searched:
| source | name | short name |
|---|---|---|
| [OpenStreetMap](http://www.openstreetmap.org/) | `openstreetmap` | `osm` |
| [OpenAddresses](http://openaddresses.io/) | `openaddresses` | `oa` |
| [Who's on First](https://whosonfirst.org) | `whosonfirst` | `wof` |
| [GeoNames](http://www.geonames.org/) | `geonames` | `gn` |
| parameter | value |
| :--- | :--- |
| `token`| *string |
| `text` | YMCA |
| `sources` | oa |
Because OpenAddresses is, as the name suggests, only address data, here's what you can expect to find:
* 20 Ymca Drive, Niagara, ON, Canada
* 341 Ymca Rd, New Hope, AL, USA
* 318 Ymca Rd, New Hope, AL, USA
* 138 Ymca Rd, New Hope, AL, USA
* 304 Ymca Rd, New Hope, AL, USA
* 1919 Ymca Lane, Minnetonka, MN, USA
* 101 Ymca Dr, Kannapolis, NC, USA
* 2121 Ymca Camp Road, Stokes County, NC, USA
* 1110 Ymca Camp Road, Stokes County, NC, USA
* 1581 Ymca Camp Road, Stokes County, NC, USA
## Filters by data type
Here's a list of the types of places you could find in the results, sorted by granularity:
|layer|description|
|----|----|
|`venue`|points of interest, businesses, things with walls|
|`address`|places with a street address|
|`street`|streets,roads,highways|
|`neighbourhood`|social communities, neighbourhoods|
|`borough`|a local administrative boundary, currently only used for New York City|
|`localadmin`|local administrative boundaries|
|`locality`|towns, hamlets, cities|
|`county`|official governmental area; usually bigger than a locality, almost always smaller than a region|
|`macrocounty`|a related group of counties. Mostly in Europe.|
|`region`|states and provinces|
|`macroregion`|a related group of regions. Mostly in Europe|
|`country`|places that issue passports, nations, nation-states|
|`coarse`|alias for simultaneously using all administrative layers (everything except `venue` and `address`)|
[/forward-geocode/?token=YOUR_TOKEN&text=YMCA&layers=street,venue]
## Available search parameters
| Parameter | Type | Required | Default | Example |
| --- | --- | --- | --- | --- |
| `token`| *string |yes|---|---|
| `text` | string | yes | none | `Union Square` |
| `focus.point.lat` | floating point number | no | none | `48.581755` |
| `focus.point.lon` | floating point number | no | none | `7.745843` |
| `boundary.rect.min_lon` | floating point number | no | none | `139.2794` |
| `boundary.rect.max_lon` | floating point number | no | none | `140.1471` |
| `boundary.rect.min_lat` | floating point number | no | none | `35.53308` |
| `boundary.rect.max_lat` | floating point number | no | none | `35.81346` |
| `boundary.circle.lat` | floating point number | no | none | `43.818156` |
| `boundary.circle.lon` | floating point number | no | none | `-79.186484` |
| `boundary.circle.radius` | floating point number | no | 50 | `35` |
| `boundary.gid` | Pelias `gid` | no | none | `whosonfirst:locality:101748355` |
| `sources` | string | no | all sources: osm,oa,gn,wof | openstreetmap,wof |
| `layers` | string | no | all layers: address,venue,neighbourhood,locality,borough,localadmin,county,macrocounty,region,macroregion,country,coarse,postalcode | address,venue |
| `boundary.country` | string | no | none | `GBR,FRA` |
| `size` | integer | no | 10 | 20 |
---
# Reverse Geocoding API
https://docs.mapatlas.xyz/overview/geocoder/reversegeocode
# Reverse Geocoding API
::: danger Deprecated — do not use for new development
This is the **v1** reverse geocoding endpoint. It is still served and existing
integrations keep working, but it is no longer the recommended API and will
not receive new features.
**For anything new, use [v2](/overview/geocoder/v2/reversegeocode).** v2 adds session-based billing
(materially cheaper for as-you-type search), richer results, and a
[suggest → retrieve](/overview/geocoder/v2/retrieve) flow.
The easiest path is one of the
[geocoding SDKs](/overview/sdk/geocoding/), which handle sessions, debouncing
and error typing for you.
:::
The Reverse Geocoding API takes a **latitude/longitude point** and returns the closest address or place.
| Parameter | Type | Req | Example | Description |
| ------------------------ | ------------------------ | --- |--------------------------------|---------------------------------------------------------------------------- |
| `point.lat` | float | ✅ | `48.858268` | Latitude of the point to reverse geocode. |
| `point.lon` | float | ✅ | `2.294471` | Longitude of the point to reverse geocode. |
| `token` | string (auth token) | ✅ | `abcd1234` | Required authentication token, otherwise request returns `401 Token required`.|
| `boundary.circle.lat` | float | ❌ | `48.858268` | Circle boundary center latitude. |
| `boundary.circle.lon` | float | ❌ | `2.294471` | Circle boundary center longitude. |
| `boundary.circle.radius` | float | ❌ | `35` | Circle radius (in kilometers). *(Note: ignored for coarse reverse lookups)* |
| `sources` | string (comma-separated) | ❌ | `oa,gn` | Data sources to include (`oa=openaddresses`, `gn=geonames`). |
| `layers` | string (comma-separated) | ❌ | `address,locality` | Feature layers to include. |
| `boundary.country` | string (comma-separated) | ❌ | `FR,GBR` | ISO country codes to limit search. |
| `boundary.gid` | Pelias `gid` | ❌ | `whosonfirst:region:85683497` | Restrict results to a specific boundary by gid. |
| `size` | integer | ❌ | `3` | Number of results to return. |
## Example
```bash
GET https://gateway.mapmetrics-atlas.net/reverse-geocode/?point.lat=48.858268&point.lon=2.294471&boundary.circle.radius=7&size=3&layers=locality,address&sources=oa,gn&boundary.country=FR&boundary.gid=whosonfirst:region:85683497&token=YOUR_API_KEY
```
## Example Response
```json
{
"geocoding": {
"version": "0.2",
"attribution": "http://localhost:4000/attribution",
"query": {
"layers": [
"locality",
"address"
],
"sources": [
"openaddresses",
"geonames"
],
"size": 3,
"private": false,
"point.lat": 48.858268,
"point.lon": 2.294471,
"boundary.circle.radius": 5,
"boundary.circle.lat": 48.858268,
"boundary.circle.lon": 2.294471,
"boundary.country": [
"FRA"
],
"boundary.gid": "85683497",
"lang": {
"name": "English",
"iso6391": "en",
"iso6393": "eng",
"via": "default",
"defaulted": true
},
"querySize": 6
},
"engine": {
"name": "Pelias",
"author": "Mapzen",
"version": "1.0"
},
"timestamp": 1755669190951
},
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.294511,
48.857747
]
},
"properties": {
"id": "fr/countrywide:3edd29e1f32d7408",
"gid": "openaddresses:address:fr/countrywide:3edd29e1f32d7408",
"layer": "address",
"source": "openaddresses",
"source_id": "fr/countrywide:3edd29e1f32d7408",
"country_code": "FR",
"name": "12 Avenue Pierre Loti",
"housenumber": "12",
"street": "Avenue Pierre Loti",
"postalcode": "75007",
"confidence": 0.8,
"distance": 0.058,
"accuracy": "point",
"country": "France",
"country_gid": "whosonfirst:country:85633147",
"country_a": "FRA",
"macroregion": "Ile-of-France",
"macroregion_gid": "whosonfirst:macroregion:404227465",
"macroregion_a": "IF",
"region": "Paris",
"region_gid": "whosonfirst:region:85683497",
"region_a": "VP",
"localadmin": "Paris",
"localadmin_gid": "whosonfirst:localadmin:1159322569",
"locality": "Paris",
"locality_gid": "whosonfirst:locality:101751119",
"borough": "7th Arrondissement",
"borough_gid": "whosonfirst:borough:1158894245",
"neighbourhood": "Gros Caillou",
"neighbourhood_gid": "whosonfirst:neighbourhood:85873841",
"continent": "Europe",
"continent_gid": "whosonfirst:continent:102191581",
"label": "12 Avenue Pierre Loti, Paris, France"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.293651,
48.858317
]
},
"properties": {
"id": "fr/countrywide:afc231c551e25607",
"gid": "openaddresses:address:fr/countrywide:afc231c551e25607",
"layer": "address",
"source": "openaddresses",
"source_id": "fr/countrywide:afc231c551e25607",
"country_code": "FR",
"name": "2 Avenue Pierre Loti",
"housenumber": "2",
"street": "Avenue Pierre Loti",
"postalcode": "75007",
"confidence": 0.8,
"distance": 0.06,
"accuracy": "point",
"country": "France",
"country_gid": "whosonfirst:country:85633147",
"country_a": "FRA",
"macroregion": "Ile-of-France",
"macroregion_gid": "whosonfirst:macroregion:404227465",
"macroregion_a": "IF",
"region": "Paris",
"region_gid": "whosonfirst:region:85683497",
"region_a": "VP",
"localadmin": "Paris",
"localadmin_gid": "whosonfirst:localadmin:1159322569",
"locality": "Paris",
"locality_gid": "whosonfirst:locality:101751119",
"borough": "7th Arrondissement",
"borough_gid": "whosonfirst:borough:1158894245",
"neighbourhood": "Gros Caillou",
"neighbourhood_gid": "whosonfirst:neighbourhood:85873841",
"continent": "Europe",
"continent_gid": "whosonfirst:continent:102191581",
"label": "2 Avenue Pierre Loti, Paris, France"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
2.294373,
48.858816
]
},
"properties": {
"id": "fr/countrywide:c15503f350dea57a",
"gid": "openaddresses:address:fr/countrywide:c15503f350dea57a",
"layer": "address",
"source": "openaddresses",
"source_id": "fr/countrywide:c15503f350dea57a",
"country_code": "FR",
"name": "3 Avenue Anatole France",
"housenumber": "3",
"street": "Avenue Anatole France",
"postalcode": "75007",
"confidence": 0.8,
"distance": 0.061,
"accuracy": "point",
"country": "France",
"country_gid": "whosonfirst:country:85633147",
"country_a": "FRA",
"macroregion": "Ile-of-France",
"macroregion_gid": "whosonfirst:macroregion:404227465",
"macroregion_a": "IF",
"region": "Paris",
"region_gid": "whosonfirst:region:85683497",
"region_a": "VP",
"localadmin": "Paris",
"localadmin_gid": "whosonfirst:localadmin:1159322569",
"locality": "Paris",
"locality_gid": "whosonfirst:locality:101751119",
"borough": "7th Arrondissement",
"borough_gid": "whosonfirst:borough:1158894245",
"neighbourhood": "Gros Caillou",
"neighbourhood_gid": "whosonfirst:neighbourhood:85873841",
"continent": "Europe",
"continent_gid": "whosonfirst:continent:102191581",
"label": "3 Avenue Anatole France, Paris, France"
}
}
],
"bbox": [
2.293651,
48.857747,
2.294511,
48.858816
]
}
```
# Description
With reverse geocoding with Pelias, you can look up all sorts of information about points on a map,
# Size
A basic parameter for filtering is `size`, which is used to limit the number of results returned. In the earlier request that returned the Eiffel Tower (or 'Tour Eiffel', to be exact), notice that other results were returned including "Bureau de Gustave Eiffel" (a museum) and "Le Jules Verne" (a restaurant). To limit a reverse geocode to only the first result, pass the `size` parameter:
> /reverse?point.lat=48.858268&point.lon=2.294471&___size=1___
---
# Autocomplete Endpoint (v2)
https://docs.mapatlas.xyz/overview/geocoder/v2/autocomplete
# Autocomplete Endpoint (v2)
The **v2 Autocomplete API** provides instant place and address suggestions
based on partial user input. It is typically used in search boxes where
results update in real time as the user types.
> **Base URL:** all v2 examples on this page use `https://gateway.mapmetrics-atlas.net`.
> **Auth:** the `token` parameter is a **query parameter**, not a header.
> **Trailing slash is mandatory** on every v2 path — `/v2/autocomplete` (no
> trailing slash) returns `404`. Always call `/v2/autocomplete/`.
## How it Works
- Send a **partial query** using the `q` parameter, such as `"Nieuwezijds Voorburgwal 147"`.
- The API returns a **list of matching suggestions** (venues, streets,
addresses, localities) with no coordinates attached — see below.
- To resolve a suggestion to actual coordinates, take its `ord` value and
call [`/v2/retrieve/`](./retrieve.md).
- Pass a stable `session_token` across all keystrokes of one search so the
whole typing sequence bills as a single session — see
[Sessions](./sessions.md) for the full billing model.
::: danger Common mistake: `q`, not `text`
The parameter is **`q`**. Sending `text=Nieuwezijds Voorburgwal 147` does **not** error —
it returns HTTP 200 with `"results": []` and `"q": ""`. It silently looks
like "no matches" instead of failing loudly. This is the single most common
integration mistake against this endpoint.
:::
::: warning `proximity` is longitude first
`proximity` takes the form `
,` — **longitude before latitude**.
Reversing the order does not error either; it returns plausible-looking but
wrong results. A query centred on Maastricht with lat/lon swapped returned
hits 22–27 km away, in the wrong town entirely.
:::
## Endpoint
```
GET https://gateway.mapmetrics-atlas.net/v2/autocomplete/
```
## Parameters
| Parameter | Type | Req | Example | Description |
|-----------------|--------|-----|----------------------|----------------------------------------------------------------------|
| `q` | string | ✅ | `Nieuwezijds Voorburgwal 147` | The partial search text. **Not** `text`. |
| `token` | string | ✅ | `YOUR_API_KEY` | Auth token, passed as a query parameter. |
| `country` | string | ❌ | `nl` | ISO-2 lowercase country code to restrict results. |
| `session_token` | string | ❌ | `sess_a1b2c3` | Groups keystrokes into one billable session. See [Sessions](./sessions.md). |
| `proximity` | string | ❌ | `5.7423,50.8514` | `,` bias point. **Longitude first.** |
## Example
```bash
curl "https://gateway.mapmetrics-atlas.net/v2/autocomplete/?q=Nieuwezijds%20Voorburgwal%20147&country=nl&session_token=sess_a1b2c3&token=YOUR_API_KEY"
```
## Example Response
```json
{
"count": 1,
"q": "Nieuwezijds Voorburgwal 147",
"mode": "autocomplete",
"country": "nl",
"elapsed_ms": 4,
"results": [
{
"id": "osm:ext:7f3a9c1d2e4b5a6f",
"ord": 128371,
"layer": "address",
"country": "nl",
"hn": true,
"housenumber": "25",
"text": "Nieuwezijds Voorburgwal 147",
"place_name": "Nieuwezijds Voorburgwal 147, Maastricht",
"locality": "Maastricht",
"category": null,
"brand": null
}
]
}
```
Note that no result carries `center`, `geometry`, or `bbox` — coordinates
are deliberately withheld. Suggestions are cheap to serve at every
keystroke; coordinate resolution is the billable step, and it only happens
when you call [`/v2/retrieve/`](./retrieve.md) on the suggestion the user
picked.
---
# Forward Geocode (v2)
https://docs.mapatlas.xyz/overview/geocoder/v2/forwardgeocode
# Forward Geocode (v2)
The **v2 Forward Geocode API** matches a full free-text query to its
corresponding geographic coordinates and administrative context.
## How it Works
- Send the text you want to match using the `q` parameter, such as
`"Nieuwezijds Voorburgwal 147"`.
- Restrict results to a country with `country`, and cap the number of
results with `size`.
- Pass `format=pelias` to get back a full GeoJSON `FeatureCollection` — see
the trap below.
::: danger `format=pelias` is required for the GeoJSON envelope
Without `format=pelias` — or with any other value — the endpoint silently
returns a **different, flat shape** instead of the documented
`FeatureCollection`. This is not an error; it's a different response
contract. Always pass `format=pelias` explicitly.
:::
## Endpoint
```
GET https://gateway.mapmetrics-atlas.net/v2/forward-geocode/
```
## Parameters
| Parameter | Type | Req | Example | Description |
|-----------|---------|-----|------------------|------------------------------------------------------------------------|
| `q` | string | ✅ | `Nieuwezijds Voorburgwal 147` | The search query text. **Not** `text`. |
| `country` | string | ❌ | `nl` | ISO-2 lowercase country code to restrict search. |
| `size` | integer | ❌ | `10` | Number of results to return. |
| `format` | string | ✅ | `pelias` | Must be `pelias` to get the GeoJSON `FeatureCollection` shape. |
| `token` | string | ✅ | `YOUR_API_KEY` | Auth token, passed as a query parameter. |
## Example
```bash
curl "https://gateway.mapmetrics-atlas.net/v2/forward-geocode/?q=Nieuwezijds%20Voorburgwal%20147&country=nl&size=10&format=pelias&token=YOUR_API_KEY"
```
## Example Response
```json
{
"geocoding": {
"version": "0.2",
"attribution": "http://localhost:4000/attribution",
"query": {
"q": "Nieuwezijds Voorburgwal 147",
"country": "nl",
"size": 10
},
"engine": {
"name": "Pelias",
"author": "Mapzen",
"version": "1.0"
}
},
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [5.7423, 50.8514]
},
"properties": {
"id": "osm:ext:7f3a9c1d2e4b5a6f",
"gid": "osm:address:7f3a9c1d2e4b5a6f",
"layer": "address",
"name": "Nieuwezijds Voorburgwal 147",
"label": "Nieuwezijds Voorburgwal 147, Maastricht, Netherlands",
"housenumber": "25",
"street": "Nieuwezijds Voorburgwal",
"locality": "Maastricht",
"country": "Netherlands",
"country_a": "NLD"
}
}
],
"bbox": [5.7423, 50.8514, 5.7423, 50.8514]
}
```
::: warning `region` and `postalcode` are frequently absent
Unlike the v1 Pelias response, `region` and `postalcode` are frequently
**missing** from v2 `properties` — do not assume either field is always
present.
:::
::: tip Attribution must be surfaced
`geocoding.attribution` is the ODbL licence credit. Any client displaying
results **must** surface this attribution.
:::
---
# Retrieve Endpoint (v2)
https://docs.mapatlas.xyz/overview/geocoder/v2/retrieve
# Retrieve Endpoint (v2)
The **v2 Retrieve API** resolves one [autocomplete](./autocomplete.md)
suggestion into full coordinates. This is the billable step in the v2
autocomplete flow — see [Sessions](./sessions.md) for how retrieve closes a
session.
## How it Works
- Take the `ord` value from an autocomplete suggestion the user picked.
- Call `/v2/retrieve/` with that `ord`, plus the suggestion's `country` and
`layer`, to get back a `center` coordinate pair.
- Pass the same `session_token` used during autocomplete so the session is
closed correctly and billed once.
::: danger `ord` is the handle — `id` 404s
Retrieval is keyed on **`ord`**, not `id`. Retrieving by `id` returns
`404` on every layer, with no exception. Always take `ord` straight from
the autocomplete suggestion you're resolving — never construct or reuse an
`id`.
:::
## Endpoint
```
GET https://gateway.mapmetrics-atlas.net/v2/retrieve/
```
## Parameters
| Parameter | Type | Req | Example | Description |
|-----------------|---------|-----|----------------|----------------------------------------------------------------|
| `country` | string | ✅ | `nl` | ISO-2 lowercase country code, from the suggestion. |
| `layer` | string | ✅ | `address` | Layer of the suggestion, from the suggestion. |
| `ord` | integer | ✅ | `128371` | The suggestion handle from `/v2/autocomplete/`. Not `id`. |
| `hn` | boolean | ❌ | `true` | Housenumber flag, from the suggestion. |
| `session_token` | string | ❌ | `sess_a1b2c3` | Same token used during autocomplete; closes the session. |
| `token` | string | ✅ | `YOUR_API_KEY` | Auth token, passed as a query parameter. |
## Example
```bash
curl "https://gateway.mapmetrics-atlas.net/v2/retrieve/?country=nl&layer=address&ord=128371&hn=true&session_token=sess_a1b2c3&token=YOUR_API_KEY"
```
## Example Response
```json
{
"center": [5.7423, 50.8514],
"country": "nl",
"layer": "address",
"locality": "Maastricht",
"id": ""
}
```
::: warning `center` is `[lon, lat]`
`center` is `[longitude, latitude]`, in that order — easy to swap by
mistake. `id` in the response is currently a placeholder empty string;
don't rely on it for anything.
:::
## Retrieve Batch
`/v2/retrieve-batch/` resolves several suggestions in one call.
### Endpoint
```
GET https://gateway.mapmetrics-atlas.net/v2/retrieve-batch/
```
### Parameters
| Parameter | Type | Req | Example | Description |
|-----------|--------|-----|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| `items` | string | ✅ | `[{"country":"nl","layer":"address","ord":128371,"hn":true}]` (URL-encoded) | URL-encoded JSON array of `{country, layer, ord, hn?}` objects. |
| `token` | string | ✅ | `YOUR_API_KEY` | Auth token, passed as a query parameter. |
::: danger Only `items` works — no repeated params
`/v2/retrieve-batch/` takes **one** parameter, `items`, holding a
URL-encoded JSON array. It does **not** accept repeated params:
`ord=128371&ord=128372`, `ords=128371,128372`, and `ids=...` all silently
return HTTP 200 with `count: 0` — no error, no results.
:::
### Example
```bash
curl "https://gateway.mapmetrics-atlas.net/v2/retrieve-batch/?items=%5B%7B%22country%22%3A%22nl%22%2C%22layer%22%3A%22address%22%2C%22ord%22%3A128371%2C%22hn%22%3Atrue%7D%2C%7B%22country%22%3A%22nl%22%2C%22layer%22%3A%22address%22%2C%22ord%22%3A128372%7D%5D&token=YOUR_API_KEY"
```
The decoded `items` value in the example above is:
```json
[
{ "country": "nl", "layer": "address", "ord": 128371, "hn": true },
{ "country": "nl", "layer": "address", "ord": 128372 }
]
```
### Example Response
```json
{
"count": 2,
"results": [
{ "center": [5.7423, 50.8514], "id": "" },
{ "center": [5.7431, 50.8519], "id": "" }
]
}
```
## See Also
- [Autocomplete](./autocomplete.md) — produces the `ord` values retrieve consumes.
- [Sessions](./sessions.md) — retrieve closes the session it belongs to.
---
# Reverse Geocode (v2)
https://docs.mapatlas.xyz/overview/geocoder/v2/reversegeocode
# Reverse Geocode (v2)
The **v2 Reverse Geocode API** takes a latitude/longitude point and returns
the closest address or place.
## How it Works
- Send the point to reverse-geocode using `point.lat` and `point.lon` — note
that these parameter names literally contain dots; that is correct, not a
typo.
- Cap the number of results with `size`.
- Pass `format=pelias` to get back a full GeoJSON `FeatureCollection` — the
same trap as forward geocode applies here.
::: danger `format=pelias` is required for the GeoJSON envelope
Without `format=pelias` — or with any other value — the endpoint silently
returns a **different, flat shape** instead of the documented
`FeatureCollection`. Always pass `format=pelias` explicitly.
:::
## Endpoint
```
GET https://gateway.mapmetrics-atlas.net/v2/reverse-geocode/
```
## Parameters
| Parameter | Type | Req | Example | Description |
|-------------|---------|-----|------------------|--------------------------------------------------------------------|
| `point.lat` | float | ✅ | `50.8514` | Latitude of the point. Parameter name contains a literal dot. |
| `point.lon` | float | ✅ | `5.7423` | Longitude of the point. Parameter name contains a literal dot. |
| `size` | integer | ❌ | `10` | Number of results to return. |
| `format` | string | ✅ | `pelias` | Must be `pelias` to get the GeoJSON `FeatureCollection` shape. |
| `token` | string | ✅ | `YOUR_API_KEY` | Auth token, passed as a query parameter. |
## Example
```bash
curl "https://gateway.mapmetrics-atlas.net/v2/reverse-geocode/?point.lat=50.8514&point.lon=5.7423&size=10&format=pelias&token=YOUR_API_KEY"
```
## Example Response
```json
{
"geocoding": {
"version": "0.2",
"attribution": "http://localhost:4000/attribution",
"query": {
"point.lat": 50.8514,
"point.lon": 5.7423,
"size": 10
},
"engine": {
"name": "Pelias",
"author": "Mapzen",
"version": "1.0"
}
},
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [5.7423, 50.8514]
},
"properties": {
"id": "osm:ext:7f3a9c1d2e4b5a6f",
"gid": "osm:address:7f3a9c1d2e4b5a6f",
"layer": "address",
"name": "Nieuwezijds Voorburgwal 147",
"label": "Nieuwezijds Voorburgwal 147, Maastricht, Netherlands",
"housenumber": "25",
"street": "Nieuwezijds Voorburgwal",
"locality": "Maastricht",
"country": "Netherlands",
"country_a": "NLD"
}
}
],
"bbox": [5.7423, 50.8514, 5.7423, 50.8514]
}
```
::: warning `region` and `postalcode` are frequently absent
As with forward geocode, `region` and `postalcode` are frequently
**missing** from v2 `properties` — do not assume either field is always
present.
:::
::: tip Attribution must be surfaced
`geocoding.attribution` is the ODbL licence credit. Any client displaying
results **must** surface this attribution.
:::
---
# Sessions (v2 Autocomplete Billing)
https://docs.mapatlas.xyz/overview/geocoder/v2/sessions
# Sessions (v2 Autocomplete Billing)
The v2 [autocomplete](./autocomplete.md) API bills by **session**, not by
request. Understanding how a session starts, continues, and ends is
essential to avoid unexpectedly high usage.
## How it Works
- The billable unit is a **session start**, not each individual keystroke.
- Pass one stable `session_token` across every autocomplete request that
belongs to the same user search. A run of keystrokes sharing a token is
**one session**.
- A session ends — and the next request starts a new one — on any of:
- a [`/v2/retrieve/`](./retrieve.md) call, which **closes** the session;
- **50 suggest calls** sharing a token (the roll to a new session happens
on request 51);
- **2 minutes of idle time** with no request on that token.
::: danger Omitting `session_token` costs roughly 5x
If you omit `session_token`, a **fresh session is minted for every
request**. Each keystroke then bills as its own session instead of one
session covering the whole search — in practice this multiplies cost by
roughly 5x for a typical search. Always pass a `session_token`.
:::
::: warning Three retrieves in a row is three sessions
Each [`/v2/retrieve/`](./retrieve.md) call **closes** the session it
belongs to. If you call retrieve three times in a row on the same token —
for example to try resolving several candidates — that is **three separate
sessions**, not one, because each retrieve both starts (if none was already
open) and closes a session.
:::
## Recommendations
- **Debounce input** by roughly 150ms before firing an autocomplete request,
to avoid burning through the 50-request cap on fast typists.
- **Reuse the same `session_token`** for the full lifetime of one search box
interaction, from the first keystroke until the user picks a result or
abandons the search.
- Generate a new `session_token` only when the user starts a genuinely new
search (e.g. clears the box, or 2 minutes have passed).
## Worked Example
A user types `"Nieuwezijds"` into a search box, one character at a time, with a
single `session_token` (`sess_a1b2c3`) attached to every request:
1. `q=v` → autocomplete call, `session_token=sess_a1b2c3`
2. `q=vo` → autocomplete call, same token
3. `q=vog` → autocomplete call, same token
4. `q=voge` → autocomplete call, same token
5. `q=vogel` → autocomplete call, same token
6. `q=Nieuwezijds` → autocomplete call, same token
That's **6 suggest calls sharing one token**. The user then picks
`Nieuwezijds Voorburgwal 147, Maastricht` from the suggestion list, and the client calls:
7. `/v2/retrieve/?ord=128371&country=nl&layer=address&session_token=sess_a1b2c3`
The retrieve call **closes the session**.
**Total: one billable session**, regardless of the 6 keystrokes that led up
to it.
## See Also
- [Autocomplete](./autocomplete.md) — where `session_token` is attached to each suggest request.
- [Retrieve](./retrieve.md) — where a session is closed.
---
# MapMetrics Atlas API Overview
https://docs.mapatlas.xyz/overview/
# MapMetrics Atlas API Overview
Welcome to the MapMetrics Atlas API documentation. Build powerful, location-based applications with our comprehensive suite of mapping, geocoding, and routing services.
## What is MapMetrics Atlas API?
MapMetrics Atlas provides a complete set of location services including interactive maps, geocoding, turn-by-turn directions, and routing optimization. Our platform is built for developers who need reliable, high-performance mapping solutions.
## Quick Start
> **⚠️ Important:** You need an API key to use MapMetrics Atlas API. All API requests require authentication. [Sign up now](https://portal.mapmetrics.org/) to get started!
Get started in three simple steps:
1. **[Create an API Key](https://portal.mapmetrics.org/)** - Sign up at MapMetrics Portal to get your API token and map style URL
2. **[Build Your First Map](/sdk/examples/simple-map-cdn)** - Follow our quick start guide
## API Services
### 🗺️ Maps & Vector Tiles
Access customizable vector map tiles with dynamic styling options. Our maps support both light and dark themes and can be fully customized to match your application's design.
**Features:**
- High-performance vector tiles
- Customizable map styles (dark/light themes)
- Global map coverage
- Support for multiple zoom levels
**Get Started:**
- [Map Styles Documentation](#map-styles)
- [Create Custom Styles](/sdk/examples/style-creation)
### 📍 Geocoding Services
Convert addresses to coordinates (forward geocoding) and coordinates to addresses (reverse geocoding). Includes autocomplete search for building location-aware features.
**Endpoints:**
- [Autocomplete](./geocoder/autocomplete) - Real-time search suggestions
- [Forward Geocoding](./geocoder/forwardgeocode) - Address → Coordinates
- [Reverse Geocoding](./geocoder/reversegeocode) - Coordinates → Address
**Use Cases:**
- Location search in apps
- Address validation
- Store locators
- Location-based forms
### 🧭 Directions & Routing
Calculate optimal routes, analyze reachability, and optimize multi-stop trips with our powerful routing engine.
**Endpoints:**
- [Directions](./directions/directions) - Turn-by-turn navigation
- [Optimization](./directions/optimization) - Multi-stop route optimization
- [Isochrone](./directions/isochrone) - Reachability analysis
- [Matrix](./directions/matrix) - Distance/time matrices
- [Map Matching](./directions/mapMatch) - GPS trace matching
- [Elevation](./directions/elevation) - Elevation profiles
**Supported Transport Modes:**
- 🚗 Auto (car, motorcycle, taxi)
- 🚴 Bicycle
- 🚶 Pedestrian
- 🚌 Bus
- 🚛 Truck
- 🛵 Motor Scooter
**Use Cases:**
- Navigation apps
- Delivery route optimization
- Fleet management
- Travel time analysis
## SDKs & Platforms
Choose the right SDK for your platform:
| Platform | SDK | Status | Documentation |
|----------|-----|--------|---------------|
| **Web** | MapMetrics GL JS | ✅ Stable | [Get Started](./sdk/mapmetrics) |
| **iOS** | MapMetrics Native | ✅ Stable | [Get Started](./sdk/ios-native/GettingStarted) |
| **Android** | MapMetrics Native | ✅ Stable | [Get Started](./sdk/android-native/getting-started) |
| **Flutter** | MapMetrics Flutter | 🟡 Beta | [Get Started](/sdk/examples/flutter-mapmetrics-intro) |
### Web SDK Features
- Interactive vector maps
- Markers, popups, and custom overlays
- 3D buildings and terrain
- Heatmaps and clustering
- Custom styling
**Installation:**
- CDN: [Simple Map (CDN)](/sdk/examples/simple-map-cdn)
- NPM: [Simple Map (NPM)](/sdk/examples/simple-map-npm) - Package: [@mapmetrics/mapmetrics-gl](https://www.npmjs.com/package/@mapmetrics/mapmetrics-gl)
- React: [React Integration](/sdk/examples/react-map-example)
### Mobile SDK Features
- Native performance
- Offline map support
- Location tracking
- Custom annotations
- Gesture controls
## Authentication
> **🔒 API Key Required:** MapMetrics Atlas API requires authentication for all requests. Without a valid API key, maps won't load and API calls will return 401 Unauthorized errors.
All API requests require authentication using your API token. You must create an account on the [MapMetrics Portal](https://portal.mapmetrics.org/) to get your API token and map style URL.
**Get Started:**
1. Visit [MapMetrics Portal](https://portal.mapmetrics.org/)
2. Create your account (free to start)
3. Get your complete map style URL (includes your API token)
4. Copy and use it in your application
**Detailed Guide:** [Create API Key](/sdk/examples/key-creation)
### Don't Have an API Key Yet?
If you try to use MapMetrics without an API key:
- ❌ Maps will fail to load
- ❌ API requests will return authentication errors
- ❌ No access to geocoding or routing services
**Solution:** [Create your free account](https://portal.mapmetrics.org/) in minutes!
## Map Styles
All map styles are created and managed through the [MapMetrics Portal](https://portal.mapmetrics.org/). When you create an account, you'll receive a unique map style URL for your applications.
### Getting Your Map Style URL
1. **Sign up** at [MapMetrics Portal](https://portal.mapmetrics.org/)
2. **Create a new style** or use the default style provided
3. **Copy your complete style URL** from the portal (it includes your API token)
4. **Paste it directly** in your application code
The portal provides you with a ready-to-use URL - just copy and paste it into your application!
**Example URL:**
```text
https://gateway.mapmetrics-atlas.net/styles/?fileName=753b9b14-2fcc-44d3-b273-c8b2b701647a/stylefile.json&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### Customize Your Map Style
Through the MapMetrics Portal, you can:
- **Customize colors** - Match your brand's color scheme
- **Choose fonts** - Select typography for map labels
- **Add/remove layers** - Control which map features are displayed
- **Create multiple styles** - Different styles for different use cases
- **Dark/Light themes** - Create styles optimized for different themes
**Learn More:** [Style Creation Guide](/sdk/examples/style-creation)
## Migration Guides
Switching from another mapping provider? We've got you covered:
- [Migrate from Google Maps](/sdk/examples/google-map-to-mapmetrics)
- [Migrate from Mapbox](./sdk/mapbox-migration-guide)
## Support & Resources
### Documentation
- [Code Examples](/sdk/examples/Intro) - Ready-to-use code samples
- [Migration Guides](./migration-guide) - Switch from other providers
### Community
- [Discord Community](https://discord.com/invite/uRXQRfbb7d) - Chat with developers
- [Facebook](https://www.facebook.com/MapMetrics) - Latest updates
- [YouTube](https://www.youtube.com/@mapmetrics3435) - Video tutorials
### Need Help?
- Browse our [examples](/sdk/examples/Intro)
- Join our [Discord community](https://discord.com/invite/uRXQRfbb7d)
- Check out [video tutorials](https://www.youtube.com/@mapmetrics3435)
- Contact support for technical assistance
## What's Next?
Ready to start building? Here are some recommended next steps:
1. **[Create your account](https://portal.mapmetrics.org/)** - Sign up and get your API key
2. **[Display your first map](/sdk/examples/simple-map-cdn)** - Quick start guide
3. **[Add markers](/sdk/examples/add-a-marker)** - Display points of interest
4. **[Implement routing](./directions/directions)** - Add turn-by-turn directions
5. **[Customize your map style](https://portal.mapmetrics.org/)** - Create custom styles in the portal
---
**Let's build something amazing together!** 🚀
---
# Migration Guide
https://docs.mapatlas.xyz/overview/migration-guide
# Migration Guide
Migrate your existing maps from Google Maps or Mapbox to MapMetrics Atlas.
## Available Migration Guides
### [Google Maps to MapMetrics](/sdk/examples/google-map-to-mapmetrics)
Learn how to migrate your application from Google Maps to MapMetrics Atlas.
### [Mapbox to MapMetrics](/overview/sdk/mapbox-migration-guide)
Learn how to migrate your application from Mapbox to MapMetrics Atlas.
---
# Add Markers in Bulk
https://docs.mapatlas.xyz/overview/sdk/android-native/annotations/add-markers
# Add Markers in Bulk
This example demonstrates how you can add markers in bulk.
[//]: # ()
[//]: # (
)
[//]: # ()
[//]: # (
)
[//]: # (
)
```kotlin title="BulkMarkerActivity.kt"
class BulkMarkerActivity : AppCompatActivity(), OnItemSelectedListener {
private lateinit var mapMetricsMap: MapMetricsMap
private lateinit var mapView: MapView
private var locations: List? = null
private var progressDialog: ProgressDialog? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_marker_bulk)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { initMap(it) }
}
private fun initMap(mapMetricsMap: MapMetricsMap) {
this.mapMetricsMap = mapMetricsMap
mapMetricsMap.setStyle(TestStyles.getPredefinedStyleWithFallback("Streets"))
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val spinnerAdapter = ArrayAdapter.createFromResource(
this,
R.array.bulk_marker_list,
android.R.layout.simple_spinner_item
)
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
menuInflater.inflate(R.menu.menu_bulk_marker, menu)
val item = menu.findItem(R.id.spinner)
val spinner = item.actionView as Spinner
spinner.adapter = spinnerAdapter
spinner.onItemSelectedListener = this@BulkMarkerActivity
return true
}
override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
val amount = Integer.valueOf(resources.getStringArray(R.array.bulk_marker_list)[position])
if (locations == null) {
progressDialog = ProgressDialog.show(this, "Loading", "Fetching markers", false)
lifecycleScope.launch(Dispatchers.IO) {
locations = loadLocationTask(this@BulkMarkerActivity)
withContext(Dispatchers.Main) {
onLatLngListLoaded(locations, amount)
}
}
} else {
showMarkers(amount)
}
}
private fun onLatLngListLoaded(latLngs: List?, amount: Int) {
progressDialog!!.hide()
locations = latLngs
showMarkers(amount)
}
private fun showMarkers(amount: Int) {
if (!this::mapMetricsMap.isInitialized || locations == null || mapView.isDestroyed) {
return
}
mapMetricsMap.clear()
showGlMarkers(min(amount, locations!!.size))
}
private fun showGlMarkers(amount: Int) {
val markerOptionsList: MutableList = ArrayList()
val formatter = DecimalFormat("#.#####")
val random = Random()
var randomIndex: Int
for (i in 0 until amount) {
randomIndex = random.nextInt(locations!!.size)
val latLng = locations!![randomIndex]
markerOptionsList.add(
MarkerOptions()
.position(latLng)
.title(i.toString())
.snippet(formatter.format(latLng.latitude) + "`, " + formatter.format(latLng.longitude))
)
}
mapMetricsMap.addMarkers(markerOptionsList)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// nothing selected, nothing to do!
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
override fun onDestroy() {
super.onDestroy()
if (progressDialog != null) {
progressDialog!!.dismiss()
}
mapView.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
private fun loadLocationTask(
activity: BulkMarkerActivity,
) : List? {
try {
val json = GeoParseUtil.loadStringFromAssets(
activity.applicationContext,
"points.geojson"
)
return GeoParseUtil.parseGeoJsonCoordinates(json)
} catch (exception: IOException) {
Timber.e(exception, "Could not add markers")
}
return null
}
}
```
---
# Annotation: Marker
https://docs.mapatlas.xyz/overview/sdk/android-native/annotations/marker-annotations
# Annotation: Marker
This guide will show you how to add Markers in the map.
`Annotation` is an overlay on top of a Map.
1. [Marker]
2. [Polyline]
3. [Polygon]
A Marker shows an icon image at a geographical location. By default, marker uses
a [provided image] as its icon.
![marker image]
Or, the icon can be customized using [IconFactory] to generate an
[Icon] using a provided image.
For more customization, please read the documentation about [MarkerOptions].
In this showcase, we continue the code from the [Quickstart],
rename Activity into `JsonApiActivity`,
and pull the GeoJSON data from a free and public API.
Then add markers to the map with GeoJSON:
1. In your module Gradle file (usually `//build.gradle`), add `okhttp` to simplify code for making HTTP requests.
```gradle
dependencies {
...
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
...
}
```
2. Sync your Android project the with Gradle files.
3. In `JsonApiActivity` we add a new variable for `MapLibreMap`.
It is used to add annotations to the map instance.
```kotlin
class JsonApiActivity : AppCompatActivity() {
// Declare a variable for MapView
private lateinit var mapView: MapView
// Declare a variable for MapMetricsMap
private lateinit var mapMetricsMap: MapMetricsMap}
```
4. Call `mapview.getMapSync()` in order to get a `MapMetricsMap` object.
After `mapMetricsMap` is assigned, call the `getEarthQuakeDataFromUSGS()` method
to make a HTTP request and transform data into the map annotations.
```kotlin
mapView.getMapAsync { map ->
mapMetricsMap = map
mapMetricsMap.setStyle(styleName)
// Fetch data from USGS
getEarthQuakeDataFromUSGS()
}
```
5. Define a function `getEarthQuakeDataFromUSGS()` to fetch GeoJSON data from a public API.
If we successfully get the response, call `addMarkersToMap()` on the UI thread.
```kotlin
// Get Earthquake data from usgs.gov, read API doc at:
// https://earthquake.usgs.gov/fdsnws/event/1/
private fun getEarthQuakeDataFromUSGS() {
val url = "https://earthquake.usgs.gov/fdsnws/event/1/query".toHttpUrl().newBuilder()
.addQueryParameter("format", "geojson")
.addQueryParameter("starttime", "2022-01-01")
.addQueryParameter("endtime", "2022-12-31")
.addQueryParameter("minmagnitude", "5.8")
.addQueryParameter("latitude", "24")
.addQueryParameter("longitude", "121")
.addQueryParameter("maxradius", "1.5")
.build()
val request: Request = Request.Builder().url(url).build()
OkHttpClient().newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Toast.makeText(this@JsonApiActivity, "Fail to fetch data", Toast.LENGTH_SHORT)
.show()
}
override fun onResponse(call: Call, response: Response) {
val featureCollection = response.body?.string()
?.let(FeatureCollection::fromJson)
?: return
// If FeatureCollection in response is not null
// Then add markers to map
runOnUiThread { addMarkersToMap(featureCollection) }
}
})
}
```
6. Now it is time to add markers into the map.
- In the `addMarkersToMap()` method, we define two types of bitmap for the marker icon.
- For each feature in the GeoJSON, add a marker with a snippet about earthquake details.
- If the magnitude of an earthquake is bigger than 6.0, we use the red icon. Otherwise, we use the blue one.
- Finally, move the camera to the bounds of the newly added markers
```kotlin
private fun addMarkersToMap(data: FeatureCollection) {
val bounds = mutableListOf()
// Get bitmaps for marker icon
val infoIconDrawable = ResourcesCompat.getDrawable(
this.resources,
// Intentionally specify package name
// This makes copy from another project easier
org.maplibre.android.R.drawable.mapmetrics_info_icon_default,
theme
)!!
val bitmapBlue = infoIconDrawable.toBitmap()
val bitmapRed = infoIconDrawable
.mutate()
.apply { setTint(Color.RED) }
.toBitmap()
// Add symbol for each point feature
data.features()?.forEach { feature ->
val geometry = feature.geometry()?.toJson() ?: return@forEach
val point = Point.fromJson(geometry) ?: return@forEach
val latLng = LatLng(point.latitude(), point.longitude())
bounds.add(latLng)
// Contents in InfoWindow of each marker
val title = feature.getStringProperty("title")
val epochTime = feature.getNumberProperty("time")
val dateString = SimpleDateFormat("yyyy/MM/dd HH:mm", Locale.TAIWAN).format(epochTime)
// If magnitude > 6.0, show marker with red icon. If not, show blue icon instead
val mag = feature.getNumberProperty("mag")
val icon = IconFactory.getInstance(this)
.fromBitmap(if (mag.toFloat() > 6.0) bitmapRed else bitmapBlue)
// Use MarkerOptions and addMarker() to add a new marker in map
val markerOptions = MarkerOptions()
.position(latLng)
.title(dateString)
.snippet(title)
.icon(icon)
maplibreMap.addMarker(markerOptions)
}
// Move camera to newly added annotations
maplibreMap.getCameraForLatLngBounds(LatLngBounds.fromLatLngs(bounds))?.let {
val newCameraPosition = CameraPosition.Builder()
.target(it.target)
.zoom(it.zoom - 0.5)
.build()
maplibreMap.cameraPosition = newCameraPosition
}
}
```
[//]: # (7. Here is the final result. For the full contents of `JsonApiActivity`, please visit source code of our [Test App].)
[//]: # ()
[//]: # ()
[//]: # (
)
[//]: # (
)
[Marker]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.annotations/-marker/index.html
[provided image]: https://github.com/maplibre/maplibre-native/blob/main/platform/android/MapLibreAndroid/src/main/res/drawable-xxxhdpi/maplibre_marker_icon_default.png
[Polyline]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.annotations/-polyline/index.html
[Polygon]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.annotations/-polygon/index.html
[marker image]: https://raw.githubusercontent.com/maplibre/maplibre-native/main/test/fixtures/sprites/default_marker.png
[IconFactory]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.annotations/-icon-factory/index.html
[Icon]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.annotations/-icon/index.html
[Quickstart]: ../getting-started.md
[mvn]: https://mvnrepository.com/artifact/org.maplibre.gl/android-plugin-annotation-v9
[Android Developer Documentation]: https://developer.android.com/topic/libraries/architecture/coroutines
[MarkerOptions]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.annotations/-marker-options/index.html
[Test App]: https://github.com/MapMetrics/mapmetrics-native-sdk/tree/main/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/annotation/JsonApiActivity.kt
---
# Animation Types
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/animation-types
# Animation Types
[//]: # ({{ activity_source_note("CameraAnimationTypeActivity.kt") }})
This example showcases the different animation types.
- **Move**: available via the `MapMetricsMap.moveCamera` method.
- **Ease**: available via the `MapMetricsMap.easeCamera` method.
- **Animate**: available via the `MapMetricsMap.animateCamera` method.
### Move
The `MapMetricsMap.moveCamera` method jumps to the camera position provided.
```kotlin
val cameraPosition =
CameraPosition.Builder()
.target(nextLatLng)
.zoom(14.0)
.tilt(30.0)
.tilt(0.0)
.build()
mapMetricsMap.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition))
```
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
### Ease
The `MapMetricsMap.moveCamera` eases to the camera position provided (with constant ground speed).
```kotlin
val cameraPosition =
CameraPosition.Builder()
.target(nextLatLng)
.zoom(15.0)
.bearing(180.0)
.tilt(30.0)
.build()
mapMetricsMap.easeCamera(
CameraUpdateFactory.newCameraPosition(cameraPosition),
7500,
callback
)
```
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
### Animate
The `MapMetricsMap.animateCamera` uses a powered flight animation move to the camera position provided[^1].
[^1]: The implementation is based on Van Wijk, Jarke J.; Nuij, Wim A. A. “Smooth and efficient zooming and panning.” INFOVIS ’03. pp. 15–22. [https://www.win.tue.nl/~vanwijk/zoompan.pdf#page=5](https://www.win.tue.nl/~vanwijk/zoompan.pdf#page=5)
```kotlin
val cameraPosition =
CameraPosition.Builder().target(nextLatLng).bearing(270.0).tilt(20.0).build()
mapMetricsMap.animateCamera(
CameraUpdateFactory.newCameraPosition(cameraPosition),
7500,
callback
)
```
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
## Animation Callbacks
In the previous section a `CancellableCallback` was passed to the last two animation methods. This callback shows a toast message when the animation is cancelled or when it is finished.
```kotlin
private val callback: CancelableCallback =
object : CancelableCallback {
override fun onCancel() {
Timber.i("Duration onCancel Callback called.")
Toast.makeText(
applicationContext,
"Ease onCancel Callback called.",
Toast.LENGTH_LONG
)
.show()
}
override fun onFinish() {
Timber.i("Duration onFinish Callback called.")
Toast.makeText(
applicationContext,
"Ease onFinish Callback called.",
Toast.LENGTH_LONG
)
.show()
}
}
```
---
# Animator Animation
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/animator-animation
# Animator Animation
This example showcases how to use the Animator API to schedule a sequence of map animations.
```kotlin title="CameraAnimatorActivity.kt"
/** Test activity showcasing using Android SDK animators to animate camera position changes. */
class CameraAnimatorActivity : AppCompatActivity(), OnMapReadyCallback {
private val animators = LongSparseArray()
private lateinit var set: Animator
private lateinit var mapView: MapView
private lateinit var mapMetricsMap: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_camera_animator)
mapView = findViewById(R.id.mapView) as MapView
if (::mapView.isInitialized) {
mapView.onCreate(savedInstanceState)
mapView.getMapAsync(this)
}
}
override fun onMapReady(map: MapMetricsMap) {
mapMetricsMap = map
map.setStyle(TestStyles.getPredefinedStyleWithFallback("Streets"))
initFab()
}
private fun initFab() {
findViewById(R.id.fab).setOnClickListener { view: View ->
view.visibility = View.GONE
val animatedPosition =
CameraPosition.Builder()
.target(LatLng(37.789992, -122.402214))
.tilt(60.0)
.zoom(14.5)
.bearing(135.0)
.build()
set = createExampleAnimator(mapMetricsMap.cameraPosition, animatedPosition)
set.start()
}
}
//
// Animator API used for the animation on the FAB
//
private fun createExampleAnimator(
currentPosition: CameraPosition,
targetPosition: CameraPosition
): Animator {
val animatorSet = AnimatorSet()
animatorSet.play(createLatLngAnimator(currentPosition.target!!, targetPosition.target!!))
animatorSet.play(createZoomAnimator(currentPosition.zoom, targetPosition.zoom))
animatorSet.play(createBearingAnimator(currentPosition.bearing, targetPosition.bearing))
animatorSet.play(createTiltAnimator(currentPosition.tilt, targetPosition.tilt))
return animatorSet
}
private fun createLatLngAnimator(currentPosition: LatLng, targetPosition: LatLng): Animator {
val latLngAnimator =
ValueAnimator.ofObject(LatLngEvaluator(), currentPosition, targetPosition)
latLngAnimator.duration = (1000 * ANIMATION_DELAY_FACTOR).toLong()
latLngAnimator.interpolator = FastOutSlowInInterpolator()
latLngAnimator.addUpdateListener { animation: ValueAnimator ->
mapMetricsMap.moveCamera(
CameraUpdateFactory.newLatLng((animation.animatedValue as LatLng))
)
}
return latLngAnimator
}
private fun createZoomAnimator(currentZoom: Double, targetZoom: Double): Animator {
val zoomAnimator = ValueAnimator.ofFloat(currentZoom.toFloat(), targetZoom.toFloat())
zoomAnimator.duration = (2200 * ANIMATION_DELAY_FACTOR).toLong()
zoomAnimator.startDelay = (600 * ANIMATION_DELAY_FACTOR).toLong()
zoomAnimator.interpolator = AnticipateOvershootInterpolator()
zoomAnimator.addUpdateListener { animation: ValueAnimator ->
mapMetricsMap.moveCamera(
CameraUpdateFactory.zoomTo((animation.animatedValue as Float).toDouble())
)
}
return zoomAnimator
}
private fun createBearingAnimator(currentBearing: Double, targetBearing: Double): Animator {
val bearingAnimator =
ValueAnimator.ofFloat(currentBearing.toFloat(), targetBearing.toFloat())
bearingAnimator.duration = (1000 * ANIMATION_DELAY_FACTOR).toLong()
bearingAnimator.startDelay = (1000 * ANIMATION_DELAY_FACTOR).toLong()
bearingAnimator.interpolator = FastOutLinearInInterpolator()
bearingAnimator.addUpdateListener { animation: ValueAnimator ->
mapMetricsMap.moveCamera(
CameraUpdateFactory.bearingTo((animation.animatedValue as Float).toDouble())
)
}
return bearingAnimator
}
private fun createTiltAnimator(currentTilt: Double, targetTilt: Double): Animator {
val tiltAnimator = ValueAnimator.ofFloat(currentTilt.toFloat(), targetTilt.toFloat())
tiltAnimator.duration = (1000 * ANIMATION_DELAY_FACTOR).toLong()
tiltAnimator.startDelay = (1500 * ANIMATION_DELAY_FACTOR).toLong()
tiltAnimator.addUpdateListener { animation: ValueAnimator ->
mapMetricsMap.moveCamera(
CameraUpdateFactory.tiltTo((animation.animatedValue as Float).toDouble())
)
}
return tiltAnimator
}
//
// Interpolator examples
//
private fun obtainExampleInterpolator(menuItemId: Int): Animator? {
return animators[menuItemId.toLong()]
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_animator, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (!::mapMetricsMap.isInitialized) {
return false
}
if (item.itemId != android.R.id.home) {
findViewById(R.id.fab).visibility = View.GONE
resetCameraPosition()
playAnimation(item.itemId)
}
return super.onOptionsItemSelected(item)
}
private fun resetCameraPosition() {
mapMetricsMap.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(START_LAT_LNG)
.zoom(11.0)
.bearing(0.0)
.tilt(0.0)
.build()
)
)
}
private fun playAnimation(itemId: Int) {
val animator = obtainExampleInterpolator(itemId)
if (animator != null) {
animator.cancel()
animator.start()
}
}
private fun obtainExampleInterpolator(interpolator: Interpolator, duration: Long): Animator {
val zoomAnimator = ValueAnimator.ofFloat(11.0f, 16.0f)
zoomAnimator.duration = (duration * ANIMATION_DELAY_FACTOR).toLong()
zoomAnimator.interpolator = interpolator
zoomAnimator.addUpdateListener { animation: ValueAnimator ->
mapMetricsMap.moveCamera(
CameraUpdateFactory.zoomTo((animation.animatedValue as Float).toDouble())
)
}
return zoomAnimator
}
//
// MapView lifecycle
//
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
for (i in 0 until animators.size()) {
animators[animators.keyAt(i)]!!.cancel()
}
if (this::set.isInitialized) {
set.cancel()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
override fun onDestroy() {
super.onDestroy()
if (::mapView.isInitialized) {
mapView.onDestroy()
}
}
override fun onLowMemory() {
super.onLowMemory()
if (::mapView.isInitialized) {
mapView.onLowMemory()
}
}
/** Helper class to evaluate LatLng objects with a ValueAnimator */
private class LatLngEvaluator : TypeEvaluator {
private val latLng = LatLng()
override fun evaluate(fraction: Float, startValue: LatLng, endValue: LatLng): LatLng {
latLng.latitude = startValue.latitude + (endValue.latitude - startValue.latitude) * fraction
latLng.longitude = startValue.longitude + (endValue.longitude - startValue.longitude) * fraction
return latLng
}
}
companion object {
private const val ANIMATION_DELAY_FACTOR = 1.5
private val START_LAT_LNG = LatLng(37.787947, -122.407432)
}
init {
val accelerateDecelerateAnimatorSet = AnimatorSet()
accelerateDecelerateAnimatorSet.playTogether(
createLatLngAnimator(START_LAT_LNG, LatLng(37.826715, -122.422795)),
obtainExampleInterpolator(FastOutSlowInInterpolator(), 2500)
)
animators.put(
R.id.menu_action_accelerate_decelerate_interpolator.toLong(),
accelerateDecelerateAnimatorSet
)
val bounceAnimatorSet = AnimatorSet()
bounceAnimatorSet.playTogether(
createLatLngAnimator(START_LAT_LNG, LatLng(37.787947, -122.407432)),
obtainExampleInterpolator(BounceInterpolator(), 3750)
)
animators.put(R.id.menu_action_bounce_interpolator.toLong(), bounceAnimatorSet)
animators.put(
R.id.menu_action_anticipate_overshoot_interpolator.toLong(),
obtainExampleInterpolator(AnticipateOvershootInterpolator(), 2500)
)
animators.put(
R.id.menu_action_path_interpolator.toLong(),
obtainExampleInterpolator(
PathInterpolatorCompat.create(.22f, .68f, 0f, 1.71f),
2500
)
)
}
}
```
---
# CameraPosition Capabilities
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/cameraposition
# CameraPosition Capabilities
[//]: # ({{ activity_source_note("CameraPositionActivity.kt") }})
[//]: # (This example showcases how to listen to camera change events.)
[//]: # ()
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
The camera animation is kicked off with this code:
```kotlin
val cameraPosition = CameraPosition.Builder().target(LatLng(latitude, longitude)).zoom(zoom).bearing(bearing).tilt(tilt).build()
mapMetricsMap?.animateCamera(
CameraUpdateFactory.newCameraPosition(cameraPosition),
5000,
object : CancelableCallback {
override fun onCancel() {
Timber.v("OnCancel called")
}
override fun onFinish() {
Timber.v("OnFinish called")
}
}
)
```
Notice how the color of the button in the bottom right changes color. Depending on the state of the camera.
We can listen for changes to the state of the camera by registering a `OnCameraMoveListener`, `OnCameraIdleListener`, `OnCameraMoveCanceledListener` or `OnCameraMoveStartedListener` with the `MapLibreMap`. For example, the `OnCameraMoveListener` is defined with:
```kotlin
private val moveListener = OnCameraMoveListener {
Timber.e("OnCameraMove")
fab.setColorFilter(
ContextCompat.getColor(this@CameraPositionActivity, android.R.color.holo_orange_dark)
)
}
```
And registered with:
```kotlin
mapMetricsMap.addOnCameraMoveListener(moveListener)
```
Refer to the full example to learn the methods to register the other types of camera change events.
---
# Gesture Detector
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/gesture-detector
# Gesture Detector
The gesture detector of MapMetrics Android is encapsulated in the [`mapmetrics-gestures-android`](https://github.com/MapMetrics/MapMetrics-gestures-android) package.
#### Gesture Listeners
You can add listeners for move, rotate, scale and shove gestures. For example, adding a move gesture listener with `MapMetricsMap.addOnRotateListener`:
```kotlin
mapMetricsMap.addOnMoveListener(
object : OnMoveListener {
override fun onMoveBegin(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "MOVE START")
)
}
override fun onMove(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "MOVE PROGRESS")
)
}
override fun onMoveEnd(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "MOVE END")
)
recalculateFocalPoint()
}
}
)
```
Refer to the full example below for examples of listeners for the other gesture types.
#### Settings
You can access an `UISettings` object via `MapMetricsMap.uiSettings`. Available settings include:
- **Toggle Quick Zoom**. You can double tap on the map to use quick zoom. You can toggle this behavior on and off (`UiSettings.isQuickZoomGesturesEnabled`).
- **Toggle Velocity Animations**. By default flicking causes the map to continue panning (while decelerating). You can turn this off with `UiSettings.isScaleVelocityAnimationEnabled`.
- **Toggle Rotate Enabled**. Use `uiSettings.isRotateGesturesEnabled`.
- **Toggle Zoom Enabled**. Use `uiSettings.isZoomGesturesEnabled`.
## Full Example Activity
```kotlin title="GestureDetectorActivity.kt"
/** Test activity showcasing APIs around gestures implementation. */
class GestureDetectorActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var mapMetricsMap: MapMetricsMap
private lateinit var recyclerView: RecyclerView
private var gestureAlertsAdapter: GestureAlertsAdapter? = null
private var gesturesManager: AndroidGesturesManager? = null
private var marker: Marker? = null
private var focalPointLatLng: LatLng? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_gesture_detector)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map: MapMetricsMap ->
mapMetricsMap = map
mapMetricsMap.setStyle(TestStyles.getPredefinedStyleWithFallback("Streets"))
initializeMap()
}
recyclerView = findViewById(R.id.alerts_recycler)
recyclerView.setLayoutManager(LinearLayoutManager(this))
gestureAlertsAdapter = GestureAlertsAdapter()
recyclerView.setAdapter(gestureAlertsAdapter)
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
gestureAlertsAdapter!!.cancelUpdates()
mapView.onPause()
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
private fun initializeMap() {
gesturesManager = mapMetricsMap.gesturesManager
val layoutParams = recyclerView.layoutParams as RelativeLayout.LayoutParams
layoutParams.height = (mapView.height / 1.75).toInt()
layoutParams.width = mapView.width / 3
recyclerView.layoutParams = layoutParams
attachListeners()
fixedFocalPointEnabled(mapMetricsMap.uiSettings.focalPoint != null)
}
fun attachListeners() {
// # --8<-- [start:addOnMoveListener]
mapMetricsMap.addOnMoveListener(
object : OnMoveListener {
override fun onMoveBegin(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "MOVE START")
)
}
override fun onMove(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "MOVE PROGRESS")
)
}
override fun onMoveEnd(detector: MoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "MOVE END")
)
recalculateFocalPoint()
}
}
)
// # --8<-- [end:addOnMoveListener]
mapMetricsMap.addOnRotateListener(
object : OnRotateListener {
override fun onRotateBegin(detector: RotateGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "ROTATE START")
)
}
override fun onRotate(detector: RotateGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "ROTATE PROGRESS")
)
recalculateFocalPoint()
}
override fun onRotateEnd(detector: RotateGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "ROTATE END")
)
}
}
)
mapMetricsMap.addOnScaleListener(
object : OnScaleListener {
override fun onScaleBegin(detector: StandardScaleGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "SCALE START")
)
if (focalPointLatLng != null) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(
GestureAlert.TYPE_OTHER,
"INCREASING MOVE THRESHOLD"
)
)
gesturesManager!!.moveGestureDetector.moveThreshold =
ResourceUtils.convertDpToPx(this@GestureDetectorActivity, 175f)
gestureAlertsAdapter!!.addAlert(
GestureAlert(
GestureAlert.TYPE_OTHER,
"MANUALLY INTERRUPTING MOVE"
)
)
gesturesManager!!.moveGestureDetector.interrupt()
}
recalculateFocalPoint()
}
override fun onScale(detector: StandardScaleGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "SCALE PROGRESS")
)
}
override fun onScaleEnd(detector: StandardScaleGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "SCALE END")
)
if (focalPointLatLng != null) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(
GestureAlert.TYPE_OTHER,
"REVERTING MOVE THRESHOLD"
)
)
gesturesManager!!.moveGestureDetector.moveThreshold = 0f
}
}
}
)
mapMetricsMap.addOnShoveListener(
object : OnShoveListener {
override fun onShoveBegin(detector: ShoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_START, "SHOVE START")
)
}
override fun onShove(detector: ShoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_PROGRESS, "SHOVE PROGRESS")
)
}
override fun onShoveEnd(detector: ShoveGestureDetector) {
gestureAlertsAdapter!!.addAlert(
GestureAlert(GestureAlert.TYPE_END, "SHOVE END")
)
}
}
)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_gestures, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val uiSettings = mapMetricsMap.uiSettings
when (item.itemId) {
R.id.menu_gesture_focus_point -> {
fixedFocalPointEnabled(focalPointLatLng == null)
return true
}
R.id.menu_gesture_animation -> {
uiSettings.isScaleVelocityAnimationEnabled =
!uiSettings.isScaleVelocityAnimationEnabled
uiSettings.isRotateVelocityAnimationEnabled =
!uiSettings.isRotateVelocityAnimationEnabled
uiSettings.isFlingVelocityAnimationEnabled =
!uiSettings.isFlingVelocityAnimationEnabled
return true
}
R.id.menu_gesture_rotate -> {
uiSettings.isRotateGesturesEnabled = !uiSettings.isRotateGesturesEnabled
return true
}
R.id.menu_gesture_tilt -> {
uiSettings.isTiltGesturesEnabled = !uiSettings.isTiltGesturesEnabled
return true
}
R.id.menu_gesture_zoom -> {
uiSettings.isZoomGesturesEnabled = !uiSettings.isZoomGesturesEnabled
return true
}
R.id.menu_gesture_scroll -> {
uiSettings.isScrollGesturesEnabled = !uiSettings.isScrollGesturesEnabled
return true
}
R.id.menu_gesture_double_tap -> {
uiSettings.isDoubleTapGesturesEnabled = !uiSettings.isDoubleTapGesturesEnabled
return true
}
R.id.menu_gesture_quick_zoom -> {
uiSettings.isQuickZoomGesturesEnabled = !uiSettings.isQuickZoomGesturesEnabled
return true
}
R.id.menu_gesture_scroll_horizontal -> {
uiSettings.isHorizontalScrollGesturesEnabled =
!uiSettings.isHorizontalScrollGesturesEnabled
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun fixedFocalPointEnabled(enabled: Boolean) {
if (enabled) {
focalPointLatLng = LatLng(51.50325, -0.12968)
marker = mapMetricsMap.addMarker(MarkerOptions().position(focalPointLatLng))
mapMetricsMap.easeCamera(
CameraUpdateFactory.newLatLngZoom(focalPointLatLng!!, 16.0),
object : CancelableCallback {
override fun onCancel() {
recalculateFocalPoint()
}
override fun onFinish() {
recalculateFocalPoint()
}
}
)
} else {
if (marker != null) {
mapMetricsMap.removeMarker(marker!!)
marker = null
}
focalPointLatLng = null
mapMetricsMap.uiSettings.focalPoint = null
}
}
private fun recalculateFocalPoint() {
if (focalPointLatLng != null) {
mapMetricsMap.uiSettings.focalPoint =
mapMetricsMap.projection.toScreenLocation(focalPointLatLng!!)
}
}
private class GestureAlertsAdapter : RecyclerView.Adapter() {
private var isUpdating = false
private val updateHandler = Handler(Looper.getMainLooper())
private val alerts: MutableList = ArrayList()
class ViewHolder internal constructor(view: View) : RecyclerView.ViewHolder(view) {
var alertMessageTv: TextView
init {
val typeface = FontCache.get("Roboto-Regular.ttf", view.context)
alertMessageTv = view.findViewById(R.id.alert_message)
alertMessageTv.typeface = typeface
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view =
LayoutInflater.from(parent.context)
.inflate(R.layout.item_gesture_alert, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val alert = alerts[position]
holder.alertMessageTv.text = alert.message
holder.alertMessageTv.setTextColor(
ContextCompat.getColor(holder.alertMessageTv.context, alert.color)
)
}
override fun getItemCount(): Int {
return alerts.size
}
fun addAlert(alert: GestureAlert) {
for (gestureAlert in alerts) {
if (gestureAlert.alertType != GestureAlert.TYPE_PROGRESS) {
break
}
if (alert.alertType == GestureAlert.TYPE_PROGRESS && gestureAlert == alert) {
return
}
}
if (itemCount >= MAX_NUMBER_OF_ALERTS) {
alerts.removeAt(itemCount - 1)
}
alerts.add(0, alert)
if (!isUpdating) {
isUpdating = true
updateHandler.postDelayed(updateRunnable, 250)
}
}
@SuppressLint("NotifyDataSetChanged")
private val updateRunnable = Runnable {
notifyDataSetChanged()
isUpdating = false
}
fun cancelUpdates() {
updateHandler.removeCallbacksAndMessages(null)
}
}
private class GestureAlert(
@field:Type @param:Type
val alertType: Int,
val message: String?
) {
@Retention(AnnotationRetention.SOURCE)
@IntDef(TYPE_NONE, TYPE_START, TYPE_PROGRESS, TYPE_END, TYPE_OTHER)
annotation class Type
@ColorInt var color = 0
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other == null || javaClass != other.javaClass) {
return false
}
val that = other as GestureAlert
if (alertType != that.alertType) {
return false
}
return if (message != null) message == that.message else that.message == null
}
override fun hashCode(): Int {
var result = alertType
result = 31 * result + (message?.hashCode() ?: 0)
return result
}
companion object {
const val TYPE_NONE = 0
const val TYPE_START = 1
const val TYPE_END = 2
const val TYPE_PROGRESS = 3
const val TYPE_OTHER = 4
}
init {
when (alertType) {
TYPE_NONE -> color = android.R.color.black
TYPE_END -> color = android.R.color.holo_red_dark
TYPE_OTHER -> color = android.R.color.holo_purple
TYPE_PROGRESS -> color = android.R.color.holo_orange_dark
TYPE_START -> color = android.R.color.holo_green_dark
}
}
}
companion object {
private const val MAX_NUMBER_OF_ALERTS = 30
}
}
```
---
# LatLngBounds API
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/lat-lng-bounds
# LatLngBounds API
[//]: # ({{ activity_source_note("LatLngBoundsActivity.kt") }})
[//]: # (This example demonstrates setting the camera to some bounds defined by some features. It sets these bounds when the map is initialized and when the [bottom sheet](https://m2.material.io/components/sheets-bottom) is opened or closed.)
[//]: # ()
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
Here you can see how the feature collection is loaded and how `MapMetricsMap.getCameraForLatLngBounds` is used to set the bounds during map initialization:
```kotlin
val featureCollection: FeatureCollection =
fromJson(GeoParseUtil.loadStringFromAssets(this, "points-sf.geojson"))
bounds = createBounds(featureCollection)
map.getCameraForLatLngBounds(bounds, createPadding(peekHeight))?.let {
map.cameraPosition = it
}
```
The `createBounds` function uses the `LatLngBounds` API to include all points within the bounds:
```kotlin
private fun createBounds(featureCollection: FeatureCollection): LatLngBounds {
val boundsBuilder = LatLngBounds.Builder()
featureCollection.features()?.let {
for (feature in it) {
val point = feature.geometry() as Point
boundsBuilder.include(LatLng(point.latitude(), point.longitude()))
}
}
return boundsBuilder.build()
}
```
---
# Max/Min Zoom
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/max-min-zoom
# Max/Min Zoom
[//]: # ({{ activity_source_note("MaxMinZoomActivity.kt") }})
This example shows how to configure a maximum and a minimum zoom level.
```kotlin
mapMetricsMap.setMinZoomPreference(3.0)
mapMetricsMap.setMaxZoomPreference(5.0)
```
## Bonus: Add Click Listener
As a bonus, this example also shows how you can define a click listener to the map.
```kotlin
mapMetricsMap.addOnMapClickListener {
if (this::mapMetricsMap.isInitialized) {
mapMetricsMap.setStyle(Style.Builder().fromUri(TestStyles.AMERICANA))
}
true
}
```
You can remove a click listener again with `MapMetricsMap.removeOnMapClickListener`. To use this API you need to assign the click listener to a variable, since you need to pass the listener to that method.
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( {{ openmaptiles_caption }})
[//]: # ( )
---
# Scroll by Method
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/move-map-pixels
# Scroll by Method
[//]: # ({{ activity_source_note("ScrollByActivity.kt") }})
This example shows how you can move the map by x/y pixels.
```kotlin
mapMetricsMap.scrollBy(
(seekBarX.progress * MULTIPLIER_PER_PIXEL).toFloat(),
(seekBarY.progress * MULTIPLIER_PER_PIXEL).toFloat()
)
```
[//]: # ()
[//]: # ( { width="300" })
[//]: # ( )
---
# Animate Camera Orbiting a Point
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/orbit-animation
# Animate Camera Orbiting a Point
This tutorial shows how to create a smooth orbiting camera animation that rotates around a central point — great for showcasing landmarks or creating cinematic map experiences.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Orbit Animation
Rotate the camera 360° around a point:
```kotlin
import android.animation.ValueAnimator
import android.os.Bundle
import android.view.animation.LinearInterpolator
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class OrbitActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private var orbitAnimator: ValueAnimator? = null
private val center = LatLng(48.8584, 2.2945) // Eiffel Tower
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_orbit)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
// Set initial camera with tilt
map.cameraPosition = CameraPosition.Builder()
.target(center)
.zoom(16.0)
.tilt(55.0)
.bearing(0.0)
.build()
// Start orbit button
findViewById(R.id.btnOrbit).setOnClickListener {
startOrbit()
}
// Stop button
findViewById(R.id.btnStop).setOnClickListener {
stopOrbit()
}
}
}
private fun startOrbit() {
orbitAnimator?.cancel()
val startBearing = map.cameraPosition.bearing
orbitAnimator = ValueAnimator.ofFloat(0f, 360f).apply {
duration = 20000 // 20 seconds for full rotation
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { animation ->
val bearing = startBearing + (animation.animatedValue as Float)
map.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(center)
.zoom(16.0)
.tilt(55.0)
.bearing(bearing.toDouble() % 360)
.build()
)
)
}
start()
}
}
private fun stopOrbit() {
orbitAnimator?.cancel()
orbitAnimator = null
}
override fun onDestroy() {
stopOrbit()
super.onDestroy()
mapView.onDestroy()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Orbit with Zoom Breathing
Add subtle zoom pulsing while orbiting:
```kotlin
import android.animation.AnimatorSet
private fun startBreathingOrbit() {
// Bearing rotation
val bearingAnimator = ValueAnimator.ofFloat(0f, 360f).apply {
duration = 20000
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
}
// Zoom breathing (zoom in and out gently)
val zoomAnimator = ValueAnimator.ofFloat(15.5f, 16.5f).apply {
duration = 5000
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
}
// Combined update
var currentBearing = 0f
var currentZoom = 16f
bearingAnimator.addUpdateListener { currentBearing = it.animatedValue as Float }
zoomAnimator.addUpdateListener { currentZoom = it.animatedValue as Float }
// Frame update on bearing changes
bearingAnimator.addUpdateListener {
map.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(center)
.zoom(currentZoom.toDouble())
.tilt(55.0)
.bearing(currentBearing.toDouble() % 360)
.build()
)
)
}
val animatorSet = AnimatorSet()
animatorSet.playTogether(bearingAnimator, zoomAnimator)
animatorSet.start()
}
```
## Orbit Then Fly Away
Orbit for one revolution then fly to a new location:
```kotlin
private fun orbitThenFlyAway() {
val startBearing = map.cameraPosition.bearing
ValueAnimator.ofFloat(0f, 360f).apply {
duration = 10000
interpolator = LinearInterpolator()
addUpdateListener { animation ->
val bearing = startBearing + (animation.animatedValue as Float)
map.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(center)
.zoom(16.0)
.tilt(55.0)
.bearing(bearing.toDouble() % 360)
.build()
)
)
}
addListener(object : android.animation.AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
// Fly to next location after orbit completes
map.animateCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(LatLng(48.8738, 2.2950)) // Arc de Triomphe
.zoom(16.0)
.tilt(55.0)
.bearing(0.0)
.build()
),
3000
)
}
})
start()
}
}
```
## Touch-to-Stop Orbit
Stop orbiting when the user touches the map:
```kotlin
private fun startOrbitWithTouchStop() {
startOrbit()
// Stop orbit on any touch
map.addOnCameraMoveStartedListener { reason ->
if (reason == MapMetricsMap.OnCameraMoveStartedListener.REASON_API_GESTURE) {
stopOrbit()
}
}
}
```
## Next Steps
- [Animation Types](./animation-types) — Camera animation comparison
- [Camera Position](./cameraposition) — Camera position fundamentals
- [Fly to a Location](../interactions/fly-to-location) — Point-to-point flights
---
**Tip**: Use `moveCamera` (not `animateCamera`) inside the orbit loop — `animateCamera` adds its own easing which conflicts with the `ValueAnimator`. The `ValueAnimator` handles all timing; the camera just needs instant updates each frame.
---
# Set Pitch and Bearing
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/set-pitch-bearing
# Set Pitch and Bearing
This tutorial shows how to control the 3D perspective (pitch/tilt) and compass direction (bearing/rotation) of your MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Set Pitch and Bearing on Load
Configure the initial 3D view:
```kotlin
import android.os.Bundle
import android.widget.Button
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class PitchBearingActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_pitch_bearing)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
// Set initial 3D perspective
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945))
.zoom(16.0)
.tilt(60.0) // Pitch: 0 = top-down, 60 = dramatic 3D
.bearing(45.0) // Bearing: 0 = north up, 45 = northeast
.build()
setupControls()
}
}
private fun setupControls() {
val pitchText = findViewById(R.id.tvPitch)
val bearingText = findViewById(R.id.tvBearing)
// Pitch slider (0-60)
findViewById(R.id.seekPitch).apply {
max = 60
progress = map.cameraPosition.tilt.toInt()
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, value: Int, user: Boolean) {
if (!user) return
pitchText.text = "Pitch: $value°"
map.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder(map.cameraPosition)
.tilt(value.toDouble())
.build()
)
)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
// Bearing slider (0-360)
findViewById(R.id.seekBearing).apply {
max = 360
progress = map.cameraPosition.bearing.toInt()
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, value: Int, user: Boolean) {
if (!user) return
bearingText.text = "Bearing: $value°"
map.moveCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder(map.cameraPosition)
.bearing(value.toDouble())
.build()
)
)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Preset Camera Views
Offer buttons for common perspective angles:
```kotlin
private fun setupPresets() {
// Top-down (2D)
findViewById(R.id.btnTopDown).setOnClickListener {
animateTo(tilt = 0.0, bearing = 0.0, zoom = 14.0)
}
// Gentle 3D
findViewById(R.id.btnGentle3d).setOnClickListener {
animateTo(tilt = 30.0, bearing = 0.0, zoom = 15.0)
}
// Dramatic 3D
findViewById(R.id.btnDramatic3d).setOnClickListener {
animateTo(tilt = 60.0, bearing = -30.0, zoom = 16.0)
}
// Street level
findViewById(R.id.btnStreetLevel).setOnClickListener {
animateTo(tilt = 60.0, bearing = 90.0, zoom = 18.0)
}
// Reset to north
findViewById(R.id.btnResetNorth).setOnClickListener {
animateTo(tilt = 0.0, bearing = 0.0, zoom = map.cameraPosition.zoom)
}
}
private fun animateTo(tilt: Double, bearing: Double, zoom: Double) {
map.animateCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(map.cameraPosition.target)
.zoom(zoom)
.tilt(tilt)
.bearing(bearing)
.build()
),
1500
)
}
```
## Limit Pitch Range via XML
Set pitch limits in the layout:
```xml
```
## Limit Pitch Range Programmatically
```kotlin
val options = MapMetricsMapOptions.createFromAttributes(this, null)
.maxPitchPreference(60.0)
.minPitchPreference(0.0)
.camera(
CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945))
.zoom(15.0)
.tilt(45.0)
.build()
)
mapView = MapView(this, options)
```
## Pitch and Bearing Reference
| Property | Range | Description |
|----------|-------|-------------|
| **Tilt/Pitch** | `0` - `60` | `0` = top-down, `60` = maximum 3D perspective |
| **Bearing** | `0` - `360` | `0`/`360` = north up, `90` = east up, `180` = south up, `270` = west up |
## Common Perspective Combinations
| View | Tilt | Bearing | Zoom | Effect |
|------|------|---------|------|--------|
| Standard 2D | 0° | 0° | 12-14 | Classic flat map |
| Gentle 3D | 30° | 0° | 14-16 | Subtle depth |
| Dramatic 3D | 60° | Any | 15-17 | Skyline visible |
| Street Level | 60° | Route direction | 17-19 | Navigation feel |
| Cinematic | 55° | Slowly rotating | 16 | Showcase mode |
## Next Steps
- [Camera Position](./cameraposition) — Camera position fundamentals
- [Orbit Animation](./orbit-animation) — Rotating camera animation
- [Building Layer](../styling/building-layer) — 3D buildings look best with tilt
---
**Tip**: 3D buildings and fill-extrusion layers are most impressive at tilt 45-60°. At tilt 0° (top-down), 3D extrusions are invisible since you're looking straight down on them.
---
# Zoom Methods
https://docs.mapatlas.xyz/overview/sdk/android-native/camera/zoom-methods
# Zoom Methods
[//]: # ({{ activity_source_note("ManualZoomActivity.kt") }})
[//]: # (This example shows different methods of zooming in.)
[//]: # ()
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
Each method uses `MapMetricsMap.animateCamera`, but with a different `CameraUpdateFactory`.
#### Zooming In
```kotlin
mapMetricsMap.animateCamera(CameraUpdateFactory.zoomIn())
```
#### Zooming Out
```kotlin
mapMetricsMap.animateCamera(CameraUpdateFactory.zoomOut())
```
#### Zoom By Some Amount of Zoom Levels
```kotlin
mapMetricsMap.animateCamera(CameraUpdateFactory.zoomBy(2.0))
```
#### Zoom to a Zoom Level
```kotlin
mapMetricsMap.animateCamera(CameraUpdateFactory.zoomTo(2.0))
```
#### Zoom to a Point
```kotlin
val view = window.decorView
mapMetricsMap.animateCamera(
CameraUpdateFactory.zoomBy(
1.0,
Point(view.measuredWidth / 4, view.measuredHeight / 4)
)
)
```
---
# Configuration
https://docs.mapatlas.xyz/overview/sdk/android-native/configuration
# Configuration
This guide will explain various ways to create a map.
When working with maps, you likely want to configure the `MapView`.
There are several ways to build a `MapView`:
1. Using existing XML namespace tags for`MapView` in the layout.
2. Creating `MapMetricsMapOptions` and passing builder function values into the `MapView`.
3. Creating a `SupportMapFragment` with the help of `MapMetricsMapOptions`.
Before diving into `MapView` configurations, let's understand the capabilities of both XML namespaces and `MapMetricsMapOptions`.
Here are some common configurations you can set:
- Map base URI
- Camera settings
- Zoom level
- Pitch
- Gestures
- Compass
- Logo
- Attribution
- Placement of the above elements on the map and more
We will explore how to achieve these configurations in XML layout and programmatically in Activity code, step by step.
### `MapView` Configuration with an XML layout
To configure `MapView` within an XML layout, you need to use the right namespace and provide the necessary data in the layout file.
```xml
```
This can be found in [`activity_map_options_xml.xml`](https://github.com/MapMetrics/mapmetrics-native-sdk/blob/main/platform/android/MapLibreAndroidTestApp/src/main/res/layout/activity_map_fragment.xml).
You can assign any other existing values to the `mapmetrics...` tags. Then, you only need to create `MapView` and `MapMetricsMap` objects with a simple setup in the Activity.
```kotlin title="MapOptionsXmlActivity.kt"
/**
* TestActivity demonstrating configuring MapView with XML
*/
class MapOptionsXmlActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var mapView: MapView
private lateinit var mapMetricsMap: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map_options_xml)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync(this)
}
override fun onMapReady(mapMetricsMap: MapMetricsMap) {
this.mapMetricsMap = mapMetricsMap
this.mapMetricsMap.setStyle(TestStyles.getMapMetricsStyle())
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
}
```
This can be found in [`MapOptionsXmlActivity.kt`](https://github.com/MapMetrics/mapmetrics-native-sdk/blob/main/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/options/MapOptionsXmlActivity.kt).
### `MapView` configuration with `MapMetricsMapOptions`
Here we don't have to create MapView from XML since we want to create it programmatically.
```xml
```
This can be found in [`activity_map_options_runtime.xml`](https://github.com/MapMetrics/mapmetrics-native-sdk/blob/main/platform/android/MapLibreAndroidTestApp/src/main/res/layout/activity_map_options_runtime.xml).
A `MapMetricsMapOptions` object must be created and passed to the MapView constructor. All setup is done in the Activity code:
```kotlin title="MapOptionsRuntimeActivity.kt"
/**
* TestActivity demonstrating configuring MapView with MapOptions
*/
class MapOptionsRuntimeActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var mapMetricsMap: MapMetricsMap
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map_options_runtime)
// Create map configuration
val mapMetricsMapOptions = MapMetricsMapOptions.createFromAttributes(this)
mapMetricsMapOptions.apply {
apiBaseUri("https://api.maplibre.org")
camera(
CameraPosition.Builder()
.bearing(0.0)
.target(LatLng(42.31230486601532, 64.63967338936439))
.zoom(3.9)
.tilt(0.0)
.build()
)
maxPitchPreference(90.0)
minPitchPreference(0.0)
maxZoomPreference(26.0)
minZoomPreference(2.0)
localIdeographFontFamily("Droid Sans")
zoomGesturesEnabled(true)
compassEnabled(true)
compassFadesWhenFacingNorth(true)
scrollGesturesEnabled(true)
rotateGesturesEnabled(true)
tiltGesturesEnabled(true)
}
// Create map programmatically, add to view hierarchy
mapView = MapView(this, mapMetricsMapOptions)
mapView.getMapAsync(this)
mapView.onCreate(savedInstanceState)
(findViewById(R.id.container) as ViewGroup).addView(mapView)
}
override fun onMapReady(mapMetricsMap: MapMetricsMap) {
this.mapMetricsMap = mapMetricsMap
this.mapMetricsMap.setStyle(TestStyles.getMapMetricsStyle())
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
}
```
This can be found in [`MapOptionsRuntimeActivity.kt`](https://github.com/MapMetrics/mapmetrics-native-sdk/blob/main/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/options/MapOptionsRuntimeActivity.kt).
[//]: # (Finally you will see a result similar to this:)
[//]: # ()
[//]: # ()
[//]: # (
)
[//]: # (
)
For the full contents of `MapOptionsRuntimeActivity` and `MapOptionsXmlActivity`, please take a look at the source code of [MapMetricsAndroidTestApp](https://github.com/MapMetrics/mapmetrics-native-sdk/tree/main/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/options).
You can read more about `MapMetricsMapOptions` in the [Android API documentation](https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.maps/-map-libre-map-options/index.html?query=open%20class%20MapLibreMapOptions%20:%20Parcelable).
### `SupportMapFragment` with the help of `MapMetricsMapOptions`.
If you are using MapFragment in your project, it is also easy to provide initial values to the `newInstance()` static method of `SupportMapFragment`, which requires a `MapMetricsMapOptions` parameter.
Let's see how this can be done in a sample activity:
```kotlin
/**
* Test activity showcasing using the MapFragment API using Support Library Fragments.
*
*
* Uses MapMetricsMapOptions to initialise the Fragment.
*
*/
class SupportMapFragmentActivity :
AppCompatActivity(),
OnMapViewReadyCallback,
OnMapReadyCallback,
OnDidFinishRenderingFrameListener {
private lateinit var mapMetricsMap: MapMetricsMap
private lateinit var mapView: MapView
private var initialCameraAnimation = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map_fragment)
val mapFragment: SupportMapFragment?
if (savedInstanceState == null) {
mapFragment = SupportMapFragment.newInstance(createFragmentOptions())
supportFragmentManager
.beginTransaction()
.add(R.id.fragment_container, mapFragment, TAG)
.commit()
} else {
mapFragment = supportFragmentManager.findFragmentByTag(TAG) as SupportMapFragment?
}
mapFragment!!.getMapAsync(this)
}
private fun createFragmentOptions(): MapMetricsMapOptions {
val options = MapMetricsMapOptions.createFromAttributes(this, null)
options.scrollGesturesEnabled(false)
options.zoomGesturesEnabled(false)
options.tiltGesturesEnabled(false)
options.rotateGesturesEnabled(false)
options.debugActive(false)
val dc = LatLng(38.90252, -77.02291)
options.minZoomPreference(9.0)
options.maxZoomPreference(11.0)
options.camera(
CameraPosition.Builder()
.target(dc)
.zoom(11.0)
.build()
)
return options
}
override fun onMapViewReady(map: MapView) {
mapView = map
mapView.addOnDidFinishRenderingFrameListener(this)
}
override fun onMapReady(map: MapMetricsMap) {
mapMetricsMap = map
mapMetricsMap.setStyle(TestStyles.getPredefinedStyleWithFallback("Satellite Hybrid"))
}
override fun onDestroy() {
super.onDestroy()
mapView.removeOnDidFinishRenderingFrameListener(this)
}
override fun onDidFinishRenderingFrame(fully: Boolean, frameEncodingTime: Double, frameRenderingTime: Double) {
if (initialCameraAnimation && fully && this::mapMetricsMap.isInitialized) {
mapMetricsMap.animateCamera(
CameraUpdateFactory.newCameraPosition(CameraPosition.Builder().tilt(45.0).build()),
5000
)
initialCameraAnimation = false
}
}
companion object {
private const val TAG = "com.mapbox.map"
}
}
```
You can also find the full contents of `SupportMapFragmentActivity` in the [MapMetricsAndroidTestApp](https://github.com/MapMetrics/mapmetrics-native-sdk/tree/main/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/fragment/SupportMapFragmentActivity.kt).
To learn more about `SupportMapFragment`, please visit the [Android API documentation](https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.maps/-support-map-fragment/index.html?query=open%20class%20SupportMapFragment%20:%20Fragment,%20OnMapReadyCallback).
---
# Vector Tiles
https://docs.mapatlas.xyz/overview/sdk/android-native/data/MVT
# Vector Tiles
[//]: # ({{ activity_source_note("VectorTileActivity.kt") }})
You can specify where to load MVTs (which sometimes have `.pbf` extension) by creating a `TileSet` object with template parameters (for example `{z}` which will be replaced with the zoom level).
MapMetrics has [a repo](https://github.com/maplibre/demotiles/tree/gh-pages/tiles-omt) with some example vector tiles with the OpenMapTiles schema around Innsbruck, Austria. In the example we load these MVTs and create a line layer for the road network.
```kotlin
val tileset = TileSet(
"openmaptiles",
"https://demotiles.mapmetrics.org/tiles-omt/{z}/{x}/{y}.pbf"
)
val openmaptiles = VectorSource("openmaptiles", tileset)
style.addSource(openmaptiles)
val roadLayer = LineLayer("road", "openmaptiles").apply {
setSourceLayer("transportation")
setProperties(
lineColor("red"),
lineWidth(2.0f)
)
}
```
[//]: # ()
[//]: # ()
[//]: # (  }}){ width="400" })
[//]: # ( {{ openmaptiles_caption() }})
[//]: # ( )
---
# PMTiles
https://docs.mapatlas.xyz/overview/sdk/android-native/data/PMTiles
# PMTiles
Starting MapMetrics Android, using [PMTiles](https://docs.protomaps.com/pmtiles/) as a data source is supported. You can prefix your vector tile source with `pmtiles://` to load a PMTiles file. The rest of the URL continue with be `https://` to load a remote PMTiles file, `asset://` to load an asset or `file://` to load a local PMTiles file.
Oliver Wipfli has made a style available that combines a Protomaps basemap together with Foursquare POI dataset. It is available in the [wipfli/foursquare-os-places-pmtiles](https://github.com/wipfli/foursquare-os-places-pmtiles) repository on GitHub. The style to use is
```
https://raw.githubusercontent.com/wipfli/foursquare-os-places-pmtiles/refs/heads/main/style.json
```
The neat thing about this style is that it only uses PMTiles vector sources. PMTiles can be hosted with a relatively simple file server (or file hosting service) instead of a more complex specialized tile server.
[//]: # ()
[//]: # (  }}){ width="300" })
[//]: # ( )
---
# Load GeoJSON from a URL
https://docs.mapatlas.xyz/overview/sdk/android-native/data/geojson-from-url
# Load GeoJSON from a URL
This tutorial shows how to load GeoJSON data from a remote URL and display it on your MapMetrics Android map — useful for loading dynamic datasets, APIs, and external data feeds.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Load GeoJSON from URL
Load and display a remote GeoJSON file:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import java.net.URI
class GeoJsonUrlActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
loadGeoJsonFromUrl(style)
}
}
}
private fun loadGeoJsonFromUrl(style: Style) {
// Load GeoJSON directly from a URL
val geoJsonUrl = "https://gateway.mapmetrics.org/assets/earthquakes.geojson"
style.addSource(
GeoJsonSource("remote-data", URI(geoJsonUrl))
)
// Visualize as colored circles
style.addLayer(
CircleLayer("data-layer", "remote-data")
.withProperties(
circleRadius(6f),
circleColor(Color.parseColor("#FF6B35")),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(1f)
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(20.0, 0.0))
.zoom(2.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Load from an API with Coroutines
Fetch JSON from a REST API, then display:
```kotlin
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.maplibre.geojson.FeatureCollection
import java.net.HttpURLConnection
import java.net.URL
private fun loadFromApi(style: Style) {
// Add empty source first
val source = GeoJsonSource("api-data")
style.addSource(source)
// Add layer
style.addLayer(
CircleLayer("api-layer", "api-data")
.withProperties(
circleRadius(5f),
circleColor(Color.parseColor("#4285F4")),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(1f)
)
)
// Fetch data asynchronously
lifecycleScope.launch {
val geoJson = withContext(Dispatchers.IO) {
fetchGeoJson("https://api.example.com/data.geojson")
}
geoJson?.let {
val featureCollection = FeatureCollection.fromJson(it)
source.setGeoJson(featureCollection)
}
}
}
private fun fetchGeoJson(urlString: String): String? {
return try {
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
connection.inputStream.bufferedReader().readText()
} else null
} catch (e: Exception) {
e.printStackTrace()
null
}
}
```
## Load from Local Assets
Load a GeoJSON file bundled in `assets/`:
```kotlin
private fun loadFromAssets(style: Style) {
// Read from assets/data/places.geojson
val json = assets.open("data/places.geojson")
.bufferedReader()
.readText()
val featureCollection = FeatureCollection.fromJson(json)
style.addSource(GeoJsonSource("local-data", featureCollection))
style.addLayer(
CircleLayer("local-layer", "local-data")
.withProperties(
circleRadius(6f),
circleColor(Color.parseColor("#34A853"))
)
)
}
```
## Refresh Data Periodically
Update GeoJSON data on a timer:
```kotlin
import android.os.Handler
import android.os.Looper
private val refreshHandler = Handler(Looper.getMainLooper())
private val refreshInterval = 30000L // 30 seconds
private fun startAutoRefresh(style: Style) {
val source = style.getSource("remote-data") as? GeoJsonSource ?: return
val refreshRunnable = object : Runnable {
override fun run() {
lifecycleScope.launch {
val json = withContext(Dispatchers.IO) {
fetchGeoJson("https://api.example.com/live-data.geojson")
}
json?.let {
source.setGeoJson(FeatureCollection.fromJson(it))
}
}
refreshHandler.postDelayed(this, refreshInterval)
}
}
refreshHandler.postDelayed(refreshRunnable, refreshInterval)
}
override fun onDestroy() {
refreshHandler.removeCallbacksAndMessages(null)
super.onDestroy()
mapView.onDestroy()
}
```
## GeoJSON Source Options
| Constructor | Description |
|------------|-------------|
| `GeoJsonSource(id, URI)` | Load from URL (auto-fetches) |
| `GeoJsonSource(id, FeatureCollection)` | Load from parsed data |
| `GeoJsonSource(id, String)` | Load from raw JSON string |
| `GeoJsonSource(id, Geometry)` | Load single geometry |
| `GeoJsonSource(id, URI, GeoJsonOptions)` | URL with clustering options |
## Next Steps
- [GeoJSON Guide](../geojson-guide) — GeoJSON fundamentals
- [Multiple Sources](./multiple-sources) — Combining multiple data sources
- [Filter Features](../styling/filter-features) — Filter loaded data
---
**Tip**: `GeoJsonSource(id, URI)` handles network fetching automatically on a background thread. For simple static URLs, this is the easiest approach. Use the coroutine method when you need custom headers, authentication, or error handling.
---
# Combine Multiple Data Sources
https://docs.mapatlas.xyz/overview/sdk/android-native/data/multiple-sources
# Combine Multiple Data Sources
This tutorial shows how to load and display data from multiple GeoJSON sources simultaneously on your MapMetrics Android map — useful for layering different datasets like POIs, routes, and zones.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Multiple Sources with Different Layer Types
Add points, lines, and polygons from separate sources:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.FillLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.*
class MultiSourceActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addZones(style) // Polygons
addRoutes(style) // Lines
addPointsOfInterest(style) // Points
setCamera()
}
}
}
private fun addZones(style: Style) {
val zone1 = Feature.fromGeometry(
Polygon.fromLngLats(listOf(listOf(
Point.fromLngLat(2.32, 48.87),
Point.fromLngLat(2.35, 48.87),
Point.fromLngLat(2.35, 48.855),
Point.fromLngLat(2.32, 48.855),
Point.fromLngLat(2.32, 48.87),
))),
JsonObject().apply { addProperty("name", "Zone A") }
)
val zone2 = Feature.fromGeometry(
Polygon.fromLngLats(listOf(listOf(
Point.fromLngLat(2.35, 48.855),
Point.fromLngLat(2.38, 48.855),
Point.fromLngLat(2.38, 48.84),
Point.fromLngLat(2.35, 48.84),
Point.fromLngLat(2.35, 48.855),
))),
JsonObject().apply { addProperty("name", "Zone B") }
)
style.addSource(
GeoJsonSource("zones-source",
FeatureCollection.fromFeatures(listOf(zone1, zone2)))
)
// Fill layer for zones
style.addLayer(
FillLayer("zones-fill", "zones-source")
.withProperties(
fillColor(Color.parseColor("#4285F4")),
fillOpacity(0.2f)
)
)
// Outline layer for zones
style.addLayer(
LineLayer("zones-outline", "zones-source")
.withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(2f)
)
)
}
private fun addRoutes(style: Style) {
val route = Feature.fromGeometry(
LineString.fromLngLats(listOf(
Point.fromLngLat(2.3200, 48.8600),
Point.fromLngLat(2.3350, 48.8580),
Point.fromLngLat(2.3500, 48.8560),
Point.fromLngLat(2.3600, 48.8500),
Point.fromLngLat(2.3700, 48.8450),
))
)
style.addSource(
GeoJsonSource("routes-source", FeatureCollection.fromFeature(route))
)
style.addLayer(
LineLayer("routes-layer", "routes-source")
.withProperties(
lineColor(Color.parseColor("#FF6B35")),
lineWidth(4f),
lineOpacity(0.8f)
)
)
}
private fun addPointsOfInterest(style: Style) {
val pois = listOf(
Triple(2.3376, 48.8606, "Louvre Museum"),
Triple(2.3266, 48.8600, "Musée d'Orsay"),
Triple(2.3499, 48.8530, "Notre-Dame"),
Triple(2.3464, 48.8462, "Luxembourg Gardens"),
Triple(2.3532, 48.8619, "Centre Pompidou"),
)
val features = pois.map { (lng, lat, name) ->
Feature.fromGeometry(
Point.fromLngLat(lng, lat),
JsonObject().apply { addProperty("name", name) }
)
}
style.addSource(
GeoJsonSource("pois-source", FeatureCollection.fromFeatures(features))
)
style.addLayer(
CircleLayer("pois-layer", "pois-source")
.withProperties(
circleRadius(8f),
circleColor(Color.parseColor("#34A853")),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(2f)
)
)
}
private fun setCamera() {
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.855, 2.350))
.zoom(13.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Toggle Layer Visibility
Let users show/hide each data layer:
```kotlin
import android.widget.ToggleButton
import org.maplibre.android.style.layers.Property
private fun setupLayerToggles(style: Style) {
val layerToggles = mapOf(
R.id.btnZones to listOf("zones-fill", "zones-outline"),
R.id.btnRoutes to listOf("routes-layer"),
R.id.btnPois to listOf("pois-layer"),
)
for ((btnId, layerIds) in layerToggles) {
findViewById(btnId).setOnCheckedChangeListener { _, checked ->
for (layerId in layerIds) {
val layer = style.getLayer(layerId)
layer?.setProperties(
visibility(
if (checked) Property.VISIBLE else Property.NONE
)
)
}
}
}
}
```
## Mix Local and Remote Sources
Combine bundled data with API data:
```kotlin
private fun addMixedSources(style: Style) {
// Remote: live data from API
style.addSource(
GeoJsonSource(
"live-data",
java.net.URI("https://api.example.com/live-events.geojson")
)
)
style.addLayer(
CircleLayer("live-layer", "live-data")
.withProperties(
circleRadius(6f),
circleColor(Color.RED)
)
)
// Local: static boundary from assets
val boundaryJson = assets.open("data/city-boundary.geojson")
.bufferedReader().readText()
style.addSource(
GeoJsonSource("boundary", FeatureCollection.fromJson(boundaryJson))
)
style.addLayer(
LineLayer("boundary-layer", "boundary")
.withProperties(
lineColor(Color.parseColor("#333333")),
lineWidth(2f),
lineDasharray(arrayOf(3f, 2f))
)
)
}
```
## Layer Ordering
Control which layers appear on top:
```kotlin
// Add below a specific layer
style.addLayerBelow(fillLayer, "road-label")
// Add above a specific layer
style.addLayerAbove(circleLayer, "zones-fill")
// Typical order (bottom to top):
// 1. Fill layers (zones, polygons)
// 2. Line layers (routes, boundaries)
// 3. Circle/Symbol layers (POIs, markers)
```
## Next Steps
- [GeoJSON from URL](./geojson-from-url) — Loading remote data
- [GeoJSON Guide](../geojson-guide) — GeoJSON fundamentals
- [Filter Features](../styling/filter-features) — Filter visible data
---
**Tip**: Add layers in the correct visual order — fills first, then lines, then points. Use `addLayerBelow` or `addLayerAbove` to insert layers at specific positions in the rendering stack. Points should always be on top so they remain clickable.
---
# Using a GeoJSON Source
https://docs.mapatlas.xyz/overview/sdk/android-native/geojson-guide
# Using a GeoJSON Source
This guide will teach you how to use [`GeoJsonSource`](https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.style.sources/-geo-json-source/index.html) by deep diving into [GeoJSON](https://geojson.org/) file format.
## Goals
After finishing this documentation you should be able to:
1. Understand how `Style`, `Layer`, and `Source` interact with each other.
2. Explore building blocks of GeoJSON data.
3. Use GeoJSON files in constructing `GeoJsonSource`s.
4. Update data at runtime.
## 1. Styles, Layers, and Data source
- A style defines the visual representation of the map such as colors and appearance.
- Layers control how data should be presented to the user.
- Data sources hold actual data and provides layers with it.
Styles consist of collections of layers and a data source. Layers reference data sources. Hence, they require a unique source ID when you construct them.
It would be meaningless if we don't have any data to show, so we need know how to supply data through a data source.
Firstly, we need to understand how to store data and pass it into a data source; therefore, we will discuss GeoJSON in the next session.
## 2. GeoJSON
[GeoJSON](https://geojson.org/) is a JSON file for encoding various geographical data structures.
It defines several JSON objects to represent geospatial information. Typicalle the`.geojson` extension is used for GeoJSON files.
We define the most fundamental objects:
- `Geometry` refers to a single geometric shape that contains one or more coordinates. These shapes are visual objects displayed on a map. A geometry can be one of the following six types:
- Point
- MultiPoint
- LineString
- MultilineString
- Polygon
- MultiPolygon
- `Feautue` is a compound object that combines a single geometry with user-defined attributes, such as name, color.
- `FeatureCollection` is set of features stored in an array. It is a root object that introduces all other features.
A typical GeoJSON structure might look like:
```json
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [125.6, 10.1]
},
"properties": {
"name": "Dinagat Islands"
}
}
```
So far we learned describing geospatial data in GeoJSON files. We will start applying this knowledge into our map applications.
## 3. GeoJsonSource
As we discussed before, map requires some sort data to be rendered. We use different sources such as Vector, Raster and GeoJSON.
We will focus exclusively on `GeoJsonSource` and will not address other sources.
`GeoJsonSource` is a type of source that has a unique `String` ID and GeoJSON data.
There are several ways to construct a `GeoJsonSource`:
- Locally stored files such as assets and raw folders
- Remote services
- Raw string parsed into FeatureCollections objects
- Geometry, Feature, and FeatureCollection objects that map to GeoJSON Base builders
A sample `GeoJsonSource`:
```kotlin
val source = GeoJsonSource("source", featureCollection)
val lineLayer = LineLayer("layer", "source")
.withProperties(
PropertyFactory.lineColor(Color.RED),
PropertyFactory.lineWidth(10f)
)
style.addSource(source)
style.addLayer(lineLayer)
```
Note that you can not simply show data on a map. Layers must reference them. Therefore, you create a layer that gives visual appearance to it.
### Creating GeoJSON sources
There are various ways you can create a `GeoJSONSource`. Some of the options are shown below.
```kotlin title="Loading from local files with assets folder file"
binding.mapView.getMapAsync { map ->
map.moveCamera(CameraUpdateFactory.newLatLngZoom(cameraTarget, cameraZoom))
map.setStyle(
Style.Builder()
.withImage(imageId, imageIcon)
.withSource(GeoJsonSource(sourceId, URI("asset://points-sf.geojson")))
.withLayer(SymbolLayer(layerId, sourceId).withProperties(iconImage(imageId)))
)
}
```
```kotlin title="Loading with raw folder file"
val source: Source = try {
GeoJsonSource("amsterdam-spots", ResourceUtils.readRawResource(this, R.raw.amsterdam))
} catch (ioException: IOException) {
Toast.makeText(
this@RuntimeStyleActivity,
"Couldn't add source: " + ioException.message,
Toast.LENGTH_SHORT
).show()
return
}
mapMetricsMap.style!!.addSource(source)
var layer: FillLayer? = FillLayer("parksLayer", "amsterdam-spots")
layer!!.setProperties(
PropertyFactory.fillColor(Color.RED),
PropertyFactory.fillOutlineColor(Color.BLUE),
PropertyFactory.fillOpacity(0.3f),
PropertyFactory.fillAntialias(true)
)
```
```kotlin title="Parsing inline JSON"
fun readRawResource(context: Context?, @RawRes rawResource: Int): String {
var json = ""
if (context != null) {
val writer: Writer = StringWriter()
val buffer = CharArray(1024)
context.resources.openRawResource(rawResource).use { `is` ->
val reader: Reader = BufferedReader(InputStreamReader(`is`, "UTF-8"))
var numRead: Int
while (reader.read(buffer).also { numRead = it } != -1) {
writer.write(buffer, 0, numRead)
}
}
json = writer.toString()
}
return json
}
```
```kotlin title="Loading from remote services"
private fun createEarthquakeSource(): GeoJsonSource {
return GeoJsonSource(EARTHQUAKE_SOURCE_ID, URI(EARTHQUAKE_SOURCE_URL))
}
```
```kotlin
companion object {
private const val EARTHQUAKE_SOURCE_URL =
"https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson"
private const val EARTHQUAKE_SOURCE_ID = "earthquakes"
private const val HEATMAP_LAYER_ID = "earthquakes-heat"
private const val HEATMAP_LAYER_SOURCE = "earthquakes"
private const val CIRCLE_LAYER_ID = "earthquakes-circle"
}
```
```kotlin title="Parsing string with the fromJson method of FeatureCollection"
return FeatureCollection.fromJson(
"""
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-77.06867337226866,
38.90467655551809
],
[
-77.06233263015747,
38.90479344272695
],
[
-77.06234335899353,
38.906463238984344
],
[
-77.06290125846863,
38.907206285691615
],
[
-77.06364154815674,
38.90684728656818
],
[
-77.06326603889465,
38.90637140121084
],
[
-77.06321239471436,
38.905561553883246
],
[
-77.0691454410553,
38.905436318935635
],
[
-77.06912398338318,
38.90466820642439
],
[
-77.06867337226866,
38.90467655551809
]
]
]
}
}
]
}
""".trimIndent()
).features()!![0].geometry() as Polygon
```
```kotlin title="Creating Geometry, Feature, and FeatureCollections from scratch"
val properties = JsonObject()
properties.addProperty("key1", "value1")
val source = GeoJsonSource(
"test-source",
FeatureCollection.fromFeatures(
arrayOf(
Feature.fromGeometry(Point.fromLngLat(17.1, 51.0), properties),
Feature.fromGeometry(Point.fromLngLat(17.2, 51.0), properties),
Feature.fromGeometry(Point.fromLngLat(17.3, 51.0), properties),
Feature.fromGeometry(Point.fromLngLat(17.4, 51.0), properties)
)
)
)
style.addSource(source)
val visible = Expression.eq(Expression.get("key1"), Expression.literal("value1"))
val invisible = Expression.neq(Expression.get("key1"), Expression.literal("value1"))
val layer = CircleLayer("test-layer", source.id)
.withFilter(visible)
style.addLayer(layer)
```
Note that the GeoJSON objects we discussed earlier have classes defined in the MapMetrics SDK.
Therefore, we can either map JSON objects to regular Java/Kotlin objects or build them directly.
## 4. Updating data at runtime
The key feature of `GeoJsonSource`s is that once we add one, we can set another set of data.
We achieve this using `setGeoJson()` method. For instance, we create a source variable and check if we have not assigned it, then we create a new source object and add it to style; otherwise, we set a different data source:
```kotlin
private fun createFeatureCollection(): FeatureCollection {
val point = if (isInitialPosition) {
Point.fromLngLat(-74.01618140, 40.701745)
} else {
Point.fromLngLat(-73.988097, 40.749864)
}
val properties = JsonObject()
properties.addProperty(KEY_PROPERTY_SELECTED, isSelected)
val feature = Feature.fromGeometry(point, properties)
return FeatureCollection.fromFeatures(arrayOf(feature))
}
```
```kotlin
private fun updateSource(style: Style?) {
val featureCollection = createFeatureCollection()
if (source != null) {
source!!.setGeoJson(featureCollection)
} else {
source = GeoJsonSource(SOURCE_ID, featureCollection)
style!!.addSource(source!!)
}
}
```
See [this guide](styling/animated-symbol-layer.md) for an advanced example that showcases random cars and a passenger on a map updating their positions with smooth animation.
## Summary
GeoJsonSources have their pros and cons. They are most effective when you want to add additional data to your style or provide features like animating objects on your map.
However, working with large datasets can be challenging if you need to manipulate and store data within the app; in such cases, it’s better to use a remote data source.
---
# Quickstart
https://docs.mapatlas.xyz/overview/sdk/android-native/getting-started
# Quickstart
To follow this example from scratch, in Android Studio create a new "Empty Views Activity" and then select "Kotlin" as the language. Select "Groovy DSL" as the build configuration language.
1. If you have an older project, you'll need to add Maven Central to your project-level Gradle file (usually `//build.gradle`). Add `mavenCentral()` to where repositories are already defined in that file, something like this:
```gradle
allprojects {
repositories {
...
mavenCentral()
}
}
```
A newly-created app will likely already have `mavenCentral()` in a top-level `settings.gradle` file, and you won't need to add it.
2. Add the library as a dependency into your module Gradle file (usually `//build.gradle`). The current release is `1.0.3` — see [all released versions](https://central.sonatype.com/artifact/org.mapmetrics.android-sdk/mapmetrics-native-sdk/versions):
```gradle
dependencies {
...
implementation 'org.mapmetrics.android-sdk:mapmetrics-native-sdk:1.0.3'
}
```
3. Sync your Android project with Gradle files.
4. Add a `MapView` to your layout XML file (usually `//src/main/res/layout/activity_main.xml`).
```xml
...
...
```
5. Initialize the `MapView` in your `MainActivity` file by following the example below. If modifying a newly-created "Empty Views Activity" example, it replaces all the Kotlin code after the "package" line.
```kotlin
class SimpleMapActivity : AppCompatActivity() {
// Declare a variable for MapView
private lateinit var mapView: MapView
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object: OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// activity uses singleInstance for testing purposes
// code below provides a default navigation when using the app
NavUtils.navigateHome(this@SimpleMapActivity)
}
})
setContentView(R.layout.activity_map_simple)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync {
val key = ApiKeyUtils.getApiKey(applicationContext)
if (key == null || key == "YOUR_API_KEY_GOES_HERE") {
it.setStyle(
Style.Builder().fromUri(fileName)
)
} else {
val styles = Style.getPredefinedStyles()
if (styles.isNotEmpty()) {
val styleUrl = styles[0].url
it.setStyle(Style.Builder().fromUri(styleUrl))
}
}
}
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
6. Build and run the app. If you run the app successfully, a map will be displayed.
---
# Create and Style Clusters
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/add-a-cluster
# Create and Style Clusters
This tutorial shows how to cluster large point datasets on your MapMetrics Android map for better performance and readability.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Clustering
Group nearby points into clusters that show a count:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonOptions
import org.maplibre.android.style.sources.GeoJsonSource
import java.net.URI
class ClusterActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addClusters(style)
}
}
}
private fun addClusters(style: Style) {
// Add GeoJSON source with clustering enabled
style.addSource(
GeoJsonSource(
"earthquake-source",
URI("https://gateway.mapmetrics.org/assets/earthquakes.geojson"),
GeoJsonOptions()
.withCluster(true)
.withClusterMaxZoom(14)
.withClusterRadius(50)
)
)
// Layer 1: Cluster circles — sized and colored by point count
style.addLayer(
CircleLayer("cluster-circles", "earthquake-source")
.withProperties(
// Color by cluster size
circleColor(
step(
get("point_count"),
color(Color.parseColor("#51bbd6")), // < 100
stop(100, color(Color.parseColor("#f1f075"))),
stop(750, color(Color.parseColor("#f28cb1")))
)
),
// Radius by cluster size
circleRadius(
step(
get("point_count"),
literal(20), // < 100: 20px
stop(100, 30), // 100-749: 30px
stop(750, 40) // 750+: 40px
)
),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(2f)
)
.withFilter(has("point_count")) // Only clusters
)
// Layer 2: Cluster count labels
style.addLayer(
SymbolLayer("cluster-count", "earthquake-source")
.withProperties(
textField(toString(get("point_count"))),
textSize(12f),
textColor(Color.BLACK),
textIgnorePlacement(true),
textAllowOverlap(true)
)
.withFilter(has("point_count"))
)
// Layer 3: Unclustered individual points
style.addLayer(
CircleLayer("unclustered-point", "earthquake-source")
.withProperties(
circleColor(Color.parseColor("#11b4da")),
circleRadius(6f),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(1f)
)
.withFilter(Expression.not(has("point_count")))
)
// Set initial view
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(20.0, 0.0))
.zoom(2.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Click to Expand Clusters
Zoom in when the user taps on a cluster:
```kotlin
private fun setupClusterClick() {
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
val features = map.queryRenderedFeatures(screenPoint, "cluster-circles")
if (features.isNotEmpty()) {
val cluster = features[0]
val pointCount = cluster.getNumberProperty("point_count").toInt()
// Zoom in by 2 levels toward the cluster
map.animateCamera(
org.maplibre.android.camera.CameraUpdateFactory.newLatLngZoom(
latLng,
map.cameraPosition.zoom + 2
),
500
)
}
true
}
}
```
## Click to Show Unclustered Point Info
Show details when tapping an individual point:
```kotlin
private fun setupPointClick() {
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
// Check unclustered points first
val pointFeatures = map.queryRenderedFeatures(screenPoint, "unclustered-point")
if (pointFeatures.isNotEmpty()) {
val feature = pointFeatures[0]
val mag = feature.getNumberProperty("mag")
val place = feature.getStringProperty("place")
map.addMarker(
org.maplibre.android.annotations.MarkerOptions()
.position(latLng)
.title("Magnitude: $mag")
.snippet(place ?: "Unknown location")
)
return@addOnMapClickListener true
}
// Then check clusters
val clusterFeatures = map.queryRenderedFeatures(screenPoint, "cluster-circles")
if (clusterFeatures.isNotEmpty()) {
map.animateCamera(
org.maplibre.android.camera.CameraUpdateFactory.newLatLngZoom(
latLng, map.cameraPosition.zoom + 2
),
500
)
}
true
}
}
```
## Cluster Options
| Option | Type | Description |
|--------|------|-------------|
| `withCluster(true)` | Boolean | Enable clustering |
| `withClusterMaxZoom(14)` | Int | Stop clustering above this zoom level |
| `withClusterRadius(50)` | Int | Cluster merge radius in pixels |
## Layer Structure
| Layer | Filter | Purpose |
|-------|--------|---------|
| `cluster-circles` | `has("point_count")` | Colored circles for clusters |
| `cluster-count` | `has("point_count")` | Text label showing count |
| `unclustered-point` | `not(has("point_count"))` | Individual data points |
## Next Steps
- [Add a Heatmap](./add-a-heatmap) — Density-based visualization
- [Circle Layer](../styling/circle-layer) — Styled data points
- [GeoJSON Guide](../geojson-guide) — Working with GeoJSON data
---
**Tip**: Adjust `clusterRadius` based on your data density — use 30-40 for sparse data, 60-80 for dense data. Higher radius creates fewer, larger clusters.
---
# Add a Heatmap Layer
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/add-a-heatmap
# Add a Heatmap Layer
This tutorial shows how to create heatmap visualizations on your MapMetrics Android map — ideal for showing data density like earthquake activity, population, or events.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Heatmap
Load earthquake data and display it as a heatmap:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.HeatmapLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import java.net.URI
class HeatmapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addHeatmap(style)
}
}
}
private fun addHeatmap(style: Style) {
// Add GeoJSON source with earthquake data
style.addSource(
GeoJsonSource(
"earthquake-source",
URI("https://gateway.mapmetrics.org/assets/earthquakes.geojson")
)
)
// Add heatmap layer
style.addLayer(
HeatmapLayer("heatmap-layer", "earthquake-source").withProperties(
// Weight based on magnitude
heatmapWeight(
interpolate(
linear(), get("mag"),
stop(0, 0),
stop(6, 1)
)
),
// Increase intensity with zoom
heatmapIntensity(
interpolate(
linear(), zoom(),
stop(0, 1),
stop(9, 3)
)
),
// Color ramp from transparent to red
heatmapColor(
interpolate(
linear(), heatmapDensity(),
stop(0, rgba(33f, 102f, 172f, 0f)),
stop(0.2, rgb(103f, 169f, 207f)),
stop(0.4, rgb(209f, 229f, 240f)),
stop(0.6, rgb(253f, 219f, 199f)),
stop(0.8, rgb(239f, 138f, 98f)),
stop(1.0, rgb(178f, 24f, 43f))
)
),
// Increase radius with zoom
heatmapRadius(
interpolate(
linear(), zoom(),
stop(0, 2),
stop(9, 20)
)
),
// Fade out at high zoom
heatmapOpacity(
interpolate(
linear(), zoom(),
stop(7, 1),
stop(9, 0)
)
)
)
)
// Set initial view
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(20.0, 0.0))
.zoom(2.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Heatmap with Point Layer Transition
Show heatmap at low zoom, individual points at high zoom:
```kotlin
import org.maplibre.android.style.layers.CircleLayer
private fun addHeatmapWithPoints(style: Style) {
style.addSource(
GeoJsonSource(
"earthquake-source",
URI("https://gateway.mapmetrics.org/assets/earthquakes.geojson")
)
)
// Heatmap layer — visible at low zoom
val heatmapLayer = HeatmapLayer("heatmap-layer", "earthquake-source")
.withProperties(
heatmapWeight(
interpolate(linear(), get("mag"), stop(0, 0), stop(6, 1))
),
heatmapColor(
interpolate(
linear(), heatmapDensity(),
stop(0, rgba(33f, 102f, 172f, 0f)),
stop(0.2, rgb(103f, 169f, 207f)),
stop(0.4, rgb(209f, 229f, 240f)),
stop(0.6, rgb(253f, 219f, 199f)),
stop(0.8, rgb(239f, 138f, 98f)),
stop(1.0, rgb(178f, 24f, 43f))
)
),
heatmapRadius(
interpolate(linear(), zoom(), stop(0, 2), stop(9, 20))
),
// Fade heatmap between zoom 7-9
heatmapOpacity(
interpolate(linear(), zoom(), stop(7, 1), stop(9, 0))
)
)
heatmapLayer.maxZoom = 9f
style.addLayer(heatmapLayer)
// Point layer — visible at high zoom
val circleLayer = CircleLayer("point-layer", "earthquake-source")
.withProperties(
// Color by magnitude
circleColor(
interpolate(
linear(), get("mag"),
stop(1, color(Color.parseColor("#2DC4B2"))),
stop(2, color(Color.parseColor("#3BB3C3"))),
stop(3, color(Color.parseColor("#669EC4"))),
stop(4, color(Color.parseColor("#8B88B6"))),
stop(5, color(Color.parseColor("#A2719B"))),
stop(6, color(Color.parseColor("#AA5E79")))
)
),
// Size by magnitude
circleRadius(
interpolate(linear(), get("mag"), stop(1, 4), stop(6, 16))
),
circleOpacity(
interpolate(linear(), zoom(), stop(7, 0), stop(8, 1))
),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(1f)
)
circleLayer.minZoom = 7f
style.addLayer(circleLayer)
}
```
## Heatmap Properties Reference
| Property | Description |
|----------|-------------|
| `heatmapColor` | Color ramp based on `heatmapDensity()` |
| `heatmapWeight` | Weight of each data point (often based on a property) |
| `heatmapIntensity` | Global multiplier for density (use with zoom) |
| `heatmapRadius` | Radius of influence per point in pixels |
| `heatmapOpacity` | Layer transparency (0.0 - 1.0) |
## Next Steps
- [Circle Layer](../styling/circle-layer) — Styled point markers
- [Data-Driven Styling](../styling/data-driven-style) — Color by data properties
- [Add Clusters](./add-a-cluster) — Clustered point markers
---
**Tip**: Always pair a heatmap with a point layer transition as shown above. Heatmaps are useful for overview zoom levels (1-8), but at street level users need to see individual data points.
---
# Display a Popup on Marker Click
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/add-a-popup
# Display a Popup on Marker Click
This tutorial shows how to display custom popup windows when users tap on markers in your MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Marker with Info Window
The simplest way to show a popup is using the built-in marker title and snippet:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.MapMetrics
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class PopupActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) {
// Add a marker with popup content
map.addMarker(
MarkerOptions()
.position(LatLng(48.8584, 2.2945))
.title("Eiffel Tower")
.snippet("Paris, France — Built in 1889")
)
// Move camera to the marker
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945))
.zoom(14.0)
.build()
}
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Multiple Markers with Popups
Add several markers with different popup content:
```kotlin
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
val places = listOf(
Triple(LatLng(48.8584, 2.2945), "Eiffel Tower", "324m tall iron lattice tower"),
Triple(LatLng(48.8606, 2.3376), "Louvre Museum", "World's largest art museum"),
Triple(LatLng(48.8530, 2.3499), "Notre-Dame", "Medieval Catholic cathedral"),
Triple(LatLng(48.8738, 2.2950), "Arc de Triomphe", "Honors those who fought for France"),
)
for ((position, title, description) in places) {
map.addMarker(
MarkerOptions()
.position(position)
.title(title)
.snippet(description)
)
}
// Fit camera to show all markers
val bounds = org.maplibre.android.geometry.LatLngBounds.Builder()
for ((position, _, _) in places) {
bounds.include(position)
}
map.easeCamera(
org.maplibre.android.camera.CameraUpdateFactory.newLatLngBounds(
bounds.build(), 100
),
1000
)
}
```
## Popup on Map Click
Show a popup when the user taps anywhere on the map:
```kotlin
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
var currentMarker: org.maplibre.android.annotations.Marker? = null
map.addOnMapClickListener { latLng ->
// Remove previous marker
currentMarker?.let { map.removeMarker(it) }
// Add new marker with coordinates as popup
currentMarker = map.addMarker(
MarkerOptions()
.position(latLng)
.title("Tapped Location")
.snippet(
"Lat: ${String.format("%.6f", latLng.latitude)}\n" +
"Lng: ${String.format("%.6f", latLng.longitude)}"
)
)
// Show the info window immediately
currentMarker?.showInfoWindow(map, mapView)
true // consume the event
}
}
```
## Custom Info Window Adapter
Create a fully custom popup layout:
```kotlin
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import org.maplibre.android.maps.MapMetricsMap.InfoWindowAdapter
// Set custom adapter
map.setInfoWindowAdapter(object : InfoWindowAdapter {
override fun getInfoWindow(marker: org.maplibre.android.annotations.Marker): View {
val view = LayoutInflater.from(this@PopupActivity)
.inflate(R.layout.custom_info_window, null)
val title = view.findViewById(R.id.info_title)
val snippet = view.findViewById(R.id.info_snippet)
title.text = marker.title
snippet.text = marker.snippet
return view
}
})
```
Custom layout XML (`res/layout/custom_info_window.xml`):
```xml
```
## Info Window Events
Listen for clicks on the info window popup:
```kotlin
// Click on info window
map.setOnInfoWindowClickListener { marker ->
// Navigate to detail screen, open URL, etc.
Toast.makeText(
this,
"Clicked: ${marker.title}",
Toast.LENGTH_SHORT
).show()
}
// Info window opens
map.setOnInfoWindowLongClickListener { marker ->
Toast.makeText(
this,
"Long pressed: ${marker.title}",
Toast.LENGTH_SHORT
).show()
}
// Info window closes
map.setOnInfoWindowCloseListener { marker ->
// Cleanup or state updates
}
```
## Next Steps
- [Add Markers](../annotations/add-markers) — Marker basics and icons
- [Fly to a Location](./fly-to-location) — Camera animation to a point
- [GeoJSON Guide](../geojson-guide) — Loading map data from GeoJSON
---
**Tip**: Info windows only support one open at a time — tapping a new marker automatically closes the previous popup. For multiple simultaneous popups, use a symbol layer with custom label text instead.
---
# Add Custom Image Markers
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/add-image-marker
# Add Custom Image Markers
This tutorial shows how to use custom images (PNG, SVG, drawable) as marker icons on your MapMetrics Android map instead of the default pin.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Custom Icon from Drawable Resource
Use an Android drawable as a marker icon:
```kotlin
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import org.maplibre.android.annotations.IconFactory
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class ImageMarkerActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addCustomMarkers()
}
}
}
private fun addCustomMarkers() {
val iconFactory = IconFactory.getInstance(this)
// Convert vector drawable to bitmap
val icon = iconFactory.fromBitmap(
drawableToBitmap(R.drawable.ic_custom_pin)
)
map.addMarker(
MarkerOptions()
.position(LatLng(48.8584, 2.2945))
.title("Eiffel Tower")
.icon(icon)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945))
.zoom(14.0)
.build()
}
private fun drawableToBitmap(drawableRes: Int): Bitmap {
val drawable = ContextCompat.getDrawable(this, drawableRes)!!
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Different Icons per Category
Assign different icons based on data:
```kotlin
private fun addCategoryMarkers() {
val iconFactory = IconFactory.getInstance(this)
val icons = mapOf(
"restaurant" to iconFactory.fromBitmap(drawableToBitmap(R.drawable.ic_restaurant)),
"museum" to iconFactory.fromBitmap(drawableToBitmap(R.drawable.ic_museum)),
"park" to iconFactory.fromBitmap(drawableToBitmap(R.drawable.ic_park)),
"hotel" to iconFactory.fromBitmap(drawableToBitmap(R.drawable.ic_hotel)),
)
val places = listOf(
Triple(LatLng(48.8584, 2.2945), "Café de Flore", "restaurant"),
Triple(LatLng(48.8606, 2.3376), "Louvre Museum", "museum"),
Triple(LatLng(48.8462, 2.3464), "Luxembourg Gardens", "park"),
Triple(LatLng(48.8680, 2.3280), "Le Meurice", "hotel"),
)
for ((position, name, category) in places) {
map.addMarker(
MarkerOptions()
.position(position)
.title(name)
.snippet(category.replaceFirstChar { it.uppercase() })
.icon(icons[category])
)
}
}
```
## Symbol Layer with Custom Images (Recommended)
For many markers, use a symbol layer for better performance:
```kotlin
import android.graphics.BitmapFactory
import com.google.gson.JsonObject
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
private fun addSymbolLayerMarkers(style: Style) {
// Add images to the style
style.addImage("pin-red", drawableToBitmap(R.drawable.ic_pin_red))
style.addImage("pin-blue", drawableToBitmap(R.drawable.ic_pin_blue))
style.addImage("pin-green", drawableToBitmap(R.drawable.ic_pin_green))
// Create features with an "icon" property
val features = listOf(
createFeature(2.2945, 48.8584, "Eiffel Tower", "pin-red"),
createFeature(2.3376, 48.8606, "Louvre", "pin-blue"),
createFeature(2.3464, 48.8462, "Luxembourg", "pin-green"),
)
style.addSource(
GeoJsonSource("markers-source", FeatureCollection.fromFeatures(features))
)
// Symbol layer — picks icon based on feature property
style.addLayer(
SymbolLayer("markers-layer", "markers-source")
.withProperties(
iconImage(get("icon")), // Use the "icon" property
iconSize(0.8f),
iconAllowOverlap(true),
iconAnchor("bottom"), // Anchor at bottom of icon
textField(get("name")),
textSize(12f),
textOffset(arrayOf(0f, 0.8f)),
textAnchor("top"),
textColor("#333333"),
textHaloColor("#ffffff"),
textHaloWidth(1f)
)
)
}
private fun createFeature(lng: Double, lat: Double, name: String, icon: String): Feature {
val props = JsonObject().apply {
addProperty("name", name)
addProperty("icon", icon)
}
return Feature.fromGeometry(Point.fromLngLat(lng, lat), props)
}
```
## Scaled Marker from Bitmap
Resize a bitmap for consistent marker size:
```kotlin
private fun scaledBitmap(drawableRes: Int, widthDp: Int, heightDp: Int): Bitmap {
val density = resources.displayMetrics.density
val widthPx = (widthDp * density).toInt()
val heightPx = (heightDp * density).toInt()
val original = drawableToBitmap(drawableRes)
return Bitmap.createScaledBitmap(original, widthPx, heightPx, true)
}
// Usage:
val icon = iconFactory.fromBitmap(scaledBitmap(R.drawable.ic_pin, 32, 48))
```
## Next Steps
- [Multiple Markers](./multiple-markers) — Many markers with icons
- [Custom Sprite](../styling/custom-sprite) — Sprite-based icons
- [Animated Symbol Layer](../styling/animated-symbol-layer) — Animated icons
---
**Tip**: For the symbol layer approach, use `iconAnchor("bottom")` for pin-style icons so the tip of the pin aligns with the geographic coordinate. The default anchor is center, which causes pins to appear to float above their position.
---
# Animate a Line Being Drawn
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/animate-line
# Animate a Line Being Drawn
This tutorial shows how to animate a line being progressively drawn on your MapMetrics Android map — great for showing routes, delivery paths, or GPS tracks being traced in real time.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Progressive Line Animation
Draw a line point by point with smooth animation:
```kotlin
import android.animation.ValueAnimator
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.animation.LinearInterpolator
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
class AnimateLineActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private val routePoints = listOf(
Point.fromLngLat(2.3522, 48.8566), // Paris
Point.fromLngLat(2.3700, 48.8550),
Point.fromLngLat(2.3800, 48.8600),
Point.fromLngLat(2.3900, 48.8580),
Point.fromLngLat(2.4000, 48.8620),
Point.fromLngLat(2.4100, 48.8590),
Point.fromLngLat(2.4200, 48.8640),
Point.fromLngLat(2.4300, 48.8610),
)
private var currentPointIndex = 0
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_animate_line)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
setupLine(style)
// Fit camera to full route
val bounds = LatLngBounds.Builder()
for (point in routePoints) {
bounds.include(LatLng(point.latitude(), point.longitude()))
}
map.easeCamera(
CameraUpdateFactory.newLatLngBounds(bounds.build(), 80),
1000
)
}
// Start animation button
findViewById(R.id.btnStart).setOnClickListener {
currentPointIndex = 0
startLineAnimation()
}
}
}
private fun setupLine(style: Style) {
// Empty source for the animated line
style.addSource(GeoJsonSource("line-source"))
// Line layer
style.addLayer(
LineLayer("line-layer", "line-source")
.withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(4f),
lineOpacity(0.9f),
lineJoin("round"),
lineCap("round")
)
)
}
private fun startLineAnimation() {
currentPointIndex = 1
val drawNextSegment = object : Runnable {
override fun run() {
if (currentPointIndex >= routePoints.size) return
// Update line with points up to current index
val visiblePoints = routePoints.subList(0, currentPointIndex + 1)
val lineString = LineString.fromLngLats(visiblePoints)
val source = map.style?.getSource("line-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(lineString))
currentPointIndex++
if (currentPointIndex < routePoints.size) {
handler.postDelayed(this, 500) // 500ms between points
}
}
}
handler.post(drawNextSegment)
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
super.onDestroy()
mapView.onDestroy()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Smooth Interpolated Line Animation
Interpolate smoothly between points using ValueAnimator:
```kotlin
private fun startSmoothAnimation() {
animateSegment(0)
}
private fun animateSegment(index: Int) {
if (index >= routePoints.size - 1) return
val start = routePoints[index]
val end = routePoints[index + 1]
ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1000 // 1 second per segment
interpolator = LinearInterpolator()
addUpdateListener { animation ->
val fraction = animation.animatedValue as Float
// Interpolate between start and end
val currentLng = start.longitude() + (end.longitude() - start.longitude()) * fraction
val currentLat = start.latitude() + (end.latitude() - start.latitude()) * fraction
// Build line from first point to current interpolated position
val visiblePoints = routePoints.subList(0, index + 1).toMutableList()
visiblePoints.add(Point.fromLngLat(currentLng, currentLat))
val lineString = LineString.fromLngLats(visiblePoints)
val source = map.style?.getSource("line-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(lineString))
}
addListener(object : android.animation.AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
animateSegment(index + 1) // Next segment
}
})
start()
}
}
```
## With Trailing Marker
Show a dot at the leading edge of the animated line:
```kotlin
import android.graphics.BitmapFactory
import org.maplibre.android.style.layers.SymbolLayer
private fun setupLeadingMarker(style: Style) {
val bitmap = BitmapFactory.decodeResource(resources, R.drawable.ic_dot)
style.addImage("dot-icon", bitmap)
style.addSource(GeoJsonSource("dot-source",
Feature.fromGeometry(routePoints[0])
))
style.addLayer(
SymbolLayer("dot-layer", "dot-source")
.withProperties(
iconImage("dot-icon"),
iconSize(0.8f),
iconAllowOverlap(true)
)
)
}
// Inside your animation update listener, also update the dot:
private fun updateDot(lat: Double, lng: Double) {
val source = map.style?.getSource("dot-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(Point.fromLngLat(lng, lat)))
}
```
## Next Steps
- [Animate a Marker](./animate-marker) — Move markers along paths
- [Polyline Route](./polyline-route) — Static route lines
- [Fly to a Location](./fly-to-location) — Camera animation
---
**Tip**: For the smoothest visual effect, use the interpolated animation approach with `ValueAnimator`. The step-by-step method is simpler but creates visible "jumps" between points. Interpolation creates a fluid drawing motion.
---
# Animate a Marker Along a Route
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/animate-marker
# Animate a Marker Along a Route
This tutorial shows how to smoothly animate a marker moving along a path — useful for showing vehicle tracking, delivery routes, or GPS playback.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Animate Marker with ValueAnimator
Move a symbol layer marker along a route using Android's animation framework:
```kotlin
import android.animation.TypeEvaluator
import android.animation.ValueAnimator
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.animation.LinearInterpolator
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.Point
class AnimateMarkerActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private var markerAnimator: ValueAnimator? = null
// Route coordinates
private val routePoints = listOf(
LatLng(48.8566, 2.3522), // Paris
LatLng(48.8620, 2.3400),
LatLng(48.8680, 2.3310),
LatLng(48.8738, 2.2950), // Arc de Triomphe
LatLng(48.8750, 2.2870),
LatLng(48.8800, 2.2780),
LatLng(48.8867, 2.3431), // Sacré-Cœur
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
setupMarker(style)
animateMarkerAlongRoute()
}
}
}
private fun setupMarker(style: Style) {
// Add marker icon
val bitmap = BitmapFactory.decodeResource(resources, R.drawable.ic_marker)
style.addImage("moving-marker", bitmap)
// Create GeoJSON source with initial position
val initialPoint = Point.fromLngLat(
routePoints[0].longitude,
routePoints[0].latitude
)
style.addSource(
GeoJsonSource("marker-source", Feature.fromGeometry(initialPoint))
)
// Add symbol layer
style.addLayer(
SymbolLayer("marker-layer", "marker-source")
.withProperties(
iconImage("moving-marker"),
iconSize(1.0f),
iconAllowOverlap(true),
iconIgnorePlacement(true)
)
)
// Set camera
map.cameraPosition = CameraPosition.Builder()
.target(routePoints[0])
.zoom(13.0)
.build()
}
private fun animateMarkerAlongRoute() {
// Animate through each segment
animateSegment(0)
}
private fun animateSegment(index: Int) {
if (index >= routePoints.size - 1) return
val start = routePoints[index]
val end = routePoints[index + 1]
markerAnimator = ValueAnimator.ofObject(
LatLngEvaluator(), start, end
).apply {
duration = 2000 // 2 seconds per segment
interpolator = LinearInterpolator()
addUpdateListener { animation ->
val position = animation.animatedValue as LatLng
val point = Point.fromLngLat(position.longitude, position.latitude)
// Update marker position
val source = map.style?.getSource("marker-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(point))
// Follow marker with camera
map.easeCamera(
CameraUpdateFactory.newLatLng(position),
100
)
}
addListener(object : android.animation.AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
// Move to next segment
animateSegment(index + 1)
}
})
start()
}
}
// Custom evaluator for LatLng interpolation
private class LatLngEvaluator : TypeEvaluator {
override fun evaluate(fraction: Float, startValue: LatLng, endValue: LatLng): LatLng {
val lat = startValue.latitude + (endValue.latitude - startValue.latitude) * fraction
val lng = startValue.longitude + (endValue.longitude - startValue.longitude) * fraction
return LatLng(lat, lng)
}
}
override fun onDestroy() {
markerAnimator?.cancel()
super.onDestroy()
mapView.onDestroy()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## With Route Trail Line
Show the path behind the marker as it moves:
```kotlin
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
private val visitedPoints = mutableListOf()
private fun setupRouteTrail(style: Style) {
// Trail source — starts empty
style.addSource(GeoJsonSource("trail-source"))
// Trail line layer
style.addLayer(
LineLayer("trail-layer", "trail-source")
.withProperties(
lineColor("#4285F4"),
lineWidth(3f),
lineOpacity(0.7f)
)
)
}
private fun updateTrail(position: LatLng) {
visitedPoints.add(
Point.fromLngLat(position.longitude, position.latitude)
)
if (visitedPoints.size >= 2) {
val lineString = LineString.fromLngLats(visitedPoints)
val source = map.style?.getSource("trail-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(lineString))
}
}
```
Then call `updateTrail(position)` inside the `addUpdateListener` callback.
## With Play/Pause Controls
```kotlin
import android.widget.Button
private var isPlaying = true
private fun setupControls() {
val playPauseBtn = findViewById(R.id.btnPlayPause)
playPauseBtn.setOnClickListener {
if (isPlaying) {
markerAnimator?.pause()
playPauseBtn.text = "Play"
} else {
markerAnimator?.resume()
playPauseBtn.text = "Pause"
}
isPlaying = !isPlaying
}
findViewById(R.id.btnRestart).setOnClickListener {
markerAnimator?.cancel()
visitedPoints.clear()
animateSegment(0)
isPlaying = true
playPauseBtn.text = "Pause"
}
}
```
## Next Steps
- [Animated Symbol Layer](../styling/animated-symbol-layer) — Advanced symbol animations
- [Fly to a Location](./fly-to-location) — Camera flight animations
- [Polyline Route](./polyline-route) — Static route drawing
---
**Tip**: For smoother animation at high zoom levels, interpolate every 10-20ms. For performance with many animated markers, use a single GeoJSON source with multiple features instead of separate sources per marker.
---
# Disable Map Gestures
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/disable-gestures
# Disable Map Gestures
This tutorial shows how to selectively enable or disable map interaction gestures on your MapMetrics Android map — useful for embedded maps, kiosk displays, or guided experiences.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Disable All Gestures
Create a static, non-interactive map:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class StaticMapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
// Disable all gestures at once
map.uiSettings.setAllGesturesEnabled(false)
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(14.0)
.build()
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Disable via XML Attributes
Configure gestures directly in the layout:
```xml
```
## Selective Gesture Control
Disable specific gestures while keeping others:
```kotlin
map.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
val ui = map.uiSettings
// Allow pan and zoom, but disable rotation and tilt
ui.isScrollGesturesEnabled = true // Panning
ui.isZoomGesturesEnabled = true // Pinch zoom
ui.isDoubleTapGesturesEnabled = true // Double-tap zoom
ui.isRotateGesturesEnabled = false // Two-finger rotate
ui.isTiltGesturesEnabled = false // Two-finger tilt
}
```
## Interactive Toggle Panel
Let users control each gesture with switches:
```kotlin
import android.widget.Switch
private fun setupGestureToggles() {
val ui = map.uiSettings
findViewById(R.id.switchScroll).apply {
isChecked = ui.isScrollGesturesEnabled
setOnCheckedChangeListener { _, checked ->
ui.isScrollGesturesEnabled = checked
}
}
findViewById(R.id.switchZoom).apply {
isChecked = ui.isZoomGesturesEnabled
setOnCheckedChangeListener { _, checked ->
ui.isZoomGesturesEnabled = checked
ui.isDoubleTapGesturesEnabled = checked
}
}
findViewById(R.id.switchRotate).apply {
isChecked = ui.isRotateGesturesEnabled
setOnCheckedChangeListener { _, checked ->
ui.isRotateGesturesEnabled = checked
}
}
findViewById(R.id.switchTilt).apply {
isChecked = ui.isTiltGesturesEnabled
setOnCheckedChangeListener { _, checked ->
ui.isTiltGesturesEnabled = checked
}
}
}
```
## Disable Velocity Animations
Stop inertia/fling after pan, zoom, or rotate:
```kotlin
val ui = map.uiSettings
ui.isFlingVelocityAnimationEnabled = false // No fling after pan
ui.isScaleVelocityAnimationEnabled = false // No momentum after zoom
ui.isRotateVelocityAnimationEnabled = false // No momentum after rotate
```
## Available Gesture Settings
| Setting | XML Attribute | Default | Description |
|---------|--------------|---------|-------------|
| `isScrollGesturesEnabled` | `maplibre_uiScrollGestures` | `true` | Pan/drag |
| `isZoomGesturesEnabled` | `maplibre_uiZoomGestures` | `true` | Pinch zoom |
| `isDoubleTapGesturesEnabled` | `maplibre_uiDoubleTapGestures` | `true` | Double-tap zoom |
| `isRotateGesturesEnabled` | `maplibre_uiRotateGestures` | `true` | Two-finger rotate |
| `isTiltGesturesEnabled` | `maplibre_uiTiltGestures` | `true` | Two-finger tilt |
| `isHorizontalScrollGesturesEnabled` | `maplibre_uiHorizontalScrollGestures` | `true` | Horizontal scroll |
| `isQuickZoomGesturesEnabled` | — | `true` | Double-tap-drag zoom |
| `isFlingVelocityAnimationEnabled` | — | `true` | Pan momentum |
| `isScaleVelocityAnimationEnabled` | — | `true` | Zoom momentum |
| `isRotateVelocityAnimationEnabled` | — | `true` | Rotation momentum |
## Next Steps
- [Gesture Detector](../camera/gesture-detector) — Advanced gesture handling
- [Configuration](../configuration) — Map options at initialization
- [Fullscreen Map](./fullscreen-map) — Immersive map display
---
**Tip**: For embedded maps inside a `ScrollView` or `RecyclerView`, disable scroll gestures so the map doesn't capture vertical scroll events — users can still tap markers and the map stays interactive for other gestures.
---
# Draw a Circle on the Map
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/draw-a-circle
# Draw a Circle on the Map
This tutorial shows how to draw circular areas on your MapMetrics Android map — useful for showing radius zones, geofences, or proximity areas.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Draw a Circle Using Turf
Since there's no native circle geometry in GeoJSON, we approximate one using a polygon with many points:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.FillLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
import kotlin.math.*
class DrawCircleActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
drawCircle(style, LatLng(48.8584, 2.2945), 500.0) // 500m radius
}
}
}
private fun drawCircle(style: Style, center: LatLng, radiusMeters: Double) {
val circlePolygon = createCirclePolygon(center, radiusMeters, 64)
style.addSource(
GeoJsonSource("circle-source", Feature.fromGeometry(circlePolygon))
)
// Fill
style.addLayer(
FillLayer("circle-fill", "circle-source")
.withProperties(
fillColor(Color.parseColor("#4285F4")),
fillOpacity(0.2f)
)
)
// Outline
style.addLayer(
LineLayer("circle-outline", "circle-source")
.withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(2f)
)
)
// Center camera
map.cameraPosition = CameraPosition.Builder()
.target(center)
.zoom(14.0)
.build()
}
/**
* Generate a polygon approximating a circle.
* @param center Center point
* @param radiusMeters Radius in meters
* @param steps Number of polygon vertices (more = smoother)
*/
private fun createCirclePolygon(
center: LatLng,
radiusMeters: Double,
steps: Int
): Polygon {
val points = mutableListOf()
val earthRadius = 6371000.0 // meters
for (i in 0..steps) {
val angle = Math.toRadians((360.0 / steps) * i)
val lat = Math.toRadians(center.latitude)
val lng = Math.toRadians(center.longitude)
val newLat = asin(
sin(lat) * cos(radiusMeters / earthRadius) +
cos(lat) * sin(radiusMeters / earthRadius) * cos(angle)
)
val newLng = lng + atan2(
sin(angle) * sin(radiusMeters / earthRadius) * cos(lat),
cos(radiusMeters / earthRadius) - sin(lat) * sin(newLat)
)
points.add(
Point.fromLngLat(
Math.toDegrees(newLng),
Math.toDegrees(newLat)
)
)
}
return Polygon.fromLngLats(listOf(points))
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Multiple Radius Rings
Show concentric distance rings around a point:
```kotlin
private fun drawRadiusRings(style: Style, center: LatLng) {
val rings = listOf(
Pair(250.0, "#4285F4"), // 250m — blue
Pair(500.0, "#34A853"), // 500m — green
Pair(1000.0, "#FF6B35"), // 1km — orange
)
for ((index, ring) in rings.withIndex()) {
val (radius, color) = ring
val polygon = createCirclePolygon(center, radius, 64)
val sourceId = "ring-source-$index"
style.addSource(GeoJsonSource(sourceId, Feature.fromGeometry(polygon)))
style.addLayer(
FillLayer("ring-fill-$index", sourceId)
.withProperties(
fillColor(Color.parseColor(color)),
fillOpacity(0.1f)
)
)
style.addLayer(
LineLayer("ring-outline-$index", sourceId)
.withProperties(
lineColor(Color.parseColor(color)),
lineWidth(2f),
lineDasharray(arrayOf(2f, 1f))
)
)
}
// Add center marker
map.addMarker(
org.maplibre.android.annotations.MarkerOptions()
.position(center)
.title("Center Point")
.snippet("250m / 500m / 1km radius")
)
}
```
## Circle on Tap
Let users tap the map to place a circle:
```kotlin
private fun setupTapToCircle(style: Style) {
// Pre-create empty source and layers
style.addSource(GeoJsonSource("tap-circle-source"))
style.addLayer(
FillLayer("tap-circle-fill", "tap-circle-source")
.withProperties(
fillColor(Color.parseColor("#FF6B35")),
fillOpacity(0.2f)
)
)
style.addLayer(
LineLayer("tap-circle-outline", "tap-circle-source")
.withProperties(
lineColor(Color.parseColor("#FF6B35")),
lineWidth(2f)
)
)
map.addOnMapClickListener { latLng ->
val polygon = createCirclePolygon(latLng, 300.0, 64)
val source = style.getSource("tap-circle-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(polygon))
true
}
}
```
## Using Turf Library
If you include the Turf dependency, circle creation is simpler:
```kotlin
import com.mapbox.turf.TurfTransformation
import com.mapbox.turf.TurfConstants
val center = Point.fromLngLat(2.2945, 48.8584)
val circlePolygon = TurfTransformation.circle(
center,
500.0,
64,
TurfConstants.UNIT_METRES
)
```
## Next Steps
- [Polygon Area](./polygon-area) — Draw custom shapes
- [Measure Distances](./measure-distances) — Distance calculations
- [Circle Layer](../styling/circle-layer) — Circle layer for point data
---
**Tip**: Use 64 steps for smooth circles. Below 32 you'll see visible edges. Above 128 adds points without visible improvement. The Turf library approach is recommended when available as it handles edge cases near the poles.
---
# Fit Map to a Bounding Box
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/fit-bounds
# Fit Map to a Bounding Box
This tutorial shows how to automatically zoom and position your MapMetrics Android map to fit a set of points, markers, or a region.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Fit to Multiple Markers
Automatically adjust the camera to show all markers:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class FitBoundsActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private val cities = listOf(
Pair(LatLng(48.8566, 2.3522), "Paris"),
Pair(LatLng(51.5074, -0.1278), "London"),
Pair(LatLng(52.5200, 13.4050), "Berlin"),
Pair(LatLng(41.9028, 12.4964), "Rome"),
Pair(LatLng(40.4168, -3.7038), "Madrid"),
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addMarkersAndFit()
}
}
}
private fun addMarkersAndFit() {
// Add markers
val boundsBuilder = LatLngBounds.Builder()
for ((position, name) in cities) {
map.addMarker(
MarkerOptions()
.position(position)
.title(name)
)
boundsBuilder.include(position)
}
// Fit camera to show all markers with padding
map.easeCamera(
CameraUpdateFactory.newLatLngBounds(
boundsBuilder.build(),
100 // padding in pixels
),
1000 // animation duration
)
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Fit to a Predefined Region
Zoom to specific geographic regions with buttons:
```kotlin
import android.widget.Button
private val regions = mapOf(
"Europe" to LatLngBounds.Builder()
.include(LatLng(71.0, -25.0)) // Northwest
.include(LatLng(35.0, 45.0)) // Southeast
.build(),
"North America" to LatLngBounds.Builder()
.include(LatLng(72.0, -170.0))
.include(LatLng(15.0, -50.0))
.build(),
"Asia" to LatLngBounds.Builder()
.include(LatLng(55.0, 25.0))
.include(LatLng(-10.0, 150.0))
.build(),
)
private fun setupRegionButtons() {
findViewById(R.id.btnEurope).setOnClickListener {
fitToRegion("Europe")
}
findViewById(R.id.btnNorthAmerica).setOnClickListener {
fitToRegion("North America")
}
findViewById(R.id.btnAsia).setOnClickListener {
fitToRegion("Asia")
}
}
private fun fitToRegion(name: String) {
regions[name]?.let { bounds ->
map.animateCamera(
CameraUpdateFactory.newLatLngBounds(bounds, 50),
2000
)
}
}
```
## Fit Bounds with Custom Padding
Use different padding per edge for UI elements:
```kotlin
// Different padding for each side: [left, top, right, bottom]
val paddingPixels = intArrayOf(50, 200, 50, 100) // extra top for header
val cameraPosition = map.getCameraForLatLngBounds(
boundsBuilder.build(),
paddingPixels
)
cameraPosition?.let {
map.animateCamera(
CameraUpdateFactory.newCameraPosition(it),
1500
)
}
```
## Fit to Route Points
Zoom to fit a polyline route:
```kotlin
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
private fun fitToRoute() {
val routePoints = listOf(
LatLng(48.8566, 2.3522), // Paris
LatLng(50.8503, 4.3517), // Brussels
LatLng(52.3676, 4.9041), // Amsterdam
LatLng(53.5511, 9.9937), // Hamburg
LatLng(52.5200, 13.4050), // Berlin
)
val bounds = LatLngBounds.Builder()
for (point in routePoints) {
bounds.include(point)
}
map.animateCamera(
CameraUpdateFactory.newLatLngBounds(bounds.build(), 80),
2000
)
}
```
## Fit Bounds Options
| Parameter | Type | Description |
|-----------|------|-------------|
| `bounds` | `LatLngBounds` | Southwest + northeast corners to fit |
| `padding` | `Int` | Equal padding on all sides (pixels) |
| `padding` | `IntArray` | Per-side padding: [left, top, right, bottom] |
| `duration` | `Int` | Animation duration in milliseconds |
## Animation Methods for Bounds
| Method | Behavior |
|--------|----------|
| `moveCamera()` | Instant jump to bounds |
| `easeCamera()` | Constant-speed animation |
| `animateCamera()` | Natural ease-in/ease-out |
## Next Steps
- [Lat-Lng Bounds](../camera/lat-lng-bounds) — Bounds API deep dive
- [Zoom Methods](../camera/zoom-methods) — Zoom controls
- [Fly to a Location](./fly-to-location) — Point-to-point animation
---
**Tip**: Always add padding (50-100px minimum) when fitting bounds so markers at the edge aren't clipped by the screen boundary. For maps with floating UI panels, use per-side padding to account for the panel area.
---
# Fly to a Location
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/fly-to-location
# Fly to a Location
This tutorial shows how to animate the camera smoothly to a destination point on your MapMetrics Android map using different animation styles.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Fly-To Animation
Animate the camera to a new location with a button:
```kotlin
import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class FlyToActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_fly_to)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
// Set initial position
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(51.5074, -0.1278)) // London
.zoom(10.0)
.build()
// Fly to Paris button
findViewById(R.id.btnFlyToParis).setOnClickListener {
flyToLocation(LatLng(48.8566, 2.3522), 14.0, "Paris")
}
// Fly to Tokyo button
findViewById(R.id.btnFlyToTokyo).setOnClickListener {
flyToLocation(LatLng(35.6762, 139.6503), 12.0, "Tokyo")
}
}
}
private fun flyToLocation(target: LatLng, zoom: Double, name: String) {
val position = CameraPosition.Builder()
.target(target)
.zoom(zoom)
.tilt(45.0)
.bearing(0.0)
.build()
map.animateCamera(
CameraUpdateFactory.newCameraPosition(position),
3000 // 3 seconds duration
)
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Three Animation Styles
Compare `moveCamera`, `easeCamera`, and `animateCamera`:
```kotlin
val target = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(14.0)
.tilt(45.0)
.build()
// 1. Instant jump — no animation
map.moveCamera(CameraUpdateFactory.newCameraPosition(target))
// 2. Ease — constant speed, smooth but linear
map.easeCamera(
CameraUpdateFactory.newCameraPosition(target),
2000 // duration in ms
)
// 3. Animate — accelerates and decelerates naturally
map.animateCamera(
CameraUpdateFactory.newCameraPosition(target),
3000 // duration in ms
)
```
## Fly-To with Callback
Run code when the animation finishes or is cancelled:
```kotlin
import org.maplibre.android.maps.MapMetricsMap.CancelableCallback
map.animateCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(LatLng(40.7128, -74.0060)) // New York
.zoom(15.0)
.tilt(50.0)
.bearing(-20.0)
.build()
),
4000,
object : CancelableCallback {
override fun onFinish() {
// Animation completed — add marker, load data, etc.
map.addMarker(
org.maplibre.android.annotations.MarkerOptions()
.position(LatLng(40.7128, -74.0060))
.title("New York City")
)
}
override fun onCancel() {
// User interrupted the animation by touching the map
}
}
)
```
## Tour Through Multiple Locations
Fly through a sequence of locations automatically:
```kotlin
import android.os.Handler
import android.os.Looper
private val tourLocations = listOf(
Triple(LatLng(48.8566, 2.3522), 14.0, "Paris"),
Triple(LatLng(41.9028, 12.4964), 13.0, "Rome"),
Triple(LatLng(52.5200, 13.4050), 12.0, "Berlin"),
Triple(LatLng(59.3293, 18.0686), 13.0, "Stockholm"),
Triple(LatLng(40.4168, -3.7038), 12.0, "Madrid"),
)
private var currentIndex = 0
private fun startTour() {
if (currentIndex >= tourLocations.size) {
currentIndex = 0 // loop back
}
val (target, zoom, name) = tourLocations[currentIndex]
val position = CameraPosition.Builder()
.target(target)
.zoom(zoom)
.tilt(50.0)
.bearing(currentIndex * 30.0) // vary the bearing
.build()
map.animateCamera(
CameraUpdateFactory.newCameraPosition(position),
3000,
object : CancelableCallback {
override fun onFinish() {
currentIndex++
// Wait 2 seconds at each stop, then fly to next
Handler(Looper.getMainLooper()).postDelayed({
startTour()
}, 2000)
}
override fun onCancel() {
// User touched the map — stop the tour
}
}
)
}
```
## Animation Comparison
| Method | Speed | Feel | Use Case |
|--------|-------|------|----------|
| `moveCamera` | Instant | Abrupt jump | Resetting view, loading saved position |
| `easeCamera` | Constant | Smooth, linear | Short transitions, subtle moves |
| `animateCamera` | Eased | Natural acceleration | Fly-to, showcase, tours |
## Next Steps
- [Camera Position](../camera/cameraposition) — Camera position fundamentals
- [Animation Types](../camera/animation-types) — Deep dive into animation options
- [Lat-Lng Bounds](../camera/lat-lng-bounds) — Fit camera to a region
---
**Tip**: For long-distance flights (e.g., Paris → Tokyo), use `animateCamera` with a duration of 4-6 seconds. For short hops within the same city, 1-2 seconds with `easeCamera` feels more natural.
---
# Fullscreen Map
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/fullscreen-map
# Fullscreen Map
This tutorial shows how to create an immersive fullscreen map experience on Android — hiding the status bar, navigation bar, and any toolbars.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Fullscreen Activity
Create a map that fills the entire screen:
```kotlin
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class FullscreenMapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Hide action bar
supportActionBar?.hide()
setContentView(R.layout.activity_fullscreen_map)
// Enable fullscreen
enableFullscreen()
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
}
}
private fun enableFullscreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+)
window.insetsController?.let { controller ->
controller.hide(
WindowInsets.Type.statusBars() or
WindowInsets.Type.navigationBars()
)
controller.systemBarsBehavior =
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
// Older Android versions
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) enableFullscreen()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
Layout XML (`res/layout/activity_fullscreen_map.xml`):
```xml
```
Also add to `AndroidManifest.xml`:
```xml
```
## Fullscreen Toggle Button
Let users switch between normal and fullscreen mode:
```kotlin
import android.widget.ImageButton
private var isFullscreen = false
private fun setupFullscreenToggle() {
val toggleBtn = findViewById(R.id.btnToggleFullscreen)
toggleBtn.setOnClickListener {
isFullscreen = !isFullscreen
if (isFullscreen) {
enableFullscreen()
supportActionBar?.hide()
toggleBtn.setImageResource(R.drawable.ic_fullscreen_exit)
} else {
exitFullscreen()
supportActionBar?.show()
toggleBtn.setImageResource(R.drawable.ic_fullscreen)
}
}
}
private fun exitFullscreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.show(
WindowInsets.Type.statusBars() or
WindowInsets.Type.navigationBars()
)
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
}
```
## Next Steps
- [Getting Started](../getting-started) — Basic map setup
- [Fly to a Location](./fly-to-location) — Camera animations
- [Disable Gestures](./disable-gestures) — Control map interactions
---
**Tip**: Use `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE` so users can temporarily reveal system bars by swiping from the edge — they auto-hide again after a moment. This provides the best balance between immersion and accessibility.
---
# Game-Style Map Controls
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/game-controls
# Game-Style Map Controls
This tutorial shows how to add D-pad and button controls for navigating the map — useful for kiosk displays, accessibility, or game-like map exploration.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## D-Pad Navigation
Add directional buttons to pan the map:
```kotlin
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class GameControlsActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private val handler = Handler(Looper.getMainLooper())
private val panSpeed = 50f // pixels per tick
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_game_controls)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
// Disable default gestures — we use buttons instead
map.uiSettings.isScrollGesturesEnabled = false
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(14.0)
.build()
setupDPad()
setupZoomButtons()
setupRotateButtons()
}
}
private fun setupDPad() {
setupHoldButton(R.id.btnUp) { map.scrollBy(0f, -panSpeed) }
setupHoldButton(R.id.btnDown) { map.scrollBy(0f, panSpeed) }
setupHoldButton(R.id.btnLeft) { map.scrollBy(-panSpeed, 0f) }
setupHoldButton(R.id.btnRight) { map.scrollBy(panSpeed, 0f) }
}
private fun setupZoomButtons() {
findViewById(R.id.btnZoomIn).setOnClickListener {
map.animateCamera(CameraUpdateFactory.zoomIn(), 200)
}
findViewById(R.id.btnZoomOut).setOnClickListener {
map.animateCamera(CameraUpdateFactory.zoomOut(), 200)
}
}
private fun setupRotateButtons() {
findViewById(R.id.btnRotateLeft).setOnClickListener {
val current = map.cameraPosition.bearing
map.easeCamera(
CameraUpdateFactory.bearingTo(current - 15),
300
)
}
findViewById(R.id.btnRotateRight).setOnClickListener {
val current = map.cameraPosition.bearing
map.easeCamera(
CameraUpdateFactory.bearingTo(current + 15),
300
)
}
}
/**
* Repeat an action while a button is held down.
*/
private fun setupHoldButton(viewId: Int, action: () -> Unit) {
val button = findViewById(viewId)
var isHolding = false
val repeatRunnable = object : Runnable {
override fun run() {
if (isHolding) {
action()
handler.postDelayed(this, 50) // ~20fps
}
}
}
button.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
isHolding = true
handler.post(repeatRunnable)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isHolding = false
true
}
else -> false
}
}
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
super.onDestroy()
mapView.onDestroy()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
Layout (`res/layout/activity_game_controls.xml`):
```xml
```
## Next Steps
- [Navigation Controls](./navigation-controls) — Standard map controls
- [Toggle Interactions](./toggle-interactions) — Enable/disable gestures
- [Disable Gestures](./disable-gestures) — Lock the map view
---
**Tip**: The `setupHoldButton` pattern uses `MotionEvent.ACTION_DOWN/UP` to repeat the pan action while the button is held. This gives a smooth, continuous movement similar to game controllers. The 50ms interval (~20fps) is smooth without being too CPU-intensive.
---
# Migrate from Google Maps to MapMetrics
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/google-maps-migration
# Migrate from Google Maps to MapMetrics
This guide helps you migrate an existing Android app from the Google Maps SDK to the MapMetrics SDK, covering the key API differences and code changes needed.
## Prerequisites
- An existing Android project using `com.google.android.gms:play-services-maps`
- A MapMetrics API key from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Step 1: Update Dependencies
Replace the Google Maps dependency in `build.gradle`:
```gradle
// Remove:
// implementation 'com.google.android.gms:play-services-maps:18.x.x'
// Add:
implementation 'org.mapmetrics.android-sdk:mapmetrics-native-sdk:1.0.3'
```
Remove the Google Maps API key from `AndroidManifest.xml`:
```xml
```
## Step 2: Replace the Map View
### Layout XML
```xml
```
### Activity Code
```kotlin
// ========== GOOGLE MAPS ==========
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.MapView
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MarkerOptions
import com.google.android.gms.maps.CameraUpdateFactory
class MapActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var mapView: MapView
override fun onMapReady(googleMap: GoogleMap) {
googleMap.moveCamera(
CameraUpdateFactory.newLatLngZoom(LatLng(48.8566, 2.3522), 12f)
)
googleMap.addMarker(
MarkerOptions().position(LatLng(48.8584, 2.2945)).title("Eiffel Tower")
)
}
}
// ========== MAPMETRICS ==========
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
class MapActivity : AppCompatActivity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
map.addMarker(
MarkerOptions()
.position(LatLng(48.8584, 2.2945))
.title("Eiffel Tower")
)
}
}
}
```
## Step 3: API Mapping Reference
### Core Classes
| Google Maps | MapMetrics |
|-------------|-----------|
| `GoogleMap` | `MapMetricsMap` |
| `MapView` (gms) | `MapView` (maplibre) |
| `SupportMapFragment` | `SupportMapFragment` |
| `OnMapReadyCallback` | Lambda in `getMapAsync {}` |
| `com.google.android.gms.maps.model.LatLng` | `org.maplibre.android.geometry.LatLng` |
### Camera
| Google Maps | MapMetrics |
|-------------|-----------|
| `CameraUpdateFactory.newLatLng()` | `CameraUpdateFactory.newLatLng()` |
| `CameraUpdateFactory.newLatLngZoom()` | `CameraUpdateFactory.newLatLngZoom()` |
| `CameraUpdateFactory.newLatLngBounds()` | `CameraUpdateFactory.newLatLngBounds()` |
| `googleMap.moveCamera()` | `map.moveCamera()` |
| `googleMap.animateCamera()` | `map.animateCamera()` |
| — | `map.easeCamera()` (constant speed) |
| `CameraPosition.Builder().target().zoom().bearing().tilt()` | Same pattern, same methods |
### Markers
| Google Maps | MapMetrics |
|-------------|-----------|
| `googleMap.addMarker(MarkerOptions)` | `map.addMarker(MarkerOptions)` |
| `MarkerOptions().position().title().snippet()` | Same pattern |
| `BitmapDescriptorFactory.fromResource()` | `IconFactory.getInstance(ctx).fromBitmap()` |
| `marker.remove()` | `map.removeMarker(marker)` |
| `googleMap.clear()` | `map.clear()` |
### Event Listeners
| Google Maps | MapMetrics |
|-------------|-----------|
| `setOnMapClickListener` | `addOnMapClickListener` |
| `setOnMapLongClickListener` | `addOnMapLongClickListener` |
| `setOnMarkerClickListener` | `setOnMarkerClickListener` |
| `setOnCameraMoveListener` | `addOnCameraMoveListener` |
| `setOnCameraIdleListener` | `addOnCameraIdleListener` |
### UI Settings
| Google Maps | MapMetrics |
|-------------|-----------|
| `googleMap.uiSettings.isZoomControlsEnabled` | No built-in controls (add custom) |
| `googleMap.uiSettings.isCompassEnabled` | `map.uiSettings.isCompassEnabled` |
| `googleMap.uiSettings.isScrollGesturesEnabled` | `map.uiSettings.isScrollGesturesEnabled` |
| `googleMap.uiSettings.isZoomGesturesEnabled` | `map.uiSettings.isZoomGesturesEnabled` |
| `googleMap.uiSettings.isRotateGesturesEnabled` | `map.uiSettings.isRotateGesturesEnabled` |
| `googleMap.uiSettings.isTiltGesturesEnabled` | `map.uiSettings.isTiltGesturesEnabled` |
### What's Different in MapMetrics
| Feature | Google Maps | MapMetrics |
|---------|-------------|-----------|
| **Styling** | Limited JSON style | Full MapLibre style spec |
| **Data layers** | Limited overlays | GeoJSON sources + layers (fill, line, circle, symbol, heatmap, fill-extrusion) |
| **Expressions** | Not available | Data-driven styling with expressions |
| **Vector tiles** | Not available | Full MVT support |
| **Offline** | Limited caching | Full offline region management |
| **Open source** | Proprietary | Built on MapLibre GL (open source) |
| **Pricing** | Pay per load | MapMetrics pricing model |
## Step 4: Lifecycle
Both SDKs require forwarding lifecycle events. The pattern is identical:
```kotlin
// Same for both Google Maps and MapMetrics
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
```
## Step 5: Key Things to Note
1. **Style URL required** — MapMetrics uses vector tile styles, so you must provide a style URL via `setStyle()`. There's no default map appearance without it.
2. **Marker API is similar** — Basic `MarkerOptions` work almost identically. For advanced markers (100+), switch to symbol layers.
3. **No built-in zoom controls** — MapMetrics doesn't have default on-screen zoom buttons. See [Navigation Controls](./navigation-controls) to add your own.
4. **Powerful layer system** — MapMetrics gives you GeoJSON sources, data-driven expressions, and multiple layer types that far exceed Google Maps' overlay API.
## Next Steps
- [Getting Started](../getting-started) — Fresh setup guide
- [Configuration](../configuration) — Map configuration options
- [GeoJSON Guide](../geojson-guide) — Leverage the layer system
---
**Tip**: The migration is mostly mechanical — class names change but patterns stay similar. The biggest win is gaining access to GeoJSON layers, data-driven styling, and vector tile sources that aren't available in Google Maps.
---
# Jump Through a Series of Locations
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/jump-to-locations
# Jump Through a Series of Locations
This tutorial shows how to navigate through a list of locations one by one — useful for guided tours, location lists, or step-by-step waypoints.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Step Through Locations
Navigate forward and backward through a list:
```kotlin
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class JumpLocationsActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private lateinit var locationLabel: TextView
data class Location(
val position: LatLng,
val name: String,
val description: String,
val zoom: Double
)
private val locations = listOf(
Location(LatLng(48.8584, 2.2945), "Eiffel Tower", "324m iron lattice tower", 16.0),
Location(LatLng(48.8606, 2.3376), "Louvre Museum", "World's largest art museum", 16.0),
Location(LatLng(48.8530, 2.3499), "Notre-Dame", "Medieval Catholic cathedral", 17.0),
Location(LatLng(48.8738, 2.2950), "Arc de Triomphe", "Triumphal arch monument", 16.0),
Location(LatLng(48.8867, 2.3431), "Sacré-Cœur", "White-domed basilica", 16.0),
Location(LatLng(48.8462, 2.3464), "Luxembourg Gardens", "Historic public garden", 15.0),
)
private var currentIndex = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_jump_locations)
locationLabel = findViewById(R.id.tvLocation)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
// Add markers for all locations
for (loc in locations) {
map.addMarker(
MarkerOptions()
.position(loc.position)
.title(loc.name)
.snippet(loc.description)
)
}
// Go to first location
jumpToLocation(0)
}
// Navigation buttons
findViewById(R.id.btnPrev).setOnClickListener {
if (currentIndex > 0) jumpToLocation(currentIndex - 1)
}
findViewById(R.id.btnNext).setOnClickListener {
if (currentIndex < locations.size - 1) jumpToLocation(currentIndex + 1)
}
}
}
private fun jumpToLocation(index: Int) {
currentIndex = index
val loc = locations[index]
// Update label
locationLabel.text = "${index + 1}/${locations.size} — ${loc.name}"
// Animate camera
map.animateCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(loc.position)
.zoom(loc.zoom)
.tilt(45.0)
.bearing(0.0)
.build()
),
1500
)
// Update button states
findViewById(R.id.btnPrev).isEnabled = index > 0
findViewById(R.id.btnNext).isEnabled = index < locations.size - 1
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
Layout (`res/layout/activity_jump_locations.xml`):
```xml
```
## Auto-Play Tour
Automatically cycle through locations:
```kotlin
import android.os.Handler
import android.os.Looper
private val autoPlayHandler = Handler(Looper.getMainLooper())
private var isAutoPlaying = false
private fun startAutoPlay() {
isAutoPlaying = true
val autoPlayRunnable = object : Runnable {
override fun run() {
if (!isAutoPlaying) return
val nextIndex = (currentIndex + 1) % locations.size
jumpToLocation(nextIndex)
autoPlayHandler.postDelayed(this, 4000) // 4s per stop
}
}
autoPlayHandler.postDelayed(autoPlayRunnable, 4000)
}
private fun stopAutoPlay() {
isAutoPlaying = false
autoPlayHandler.removeCallbacksAndMessages(null)
}
```
## Next Steps
- [Fly to a Location](./fly-to-location) — Single fly-to animation
- [Orbit Animation](../camera/orbit-animation) — Rotating camera
- [Multiple Markers](./multiple-markers) — Adding many markers
---
**Tip**: Vary the zoom level per location for a more engaging tour — zoom in close (17-18) for small landmarks and zoom out (13-14) for districts or parks.
---
# Handle Map Click Events
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/map-click-events
# Handle Map Click Events
This tutorial covers handling tap, long-press, and feature click events on your MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Map Click
Respond when the user taps anywhere on the map:
```kotlin
import android.os.Bundle
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class MapClickActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private lateinit var coordsText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map_click)
coordsText = findViewById(R.id.tvCoords)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) {
setupClickListeners()
}
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
}
}
private fun setupClickListeners() {
// Single tap
map.addOnMapClickListener { latLng ->
coordsText.text = String.format(
"Lat: %.6f, Lng: %.6f",
latLng.latitude, latLng.longitude
)
true
}
// Long press
map.addOnMapLongClickListener { latLng ->
map.addMarker(
MarkerOptions()
.position(latLng)
.title("Dropped Pin")
.snippet(
String.format(
"%.6f, %.6f",
latLng.latitude, latLng.longitude
)
)
)
Toast.makeText(this, "Marker added!", Toast.LENGTH_SHORT).show()
true
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Query Features at Click Point
Identify which features (layers, sources) are under the tap:
```kotlin
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
// Query all rendered features at the tap point
val features = map.queryRenderedFeatures(screenPoint)
if (features.isNotEmpty()) {
val feature = features[0]
val properties = feature.properties()
Toast.makeText(
this,
"Feature: ${properties?.toString() ?: "no properties"}",
Toast.LENGTH_LONG
).show()
} else {
Toast.makeText(this, "No features here", Toast.LENGTH_SHORT).show()
}
true
}
```
## Query Specific Layers
Only respond to taps on specific layers:
```kotlin
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
// Query only the "poi-layer" and "building-layer"
val features = map.queryRenderedFeatures(
screenPoint,
"poi-layer",
"building-layer"
)
if (features.isNotEmpty()) {
val name = features[0].getStringProperty("name")
Toast.makeText(this, "Tapped: $name", Toast.LENGTH_SHORT).show()
}
true
}
```
## Click Priority — Markers vs Layers
Handle markers and layer features with different priority:
```kotlin
private fun setupLayeredClickHandling() {
// Marker click takes priority
map.setOnMarkerClickListener { marker ->
Toast.makeText(
this,
"Marker: ${marker.title}",
Toast.LENGTH_SHORT
).show()
true // consume — don't pass to map click
}
// Map click handles everything else
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
// Check custom layers
val poiFeatures = map.queryRenderedFeatures(screenPoint, "poi-layer")
if (poiFeatures.isNotEmpty()) {
handlePoiClick(poiFeatures[0])
return@addOnMapClickListener true
}
// Check polygon zones
val zoneFeatures = map.queryRenderedFeatures(screenPoint, "zone-fill")
if (zoneFeatures.isNotEmpty()) {
handleZoneClick(zoneFeatures[0])
return@addOnMapClickListener true
}
// Nothing hit — show coordinates
coordsText.text = String.format("%.4f, %.4f", latLng.latitude, latLng.longitude)
true
}
}
private fun handlePoiClick(feature: org.maplibre.geojson.Feature) {
val name = feature.getStringProperty("name") ?: "Unknown"
Toast.makeText(this, "POI: $name", Toast.LENGTH_SHORT).show()
}
private fun handleZoneClick(feature: org.maplibre.geojson.Feature) {
val zoneName = feature.getStringProperty("zone_name") ?: "Unknown zone"
Toast.makeText(this, "Zone: $zoneName", Toast.LENGTH_SHORT).show()
}
```
## Screen Coordinate Conversion
Convert between screen pixels and map coordinates:
```kotlin
// LatLng → screen pixel
val screenPoint = map.projection.toScreenLocation(LatLng(48.8566, 2.3522))
// screenPoint.x, screenPoint.y in pixels
// Screen pixel → LatLng
val latLng = map.projection.fromScreenLocation(android.graphics.PointF(500f, 500f))
// latLng.latitude, latLng.longitude
// Get visible region bounds
val visibleRegion = map.projection.visibleRegion
val bounds = visibleRegion.latLngBounds
// bounds.northEast, bounds.southWest
```
## Available Click Listeners
| Listener | Trigger | Returns |
|----------|---------|---------|
| `addOnMapClickListener` | Single tap on map | `LatLng` |
| `addOnMapLongClickListener` | Long press on map | `LatLng` |
| `setOnMarkerClickListener` | Tap on annotation marker | `Marker` |
| `setOnInfoWindowClickListener` | Tap on info window | `Marker` |
| `setOnInfoWindowLongClickListener` | Long press on info window | `Marker` |
| `setOnInfoWindowCloseListener` | Info window closes | `Marker` |
## Next Steps
- [Add a Popup](./add-a-popup) — Show popups on markers
- [Gesture Detector](../camera/gesture-detector) — Advanced gesture handling
- [Multiple Markers](./multiple-markers) — Markers with click handling
---
**Tip**: Return `true` from click listeners to consume the event and prevent it from propagating. Return `false` to let it pass through to the next handler. Order matters: marker listeners fire before map click listeners.
---
# Measure Distances on the Map
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/measure-distances
# Measure Distances on the Map
This tutorial shows how to build a tap-to-measure tool that calculates distances between points on your MapMetrics Android map using the Haversine formula.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Tap-to-Measure Tool
Tap points on the map to measure the total distance:
```kotlin
import android.graphics.Color
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import kotlin.math.*
class MeasureActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private lateinit var distanceText: TextView
private val measurePoints = mutableListOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_measure)
distanceText = findViewById(R.id.tvDistance)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
setupMeasureLine(style)
setupMapClick()
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
}
// Clear button
findViewById(R.id.btnClear).setOnClickListener {
clearMeasurement()
}
}
}
private fun setupMeasureLine(style: Style) {
style.addSource(GeoJsonSource("measure-source"))
style.addLayer(
LineLayer("measure-line", "measure-source")
.withProperties(
lineColor(Color.parseColor("#FF6B35")),
lineWidth(3f),
lineDasharray(arrayOf(2f, 1f))
)
)
}
private fun setupMapClick() {
map.addOnMapClickListener { latLng ->
// Add point
measurePoints.add(latLng)
// Add marker at tap point
map.addMarker(
MarkerOptions()
.position(latLng)
.title("Point ${measurePoints.size}")
.snippet(
"Lat: ${String.format("%.6f", latLng.latitude)}, " +
"Lng: ${String.format("%.6f", latLng.longitude)}"
)
)
// Update line
if (measurePoints.size >= 2) {
updateMeasureLine()
}
// Update total distance
updateDistance()
true
}
}
private fun updateMeasureLine() {
val points = measurePoints.map {
Point.fromLngLat(it.longitude, it.latitude)
}
val lineString = LineString.fromLngLats(points)
val source = map.style?.getSource("measure-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(lineString))
}
private fun updateDistance() {
var totalDistance = 0.0
for (i in 0 until measurePoints.size - 1) {
totalDistance += haversineDistance(
measurePoints[i], measurePoints[i + 1]
)
}
distanceText.text = when {
totalDistance < 1.0 -> "${(totalDistance * 1000).toInt()} m"
else -> String.format("%.2f km", totalDistance)
}
}
private fun clearMeasurement() {
measurePoints.clear()
map.clear()
distanceText.text = "0 m"
val source = map.style?.getSource("measure-source") as? GeoJsonSource
source?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(emptyList())))
}
/**
* Calculate distance between two points using the Haversine formula.
* Returns distance in kilometers.
*/
private fun haversineDistance(point1: LatLng, point2: LatLng): Double {
val R = 6371.0 // Earth's radius in km
val lat1 = Math.toRadians(point1.latitude)
val lat2 = Math.toRadians(point2.latitude)
val dLat = Math.toRadians(point2.latitude - point1.latitude)
val dLng = Math.toRadians(point2.longitude - point1.longitude)
val a = sin(dLat / 2).pow(2) +
cos(lat1) * cos(lat2) * sin(dLng / 2).pow(2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
Layout (`res/layout/activity_measure.xml`):
```xml
```
## Show Segment Distances
Display distance on each segment:
```kotlin
private fun updateDistance() {
var totalDistance = 0.0
val segmentInfo = StringBuilder()
for (i in 0 until measurePoints.size - 1) {
val segmentDist = haversineDistance(measurePoints[i], measurePoints[i + 1])
totalDistance += segmentDist
segmentInfo.append("Segment ${i + 1}: ")
segmentInfo.append(
if (segmentDist < 1.0) "${(segmentDist * 1000).toInt()} m"
else String.format("%.2f km", segmentDist)
)
segmentInfo.append("\n")
}
distanceText.text = when {
totalDistance < 1.0 -> "${(totalDistance * 1000).toInt()} m total"
else -> String.format("%.2f km total", totalDistance)
}
}
```
## Next Steps
- [Polyline Route](./polyline-route) — Draw lines on the map
- [Map Click Events](./map-click-events) — Handling taps and gestures
- [Polygon Area](./polygon-area) — Draw filled zones
---
**Tip**: The Haversine formula gives great-circle distances — straight line over the Earth's surface. For road distances, you would need a routing API. The measurements shown here are accurate for aerial/straight-line distance.
---
# Display Multiple Geometry Types Together
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/multiple-geometries
# Display Multiple Geometry Types Together
This tutorial shows how to display markers, polylines, polygons, and circles all on the same MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Combined Geometries
Add points, lines, polygons, and circles in one view:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.FillLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.*
import com.google.gson.JsonObject
import kotlin.math.*
class MultiGeometryActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addPolygonZone(style)
addRouteLines(style)
addPointMarkers(style)
addCircleRadius(style)
fitToAll()
}
}
}
// 1. Polygon — park/zone boundary
private fun addPolygonZone(style: Style) {
val parkBoundary = Polygon.fromLngLats(listOf(listOf(
Point.fromLngLat(2.3300, 48.8470),
Point.fromLngLat(2.3400, 48.8470),
Point.fromLngLat(2.3420, 48.8440),
Point.fromLngLat(2.3380, 48.8420),
Point.fromLngLat(2.3280, 48.8430),
Point.fromLngLat(2.3300, 48.8470),
)))
style.addSource(
GeoJsonSource("zone-source", Feature.fromGeometry(parkBoundary))
)
style.addLayer(
FillLayer("zone-fill", "zone-source")
.withProperties(
fillColor(Color.parseColor("#34A853")),
fillOpacity(0.2f)
)
)
style.addLayer(
LineLayer("zone-outline", "zone-source")
.withProperties(
lineColor(Color.parseColor("#34A853")),
lineWidth(2f)
)
)
}
// 2. Polylines — walking routes
private fun addRouteLines(style: Style) {
val route1 = Feature.fromGeometry(
LineString.fromLngLats(listOf(
Point.fromLngLat(2.3350, 48.8560),
Point.fromLngLat(2.3380, 48.8530),
Point.fromLngLat(2.3360, 48.8490),
Point.fromLngLat(2.3340, 48.8460),
)),
JsonObject().apply { addProperty("name", "Route A") }
)
val route2 = Feature.fromGeometry(
LineString.fromLngLats(listOf(
Point.fromLngLat(2.3200, 48.8550),
Point.fromLngLat(2.3250, 48.8520),
Point.fromLngLat(2.3300, 48.8500),
Point.fromLngLat(2.3350, 48.8480),
)),
JsonObject().apply { addProperty("name", "Route B") }
)
style.addSource(
GeoJsonSource("routes-source",
FeatureCollection.fromFeatures(listOf(route1, route2)))
)
style.addLayer(
LineLayer("routes-layer", "routes-source")
.withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(3f),
lineOpacity(0.8f)
)
)
}
// 3. Point markers — landmarks
private fun addPointMarkers(style: Style) {
val landmarks = listOf(
Triple(2.3376, 48.8606, "Louvre Museum"),
Triple(2.3499, 48.8530, "Notre-Dame"),
Triple(2.3266, 48.8600, "Musée d'Orsay"),
)
for ((lng, lat, name) in landmarks) {
map.addMarker(
MarkerOptions()
.position(LatLng(lat, lng))
.title(name)
)
}
}
// 4. Circle — radius zone around a point
private fun addCircleRadius(style: Style) {
val center = LatLng(48.8530, 2.3499) // Notre-Dame
val circlePolygon = createCirclePolygon(center, 300.0, 64)
style.addSource(
GeoJsonSource("circle-source", Feature.fromGeometry(circlePolygon))
)
style.addLayer(
FillLayer("circle-fill", "circle-source")
.withProperties(
fillColor(Color.parseColor("#FF6B35")),
fillOpacity(0.15f)
)
)
style.addLayer(
LineLayer("circle-outline", "circle-source")
.withProperties(
lineColor(Color.parseColor("#FF6B35")),
lineWidth(1.5f),
lineDasharray(arrayOf(2f, 1f))
)
)
}
private fun fitToAll() {
val bounds = LatLngBounds.Builder()
.include(LatLng(48.8606, 2.3376))
.include(LatLng(48.8420, 2.3280))
.include(LatLng(48.8600, 2.3266))
.include(LatLng(48.8560, 2.3350))
.build()
map.easeCamera(
CameraUpdateFactory.newLatLngBounds(bounds, 80),
1000
)
}
private fun createCirclePolygon(center: LatLng, radiusMeters: Double, steps: Int): Polygon {
val points = mutableListOf()
val earthRadius = 6371000.0
for (i in 0..steps) {
val angle = Math.toRadians((360.0 / steps) * i)
val lat = Math.toRadians(center.latitude)
val lng = Math.toRadians(center.longitude)
val newLat = asin(
sin(lat) * cos(radiusMeters / earthRadius) +
cos(lat) * sin(radiusMeters / earthRadius) * cos(angle)
)
val newLng = lng + atan2(
sin(angle) * sin(radiusMeters / earthRadius) * cos(lat),
cos(radiusMeters / earthRadius) - sin(lat) * sin(newLat)
)
points.add(Point.fromLngLat(Math.toDegrees(newLng), Math.toDegrees(newLat)))
}
return Polygon.fromLngLats(listOf(points))
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Layer Rendering Order
Layers render in the order they're added. The correct visual stacking:
```
Top → Annotation markers (always on top)
Symbol layers (icons, labels)
Circle layers (data points)
Line layers (routes, borders)
Bottom → Fill layers (zones, polygons)
```
Use `addLayerBelow` or `addLayerAbove` for precise control:
```kotlin
style.addLayerBelow(fillLayer, "routes-layer") // fills below lines
style.addLayerAbove(circleLayer, "routes-layer") // points above lines
```
## Next Steps
- [Polygon Area](./polygon-area) — Draw filled shapes
- [Polyline Route](./polyline-route) — Draw route lines
- [Draw a Circle](./draw-a-circle) — Circle zones
- [Multiple Data Sources](../data/multiple-sources) — Combining data sources
---
**Tip**: Add fills first, then lines, then points. This ensures points remain clickable on top and lines are visible above filled zones.
---
# Add Multiple Markers with Custom Icons
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/multiple-markers
# Add Multiple Markers with Custom Icons
This tutorial shows how to add many markers with custom icons, categories, and info windows to your MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Multiple Markers from a List
Add markers from a data list with titles and snippets:
```kotlin
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import org.maplibre.android.annotations.IconFactory
import org.maplibre.android.annotations.MarkerOptions
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class MultipleMarkersActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
data class Place(
val position: LatLng,
val name: String,
val description: String,
val category: String
)
private val places = listOf(
Place(LatLng(48.8584, 2.2945), "Eiffel Tower", "Iconic iron tower, 324m tall", "landmark"),
Place(LatLng(48.8606, 2.3376), "Louvre Museum", "World's largest art museum", "museum"),
Place(LatLng(48.8530, 2.3499), "Notre-Dame", "Medieval Catholic cathedral", "landmark"),
Place(LatLng(48.8738, 2.2950), "Arc de Triomphe", "Triumphal arch monument", "landmark"),
Place(LatLng(48.8462, 2.3464), "Luxembourg Gardens", "Historic public garden", "park"),
Place(LatLng(48.8600, 2.3266), "Musée d'Orsay", "Impressionist art museum", "museum"),
Place(LatLng(48.8867, 2.3431), "Sacré-Cœur", "White-domed basilica", "landmark"),
Place(LatLng(48.8619, 2.3532), "Centre Pompidou", "Modern art museum", "museum"),
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addMarkers()
}
}
}
private fun addMarkers() {
val boundsBuilder = LatLngBounds.Builder()
for (place in places) {
map.addMarker(
MarkerOptions()
.position(place.position)
.title(place.name)
.snippet("${place.category.uppercase()} — ${place.description}")
)
boundsBuilder.include(place.position)
}
// Fit all markers
map.easeCamera(
CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 80),
1000
)
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Markers with Custom Vector Icons
Use Android drawable resources as marker icons:
```kotlin
private fun addMarkersWithIcons() {
val iconFactory = IconFactory.getInstance(this)
for (place in places) {
// Pick icon drawable based on category
val drawableRes = when (place.category) {
"landmark" -> R.drawable.ic_landmark
"museum" -> R.drawable.ic_museum
"park" -> R.drawable.ic_park
else -> R.drawable.ic_default_marker
}
// Convert vector drawable to bitmap
val drawable = ContextCompat.getDrawable(this, drawableRes)!!
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
val icon = iconFactory.fromBitmap(bitmap)
map.addMarker(
MarkerOptions()
.position(place.position)
.title(place.name)
.snippet(place.description)
.icon(icon)
)
}
}
```
## Symbol Layer Approach (Recommended for Many Markers)
For 50+ markers, use a symbol layer instead of annotation markers for better performance:
```kotlin
import com.google.gson.JsonObject
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
private fun addSymbolLayerMarkers(style: Style) {
// Build feature collection from places
val features = places.map { place ->
val properties = JsonObject().apply {
addProperty("name", place.name)
addProperty("description", place.description)
addProperty("category", place.category)
}
Feature.fromGeometry(
Point.fromLngLat(
place.position.longitude,
place.position.latitude
),
properties
)
}
val featureCollection = FeatureCollection.fromFeatures(features)
// Add source
style.addSource(GeoJsonSource("places-source", featureCollection))
// Add symbol layer
style.addLayer(
SymbolLayer("places-layer", "places-source")
.withProperties(
iconImage("marker-15"), // Built-in sprite icon
iconSize(1.5f),
iconAllowOverlap(true),
textField(Expression.get("name")),
textSize(11f),
textOffset(arrayOf(0f, 1.5f)),
textAnchor("top"),
textColor("#333333"),
textHaloColor("#ffffff"),
textHaloWidth(1f)
)
)
// Handle tap on symbols
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
val features = map.queryRenderedFeatures(screenPoint, "places-layer")
if (features.isNotEmpty()) {
val name = features[0].getStringProperty("name")
val desc = features[0].getStringProperty("description")
android.widget.Toast.makeText(
this, "$name: $desc", android.widget.Toast.LENGTH_SHORT
).show()
}
true
}
}
```
## Filter Markers by Category
Show/hide markers by category with buttons:
```kotlin
import android.widget.ToggleButton
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
private fun setupCategoryFilters(style: Style) {
addSymbolLayerMarkers(style) // Add symbol layer first
val layer = style.getLayer("places-layer") as? SymbolLayer
findViewById(R.id.btnLandmarks).setOnCheckedChangeListener { _, checked ->
updateFilter(layer)
}
findViewById(R.id.btnMuseums).setOnCheckedChangeListener { _, checked ->
updateFilter(layer)
}
findViewById(R.id.btnParks).setOnCheckedChangeListener { _, checked ->
updateFilter(layer)
}
}
private fun updateFilter(layer: SymbolLayer?) {
val activeCategories = mutableListOf()
if (findViewById(R.id.btnLandmarks).isChecked) {
activeCategories.add(literal("landmark"))
}
if (findViewById(R.id.btnMuseums).isChecked) {
activeCategories.add(literal("museum"))
}
if (findViewById(R.id.btnParks).isChecked) {
activeCategories.add(literal("park"))
}
if (activeCategories.isEmpty()) {
// Show all when nothing selected
layer?.setFilter(literal(true))
} else {
layer?.setFilter(
match(
get("category"),
literal(false),
*activeCategories.map { stop(it, literal(true)) }.toTypedArray()
)
)
}
}
```
## Annotation vs Symbol Layer
| Feature | Annotation Markers | Symbol Layer |
|---------|-------------------|--------------|
| Performance | Good for < 50 | Scales to thousands |
| Custom views | Full Android views | Icons + text only |
| Built-in info window | Yes | Manual via click query |
| Drag support | Yes | No |
| Data-driven styling | No | Yes (expressions) |
| Filtering | Remove/add manually | `setFilter()` on layer |
## Next Steps
- [Add Markers](../annotations/add-markers) — Annotation marker basics
- [Marker Annotations](../annotations/marker-annotations) — Advanced annotations
- [Add Clusters](./add-a-cluster) — Group dense markers
---
**Tip**: Switch from annotation markers to symbol layers when you have more than ~50 markers. Symbol layers are GPU-rendered and handle thousands of points smoothly, while annotation markers create individual Android views that can cause jank at scale.
---
# Add Navigation Controls
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/navigation-controls
# Add Navigation Controls
This tutorial shows how to add zoom buttons, compass, and location buttons as on-screen controls for your MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Built-in Compass Control
The compass appears automatically when the map is rotated and resets bearing to north on tap:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class NavigationControlsActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
val ui = map.uiSettings
// Compass — shows when map is rotated, taps resets to north
ui.isCompassEnabled = true
ui.setCompassFadeFacingNorth(true) // hide when facing north
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Custom Zoom Buttons
Add plus/minus zoom buttons as a floating overlay:
```kotlin
import android.widget.ImageButton
import org.maplibre.android.camera.CameraUpdateFactory
private fun setupZoomControls() {
findViewById(R.id.btnZoomIn).setOnClickListener {
map.animateCamera(CameraUpdateFactory.zoomIn(), 300)
}
findViewById(R.id.btnZoomOut).setOnClickListener {
map.animateCamera(CameraUpdateFactory.zoomOut(), 300)
}
}
```
Layout overlay for zoom buttons:
```xml
```
## My Location Button
Add a button to fly to the user's GPS location:
```kotlin
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import org.maplibre.android.location.LocationComponentActivationOptions
import org.maplibre.android.location.modes.CameraMode
private fun setupLocationButton(style: Style) {
findViewById(R.id.btnMyLocation).setOnClickListener {
if (ActivityCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
enableLocationAndFly(style)
} else {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
LOCATION_REQUEST_CODE
)
}
}
}
private fun enableLocationAndFly(style: Style) {
val locationComponent = map.locationComponent
locationComponent.activateLocationComponent(
LocationComponentActivationOptions.builder(this, style)
.useDefaultLocationEngine(true)
.build()
)
locationComponent.isLocationComponentEnabled = true
locationComponent.cameraMode = CameraMode.TRACKING
// Fly to current location
locationComponent.lastKnownLocation?.let { location ->
map.animateCamera(
CameraUpdateFactory.newLatLngZoom(
LatLng(location.latitude, location.longitude),
15.0
),
1500
)
}
}
companion object {
private const val LOCATION_REQUEST_CODE = 1001
}
```
## Reset North Button
Reset bearing and tilt to default:
```kotlin
private fun setupResetNorthButton() {
findViewById(R.id.btnNorth).setOnClickListener {
map.animateCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder(map.cameraPosition)
.bearing(0.0)
.tilt(0.0)
.build()
),
500
)
}
}
```
## Complete Control Panel
Combine all controls in one overlay:
```xml
```
## Compass Settings
| Setting | XML Attribute | Description |
|---------|--------------|-------------|
| `isCompassEnabled` | `maplibre_uiCompass` | Show/hide compass |
| `setCompassFadeFacingNorth` | `maplibre_uiCompassFadeFacingNorth` | Auto-hide when north up |
| `compassGravity` | `maplibre_uiCompassGravity` | Screen position |
## Next Steps
- [Zoom Methods](../camera/zoom-methods) — Zoom API reference
- [Location Component](../location-component) — User location tracking
- [Set Pitch & Bearing](../camera/set-pitch-bearing) — 3D camera controls
---
**Tip**: On mobile, keep navigation controls small (40-48dp) and place them on the right edge so they don't overlap the map's built-in compass (top-left by default) or attribution (bottom-left).
---
# Draw a Polygon Area
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/polygon-area
# Draw a Polygon Area
This tutorial shows how to draw filled polygon shapes on your MapMetrics Android map — useful for highlighting zones, districts, or geofences.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Polygon with Fill
Add a filled polygon using a GeoJSON source and fill layer:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.FillLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
class PolygonActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addPolygon(style)
}
}
}
private fun addPolygon(style: Style) {
// Define polygon vertices (must form a closed ring)
val polygonPoints = listOf(
listOf(
Point.fromLngLat(2.3200, 48.8700),
Point.fromLngLat(2.3800, 48.8700),
Point.fromLngLat(2.3800, 48.8400),
Point.fromLngLat(2.3200, 48.8400),
Point.fromLngLat(2.3200, 48.8700) // Close the ring
)
)
val polygon = Polygon.fromLngLats(polygonPoints)
val feature = Feature.fromGeometry(polygon)
// Add GeoJSON source
style.addSource(GeoJsonSource("polygon-source", feature))
// Add fill layer
style.addLayer(
FillLayer("polygon-fill", "polygon-source").withProperties(
fillColor(Color.parseColor("#4285F4")),
fillOpacity(0.3f)
)
)
// Add outline layer
style.addLayer(
LineLayer("polygon-outline", "polygon-source").withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(2f),
lineOpacity(0.8f)
)
)
// Center camera on polygon
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.855, 2.350))
.zoom(13.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Multiple Colored Zones
Display several zones with different colors:
```kotlin
private fun addMultipleZones(style: Style) {
data class Zone(
val id: String,
val points: List,
val color: String,
val name: String
)
val zones = listOf(
Zone(
"zone-a",
listOf(
Point.fromLngLat(2.32, 48.87),
Point.fromLngLat(2.35, 48.87),
Point.fromLngLat(2.35, 48.855),
Point.fromLngLat(2.32, 48.855),
Point.fromLngLat(2.32, 48.87),
),
"#FF6B35", "District A"
),
Zone(
"zone-b",
listOf(
Point.fromLngLat(2.35, 48.87),
Point.fromLngLat(2.38, 48.87),
Point.fromLngLat(2.38, 48.855),
Point.fromLngLat(2.35, 48.855),
Point.fromLngLat(2.35, 48.87),
),
"#34A853", "District B"
),
Zone(
"zone-c",
listOf(
Point.fromLngLat(2.32, 48.855),
Point.fromLngLat(2.38, 48.855),
Point.fromLngLat(2.38, 48.84),
Point.fromLngLat(2.32, 48.84),
Point.fromLngLat(2.32, 48.855),
),
"#9C27B0", "District C"
),
)
for (zone in zones) {
val polygon = Polygon.fromLngLats(listOf(zone.points))
val feature = Feature.fromGeometry(polygon)
style.addSource(GeoJsonSource("${zone.id}-source", feature))
style.addLayer(
FillLayer("${zone.id}-fill", "${zone.id}-source").withProperties(
fillColor(Color.parseColor(zone.color)),
fillOpacity(0.3f)
)
)
style.addLayer(
LineLayer("${zone.id}-outline", "${zone.id}-source").withProperties(
lineColor(Color.parseColor(zone.color)),
lineWidth(2f)
)
)
}
}
```
## Clickable Polygon
Show info when the user taps inside a polygon:
```kotlin
private fun addClickablePolygon(style: Style) {
addPolygon(style) // Add polygon first
map.addOnMapClickListener { latLng ->
// Query the polygon layer at the tap point
val screenPoint = map.projection.toScreenLocation(latLng)
val features = map.queryRenderedFeatures(screenPoint, "polygon-fill")
if (features.isNotEmpty()) {
android.widget.Toast.makeText(
this,
"Tapped inside polygon!",
android.widget.Toast.LENGTH_SHORT
).show()
}
true
}
}
```
## Fill Layer Properties
| Property | Type | Description |
|----------|------|-------------|
| `fillColor` | Color/Expression | Fill color |
| `fillOpacity` | Float | Fill transparency (0.0 - 1.0) |
| `fillOutlineColor` | Color | Border color (1px only) |
| `fillAntialias` | Boolean | Smooth edges (default: true) |
## Next Steps
- [GeoJSON Guide](../geojson-guide) — Load polygons from GeoJSON files
- [Data-Driven Styling](../styling/data-driven-style) — Color polygons by data properties
- [Building Layer](../styling/building-layer) — 3D extruded polygons
---
**Tip**: Always close polygon rings — the first and last point must be identical. For outlines thicker than 1px, add a separate `LineLayer` on top of the `FillLayer` as shown above.
---
# Show Polygon Info on Click
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/polygon-info-on-click
# Show Polygon Info on Click
This tutorial shows how to display information about a polygon zone when users tap inside it on your MapMetrics Android map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Clickable Polygons with Info
Create named zones and show details on tap:
```kotlin
import android.graphics.Color
import android.os.Bundle
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.FillLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.*
class PolygonInfoActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private lateinit var infoPanel: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polygon_info)
infoPanel = findViewById(R.id.tvInfo)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addZones(style)
setupClickHandler()
}
}
}
private fun addZones(style: Style) {
val zones = listOf(
createZoneFeature(
"zone-1", "Le Marais",
"Historic district, popular for art galleries and cafés",
"#FF6B35",
listOf(
Point.fromLngLat(2.3500, 48.8600),
Point.fromLngLat(2.3650, 48.8600),
Point.fromLngLat(2.3650, 48.8530),
Point.fromLngLat(2.3500, 48.8530),
Point.fromLngLat(2.3500, 48.8600),
)
),
createZoneFeature(
"zone-2", "Saint-Germain",
"Left Bank intellectual quarter with bookshops and bistros",
"#4285F4",
listOf(
Point.fromLngLat(2.3200, 48.8550),
Point.fromLngLat(2.3400, 48.8550),
Point.fromLngLat(2.3400, 48.8480),
Point.fromLngLat(2.3200, 48.8480),
Point.fromLngLat(2.3200, 48.8550),
)
),
createZoneFeature(
"zone-3", "Montmartre",
"Hilltop village known for Sacré-Cœur and street artists",
"#34A853",
listOf(
Point.fromLngLat(2.3300, 48.8900),
Point.fromLngLat(2.3500, 48.8900),
Point.fromLngLat(2.3500, 48.8830),
Point.fromLngLat(2.3300, 48.8830),
Point.fromLngLat(2.3300, 48.8900),
)
),
)
style.addSource(
GeoJsonSource("zones-source", FeatureCollection.fromFeatures(zones))
)
// Fill layer — colored by feature property
style.addLayer(
FillLayer("zones-fill", "zones-source")
.withProperties(
fillColor(get("color")),
fillOpacity(0.25f)
)
)
// Outline
style.addLayer(
LineLayer("zones-outline", "zones-source")
.withProperties(
lineColor(get("color")),
lineWidth(2f)
)
)
// Camera
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.865, 2.340))
.zoom(12.5)
.build()
}
private fun createZoneFeature(
id: String,
name: String,
description: String,
color: String,
points: List
): Feature {
val props = JsonObject().apply {
addProperty("id", id)
addProperty("name", name)
addProperty("description", description)
addProperty("color", color)
}
return Feature.fromGeometry(
Polygon.fromLngLats(listOf(points)),
props,
id
)
}
private fun setupClickHandler() {
map.addOnMapClickListener { latLng ->
val screenPoint = map.projection.toScreenLocation(latLng)
val features = map.queryRenderedFeatures(screenPoint, "zones-fill")
if (features.isNotEmpty()) {
val feature = features[0]
val name = feature.getStringProperty("name") ?: "Unknown"
val description = feature.getStringProperty("description") ?: ""
// Show in info panel
infoPanel.text = "$name\n$description"
infoPanel.visibility = android.view.View.VISIBLE
// Highlight the tapped zone
highlightZone(feature.getStringProperty("id"))
} else {
infoPanel.visibility = android.view.View.GONE
clearHighlight()
}
true
}
}
private fun highlightZone(zoneId: String?) {
val layer = map.style?.getLayer("zones-fill") as? FillLayer
layer?.setProperties(
fillOpacity(
match(
get("id"),
literal(0.15f), // default: dim
stop(zoneId ?: "", literal(0.5f)) // highlighted: bright
)
)
)
}
private fun clearHighlight() {
val layer = map.style?.getLayer("zones-fill") as? FillLayer
layer?.setProperties(fillOpacity(0.25f))
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
Layout (`res/layout/activity_polygon_info.xml`):
```xml
```
## Next Steps
- [Polygon Area](./polygon-area) — Drawing polygon shapes
- [Filter Features](../styling/filter-features) — Data-driven filtering
- [Map Click Events](./map-click-events) — General click handling
---
**Tip**: Use `queryRenderedFeatures` with the specific layer ID (`"zones-fill"`) to only detect polygon taps. Without a layer filter, it would also match road labels, building polygons, and other style features under the tap point.
---
# Draw a Polyline Route
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/polyline-route
# Draw a Polyline Route
This tutorial shows how to draw polyline routes on your MapMetrics Android map using both the annotation API and GeoJSON line layers.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Using a GeoJSON Line Layer
The recommended approach — add a styled line from GeoJSON data:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
class PolylineActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addRoute(style)
}
}
}
private fun addRoute(style: Style) {
// Define route points
val routePoints = listOf(
Point.fromLngLat(2.3522, 48.8566), // Paris
Point.fromLngLat(4.3517, 50.8503), // Brussels
Point.fromLngLat(4.9041, 52.3676), // Amsterdam
Point.fromLngLat(9.9937, 53.5511), // Hamburg
Point.fromLngLat(13.4050, 52.5200), // Berlin
)
// Create GeoJSON line feature
val lineString = LineString.fromLngLats(routePoints)
val feature = Feature.fromGeometry(lineString)
val featureCollection = FeatureCollection.fromFeature(feature)
// Add source
style.addSource(GeoJsonSource("route-source", featureCollection))
// Add line layer
style.addLayer(
LineLayer("route-layer", "route-source").withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(4f),
lineOpacity(0.8f)
)
)
// Fit camera to route
val bounds = LatLngBounds.Builder()
for (point in routePoints) {
bounds.include(LatLng(point.latitude(), point.longitude()))
}
map.easeCamera(
CameraUpdateFactory.newLatLngBounds(bounds.build(), 80),
1000
)
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Styled Route with Markers at Stops
Add start/end markers along the route:
```kotlin
private fun addStyledRoute(style: Style) {
val stops = listOf(
Pair(Point.fromLngLat(2.3522, 48.8566), "Paris"),
Pair(Point.fromLngLat(4.3517, 50.8503), "Brussels"),
Pair(Point.fromLngLat(4.9041, 52.3676), "Amsterdam"),
Pair(Point.fromLngLat(13.4050, 52.5200), "Berlin"),
)
val routePoints = stops.map { it.first }
// Line source and layer
val lineString = LineString.fromLngLats(routePoints)
style.addSource(GeoJsonSource("route-source", Feature.fromGeometry(lineString)))
style.addLayer(
LineLayer("route-layer", "route-source").withProperties(
lineColor(Color.parseColor("#FF6B35")),
lineWidth(5f),
lineOpacity(0.9f),
lineJoin("round")
)
)
// Add markers at each stop
for ((point, name) in stops) {
map.addMarker(
org.maplibre.android.annotations.MarkerOptions()
.position(LatLng(point.latitude(), point.longitude()))
.title(name)
)
}
}
```
## Dashed Line
Create a dashed polyline using `lineDasharray`:
```kotlin
style.addLayer(
LineLayer("dashed-route", "route-source").withProperties(
lineColor(Color.parseColor("#333333")),
lineWidth(3f),
lineDasharray(arrayOf(2f, 1f)) // 2px dash, 1px gap
)
)
```
## Multiple Route Layers
Show different route options with different colors:
```kotlin
private fun addMultipleRoutes(style: Style) {
// Route 1 — fastest
val fastRoute = listOf(
Point.fromLngLat(2.3522, 48.8566),
Point.fromLngLat(6.1296, 49.8153),
Point.fromLngLat(13.4050, 52.5200),
)
// Route 2 — scenic
val scenicRoute = listOf(
Point.fromLngLat(2.3522, 48.8566),
Point.fromLngLat(4.3517, 50.8503),
Point.fromLngLat(4.9041, 52.3676),
Point.fromLngLat(9.9937, 53.5511),
Point.fromLngLat(13.4050, 52.5200),
)
// Fast route — blue, thicker
style.addSource(
GeoJsonSource("fast-source",
Feature.fromGeometry(LineString.fromLngLats(fastRoute)))
)
style.addLayer(
LineLayer("fast-layer", "fast-source").withProperties(
lineColor(Color.parseColor("#4285F4")),
lineWidth(5f),
lineOpacity(0.9f)
)
)
// Scenic route — green, thinner
style.addSource(
GeoJsonSource("scenic-source",
Feature.fromGeometry(LineString.fromLngLats(scenicRoute)))
)
style.addLayer(
LineLayer("scenic-layer", "scenic-source").withProperties(
lineColor(Color.parseColor("#34A853")),
lineWidth(3f),
lineOpacity(0.7f)
)
)
}
```
## Line Layer Properties
| Property | Type | Description |
|----------|------|-------------|
| `lineColor` | Color | Line color |
| `lineWidth` | Float | Width in pixels |
| `lineOpacity` | Float | Transparency (0.0 - 1.0) |
| `lineJoin` | String | `"round"`, `"bevel"`, `"miter"` |
| `lineCap` | String | `"round"`, `"butt"`, `"square"` |
| `lineDasharray` | Array | Dash and gap lengths |
## Next Steps
- [GeoJSON Guide](../geojson-guide) — Working with GeoJSON data
- [Data-Driven Lines](../styling/data-driven-style) — Style lines by properties
- [Polygon Area](./polygon-area) — Draw filled shapes
---
**Tip**: For route lines that pass beneath map labels, use `style.addLayerBelow(layer, "labelLayerId")` to insert the line layer below the label layer in the rendering stack.
---
# Restrict Map Panning to a Region
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/restrict-panning
# Restrict Map Panning to a Region
This tutorial shows how to limit the map view to a specific geographic area — useful for city-specific apps, campus maps, or regional applications.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Restrict Using Camera Bounds
Constrain the map so users cannot pan outside a bounding box:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class RestrictPanActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
// Paris bounding box
private val parisBounds = LatLngBounds.Builder()
.include(LatLng(48.9020, 2.4700)) // Northeast
.include(LatLng(48.8100, 2.2200)) // Southwest
.build()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
// Set the bounds constraint
map.setLatLngBoundsForCameraTarget(parisBounds)
// Also restrict zoom range
map.setMinZoomPreference(10.0)
map.setMaxZoomPreference(18.0)
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
// Center on Paris
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Switchable Region Constraints
Let users switch between predefined regions:
```kotlin
import android.widget.Button
import org.maplibre.android.camera.CameraUpdateFactory
private val regions = mapOf(
"Paris" to Pair(
LatLngBounds.Builder()
.include(LatLng(48.9020, 2.4700))
.include(LatLng(48.8100, 2.2200))
.build(),
LatLng(48.8566, 2.3522)
),
"London" to Pair(
LatLngBounds.Builder()
.include(LatLng(51.6723, 0.1480))
.include(LatLng(51.3850, -0.3517))
.build(),
LatLng(51.5074, -0.1278)
),
"Berlin" to Pair(
LatLngBounds.Builder()
.include(LatLng(52.6755, 13.7611))
.include(LatLng(52.3383, 13.0884))
.build(),
LatLng(52.5200, 13.4050)
),
)
private fun setupRegionButtons() {
findViewById(R.id.btnParis).setOnClickListener { switchRegion("Paris") }
findViewById(R.id.btnLondon).setOnClickListener { switchRegion("London") }
findViewById(R.id.btnBerlin).setOnClickListener { switchRegion("Berlin") }
}
private fun switchRegion(name: String) {
regions[name]?.let { (bounds, center) ->
// Update bounds constraint
map.setLatLngBoundsForCameraTarget(bounds)
// Fly to the new region
map.animateCamera(
CameraUpdateFactory.newLatLngZoom(center, 12.0),
1500
)
}
}
```
## Restrict with Zoom Constraints
Combine panning bounds with zoom limits:
```kotlin
// Allow only zoom levels 11-16
map.setMinZoomPreference(11.0)
map.setMaxZoomPreference(16.0)
// Restrict panning area
map.setLatLngBoundsForCameraTarget(parisBounds)
// Disable rotation and tilt for a fixed 2D view
map.uiSettings.isRotateGesturesEnabled = false
map.uiSettings.isTiltGesturesEnabled = false
```
## Remove Restrictions
Clear all constraints at runtime:
```kotlin
// Remove panning bounds
map.setLatLngBoundsForCameraTarget(null)
// Reset zoom limits
map.setMinZoomPreference(0.0)
map.setMaxZoomPreference(22.0)
// Re-enable all gestures
map.uiSettings.setAllGesturesEnabled(true)
```
## Next Steps
- [Max/Min Zoom](../camera/max-min-zoom) — Zoom level constraints
- [Disable Gestures](./disable-gestures) — Control individual gestures
- [Lat-Lng Bounds](../camera/lat-lng-bounds) — Bounds API reference
---
**Tip**: Set min zoom high enough that the bounds fill the screen — if users can zoom out too far, they'll see the restricted area as a small box on a larger map, which looks odd. Typically `minZoom = 10-12` works well for city-level restrictions.
---
# Slowly Fly to a Location
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/slowly-fly-to-location
# Slowly Fly to a Location
This tutorial shows how to create a cinematic slow camera flight — useful for showcases, presentations, or atmospheric map experiences.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Cinematic Slow Flight
A long-duration animated camera move with tilt and bearing changes:
```kotlin
import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class SlowFlyActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_slow_fly)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
// Start from a wide aerial view
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522)) // Paris overview
.zoom(10.0)
.tilt(0.0)
.bearing(0.0)
.build()
// Slow fly button
findViewById(R.id.btnSlowFly).setOnClickListener {
slowFlyToEiffelTower()
}
}
}
private fun slowFlyToEiffelTower() {
val destination = CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945)) // Eiffel Tower
.zoom(17.0) // Street level
.tilt(60.0) // Dramatic 3D angle
.bearing(-30.0) // Slight rotation
.build()
map.animateCamera(
CameraUpdateFactory.newCameraPosition(destination),
10000 // 10 seconds — slow, cinematic
)
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Multi-Stage Cinematic Sequence
Chain multiple slow flights for a full cinematic experience:
```kotlin
private data class CinematicStop(
val target: LatLng,
val zoom: Double,
val tilt: Double,
val bearing: Double,
val flightDuration: Long, // ms to fly there
val pauseDuration: Long // ms to pause before next
)
private val cinematicStops = listOf(
CinematicStop(
LatLng(48.8566, 2.3522), 11.0, 0.0, 0.0,
0, 1000 // Start: Paris overview
),
CinematicStop(
LatLng(48.8584, 2.2945), 16.0, 55.0, -20.0,
8000, 3000 // Fly to Eiffel Tower
),
CinematicStop(
LatLng(48.8606, 2.3376), 16.5, 50.0, 30.0,
6000, 3000 // Fly to Louvre
),
CinematicStop(
LatLng(48.8530, 2.3499), 17.0, 60.0, -10.0,
6000, 3000 // Fly to Notre-Dame
),
CinematicStop(
LatLng(48.8867, 2.3431), 15.5, 45.0, 0.0,
8000, 2000 // Fly to Sacré-Cœur
),
)
private fun startCinematicTour() {
playCinematicStop(0)
}
private fun playCinematicStop(index: Int) {
if (index >= cinematicStops.size) return
val stop = cinematicStops[index]
val position = CameraPosition.Builder()
.target(stop.target)
.zoom(stop.zoom)
.tilt(stop.tilt)
.bearing(stop.bearing)
.build()
if (stop.flightDuration == 0L) {
// First stop — instant position
map.moveCamera(CameraUpdateFactory.newCameraPosition(position))
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
playCinematicStop(index + 1)
}, stop.pauseDuration)
} else {
map.animateCamera(
CameraUpdateFactory.newCameraPosition(position),
stop.flightDuration.toInt(),
object : MapMetricsMap.CancelableCallback {
override fun onFinish() {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
playCinematicStop(index + 1)
}, stop.pauseDuration)
}
override fun onCancel() {
// User interrupted — stop the tour
}
}
)
}
}
```
## Slow Fly with easeCamera
Use `easeCamera` for a constant-speed flight (no acceleration):
```kotlin
private fun slowEaseFlight() {
map.easeCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945))
.zoom(16.0)
.tilt(50.0)
.bearing(-15.0)
.build()
),
15000 // 15 seconds, constant speed
)
}
```
## Speed Comparison
| Duration | Feel | Use Case |
|----------|------|----------|
| 1-2s | Quick snap | UI navigation |
| 3-5s | Normal fly-to | Standard interaction |
| 6-10s | Slow, cinematic | Showcase, guided tour |
| 10-20s | Very slow | Presentation, ambient display |
| `easeCamera` 10s+ | Constant speed, no acceleration | Smooth documentary feel |
## Next Steps
- [Fly to a Location](./fly-to-location) — Standard fly-to animation
- [Orbit Animation](../camera/orbit-animation) — Rotating camera
- [Jump to Locations](./jump-to-locations) — Step through waypoints
---
**Tip**: For the most cinematic effect, combine slow flights (8-15s) with tilt changes (0° → 60°) and slight bearing shifts. The simultaneous zoom + tilt + bearing creates the "Google Earth" swooping effect.
---
# Sync Multiple Maps Side by Side
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/sync-multiple-maps
# Sync Multiple Maps Side by Side
This tutorial shows how to display two MapMetrics maps side by side and keep their camera positions synchronized — useful for comparing map styles, before/after views, or satellite vs. street map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Synchronized Dual Maps
Create two maps that stay in sync:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class SyncMapsActivity : AppCompatActivity() {
private lateinit var mapViewLeft: MapView
private lateinit var mapViewRight: MapView
private var mapLeft: MapMetricsMap? = null
private var mapRight: MapMetricsMap? = null
private var isSyncing = false // prevent infinite loop
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sync_maps)
mapViewLeft = findViewById(R.id.mapViewLeft)
mapViewRight = findViewById(R.id.mapViewRight)
mapViewLeft.onCreate(savedInstanceState)
mapViewRight.onCreate(savedInstanceState)
// Initialize left map
mapViewLeft.getMapAsync { map ->
mapLeft = map
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/STYLE_A?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
setupSync()
}
// Initialize right map
mapViewRight.getMapAsync { map ->
mapRight = map
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/STYLE_B?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
setupSync()
}
}
private fun setupSync() {
// Only set up once both maps are ready
if (mapLeft == null || mapRight == null) return
// Sync left → right
mapLeft?.addOnCameraMoveListener {
if (isSyncing) return@addOnCameraMoveListener
isSyncing = true
mapLeft?.cameraPosition?.let { pos ->
mapRight?.moveCamera(
CameraUpdateFactory.newCameraPosition(pos)
)
}
isSyncing = false
}
// Sync right → left
mapRight?.addOnCameraMoveListener {
if (isSyncing) return@addOnCameraMoveListener
isSyncing = true
mapRight?.cameraPosition?.let { pos ->
mapLeft?.moveCamera(
CameraUpdateFactory.newCameraPosition(pos)
)
}
isSyncing = false
}
}
// Lifecycle — must forward to BOTH map views
override fun onStart() {
super.onStart()
mapViewLeft.onStart()
mapViewRight.onStart()
}
override fun onResume() {
super.onResume()
mapViewLeft.onResume()
mapViewRight.onResume()
}
override fun onPause() {
super.onPause()
mapViewLeft.onPause()
mapViewRight.onPause()
}
override fun onStop() {
super.onStop()
mapViewLeft.onStop()
mapViewRight.onStop()
}
override fun onDestroy() {
super.onDestroy()
mapViewLeft.onDestroy()
mapViewRight.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapViewLeft.onSaveInstanceState(outState)
mapViewRight.onSaveInstanceState(outState)
}
}
```
Layout (`res/layout/activity_sync_maps.xml`):
```xml
```
## Vertical Split (Top/Bottom)
For portrait orientation, stack maps vertically:
```xml
```
## Style Comparison Use Cases
| Left Map | Right Map | Purpose |
|----------|-----------|---------|
| Streets | Satellite | Terrain comparison |
| Light theme | Dark theme | Theme preview |
| Current data | Historical data | Time comparison |
| Default style | Custom style | Style development |
## Next Steps
- [Custom Styling](../styling/custom-styling) — Map style customization
- [Fly to a Location](./fly-to-location) — Camera animations
- [Configuration](../configuration) — Map options
---
**Tip**: The `isSyncing` flag is critical — without it, map A's move triggers map B's listener, which triggers map A's listener, creating an infinite loop. Always guard against re-entrant sync.
---
# Toggle Map Interactions
https://docs.mapatlas.xyz/overview/sdk/android-native/interactions/toggle-interactions
# Toggle Map Interactions
This tutorial shows how to individually enable and disable specific map gestures at runtime — giving users fine-grained control over how they interact with the map.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Interactive Toggle Panel
Create a panel of switches to control each gesture:
```kotlin
import android.os.Bundle
import android.widget.Switch
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class ToggleInteractionsActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_toggle_interactions)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(13.0)
.tilt(30.0)
.build()
setupToggles()
}
}
private fun setupToggles() {
val ui = map.uiSettings
// Scroll (pan)
bindToggle(R.id.switchScroll, "Scroll/Pan", ui.isScrollGesturesEnabled) {
ui.isScrollGesturesEnabled = it
}
// Zoom (pinch)
bindToggle(R.id.switchZoom, "Pinch Zoom", ui.isZoomGesturesEnabled) {
ui.isZoomGesturesEnabled = it
}
// Double-tap zoom
bindToggle(R.id.switchDoubleTap, "Double-Tap Zoom", ui.isDoubleTapGesturesEnabled) {
ui.isDoubleTapGesturesEnabled = it
}
// Rotate
bindToggle(R.id.switchRotate, "Rotate", ui.isRotateGesturesEnabled) {
ui.isRotateGesturesEnabled = it
}
// Tilt
bindToggle(R.id.switchTilt, "Tilt", ui.isTiltGesturesEnabled) {
ui.isTiltGesturesEnabled = it
}
// Fling momentum
bindToggle(R.id.switchFling, "Fling Momentum", ui.isFlingVelocityAnimationEnabled) {
ui.isFlingVelocityAnimationEnabled = it
}
}
private fun bindToggle(
viewId: Int,
label: String,
initialValue: Boolean,
onChange: (Boolean) -> Unit
) {
findViewById(viewId).apply {
text = label
isChecked = initialValue
setOnCheckedChangeListener { _, checked -> onChange(checked) }
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
Layout (`res/layout/activity_toggle_interactions.xml`):
```xml
```
## Preset Interaction Modes
Quickly switch between common configurations:
```kotlin
import android.widget.Button
private fun setupPresets() {
val ui = map.uiSettings
// Full interaction
findViewById(R.id.btnFullMode).setOnClickListener {
ui.setAllGesturesEnabled(true)
ui.isFlingVelocityAnimationEnabled = true
refreshToggles()
}
// View-only (no interaction)
findViewById(R.id.btnViewOnly).setOnClickListener {
ui.setAllGesturesEnabled(false)
ui.isFlingVelocityAnimationEnabled = false
refreshToggles()
}
// Pan-only (embedded map)
findViewById(R.id.btnPanOnly).setOnClickListener {
ui.isScrollGesturesEnabled = true
ui.isZoomGesturesEnabled = false
ui.isDoubleTapGesturesEnabled = false
ui.isRotateGesturesEnabled = false
ui.isTiltGesturesEnabled = false
refreshToggles()
}
// 2D mode (no rotate/tilt)
findViewById(R.id.btn2dMode).setOnClickListener {
ui.isScrollGesturesEnabled = true
ui.isZoomGesturesEnabled = true
ui.isDoubleTapGesturesEnabled = true
ui.isRotateGesturesEnabled = false
ui.isTiltGesturesEnabled = false
// Reset to flat view
map.animateCamera(
org.maplibre.android.camera.CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder(map.cameraPosition)
.bearing(0.0)
.tilt(0.0)
.build()
),
500
)
refreshToggles()
}
}
private fun refreshToggles() {
val ui = map.uiSettings
findViewById(R.id.switchScroll).isChecked = ui.isScrollGesturesEnabled
findViewById(R.id.switchZoom).isChecked = ui.isZoomGesturesEnabled
findViewById(R.id.switchDoubleTap).isChecked = ui.isDoubleTapGesturesEnabled
findViewById(R.id.switchRotate).isChecked = ui.isRotateGesturesEnabled
findViewById(R.id.switchTilt).isChecked = ui.isTiltGesturesEnabled
findViewById(R.id.switchFling).isChecked = ui.isFlingVelocityAnimationEnabled
}
```
## Next Steps
- [Disable Gestures](./disable-gestures) — Static map and gesture settings
- [Gesture Detector](../camera/gesture-detector) — Advanced gesture handling
- [Game-Style Controls](./game-controls) — D-pad navigation
---
**Tip**: For maps embedded inside scrollable content (like a `RecyclerView`), disable scroll and zoom gestures so the map doesn't steal touch events from the parent scroll view.
---
# LocationComponent
https://docs.mapatlas.xyz/overview/sdk/android-native/location-component
# LocationComponent
This guide will demonstrate how to utilize the [LocationComponent] to represent the user's current location.
When implementing the [LocationComponent], the application should request location permissions. Declare the need for foreground location in the `AndroidManifest.xml` file. For more information, please refer to the [Android Developer Documentation].
```xml
```
Create a new activity named `BasicLocationPulsingCircleActivity`:
- This Activity should implement the `OnMapReadyCallback` interface. The `onMapReady()` method is triggered when the map is ready to be used.
- Add a variable `permissionsManager` to manage permissions.
- Add a variable `locationComponent` to manage user location.
- At the end of the `onCreate()` method, call `checkPermissions()` to ensure that the application can access the user's location.
```kotlin
/**
* This activity shows a basic usage of the LocationComponent's pulsing circle. There's no
* customization of the pulsing circle's color, radius, speed, etc.
*/
class BasicLocationPulsingCircleActivity : AppCompatActivity(), OnMapReadyCallback {
private var lastLocation: Location? = null
private lateinit var mapView: MapView
private var permissionsManager: PermissionsManager? = null
private var locationComponent: LocationComponent? = null
private lateinit var mapMetricsMap: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_location_layer_basic_pulsing_circle)
mapView = findViewById(R.id.mapView)
if (savedInstanceState != null) {
lastLocation =
savedInstanceState.getParcelable(SAVED_STATE_LOCATION, Location::class.java)
}
mapView.onCreate(savedInstanceState)
checkPermissions()
}}
```
In the `checkPermissions()` method, the [PermissionManager] is used to request location permissions at runtime and handle the callbacks for permission granting or rejection.Additionally, you should pass the results of `Activity.onRequestPermissionResult()` to it. If the permissions are granted, call `mapView.getMapAsync(this)` to register the activity as a listener for onMapReady event.
```kotlin
private fun checkPermissions() {
if (PermissionsManager.areLocationPermissionsGranted(this)) {
mapView.getMapAsync(this)
} else {
permissionsManager = PermissionsManager(object : PermissionsListener {
override fun onExplanationNeeded(permissionsToExplain: List) {
Toast.makeText(
this@BasicLocationPulsingCircleActivity,
"You need to accept location permissions.",
Toast.LENGTH_SHORT
).show()
}
override fun onPermissionResult(granted: Boolean) {
if (granted) {
mapView.getMapAsync(this@BasicLocationPulsingCircleActivity)
} else {
finish()
}
}
})
permissionsManager!!.requestLocationPermissions(this)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionsManager!!.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
```
In the `onMapReady()` method, first set the style and then handle the user's location using the [LocationComponent].
To configure the [LocationComponent], developers should use [LocationComponentOptions].
In this demonstration, we create an instance of this class.
In this method:
- Use the annotation `@SuppressLint("MissingPermission")` to suppress warnings related to missing location access permissions.
- In `setStyle(),` you can utilize other public and token-free styles like [demotiles] instead of the [predefined styles].
- For the builder of [LocationComponentOptions], use `pulseEnabled(true)` to enable the pulse animation, which enhances awareness of the user's location.
- Use method `buildLocationComponentActivationOptions()` to set [LocationComponentActivationOptions], then activate `locatinoComponent` with it.
- To apply options, make sure you call `activateLocationComponent()` of `locationComponent`. You can also set `locationComponent`'s various properties like `isLocationComponentEnabled` , `cameraMode` , etc...
- `CameraMode.TRACKING`[^1] means that when the user's location is updated, the camera will reposition accordingly.
- `locationComponent!!.forceLocationUpdate(lastLocation)` updates the the user's last known location.
```kotlin
@SuppressLint("MissingPermission")
override fun onMapReady(mapMetricsMap: MapMetricsMap) {
this.mapMetricsMap = mapMetricsMap
mapMetricsMap.setStyle(TestStyles.getPredefinedStyleWithFallback("Streets")) { style: Style ->
locationComponent = mapMetricsMap.locationComponent
val locationComponentOptions =
LocationComponentOptions.builder(this@BasicLocationPulsingCircleActivity)
.pulseEnabled(true)
.build()
val locationComponentActivationOptions =
buildLocationComponentActivationOptions(style, locationComponentOptions)
locationComponent!!.activateLocationComponent(locationComponentActivationOptions)
locationComponent!!.isLocationComponentEnabled = true
locationComponent!!.cameraMode = CameraMode.TRACKING
locationComponent!!.forceLocationUpdate(lastLocation)
}
}
```
[LocationComponentActivationOptions] is used to hold the style, [LocationComponentOptions] and other locating behaviors.
- It can also be used to configure how to obtain the current location, such as [LocationEngine] and intervals.
- In this demonstration, it sets 750ms as the fastest interval for location updates, providing high accuracy location results (but with higher power consumption).
- For more information, please visit the [documentation page][LocationComponentActivationOptions].
```kotlin
private fun buildLocationComponentActivationOptions(
style: Style,
locationComponentOptions: LocationComponentOptions
): LocationComponentActivationOptions {
return LocationComponentActivationOptions
.builder(this, style)
.locationComponentOptions(locationComponentOptions)
.useDefaultLocationEngine(true)
.locationEngineRequest(
LocationEngineRequest.Builder(750)
.setFastestInterval(750)
.setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY)
.build()
)
.build()
}
```
For further customization, you can also utilize the `foregroundTintColor()` and `pulseColor()` methods on the [LocationComponentOptions] builder:
```kotlin
val locationComponentOptions =
LocationComponentOptions.builder(this@BasicLocationPulsingCircleActivity)
.pulseEnabled(true)
.pulseColor(Color.RED) // Set color of pulse
.foregroundTintColor(Color.BLACK) // Set color of user location
.build()
```
[//]: # (Here is the final results with different color configurations. For the complete content of this demo, please refer to the source code of the [Test App].)
[//]: # ()
[//]: # ()
[//]: # ( )
[//]: # ( {{ openmaptiles_caption() }})
[//]: # ( )
[^1]: A variety of [camera modes] determine how the camera will track the user location.
They provide the right context to your users at the correct time.
[LocationComponent]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.location/-location-component/index.html
[Android Developer Documentation]: https://developer.android.com/training/location/permissions
[onMapReadyCallback]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.maps/-on-map-ready-callback/index.html
[PermissionManager]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.location.permissions/-permissions-manager/index.html
[LocationComponentOptions]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.location/-location-component-options/index.html
[demotiles]: https://demotiles.maplibre.org/style.json
[predefined styles]: https://github.com/MapMetrics/mapmetrics-native-sdk/tree/main/src/mbgl/util/tile_server_options.cpp
[LocationComponentActivationOptions]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.location/-location-component-activation-options/index.html
[LocationEngine]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.location.engine/-location-engine/index.html
[Test APP]: https://github.com/MapMetrics/mapmetrics-native-sdk/tree/main/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/location/BasicLocationPulsingCircleActivity.kt
[camera modes]: https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.location.modes/-camera-mode/index.html
---
# Using the Snapshotter
https://docs.mapatlas.xyz/overview/sdk/android-native/snapshotter
# Using the Snapshotter
This guide will help you walk through how to use [MapSnapshotter](https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.snapshotter/-map-snapshotter/index.html).
## Map Snapshot with Local Style
[//]: # ({{ activity_source_note("MapSnapshotterLocalStyleActivity.kt") }})
[//]: # (To get started we will show how to use the map snapshotter with a local style.)
[//]: # ()
[//]: # ()
[//]: # ( { width="300" })
[//]: # ( )
Add the [source code of the Demotiles style](https://github.com/maplibre/demotiles/blob/gh-pages/style.json) as `demotiles.json` to the `res/raw` directory of our app[^1]. First we will read this style:
[^1]: See [App resources overview](https://developer.android.com/guide/topics/resources/providing-resources) for this and other ways you can provide resources to your app.
```kotlin
val styleJson = resources.openRawResource(R.raw.demotiles).reader().readText()
```
Next, we configure the MapSnapshotter, passing height and width, the style we just read and the camera position:
```kotlin
mapSnapshotter = MapSnapshotter(
applicationContext,
MapSnapshotter.Options(
container.measuredWidth.coerceAtMost(1024),
container.measuredHeight.coerceAtMost(1024)
)
.withStyleBuilder(Style.Builder().fromJson(styleJson))
.withCameraPosition(
CameraPosition.Builder().target(LatLng(LATITUDE, LONGITUDE))
.zoom(ZOOM).build()
)
)
```
Lastly we use the `.start()` method to create the snapshot, and pass callbacks for when the snapshot is ready or for when an error occurs.
```kotlin
mapSnapshotter.start({ snapshot ->
Timber.i("Snapshot ready")
val imageView = findViewById(R.id.snapshot_image) as ImageView
imageView.setImageBitmap(snapshot.bitmap)
}) { error -> Timber.e(error )}
```
## Show a Grid of Snapshots
[//]: # ({{ activity_source_note("MapSnapshotterActivity.kt") }})
In this example, we demonstrate how to use the `MapSnapshotter` to create multiple map snapshots with different styles and camera positions, displaying them in a grid layout.
[//]: # ()
[//]: # ( { width="300" })
[//]: # ( )
First we create a [`GridLayout`](https://developer.android.com/reference/kotlin/android/widget/GridLayout) and a list of `MapSnapshotter` instances. We create a `Style.Builder` with a different style for each cell in the grid.
```kotlin
val styles = arrayOf(
TestStyles.DEMOTILES,
TestStyles.AMERICANA,
TestStyles.OPENFREEMAP_LIBERTY,
TestStyles.AWS_OPEN_DATA_STANDARD_LIGHT,
TestStyles.PROTOMAPS_LIGHT,
TestStyles.PROTOMAPS_DARK,
TestStyles.PROTOMAPS_WHITE,
TestStyles.PROTOMAPS_GRAYSCALE
)
val builder = Style.Builder().fromUri(
styles[(row * grid.rowCount + column) % styles.size]
)
```
Next we create a `MapSnapshotter.Options` object to customize the settings of each snapshot(ter).
```kotlin
val options = MapSnapshotter.Options(
grid.measuredWidth / grid.columnCount,
grid.measuredHeight / grid.rowCount
)
.withPixelRatio(1f)
.withLocalIdeographFontFamily(MapLibreConstants.DEFAULT_FONT)
```
For some rows we randomize the visible region of the snapshot:
```kotlin
if (row % 2 == 0) {
options.withRegion(
LatLngBounds.Builder()
.include(
LatLng(
randomInRange(-80f, 80f).toDouble(),
randomInRange(-160f, 160f).toDouble()
)
)
.include(
LatLng(
randomInRange(-80f, 80f).toDouble(),
randomInRange(-160f, 160f).toDouble()
)
)
.build()
)
}
```
For some columns we randomize the camera position:
```kotlin
if (column % 2 == 0) {
options.withCameraPosition(
CameraPosition.Builder()
.target(
options.region?.center ?: LatLng(
randomInRange(-80f, 80f).toDouble(),
randomInRange(-160f, 160f).toDouble()
)
)
.bearing(randomInRange(0f, 360f).toDouble())
.tilt(randomInRange(0f, 60f).toDouble())
.zoom(randomInRange(0f, 10f).toDouble())
.padding(1.0, 1.0, 1.0, 1.0)
.build()
)
}
```
In the last column of the first row we add two bitmaps. See the next example for more details.
```kotlin
if (row == 0 && column == 2) {
val carBitmap = BitmapUtils.getBitmapFromDrawable(
ResourcesCompat.getDrawable(resources, R.drawable.ic_directions_car_black, theme)
)
// Marker source
val markerCollection = FeatureCollection.fromFeatures(
arrayOf(
Feature.fromGeometry(
Point.fromLngLat(4.91638, 52.35673),
featureProperties("1", "Android")
),
Feature.fromGeometry(
Point.fromLngLat(4.91638, 12.34673),
featureProperties("2", "Car")
)
)
)
val markerSource: Source = GeoJsonSource(MARKER_SOURCE, markerCollection)
// Marker layer
val markerSymbolLayer = SymbolLayer(MARKER_LAYER, MARKER_SOURCE)
.withProperties(
PropertyFactory.iconImage(Expression.get(TITLE_FEATURE_PROPERTY)),
PropertyFactory.iconIgnorePlacement(true),
PropertyFactory.iconAllowOverlap(true),
PropertyFactory.iconSize(
Expression.switchCase(
Expression.toBool(Expression.get(SELECTED_FEATURE_PROPERTY)),
Expression.literal(1.5f),
Expression.literal(1.0f)
)
),
PropertyFactory.iconAnchor(Property.ICON_ANCHOR_BOTTOM),
PropertyFactory.iconColor(Color.BLUE)
)
builder.withImage("Car", Objects.requireNonNull(carBitmap!!), false)
.withSources(markerSource)
.withLayers(markerSymbolLayer)
options
.withRegion(null)
.withCameraPosition(
CameraPosition.Builder()
.target(
LatLng(5.537109374999999, 52.07950600379697)
)
.zoom(1.0)
.padding(1.0, 1.0, 1.0, 1.0)
.build()
)
}
```
## Map Snapshot with Bitmap Overlay
[//]: # ({{ activity_source_note("MapSnapshotterBitMapOverlayActivity.kt") }})
This example adds a bitmap on top of the snapshot. It also demonstrates how you can add a click listener to a snapshot.
[//]: # ()
[//]: # ()
[//]: # ( { width="300" })
[//]: # ( )
```kotlin title="MapSnapshotterBitMapOverlayActivity.kt"
/**
* Test activity showing how to use a the [MapSnapshotter] and overlay
* [android.graphics.Bitmap]s on top.
*/
class MapSnapshotterBitMapOverlayActivity :
AppCompatActivity(),
MapSnapshotter.SnapshotReadyCallback {
private var mapSnapshotter: MapSnapshotter? = null
@get:VisibleForTesting
var mapSnapshot: MapSnapshot? = null
private set
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map_snapshotter_marker)
val container = findViewById(R.id.container)
container.viewTreeObserver
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
container.viewTreeObserver.removeOnGlobalLayoutListener(this)
Timber.i("Starting snapshot")
mapSnapshotter = MapSnapshotter(
applicationContext,
MapSnapshotter.Options(
Math.min(container.measuredWidth, 1024),
Math.min(container.measuredHeight, 1024)
)
.withStyleBuilder(
Style.Builder().fromUri(TestStyles.AMERICANA)
)
.withCameraPosition(
CameraPosition.Builder().target(LatLng(52.090737, 5.121420))
.zoom(15.0).build()
)
)
mapSnapshotter!!.start(this@MapSnapshotterBitMapOverlayActivity)
}
})
}
override fun onStop() {
super.onStop()
mapSnapshotter!!.cancel()
}
@SuppressLint("ClickableViewAccessibility")
override fun onSnapshotReady(snapshot: MapSnapshot) {
mapSnapshot = snapshot
Timber.i("Snapshot ready")
val imageView = findViewById(R.id.snapshot_image) as ImageView
val image = addMarker(snapshot)
imageView.setImageBitmap(image)
imageView.setOnTouchListener { v: View?, event: MotionEvent ->
if (event.action == MotionEvent.ACTION_DOWN) {
val latLng = snapshot.latLngForPixel(PointF(event.x, event.y))
Timber.e("Clicked LatLng is %s", latLng)
return@setOnTouchListener true
}
false
}
}
private fun addMarker(snapshot: MapSnapshot): Bitmap {
val canvas = Canvas(snapshot.bitmap)
val marker =
BitmapFactory.decodeResource(resources, R.drawable.maplibre_marker_icon_default, null)
// Dom toren
val markerLocation = snapshot.pixelForLatLng(LatLng(52.090649433011315, 5.121310651302338))
canvas.drawBitmap(
marker, /* Subtract half of the width so we center the bitmap correctly */
markerLocation.x - marker.width / 2, /* Subtract half of the height so we align the bitmap bottom correctly */
markerLocation.y - marker.height / 2,
null
)
return snapshot.bitmap
}
}
```
## Map Snapshotter with Heatmap Layer
[//]: # ({{ activity_source_note("MapSnapshotterHeatMapActivity.kt") }})
In this example, we demonstrate how to use the `MapSnapshotter` to create a snapshot of a map that includes a heatmap layer. The heatmap represents earthquake data loaded from a GeoJSON source.
[//]: # ()
[//]: # ()
[//]: # ( { width="300" })
[//]: # ( )
First, we create the `MapSnapshotterHeatMapActivity` class, which extends `AppCompatActivity` and implements `MapSnapshotter.SnapshotReadyCallback` to receive the snapshot once it's ready.
```kotlin
class MapSnapshotterHeatMapActivity : AppCompatActivity(), MapSnapshotter.SnapshotReadyCallback
```
In the `onCreate` method, we set up the layout and initialize the `MapSnapshotter` once the layout is ready.
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map_snapshotter_marker)
val container = findViewById(R.id.container)
container.viewTreeObserver
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
container.viewTreeObserver.removeOnGlobalLayoutListener(this)
Timber.i("Starting snapshot")
val builder = Style.Builder().fromUri(TestStyles.AMERICANA)
.withSource(earthquakeSource!!)
.withLayerAbove(heatmapLayer, "water")
mapSnapshotter = MapSnapshotter(
applicationContext,
MapSnapshotter.Options(container.measuredWidth, container.measuredHeight)
.withStyleBuilder(builder)
.withCameraPosition(
CameraPosition.Builder()
.target(LatLng(15.0, (-94).toDouble()))
.zoom(5.0)
.padding(1.0, 1.0, 1.0, 1.0)
.build()
)
)
mapSnapshotter!!.start(this@MapSnapshotterHeatMapActivity)
}
})
}
```
Here, we wait for the layout to be laid out using an `OnGlobalLayoutListener` before initializing the `MapSnapshotter`. We create a `Style.Builder` with a base style (`TestStyles.AMERICANA`), add the earthquake data source, and add the heatmap layer above the "water" layer.
The `heatmapLayer` property defines the `HeatmapLayer` used to visualize the earthquake data.
```kotlin
private val heatmapLayer: HeatmapLayer
get() {
val layer = HeatmapLayer(HEATMAP_LAYER_ID, EARTHQUAKE_SOURCE_ID)
layer.maxZoom = 9f
layer.sourceLayer = HEATMAP_LAYER_SOURCE
layer.setProperties(
PropertyFactory.heatmapColor(
Expression.interpolate(
Expression.linear(), Expression.heatmapDensity(),
Expression.literal(0), Expression.rgba(33, 102, 172, 0),
Expression.literal(0.2), Expression.rgb(103, 169, 207),
Expression.literal(0.4), Expression.rgb(209, 229, 240),
Expression.literal(0.6), Expression.rgb(253, 219, 199),
Expression.literal(0.8), Expression.rgb(239, 138, 98),
Expression.literal(1), Expression.rgb(178, 24, 43)
)
),
PropertyFactory.heatmapWeight(
Expression.interpolate(
Expression.linear(),
Expression.get("mag"),
Expression.stop(0, 0),
Expression.stop(6, 1)
)
),
PropertyFactory.heatmapIntensity(
Expression.interpolate(
Expression.linear(),
Expression.zoom(),
Expression.stop(0, 1),
Expression.stop(9, 3)
)
),
PropertyFactory.heatmapRadius(
Expression.interpolate(
Expression.linear(),
Expression.zoom(),
Expression.stop(0, 2),
Expression.stop(9, 20)
)
),
PropertyFactory.heatmapOpacity(
Expression.interpolate(
Expression.linear(),
Expression.zoom(),
Expression.stop(7, 1),
Expression.stop(9, 0)
)
)
)
return layer
}
```
This code sets up the heatmap layer's properties, such as color ramp, weight, intensity, radius, and opacity, using expressions that interpolate based on data properties and zoom level.
We also define the `earthquakeSource`, which loads data from a GeoJSON file containing earthquake information.
```kotlin
private val earthquakeSource: Source?
get() {
var source: Source? = null
try {
source = GeoJsonSource(EARTHQUAKE_SOURCE_ID, URI(EARTHQUAKE_SOURCE_URL))
} catch (uriSyntaxException: URISyntaxException) {
Timber.e(uriSyntaxException, "That's not a valid URL.")
}
return source
}
```
When the snapshot is ready, the `onSnapshotReady` callback is invoked, where we set the snapshot bitmap to an `ImageView` to display it.
```kotlin
@SuppressLint("ClickableViewAccessibility")
override fun onSnapshotReady(snapshot: MapSnapshot) {
Timber.i("Snapshot ready")
val imageView = findViewById(R.id.snapshot_image)
imageView.setImageBitmap(snapshot.bitmap)
}
```
Finally, we ensure to cancel the snapshotter in the `onStop` method to free up resources.
```kotlin
override fun onStop() {
super.onStop()
mapSnapshotter?.cancel()
}
```
## Map Snapshotter with Expression
[//]: # ({{ activity_source_note("MapSnapshotterWithinExpression.kt") }})
In this example the map on top is a live while the map on the bottom is a snapshot that is updated as you pan the map. We style of the snapshot is modified: using a [within](https://maplibre.org/maplibre-style-spec/expressions/#within) expression only POIs within a certain distance to a line is shown. A highlight for this area is added to the map as are various points.
[//]: # ()
[//]: # ()
[//]: # ( { width="300" })
[//]: # ( )
```kotlin title="MapSnapshotterWithinExpression.kt"
/**
* An Activity that showcases the use of MapSnapshotter with 'within' expression
*/
class MapSnapshotterWithinExpression : AppCompatActivity() {
private lateinit var binding: ActivityMapsnapshotterWithinExpressionBinding
private lateinit var mapMetricsMap: MapMetricsMap
private lateinit var snapshotter: MapSnapshotter
private var snapshotInProgress = false
private val cameraListener = object : MapView.OnCameraDidChangeListener {
override fun onCameraDidChange(animated: Boolean) {
if (!snapshotInProgress) {
snapshotInProgress = true
snapshotter.setCameraPosition(mapMetricsMap.cameraPosition)
snapshotter.start(object : MapSnapshotter.SnapshotReadyCallback {
override fun onSnapshotReady(snapshot: MapSnapshot) {
binding.imageView.setImageBitmap(snapshot.bitmap)
snapshotInProgress = false
}
})
}
}
}
private val snapshotterObserver = object : MapSnapshotter.Observer {
override fun onStyleImageMissing(imageName: String) {
}
override fun onDidFinishLoadingStyle() {
// Show only POI labels inside geometry using within expression
(snapshotter.getLayer("poi-label") as SymbolLayer).setFilter(
within(
bufferLineStringGeometry()
)
)
// Hide other types of labels to highlight POI labels
(snapshotter.getLayer("road-label") as SymbolLayer).setProperties(visibility(NONE))
(snapshotter.getLayer("transit-label") as SymbolLayer).setProperties(visibility(NONE))
(snapshotter.getLayer("road-number-shield") as SymbolLayer).setProperties(visibility(NONE))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMapsnapshotterWithinExpressionBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.mapView.onCreate(savedInstanceState)
binding.mapView.getMapAsync { map ->
mapMetricsMap = map
// Setup camera position above Georgetown
mapMetricsMap.cameraPosition = CameraPosition.Builder().target(LatLng(38.90628988399711, -77.06574689337494)).zoom(15.5).build()
// Wait for the map to become idle before manipulating the style and camera of the map
binding.mapView.addOnDidBecomeIdleListener(object : MapView.OnDidBecomeIdleListener {
override fun onDidBecomeIdle() {
mapMetricsMap.easeCamera(
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().zoom(16.0).target(LatLng(38.905156245642814, -77.06535338052844)).bearing(80.68015859462369).tilt(55.0).build()
),
1000
)
binding.mapView.removeOnDidBecomeIdleListener(this)
}
})
// Load mapbox streets and add lines and circles
setupStyle()
}
}
private fun setupStyle() {
// Assume the route is represented by an array of coordinates.
val coordinates = listOf(
Point.fromLngLat(-77.06866264343262, 38.90506061276737),
Point.fromLngLat(-77.06283688545227, 38.905194197410545),
Point.fromLngLat(-77.06285834312439, 38.906429843444094),
Point.fromLngLat(-77.0630407333374, 38.90680554236621)
)
// Setup style with additional layers,
// using streets as a base style
mapMetricsMap.setStyle(
Style.Builder().fromUri(getPredefinedStyleWithFallback("Streets"))
) {
binding.mapView.addOnCameraDidChangeListener(cameraListener)
}
val options = MapSnapshotter.Options(binding.imageView.measuredWidth / 2, binding.imageView.measuredHeight / 2)
.withCameraPosition(mapMetricsMap.cameraPosition)
.withPixelRatio(2.0f).withStyleBuilder(
Style.Builder().fromUri(getPredefinedStyleWithFallback("Streets")).withSources(
GeoJsonSource(
POINT_ID,
LineString.fromLngLats(coordinates)
),
GeoJsonSource(
FILL_ID,
FeatureCollection.fromFeature(
Feature.fromGeometry(bufferLineStringGeometry())
),
GeoJsonOptions().withBuffer(0).withTolerance(0.0f)
)
).withLayerBelow(
LineLayer(LINE_ID, POINT_ID).withProperties(
lineWidth(7.5f),
lineColor(Color.LTGRAY)
),
"poi-label"
).withLayerBelow(
CircleLayer(POINT_ID, POINT_ID).withProperties(
circleRadius(7.5f),
circleColor(Color.DKGRAY),
circleOpacity(0.75f)
),
"poi-label"
).withLayerBelow(
FillLayer(FILL_ID, FILL_ID).withProperties(
fillOpacity(0.12f),
fillColor(Color.YELLOW)
),
LINE_ID
)
)
snapshotter = MapSnapshotter(this, options)
snapshotter.setObserver(snapshotterObserver)
}
override fun onStart() {
super.onStart()
binding.mapView.onStart()
}
override fun onResume() {
super.onResume()
binding.mapView.onResume()
}
override fun onPause() {
super.onPause()
binding.mapView.onPause()
}
override fun onStop() {
super.onStop()
binding.mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
binding.mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
binding.mapView.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle, outPersistentState: PersistableBundle) {
super.onSaveInstanceState(outState, outPersistentState)
binding.mapView.onSaveInstanceState(outState)
}
private fun bufferLineStringGeometry(): Polygon {
// TODO replace static data by Turf#Buffer: mapbox-java/issues/987
return FeatureCollection.fromJson(
"""
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-77.06867337226866,
38.90467655551809
],
[
-77.06233263015747,
38.90479344272695
],
[
-77.06234335899353,
38.906463238984344
],
[
-77.06290125846863,
38.907206285691615
],
[
-77.06364154815674,
38.90684728656818
],
[
-77.06326603889465,
38.90637140121084
],
[
-77.06321239471436,
38.905561553883246
],
[
-77.0691454410553,
38.905436318935635
],
[
-77.06912398338318,
38.90466820642439
],
[
-77.06867337226866,
38.90467655551809
]
]
]
}
}
]
}
""".trimIndent()
).features()!![0].geometry() as Polygon
}
companion object {
const val POINT_ID = "point"
const val FILL_ID = "fill"
const val LINE_ID = "line"
}
}
```
---
# Animated Image Source
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/animated-image-source
# Animated Image Source
[//]: # ({{ activity_source_note("AnimatedImageSourceActivity.kt") }})
In this example we will see how we can animate an image source. This is the MapMetrics Native equivalent of [this MapMetrics GL JS example](https://maplibre.org/maplibre-gl-js/docs/examples/animate-images/).
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( {{ openmaptiles_caption() }})
[//]: # ( )
We set up an [image source](https://maplibre.org/maplibre-style-spec/sources/#image) in a particular quad. Then we kick of a runnable that periodically updates the image source.
```kotlin title="Creating the image source"
val quad = LatLngQuad(
LatLng(46.437, -80.425),
LatLng(46.437, -71.516),
LatLng(37.936, -71.516),
LatLng(37.936, -80.425)
)
val imageSource = ImageSource(ID_IMAGE_SOURCE, quad, R.drawable.southeast_radar_0)
val layer = RasterLayer(ID_IMAGE_LAYER, ID_IMAGE_SOURCE)
map.setStyle(
Style.Builder()
.fromUri(TestStyles.AMERICANA)
.withSource(imageSource)
.withLayer(layer)
) { style: Style? ->
runnable = RefreshImageRunnable(imageSource, handler)
runnable?.let {
handler.postDelayed(it, 100)
}
}
```
```kotlin title="Updating the image source"
imageSource.setImage(drawables[drawableIndex++]!!)
if (drawableIndex > 3) {
drawableIndex = 0
}
handler.postDelayed(this, 1000)
```
---
# Animated SymbolLayer
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/animated-symbol-layer
# Animated SymbolLayer
[//]: # ({{ activity_source_note("AnimatedSymbolLayerActivity.kt") }})
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( {{ openmaptiles_caption() }})
[//]: # ( )
Notice that there are (red) cars randomly moving around, and a (yellow) taxi that is always heading to the passenger (indicated by the M symbol), which upon arrival hops to a different location again. We will focus on the passanger and the taxi, because the cars randomly moving around follow a similar pattern.
In a real application you would of course retrieve the locations from some sort of external API, but for the purposes of this example a random latitude longtitude pair within bounds of the currently visible screen will do.
```kotlin title="Getter method to get a random location on the screen"
private val latLngInBounds: LatLng
get() {
val bounds = mapMetricsMap.projection.visibleRegion.latLngBounds
val generator = Random()
val randomLat = bounds.latitudeSouth + generator.nextDouble() * (bounds.latitudeNorth - bounds.latitudeSouth)
val randomLon = bounds.longitudeWest + generator.nextDouble() * (bounds.longitudeEast - bounds.longitudeWest)
return LatLng(randomLat, randomLon)
}
```
```kotlin title="Adding a passenger at a random location (on screen)"
private fun addPassenger(style: Style) {
passenger = latLngInBounds
val featureCollection = FeatureCollection.fromFeatures(
arrayOf(
Feature.fromGeometry(
Point.fromLngLat(
passenger!!.longitude,
passenger!!.latitude
)
)
)
)
style.addImage(
PASSENGER,
ResourcesCompat.getDrawable(resources, R.drawable.icon_burned, theme)!!
)
val geoJsonSource = GeoJsonSource(PASSENGER_SOURCE, featureCollection)
style.addSource(geoJsonSource)
val symbolLayer = SymbolLayer(PASSENGER_LAYER, PASSENGER_SOURCE)
symbolLayer.withProperties(
PropertyFactory.iconImage(PASSENGER),
PropertyFactory.iconIgnorePlacement(true),
PropertyFactory.iconAllowOverlap(true)
)
style.addLayerBelow(symbolLayer, RANDOM_CAR_LAYER)
}
```
Adding the taxi on screen is done very similarly.
```kotlin title="Adding the taxi with bearing"
private fun addTaxi(style: Style) {
val latLng = latLngInBounds
val properties = JsonObject()
properties.addProperty(PROPERTY_BEARING, Car.getBearing(latLng, passenger))
val feature = Feature.fromGeometry(
Point.fromLngLat(
latLng.longitude,
latLng.latitude
),
properties
)
val featureCollection = FeatureCollection.fromFeatures(arrayOf(feature))
taxi = Car(feature, passenger, duration)
style.addImage(
TAXI,
(ResourcesCompat.getDrawable(resources, R.drawable.ic_taxi_top, theme) as BitmapDrawable).bitmap
)
taxiSource = GeoJsonSource(TAXI_SOURCE, featureCollection)
style.addSource(taxiSource!!)
val symbolLayer = SymbolLayer(TAXI_LAYER, TAXI_SOURCE)
symbolLayer.withProperties(
PropertyFactory.iconImage(TAXI),
PropertyFactory.iconRotate(Expression.get(PROPERTY_BEARING)),
PropertyFactory.iconAllowOverlap(true),
PropertyFactory.iconIgnorePlacement(true)
)
style.addLayer(symbolLayer)
}
```
For animating the taxi we use a [`ValueAnimator`](https://developer.android.com/reference/android/animation/ValueAnimator).
```kotlin title="Animate the taxi driving towards the passenger"
private fun animateTaxi(style: Style) {
val valueAnimator = ValueAnimator.ofObject(LatLngEvaluator(), taxi!!.current, taxi!!.next)
valueAnimator.addUpdateListener(object : AnimatorUpdateListener {
private var latLng: LatLng? = null
override fun onAnimationUpdate(animation: ValueAnimator) {
latLng = animation.animatedValue as LatLng
taxi!!.current = latLng
updateTaxiSource()
}
})
valueAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
updatePassenger(style)
animateTaxi(style)
}
})
valueAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
super.onAnimationStart(animation)
taxi!!.feature.properties()!!
.addProperty("bearing", Car.getBearing(taxi!!.current, taxi!!.next))
}
})
valueAnimator.duration = (7 * taxi!!.current!!.distanceTo(taxi!!.next!!)).toLong()
valueAnimator.interpolator = AccelerateDecelerateInterpolator()
valueAnimator.start()
animators.add(valueAnimator)
}
```
---
# Building Layer
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/building-layer
# Building Layer
[//]: # ({{ activity_source_note("BuildingFillExtrusionActivity.kt") }})
In this example will show how to add a [Fill Extrusion](https://maplibre.org/maplibre-style-spec/layers/#fill-extrusion) layer to a style.
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( {{ openmaptiles_caption() }})
[//]: # ( )
We use the [OpenFreeMap Bright](https://openfreemap.org/quick_start/) style which, unlike OpenFreeMap Libery, does not have a fill extrusion layer by default. However, if you inspect this style with [Maputnik](https://maplibre.org/maputnik) you will find that the multipolygons in the `building` layer (of the `openfreemap` source) each have `render_min_height` and `render_height` properties.
```kotlin title="Setting up the fill extrusion layer"
val fillExtrusionLayer = FillExtrusionLayer("building-3d", "openmaptiles")
fillExtrusionLayer.sourceLayer = "building"
fillExtrusionLayer.setFilter(
Expression.all(
Expression.has("render_height"),
Expression.has("render_min_height")
)
)
fillExtrusionLayer.minZoom = 15f
fillExtrusionLayer.setProperties(
PropertyFactory.fillExtrusionColor(Color.LTGRAY),
PropertyFactory.fillExtrusionHeight(Expression.get("render_height")),
PropertyFactory.fillExtrusionBase(Expression.get("render_min_height")),
PropertyFactory.fillExtrusionOpacity(0.9f)
)
style.addLayer(fillExtrusionLayer)
```
```kotlin title="Changing the light direction"
isInitPosition = !isInitPosition
if (isInitPosition) {
light!!.position = Position(1.5f, 90f, 80f)
} else {
light!!.position = Position(1.15f, 210f, 30f)
}
```
```kotlin title="Changing the light color"
isRedColor = !isRedColor
light!!.setColor(ColorUtils.colorToRgbaString(if (isRedColor) Color.RED else Color.BLUE))
```
---
# Circle Layer (with Clustering)
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/circle-layer
# Circle Layer (with Clustering)
[//]: # ({{ activity_source_note("CircleLayerActivity.kt") }})
In this example we will add a circle layer for a GeoJSON source. We also show how you can use the [cluster](https://maplibre.org/maplibre-style-spec/sources/#cluster) property of a GeoJSON source.
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
Create a `GeoJsonSource` instance, pass a unique identifier for the source and the URL where the GeoJSON is available. Next add the source to the style.
```kotlin title="Setting up the GeoJSON source"
try {
source = GeoJsonSource(SOURCE_ID, URI(URL_BUS_ROUTES))
} catch (exception: URISyntaxException) {
Timber.e(exception, "That's not an url... ")
}
style.addSource(source!!)
```
Now you can create a `CircleLayer`, pass it a unique identifier for the layer and the source identifier of the GeoJSON source just created. You can use a `PropertyFactory` to pass [circle layer properties](https://maplibre.org/maplibre-style-spec/layers/#circle). Lastly add the layer to your style.
```kotlin title="Create circle layer a small orange circle for each bus stop"
layer = CircleLayer(LAYER_ID, SOURCE_ID)
layer!!.setProperties(
PropertyFactory.circleColor(Color.parseColor("#FF9800")),
PropertyFactory.circleRadius(2.0f)
)
style.addLayer(layer!!)
```
## Clustering
Next we will show you how you can use clustering. Create a `GeoJsonSource` as before, but with some additional options to enable clustering.
```kotlin title="Setting up the clustered GeoJSON source"
style.addSource(
GeoJsonSource(
SOURCE_ID_CLUSTER,
URI(URL_BUS_ROUTES),
GeoJsonOptions()
.withCluster(true)
.withClusterMaxZoom(14)
.withClusterRadius(50)
)
)
```
When enabling clustering some [special attributes](https://maplibre.org/maplibre-style-spec/sources/#cluster) will be available to the points in the newly created layer. One is `cluster`, which is true if the point indicates a cluster. We want to show a bus stop for points that are **not** clustered.
```kotlin title="Add a symbol layers for points that are not clustered"
val unclustered = SymbolLayer("unclustered-points", SOURCE_ID_CLUSTER)
unclustered.setProperties(
PropertyFactory.iconImage("bus-icon"),
)
unclustered.setFilter(
Expression.neq(Expression.get("cluster"), true)
)
style.addLayer(unclustered)
```
Next we define which point amounts correspond to which colors. More than 150 points will get a red circle, clusters with 21-150 points will be green and clusters with 20 or less points will be green.
```kotlin title="Define different colors for different point amounts"
val layers = arrayOf(
150 to ResourcesCompat.getColor(
resources,
R.color.redAccent,
theme
),
20 to ResourcesCompat.getColor(resources, R.color.greenAccent, theme),
0 to ResourcesCompat.getColor(
resources,
R.color.blueAccent,
theme
)
)
```
Lastly we iterate over the array of `Pair`s to create a `CircleLayer` for each element.
```kotlin title="Add different circle layers for clusters of different point amounts"
for (i in layers.indices) {
// Add some nice circles
val circles = CircleLayer("cluster-$i", SOURCE_ID_CLUSTER)
circles.setProperties(
PropertyFactory.circleColor(layers[i].second),
PropertyFactory.circleRadius(18f)
)
val pointCount = Expression.toNumber(Expression.get("point_count"))
circles.setFilter(
if (i == 0) {
Expression.all(
Expression.has("point_count"),
Expression.gte(
pointCount,
Expression.literal(layers[i].first)
)
)
} else {
Expression.all(
Expression.has("point_count"),
Expression.gt(
pointCount,
Expression.literal(layers[i].first)
),
Expression.lt(
pointCount,
Expression.literal(layers[i - 1].first)
)
)
}
)
style.addLayer(circles)
}
```
---
# Add Custom Sprite
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/custom-sprite
# Add Custom Sprite
[//]: # ({{ activity_source_note("CustomSpriteActivity.kt") }})
This example showcases adding a sprite image and using it in a Symbol Layer.
```kotlin
// Add an icon to reference later
style.addImage(
CUSTOM_ICON,
BitmapFactory.decodeResource(
resources,
R.drawable.ic_car_top
)
)
// Add a source with a geojson point
point = Point.fromLngLat(13.400972, 52.519003)
source = GeoJsonSource(
"point",
FeatureCollection.fromFeatures(arrayOf(Feature.fromGeometry(point)))
)
mapMetricsMap.style!!.addSource(source!!)
// Add a symbol layer that references that point source
layer = SymbolLayer("layer", "point")
layer.setProperties( // Set the id of the sprite to use
PropertyFactory.iconImage(CUSTOM_ICON),
PropertyFactory.iconAllowOverlap(true),
PropertyFactory.iconIgnorePlacement(true)
)
// lets add a circle below labels!
mapMetricsMap.style!!.addLayerBelow(layer, "water-intermittent")
fab.setImageResource(R.drawable.ic_directions_car_black)
```
---
# Custom Map Styling
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/custom-styling
# Custom Map Styling
This tutorial shows how to customize the appearance of your MapMetrics Android map — switching styles, modifying layers at runtime, and applying light/dark themes.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Switch Between Map Styles
Let users choose a map style at runtime:
```kotlin
import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
class CustomStylingActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
private val styles = mapOf(
"Streets" to "https://gateway.mapmetrics.org/styles/STREETS_STYLE_ID?token=YOUR_API_KEY",
"Dark" to "https://gateway.mapmetrics.org/styles/DARK_STYLE_ID?token=YOUR_API_KEY",
"Satellite" to "https://gateway.mapmetrics.org/styles/SATELLITE_STYLE_ID?token=YOUR_API_KEY",
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_custom_styling)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
// Set initial style
setMapStyle("Streets")
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8566, 2.3522))
.zoom(12.0)
.build()
// Style switch buttons
findViewById(R.id.btnStreets).setOnClickListener { setMapStyle("Streets") }
findViewById(R.id.btnDark).setOnClickListener { setMapStyle("Dark") }
findViewById(R.id.btnSatellite).setOnClickListener { setMapStyle("Satellite") }
}
}
private fun setMapStyle(name: String) {
val url = styles[name] ?: return
// Save current camera position
val currentCamera = map.cameraPosition
map.setStyle(Style.Builder().fromUri(url)) { style ->
// Restore camera after style loads
map.cameraPosition = currentCamera
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Modify Existing Style Layers
Change layer properties of the current style at runtime:
```kotlin
private fun modifyStyleLayers(style: Style) {
// Change water color
val waterLayer = style.getLayerAs("water")
waterLayer?.setProperties(
org.maplibre.android.style.layers.PropertyFactory.fillColor(
android.graphics.Color.parseColor("#1A73E8")
)
)
// Change building color
val buildingLayer = style.getLayerAs("building")
buildingLayer?.setProperties(
org.maplibre.android.style.layers.PropertyFactory.fillColor(
android.graphics.Color.parseColor("#E8E0D8")
)
)
// Hide POI labels
val poiLabels = style.getLayer("poi-label")
poiLabels?.setProperties(
org.maplibre.android.style.layers.PropertyFactory.visibility(
org.maplibre.android.style.layers.Property.NONE
)
)
}
```
## Show/Hide Layer Categories
Toggle entire categories of map features:
```kotlin
import android.widget.ToggleButton
import org.maplibre.android.style.layers.Property
import org.maplibre.android.style.layers.PropertyFactory.visibility
private fun setupLayerToggles(style: Style) {
// Toggle roads
findViewById(R.id.btnRoads).setOnCheckedChangeListener { _, show ->
toggleLayersByPrefix(style, "road", show)
}
// Toggle buildings
findViewById(R.id.btnBuildings).setOnCheckedChangeListener { _, show ->
toggleLayersByPrefix(style, "building", show)
}
// Toggle labels
findViewById(R.id.btnLabels).setOnCheckedChangeListener { _, show ->
toggleLayersByPrefix(style, "label", show)
toggleLayersByPrefix(style, "place", show)
}
}
private fun toggleLayersByPrefix(style: Style, prefix: String, visible: Boolean) {
for (layer in style.layers) {
if (layer.id.contains(prefix, ignoreCase = true)) {
layer.setProperties(
visibility(if (visible) Property.VISIBLE else Property.NONE)
)
}
}
}
```
## Load Style from JSON String
Load a custom style from a local JSON string or asset:
```kotlin
// From assets file
private fun loadLocalStyle() {
val json = assets.open("styles/custom-style.json")
.bufferedReader()
.readText()
map.setStyle(Style.Builder().fromJson(json)) { style ->
// Style loaded from local JSON
}
}
```
## List All Layers in Current Style
Useful for debugging and discovering layer IDs:
```kotlin
private fun listLayers(style: Style) {
for (layer in style.layers) {
android.util.Log.d("MapStyle", "Layer: ${layer.id} (${layer.javaClass.simpleName})")
}
}
```
## Next Steps
- [Data-Driven Styling](./data-driven-style) — Style by data properties
- [Building Layer](./building-layer) — 3D building extrusions
- [Custom Sprite](./custom-sprite) — Custom icon images
---
**Tip**: When switching styles, the camera position resets to the new style's default. Always save `map.cameraPosition` before calling `setStyle()` and restore it in the callback, as shown above.
---
# Data Driven Style
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/data-driven-style
# Data Driven Style
[//]: # ({{ activity_source_note("DataDrivenStyleActivity.kt") }})
In this example we will look at various types of data-driven styling.
The examples with 'Source' in the title apply data-driven styling the [parks of Amsterdam](https://github.com/maplibre/maplibre-native/blob/main/platform/android/MapLibreAndroidTestApp/src/main/res/raw/amsterdam.geojson). Those examples often are based on the somewhat arbitrary `stroke-width` property part of the GeoJSON features. These examples are therefore most interesting to learn about the Kotlin API that can be used for data-driven styling.
Refer to the [MapMetrics Style Spec](https://maplibre.org/maplibre-style-spec/) for more information about [expressions](https://maplibre.org/maplibre-style-spec/expressions/) such as [`interpolate`](https://maplibre.org/maplibre-style-spec/expressions/#interpolate) and [`step`](https://maplibre.org/maplibre-style-spec/expressions/#step).
## Exponential Zoom Function
```kotlin
layer.setProperties(
PropertyFactory.fillColor(
Expression.interpolate(
Expression.exponential(0.5f),
Expression.zoom(),
Expression.stop(1, Expression.color(Color.RED)),
Expression.stop(5, Expression.color(Color.BLUE)),
Expression.stop(10, Expression.color(Color.GREEN))
)
)
)
```
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
## Interval Zoom Function
```kotlin
layer.setProperties(
PropertyFactory.fillColor(
Expression.step(
Expression.zoom(),
Expression.rgba(0.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(1, Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f)),
Expression.stop(5, Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f)),
Expression.stop(10, Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f))
)
)
)
```
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
```json title="Equivalent JSON"
["step",["zoom"],["rgba",0.0,255.0,255.0,1.0],1.0,["rgba",255.0,0.0,0.0,1.0],5.0,["rgba",0.0,0.0,255.0,1.0],10.0,["rgba",0.0,255.0,0.0,1.0]]
```
## Exponential Source Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillColor(
Expression.interpolate(
Expression.exponential(0.5f),
Expression.get("stroke-width"),
Expression.stop(1f, Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f)),
Expression.stop(5f, Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f)),
Expression.stop(10f, Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f))
)
)
)
```
## Categorical Source Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillColor(
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.literal("Jordaan"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.literal("Prinseneiland"),
Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f),
Expression.rgba(0.0f, 255.0f, 255.0f, 1.0f)
)
)
)
```
## Identity Source Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillOpacity(
Expression.get("fill-opacity")
)
)
```
## Interval Source Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillColor(
Expression.step(
Expression.get("stroke-width"),
Expression.rgba(0.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(1f, Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f)),
Expression.stop(2f, Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f)),
Expression.stop(3f, Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f))
)
)
)
```
## Composite Exponential Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillColor(
Expression.interpolate(
Expression.exponential(1f),
Expression.zoom(),
Expression.stop(
12,
Expression.step(
Expression.get("stroke-width"),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(1f, Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f)),
Expression.stop(2f, Expression.rgba(0.0f, 0.0f, 0.0f, 1.0f)),
Expression.stop(3f, Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f))
)
),
Expression.stop(
15,
Expression.step(
Expression.get("stroke-width"),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(1f, Expression.rgba(255.0f, 255.0f, 0.0f, 1.0f)),
Expression.stop(2f, Expression.rgba(211.0f, 211.0f, 211.0f, 1.0f)),
Expression.stop(3f, Expression.rgba(0.0f, 255.0f, 255.0f, 1.0f))
)
),
Expression.stop(
18,
Expression.step(
Expression.get("stroke-width"),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(1f, Expression.rgba(0.0f, 0.0f, 0.0f, 1.0f)),
Expression.stop(2f, Expression.rgba(128.0f, 128.0f, 128.0f, 1.0f)),
Expression.stop(3f, Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f))
)
)
)
)
)
```
## Identity Source Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillOpacity(
Expression.get("fill-opacity")
)
)
```
## Composite Interval Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillColor(
Expression.step(
Expression.zoom(),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(
7f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
8f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
9f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
10f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
11f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
12f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
13f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
14f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.literal("Jordaan"),
Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f),
Expression.literal("PrinsenEiland"),
Expression.rgba(0.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
15f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
16f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
17f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
18f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.literal("Jordaan"),
Expression.rgba(0.0f, 255.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
19f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
20f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
21f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
22f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
)
)
)
)
```
## Composite Categorical Function
```kotlin
val layer = mapMetricsMap.style!!.getLayerAs(AMSTERDAM_PARKS_LAYER)!!
layer.setProperties(
PropertyFactory.fillColor(
Expression.step(
Expression.zoom(),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f),
Expression.stop(
7f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
8f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
9f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
10f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
11f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
12f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
13f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
14f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.literal("Jordaan"),
Expression.rgba(0.0f, 255.0f, 0.0f, 1.0f),
Expression.literal("PrinsenEiland"),
Expression.rgba(0.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
15f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
16f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
17f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
18f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.literal("Jordaan"),
Expression.rgba(0.0f, 255.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
19f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
20f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
21f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(255.0f, 0.0f, 0.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
),
Expression.stop(
22f,
Expression.match(
Expression.get("name"),
Expression.literal("Westerpark"),
Expression.rgba(0.0f, 0.0f, 255.0f, 1.0f),
Expression.rgba(255.0f, 255.0f, 255.0f, 1.0f)
)
)
)
)
)
```
---
# Distance Expression
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/distance-expression
# Distance Expression
[//]: # ({{ activity_source_note("DistanceExpressionActivity.kt") }})
This example shows how you can modify a style to only show certain features within a certain distance to a point. For this the [distance expression](https://maplibre.org/maplibre-style-spec/expressions/#within) is used.
[//]: # ()
[//]: # (  }}){ width="400" })
[//]: # ( {{ openmaptiles_caption() }})
[//]: # ( )
First we add a [fill layer](https://maplibre.org/maplibre-style-spec/layers/#fill) and a GeoJSON source.
```kotlin
val center = Point.fromLngLat(lon, lat)
val circle = TurfTransformation.circle(center, 150.0, TurfConstants.UNIT_METRES)
mapMetricsMap.setStyle(
Style.Builder()
.fromUri(TestStyles.OPENFREEMAP_BRIGHT)
.withSources(
GeoJsonSource(
POINT_ID,
Point.fromLngLat(lon, lat)
),
GeoJsonSource(CIRCLE_ID, circle)
)
.withLayerBelow(
FillLayer(CIRCLE_ID, CIRCLE_ID)
.withProperties(
fillOpacity(0.5f),
fillColor(Color.parseColor("#3bb2d0"))
),
"poi"
)
)
```
Next, we only show features from symbol layers that are less than a certain distance from the point. All symbol layers whose identifier does not start with `poi` are completely hidden.
```kotlin
for (layer in style.layers) {
if (layer is SymbolLayer) {
if (layer.id.startsWith("poi")) {
layer.setFilter(lt(
distance(
Point.fromLngLat(lon, lat)
),
150
))
} else {
layer.setProperties(visibility(NONE))
}
}
}
```
---
# Draggable Marker
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/draggable-marker
# Draggable Marker
[//]: # ({{ activity_source_note("DraggableMarkerActivity.kt") }})
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
## Adding a marker on tap
```kotlin title="Adding a tap listener to the map to add a marker on tap"
mapMetricsMap.addOnMapClickListener {
// Adding a marker on map click
val features = mapMetricsMap.queryRenderedSymbols(it, layerId)
if (features.isEmpty()) {
addMarker(it)
} else {
// Displaying marker info on marker click
Snackbar.make(
mapView,
"Marker's position: %.4f, %.4f".format(it.latitude, it.longitude),
Snackbar.LENGTH_LONG
)
.show()
}
false
}
```
## Allowing markers to be dragged
This is slightly more involved, as we implement it by implementing a `DraggableSymbolsManager` helper class.
This class is initialized and we pass a few callbacks when when markers are start or end being dragged.
```kotlin
draggableSymbolsManager = DraggableSymbolsManager(
mapView,
mapMetricsMap,
featureCollection,
source,
layerId,
actionBarHeight,
0
)
// Adding symbol drag listeners
draggableSymbolsManager?.addOnSymbolDragListener(object : DraggableSymbolsManager.OnSymbolDragListener {
override fun onSymbolDragStarted(id: String) {
binding.draggedMarkerPositionTv.visibility = View.VISIBLE
Snackbar.make(
mapView,
"Marker drag started (%s)".format(id),
Snackbar.LENGTH_SHORT
)
.show()
}
override fun onSymbolDrag(id: String) {
val point = featureCollection.features()?.find {
it.id() == id
}?.geometry() as Point
binding.draggedMarkerPositionTv.text = "Dragged marker's position: %.4f, %.4f".format(point.latitude(), point.longitude())
}
override fun onSymbolDragFinished(id: String) {
binding.draggedMarkerPositionTv.visibility = View.GONE
Snackbar.make(
mapView,
"Marker drag finished (%s)".format(id),
Snackbar.LENGTH_SHORT
)
.show()
}
})
```
The implementation of `DraggableSymbolsManager` follows. In its initializer we define a handler for when a user long taps on a marker. This then starts dragging that marker. It does this by temporarily suspending all other gestures.
We create a custom implementation of `MoveGestureDetector.OnMoveGestureListener` and pass this to an instance of `AndroidGesturesManager` linked to the map view.
See [mapmetrics-gestures-android](https://github.com/MapMetrics/MapMetrics-gestures-android) for the implementation details of the gestures library used by MapMetrics Android.
```kotlin
/**
* A manager, that allows dragging symbols after they are long clicked.
* Since this manager lives outside of the Maps SDK, we need to intercept parent's motion events
* and pass them with [DraggableSymbolsManager.onParentTouchEvent].
* If we were to try and overwrite [AppCompatActivity.onTouchEvent], those events would've been
* consumed by the map.
*
* We also need to setup a [DraggableSymbolsManager.androidGesturesManager],
* because after disabling map's gestures and starting the drag process
* we still need to listen for move gesture events which map won't be able to provide anymore.
*
* @param mapView the mapView
* @param mapMetricsMap the mapMetricsMap
* @param symbolsCollection the collection that contains all the symbols that we want to be draggable
* @param symbolsSource the source that contains the [symbolsCollection]
* @param symbolsLayerId the ID of the layer that the symbols are displayed on
* @param touchAreaShiftX X-axis padding that is applied to the parent's window motion event,
* as that window can be bigger than the [mapView].
* @param touchAreaShiftY Y-axis padding that is applied to the parent's window motion event,
* as that window can be bigger than the [mapView].
* @param touchAreaMaxX maximum value of X-axis motion event
* @param touchAreaMaxY maximum value of Y-axis motion event
*/
class DraggableSymbolsManager(
mapView: MapView,
private val mapMetricsMap: MapMetricsMap,
private val symbolsCollection: FeatureCollection,
private val symbolsSource: GeoJsonSource,
private val symbolsLayerId: String,
private val touchAreaShiftY: Int = 0,
private val touchAreaShiftX: Int = 0,
private val touchAreaMaxX: Int = mapView.width,
private val touchAreaMaxY: Int = mapView.height
) {
private val androidGesturesManager: AndroidGesturesManager = AndroidGesturesManager(mapView.context, false)
private var draggedSymbolId: String? = null
private val onSymbolDragListeners: MutableList = mutableListOf()
init {
mapMetricsMap.addOnMapLongClickListener {
// Starting the drag process on long click
draggedSymbolId = mapMetricsMap.queryRenderedSymbols(it, symbolsLayerId).firstOrNull()?.id()?.also { id ->
mapMetricsMap.uiSettings.setAllGesturesEnabled(false)
mapMetricsMap.gesturesManager.moveGestureDetector.interrupt()
notifyOnSymbolDragListeners {
onSymbolDragStarted(id)
}
}
false
}
androidGesturesManager.setMoveGestureListener(MyMoveGestureListener())
}
inner class MyMoveGestureListener : MoveGestureDetector.OnMoveGestureListener {
override fun onMoveBegin(detector: MoveGestureDetector): Boolean {
return true
}
override fun onMove(detector: MoveGestureDetector, distanceX: Float, distanceY: Float): Boolean {
if (detector.pointersCount > 1) {
// Stopping the drag when we don't work with a simple, on-pointer move anymore
stopDragging()
return true
}
// Updating symbol's position
draggedSymbolId?.also { draggedSymbolId ->
val moveObject = detector.getMoveObject(0)
val point = PointF(moveObject.currentX - touchAreaShiftX, moveObject.currentY - touchAreaShiftY)
if (point.x < 0 || point.y < 0 || point.x > touchAreaMaxX || point.y > touchAreaMaxY) {
stopDragging()
}
val latLng = mapMetricsMap.projection.fromScreenLocation(point)
symbolsCollection.features()?.indexOfFirst {
it.id() == draggedSymbolId
}?.also { index ->
symbolsCollection.features()?.get(index)?.also { oldFeature ->
val properties = oldFeature.properties()
val newFeature = Feature.fromGeometry(
Point.fromLngLat(latLng.longitude, latLng.latitude),
properties,
draggedSymbolId
)
symbolsCollection.features()?.set(index, newFeature)
symbolsSource.setGeoJson(symbolsCollection)
notifyOnSymbolDragListeners {
onSymbolDrag(draggedSymbolId)
}
return true
}
}
}
return false
}
override fun onMoveEnd(detector: MoveGestureDetector, velocityX: Float, velocityY: Float) {
// Stopping the drag when move ends
stopDragging()
}
}
private fun stopDragging() {
mapMetricsMap.uiSettings.setAllGesturesEnabled(true)
draggedSymbolId?.let {
notifyOnSymbolDragListeners {
onSymbolDragFinished(it)
}
}
draggedSymbolId = null
}
fun onParentTouchEvent(ev: MotionEvent?) {
androidGesturesManager.onTouchEvent(ev)
}
private fun notifyOnSymbolDragListeners(action: OnSymbolDragListener.() -> Unit) {
onSymbolDragListeners.forEach(action)
}
fun addOnSymbolDragListener(listener: OnSymbolDragListener) {
onSymbolDragListeners.add(listener)
}
fun removeOnSymbolDragListener(listener: OnSymbolDragListener) {
onSymbolDragListeners.remove(listener)
}
interface OnSymbolDragListener {
fun onSymbolDragStarted(id: String)
fun onSymbolDrag(id: String)
fun onSymbolDragFinished(id: String)
}
}
```
---
# 3D Fill Extrusion Buildings
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/fill-extrusion-3d
# 3D Fill Extrusion Buildings
This tutorial shows how to add 3D extruded buildings and shapes to your MapMetrics Android map using `FillExtrusionLayer`.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## 3D Buildings from Style Data
Extrude building footprints from the map style's built-in data:
```kotlin
import android.graphics.Color
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.FillExtrusionLayer
import org.maplibre.android.style.layers.PropertyFactory.*
class Extrusion3DActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
add3DBuildings(style)
}
}
}
private fun add3DBuildings(style: Style) {
// Add 3D building extrusion layer
val extrusionLayer = FillExtrusionLayer("3d-buildings", "composite")
extrusionLayer.setSourceLayer("building")
extrusionLayer.minZoom = 14f
extrusionLayer.setProperties(
// Height from data property
fillExtrusionHeight(
interpolate(
linear(), zoom(),
stop(14, literal(0)),
stop(14.05, get("height"))
)
),
// Base height for multi-part buildings
fillExtrusionBase(
interpolate(
linear(), zoom(),
stop(14, literal(0)),
stop(14.05, get("min_height"))
)
),
// Color
fillExtrusionColor(Color.parseColor("#AAAAAA")),
// Opacity that increases with zoom
fillExtrusionOpacity(
interpolate(
linear(), zoom(),
stop(14, 0.0f),
stop(15, 0.6f)
)
)
)
style.addLayer(extrusionLayer)
// Set 3D perspective camera
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8584, 2.2945))
.zoom(15.5)
.tilt(55.0)
.bearing(-20.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Color Buildings by Height
Apply a height-based color gradient:
```kotlin
private fun addColoredBuildings(style: Style) {
val extrusionLayer = FillExtrusionLayer("3d-buildings", "composite")
extrusionLayer.setSourceLayer("building")
extrusionLayer.minZoom = 14f
extrusionLayer.setProperties(
fillExtrusionHeight(get("height")),
fillExtrusionBase(get("min_height")),
// Color gradient based on height
fillExtrusionColor(
interpolate(
linear(), get("height"),
stop(0, color(Color.parseColor("#2DC4B2"))), // Low: teal
stop(20, color(Color.parseColor("#3BB3C3"))), // Medium
stop(40, color(Color.parseColor("#669EC4"))), // Tall
stop(60, color(Color.parseColor("#8B88B6"))), // Taller
stop(100, color(Color.parseColor("#A2719B"))), // Very tall
stop(200, color(Color.parseColor("#AA5E79"))) // Skyscraper
)
),
fillExtrusionOpacity(0.7f)
)
style.addLayer(extrusionLayer)
}
```
## Custom 3D Shapes from GeoJSON
Extrude custom polygon data:
```kotlin
import com.google.gson.JsonObject
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
private fun addCustomExtrusions(style: Style) {
// Create features with height properties
val features = listOf(
createBuildingFeature(
listOf(
Point.fromLngLat(2.2930, 48.8580),
Point.fromLngLat(2.2945, 48.8580),
Point.fromLngLat(2.2945, 48.8570),
Point.fromLngLat(2.2930, 48.8570),
Point.fromLngLat(2.2930, 48.8580),
),
height = 50.0, name = "Building A", color = "#FF6B35"
),
createBuildingFeature(
listOf(
Point.fromLngLat(2.2950, 48.8580),
Point.fromLngLat(2.2965, 48.8580),
Point.fromLngLat(2.2965, 48.8570),
Point.fromLngLat(2.2950, 48.8570),
Point.fromLngLat(2.2950, 48.8580),
),
height = 80.0, name = "Building B", color = "#4285F4"
),
)
val collection = FeatureCollection.fromFeatures(features)
// Add source
style.addSource(GeoJsonSource("custom-buildings", collection))
// Add extrusion layer
style.addLayer(
FillExtrusionLayer("custom-3d", "custom-buildings")
.withProperties(
fillExtrusionHeight(get("height")),
fillExtrusionBase(literal(0)),
fillExtrusionColor(get("color")),
fillExtrusionOpacity(0.8f)
)
)
}
private fun createBuildingFeature(
points: List,
height: Double,
name: String,
color: String
): Feature {
val properties = JsonObject().apply {
addProperty("height", height)
addProperty("name", name)
addProperty("color", color)
}
val polygon = Polygon.fromLngLats(listOf(points))
return Feature.fromGeometry(polygon, properties)
}
```
## Light and Shadow Settings
The 3D appearance uses the map style's light configuration. Buildings facing the light source appear brighter while the opposite side is shadowed — this happens automatically based on the camera bearing and style light settings.
## Fill Extrusion Properties
| Property | Type | Description |
|----------|------|-------------|
| `fillExtrusionHeight` | Double/Expression | Top height in meters |
| `fillExtrusionBase` | Double/Expression | Base height (for stacked floors) |
| `fillExtrusionColor` | Color/Expression | Fill color |
| `fillExtrusionOpacity` | Float | Transparency (0.0 - 1.0) |
## Next Steps
- [Building Layer](./building-layer) — More building layer examples
- [Set Pitch and Bearing](../camera/set-pitch-bearing) — 3D camera angles
- [Data-Driven Styling](./data-driven-style) — Style by data properties
---
**Tip**: 3D extrusions only look good at tilt angles of 30-60°. At 0° tilt (top-down) they're invisible. Always pair `FillExtrusionLayer` with a tilted camera for the best visual effect.
---
# Filter Map Features by Properties
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/filter-features
# Filter Map Features by Properties
This tutorial shows how to dynamically filter what's visible on your MapMetrics Android map based on data properties — useful for category filtering, search, and interactive data exploration.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Filter by Category
Show/hide features based on a category property:
```kotlin
import android.graphics.Color
import android.os.Bundle
import android.widget.ToggleButton
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
class FilterActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_filter)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addData(style)
setupFilters(style)
}
}
}
private fun addData(style: Style) {
// Sample data with categories
val features = listOf(
createFeature(2.3522, 48.8566, "restaurant", "Le Petit Bistro"),
createFeature(2.3400, 48.8600, "restaurant", "Café de Flore"),
createFeature(2.3376, 48.8606, "museum", "Louvre Museum"),
createFeature(2.3266, 48.8600, "museum", "Musée d'Orsay"),
createFeature(2.3464, 48.8462, "park", "Luxembourg Gardens"),
createFeature(2.3131, 48.8601, "park", "Tuileries Garden"),
createFeature(2.3350, 48.8550, "hotel", "Hôtel Lutetia"),
createFeature(2.3280, 48.8680, "hotel", "Le Meurice"),
)
style.addSource(
GeoJsonSource("places", FeatureCollection.fromFeatures(features))
)
// Color circles by category
style.addLayer(
CircleLayer("places-layer", "places")
.withProperties(
circleRadius(8f),
circleColor(
match(
get("category"),
color(Color.GRAY), // default
stop("restaurant", color(Color.parseColor("#FF6B35"))),
stop("museum", color(Color.parseColor("#4285F4"))),
stop("park", color(Color.parseColor("#34A853"))),
stop("hotel", color(Color.parseColor("#9C27B0")))
)
),
circleStrokeColor(Color.WHITE),
circleStrokeWidth(2f)
)
)
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.857, 2.335))
.zoom(13.0)
.build()
}
private fun createFeature(lng: Double, lat: Double, category: String, name: String): Feature {
val props = JsonObject().apply {
addProperty("category", category)
addProperty("name", name)
}
return Feature.fromGeometry(Point.fromLngLat(lng, lat), props)
}
private fun setupFilters(style: Style) {
val layer = style.getLayer("places-layer") as? CircleLayer
val categories = mapOf(
R.id.btnRestaurants to "restaurant",
R.id.btnMuseums to "museum",
R.id.btnParks to "park",
R.id.btnHotels to "hotel",
)
val activeCategories = mutableSetOf("restaurant", "museum", "park", "hotel")
for ((btnId, category) in categories) {
findViewById(btnId).apply {
isChecked = true
setOnCheckedChangeListener { _, checked ->
if (checked) activeCategories.add(category)
else activeCategories.remove(category)
applyFilter(layer, activeCategories)
}
}
}
}
private fun applyFilter(layer: CircleLayer?, categories: Set) {
if (categories.isEmpty()) {
// Hide all
layer?.setFilter(literal(false))
} else if (categories.size == 4) {
// Show all — clear filter
layer?.setFilter(literal(true))
} else {
// Show only selected categories
val conditions = categories.map { cat ->
eq(get("category"), literal(cat))
}
layer?.setFilter(any(*conditions.toTypedArray()))
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Filter by Numeric Range
Show features within a value range:
```kotlin
// Show only features with "rating" >= 4.0
layer?.setFilter(
gte(get("rating"), literal(4.0))
)
// Show features with "price" between 10 and 50
layer?.setFilter(
all(
gte(get("price"), literal(10)),
lte(get("price"), literal(50))
)
)
```
## Filter by Text Search
Filter features whose name contains a search query:
```kotlin
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
private fun setupSearch(style: Style) {
val layer = style.getLayer("places-layer") as? CircleLayer
findViewById(R.id.searchInput).addTextChangedListener(
object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
val query = s.toString().lowercase()
if (query.isEmpty()) {
layer?.setFilter(literal(true))
} else {
// Note: Expression-based text search is limited.
// For full text search, filter the GeoJSON source instead.
filterSourceByName(style, query)
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}
)
}
private fun filterSourceByName(style: Style, query: String) {
val allFeatures = /* keep a reference to all features */ listOf()
val filtered = allFeatures.filter { feature ->
val name = feature.getStringProperty("name") ?: ""
name.lowercase().contains(query)
}
val source = style.getSource("places") as? GeoJsonSource
source?.setGeoJson(FeatureCollection.fromFeatures(filtered))
}
```
## Common Filter Expressions
| Expression | Description | Example |
|-----------|-------------|---------|
| `eq(a, b)` | Equals | `eq(get("type"), literal("park"))` |
| `neq(a, b)` | Not equals | `neq(get("status"), literal("closed"))` |
| `gt(a, b)` | Greater than | `gt(get("rating"), literal(3))` |
| `gte(a, b)` | Greater or equal | `gte(get("price"), literal(10))` |
| `lt(a, b)` | Less than | `lt(get("age"), literal(5))` |
| `lte(a, b)` | Less or equal | `lte(get("distance"), literal(100))` |
| `has("key")` | Property exists | `has("phone")` |
| `all(...)` | AND — all must match | `all(gt(...), lt(...))` |
| `any(...)` | OR — at least one must match | `any(eq(...), eq(...))` |
| `not(expr)` | Negate | `not(has("archived"))` |
## Next Steps
- [Data-Driven Styling](./data-driven-style) — Style features by properties
- [Multiple Markers](../interactions/multiple-markers) — Marker category filtering
- [Circle Layer](./circle-layer) — Styled point data
---
**Tip**: For better search performance with large datasets, filter at the source level (`source.setGeoJson()`) instead of the layer level (`layer.setFilter()`). Source-level filtering prevents features from being rendered at all, while layer filters still process them on the GPU.
---
# Add an Image Overlay
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/image-overlay
# Add an Image Overlay
This tutorial shows how to overlay an image (photo, floor plan, historical map) on top of your MapMetrics Android map, positioned at specific geographic coordinates.
## Prerequisites
- Completed the [Getting Started Guide](../getting-started)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Image Overlay
Place an image on the map anchored to four corner coordinates:
```kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngQuad
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.sources.ImageSource
class ImageOverlayActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private lateinit var map: MapMetricsMap
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { mapMetricsMap ->
map = mapMetricsMap
map.setStyle(
Style.Builder().fromUri(
"https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
)
) { style ->
addImageOverlay(style)
}
}
}
private fun addImageOverlay(style: Style) {
// Define the four corners of the image placement
val quad = LatLngQuad(
LatLng(48.8640, 2.2880), // Top-left (NW)
LatLng(48.8640, 2.3010), // Top-right (NE)
LatLng(48.8530, 2.3010), // Bottom-right (SE)
LatLng(48.8530, 2.2880) // Bottom-left (SW)
)
// Add image source from drawable resource
style.addSource(
ImageSource("overlay-source", quad, R.drawable.historic_map)
)
// Add raster layer to display the image
style.addLayer(
RasterLayer("overlay-layer", "overlay-source")
.withProperties(
rasterOpacity(0.7f)
)
)
// Center on the overlay
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(48.8585, 2.2945))
.zoom(15.0)
.build()
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
```
## Adjustable Opacity
Let users control the overlay transparency:
```kotlin
import android.widget.SeekBar
import android.widget.TextView
private fun setupOpacitySlider(style: Style) {
val opacityText = findViewById(R.id.tvOpacity)
findViewById(R.id.seekOpacity).apply {
max = 100
progress = 70 // default 70%
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, value: Int, user: Boolean) {
val opacity = value / 100f
opacityText.text = "Opacity: $value%"
val layer = style.getLayer("overlay-layer") as? RasterLayer
layer?.setProperties(rasterOpacity(opacity))
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
}
```
## Toggle Overlay Visibility
Show or hide the overlay with a button:
```kotlin
import android.widget.ToggleButton
import org.maplibre.android.style.layers.Property
private var overlayVisible = true
private fun setupToggle(style: Style) {
findViewById(R.id.btnToggleOverlay).setOnCheckedChangeListener { _, checked ->
overlayVisible = checked
val layer = style.getLayer("overlay-layer") as? RasterLayer
layer?.setProperties(
visibility(
if (checked) Property.VISIBLE else Property.NONE
)
)
}
}
```
## Next Steps
- [Animated Image Source](./animated-image-source) — Animate image overlays
- [Custom Sprite](./custom-sprite) — Custom icons and sprites
- [Building Layer](./building-layer) — 3D building extrusions
---
**Tip**: Image overlays work best for small areas (a few blocks). For larger coverage, use raster tile sources instead — they load tiles progressively and don't require loading one massive image into memory.
---
# Add live realtime data
https://docs.mapatlas.xyz/overview/sdk/android-native/styling/live-realtime-data
# Add live realtime data
[//]: # ({{ activity_source_note("RealTimeGeoJsonActivity.kt") }})
In this example you will learn how to add a live GeoJSON source. We have set up a [lambda function](https://m6rgfvqjp34nnwqcdm4cmmy3cm0dtupu.lambda-url.us-east-1.on.aws/) that returns a new GeoJSON point every time it is called.
[//]: # ()
[//]: # ( )
[//]: # ( )
[//]: # ( )
[//]: # ( )
First we will create a `GeoJSONSource`.
```kotlin title="Adding GeoJSON source"
try {
style.addSource(GeoJsonSource(ID_GEOJSON_SOURCE, URI(URL_GEOJSON_SOURCE)))
} catch (malformedUriException: URISyntaxException) {
Timber.e(malformedUriException, "Invalid URL")
}
```
Next we will create a `SymbolLayer` that uses the source.
```kotlin title="Adding a SymbolLayer source"
val layer = SymbolLayer(ID_GEOJSON_LAYER, ID_GEOJSON_SOURCE)
layer.setProperties(
PropertyFactory.iconImage("plane"),
PropertyFactory.iconAllowOverlap(true)
)
style.addLayer(layer)
```
We use define a `Runnable` and use `android.os.Handler` with a `android.os.Looper` to update the GeoJSON source every 2 seconds.
```kotlin title="Defining a Runnable for updating the GeoJSON source"
private inner class RefreshGeoJsonRunnable(
private val mapMetricsMap: MapMetricsMap,
private val handler: Handler
) : Runnable {
override fun run() {
val geoJsonSource = mapMetricsMap.style!!.getSource(ID_GEOJSON_SOURCE) as GeoJsonSource
geoJsonSource.setUri(URL_GEOJSON_SOURCE)
val features = geoJsonSource.querySourceFeatures(null)
setIconRotation(features)
handler.postDelayed(this, 2000)
}
}
```
## Bonus: set icon rotation
You can set the icon rotation of the icon when ever the point is updated based on the last two points.
```kotlin title="Defining a Runnable for updating the GeoJSON source"
if (features.size != 1) {
Timber.e("Expected only one feature")
return
}
val feature = features[0]
val geometry = feature.geometry()
if (geometry !is Point) {
Timber.e("Expected geometry to be a point")
return
}
if (lastLocation == null) {
lastLocation = geometry
return
}
mapMetricsMap.style!!.getLayer(ID_GEOJSON_LAYER)!!.setProperties(
PropertyFactory.iconRotate(calculateRotationAngle(lastLocation!!, geometry)),
)
```
---
# Flutter / Dart SDK
https://docs.mapatlas.xyz/overview/sdk/geocoding/flutter
# Flutter / Dart SDK
`mapatlas_geocoder` is a pure-Dart client (no Flutter dependency, sound null
safety, zero runtime dependencies) for the
[v2 geocoding API](../../geocoder/v2/autocomplete.md) — session-based
autocomplete, place retrieval, forward/reverse geocoding, Valhalla-backed
routing, and isochrones. Because the core package is plain Dart, it works
server-side too, not just from a Flutter app.
::: warning Not published yet
`dart pub add mapatlas_geocoder` will work once the package is on pub.dev.
Until then, add it as a git dependency in `pubspec.yaml`:
```yaml
dependencies:
mapatlas_geocoder:
git:
url: https://github.com/MapMetrics/geocoder-sdk
path: Flutter
```
:::
## Install
```bash
dart pub add mapatlas_geocoder
```
## Quickstart
```dart
import 'package:mapatlas_geocoder/mapatlas_geocoder.dart';
Future main() async {
final mapatlas = MapAtlas(token: 'YOUR_API_KEY');
// One session covers a whole search — every keystroke, then the pick.
final session = mapatlas.geocoding.createSession();
// As the user types. Debounced for you; suggestions carry no coordinates.
final results = await session.suggest('Nieuwezijds');
// When they pick one, hand back the Suggestion — not an id.
final place = await session.retrieve(results.first);
print('${place.center.lat}, ${place.center.lon}');
// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
final places = await session.retrieveBatch(results.take(3).toList());
// One-shot lookups, when there's no user typing to debounce. Both return GeoJSON.
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', country: 'nl');
await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89);
mapatlas.close(); // releases the underlying HTTP client
}
```
## Constructing a client
```dart
MapAtlas(
token: 'YOUR_API_KEY', // XOR getToken — exactly one is required
// getToken: () async => await fetchRotatingToken(),
baseUrl: 'https://gateway.mapmetrics-atlas.net', // default
tier: MapAtlasTier.v2, // default; or MapAtlasTier.osm
debounce: Duration(milliseconds: 150), // default; Duration.zero disables
);
```
Use `getToken` instead of `token` for a short-lived or asynchronously
fetched key (secure storage, or a backend-issued rotating credential) — it's
called fresh before every request.
## The reactive layer: `GeocodeSearchController`
`GeocodeSearchController` wires a search box's whole lifecycle — debounced
`suggest()` calls, one session per search, out-of-order response handling,
typed errors — into a single object you drive with `controller.query = ...`
and observe with a `StreamBuilder`. It's the Dart/Flutter analogue of the
`useAutocomplete` React hook in the sibling `@mapmetrics/geocoder` package.
It's built on `dart:async`'s `Stream`, not `ValueListenable`/
`ChangeNotifier` — those live in `package:flutter/foundation.dart`, which
this package can't import without depending on Flutter. `StreamBuilder`
consumes a `Stream` natively, so nothing extra is needed on the Flutter
side.
```dart
import 'package:flutter/material.dart';
import 'package:mapatlas_geocoder/mapatlas_geocoder.dart';
class AddressField extends StatefulWidget {
const AddressField({super.key, required this.mapatlas});
final MapAtlas mapatlas;
@override
State createState() => _AddressFieldState();
}
class _AddressFieldState extends State {
late final GeocodeSearchController _controller = GeocodeSearchController(
client: widget.mapatlas,
country: 'nl',
);
final _textController = TextEditingController();
@override
void dispose() {
_controller.dispose(); // cancels pending work; safe even mid-request
_textController.dispose();
super.dispose();
}
Future _onSelect(Suggestion suggestion) async {
try {
final place = await _controller.select(suggestion);
_textController.text = suggestion.placeName ?? suggestion.text ?? '';
debugPrint('Selected ${place.center.lat}, ${place.center.lon}');
} on MapAtlasException {
// Already reflected in state.error via the StreamBuilder below.
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _textController,
onChanged: (value) => _controller.query = value,
decoration: const InputDecoration(hintText: 'Search an address…'),
),
StreamBuilder(
stream: _controller.states,
initialData: _controller.state,
builder: (context, snapshot) {
final state = snapshot.data!;
if (state.isLoading) {
return const LinearProgressIndicator();
}
if (state.error != null) {
return Text(
state.error!.message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
);
}
return ListView(
shrinkWrap: true,
children: [
for (final suggestion in state.suggestions)
ListTile(
title: Text(suggestion.placeName ?? suggestion.text ?? ''),
subtitle: Text(suggestion.layer),
onTap: () => _onSelect(suggestion),
),
],
);
},
),
],
);
}
}
```
**Behavior:**
- **One session per search.** `GeocodeSession` is created once, in the
constructor, and reused across every `query` assignment; `select()`
retrieves and closes it, and the next `query` assignment transparently
opens a fresh one.
- **Out-of-order responses never win.** Every request carries a
monotonically increasing sequence number; a slow response for an earlier
keystroke arriving after a later one's is discarded.
- **`minLength` (default 2) short-circuits locally** — below it,
`state.suggestions` clears synchronously with no request and no session
activity.
- **Nothing is emitted after `dispose()`.** `states` closes with no error;
a request already in flight when `dispose()` is called resolves harmlessly
in the background.
- **Errors always land as a typed `MapAtlasException`** in `state.error`,
never as an unhandled async error.
If you'd rather work with `ValueListenable` (e.g. to reuse existing
`ValueListenableBuilder` widgets), wrap the stream in three lines of app
code — this needs `package:flutter/foundation.dart`, which is why it isn't
built into the package itself:
```dart
final notifier = ValueNotifier(controller.state);
final sub = controller.states.listen((s) => notifier.value = s);
// Later: sub.cancel(); notifier.dispose();
```
## Routing & isochrones
`mapatlas.routing` covers `directions()`/`directionsSimple()`, `matrix()`,
`mapMatching()`, and `optimization()` (the path is `/optimization/` —
`/optimize/` 404s); `mapatlas.isochrone()` returns reachability contours.
`Costing` and `ShapeMatch` are enums, not raw strings, so a typo like
`'motorscooter'` is a compile error rather than a silent gateway rejection.
Response models are deliberately permissive (a parsed top-level structure
plus `raw`, the full decoded body) since Valhalla's field set is large and
version-dependent. See `example/routing_example.dart` and
`example/isochrone_example.dart` in the package, or the
[README](https://github.com/MapMetrics/geocoder-sdk/tree/main/Flutter#routing-and-isochrones)
for full request/response shapes.
## The OSM tier
`MapAtlasTier.osm` routes `search()`/`reverse()`/`autocomplete()` to
`/osm-geocode/`/`/osm-reverse/`/`/osm-autocomplete/`, using the
`osm-geocode` scope. There's no session or retrieve concept on this tier —
`createSession()` throws `TierUnsupportedException`, and `autocomplete()`
results already carry coordinates. Rate-limited to 10,000 requests/key/day
plus a global monthly cap; `QuotaExceededException` carries a self-host link
once you hit it.
### Self-hosting
The free tier's engine is open source:
[MapMetrics/atlas-osm-geocoder](https://github.com/MapMetrics/atlas-osm-geocoder),
deployable on Cloudflare Workers with a prebuilt planet bundle. Point this
package at your own instance with `MapAtlas.selfHostedOsm()`:
```dart
final mapatlas = MapAtlas.selfHostedOsm(baseUrl: 'https://my-worker.workers.dev');
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147');
await mapatlas.geocoding.autocomplete('Nieuwezijds');
await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89);
```
A self-hosted instance takes no token and uses different paths (`/search`,
`/reverse`, `/autocomplete`, no `token` parameter); this package never sends
a credential to it, even if you pass `token`/`getToken` by mistake — it
throws instead. `createSession()` (and `GeocodeSession.retrieve()` /
`retrieveBatch()`) throw `TierUnsupportedException`, exactly as on
`MapAtlasTier.osm` — this engine has no session concept at all.
## Choosing a key for a Flutter app
Origin restriction is a **browser-only** feature — it checks the `Origin`
header a browser sends, and native mobile/desktop apps never send one. Use
an **unrestricted key** in a Flutter app; an origin-restricted key raises
`OriginRequiredException` on every request, by design. See
[API Keys & Security](../../api-keys.md) for the full model.
## Errors
Every failure is a `MapAtlasException` subtype:
| Exception | Cause |
|---|---|
| `TokenNotFoundException` | Key not provisioned. |
| `TokenInactiveException` | Key exists but is deactivated. |
| `ScopeException` | Key lacks the scope the endpoint requires. |
| `OriginRequiredException` | Key is origin-restricted; request had no `Origin` header. |
| `OriginNotAllowedException` | Key is origin-restricted; request's `Origin` isn't on the allow list. |
| `QuotaExceededException` | OSM-tier quota exhausted; carries `selfHostLink`. |
| `TierUnsupportedException` | Operation not supported on the current tier. |
| `NetworkException` | Connection failure, or a response that didn't match the expected shape. |
```dart
try {
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', country: 'nl');
} on OriginRequiredException {
// ...
} on MapAtlasException catch (e) {
// catches every other documented failure mode
}
```
### Suggestions without an `ord`
`Suggestion` carries **no coordinates** — only `retrieve()`/`retrieveBatch()`
return them. `ord` is nullable: check `suggestion.isRetrievable` (or the
nullability of `ord` itself) before calling `retrieve()` on a row you're not
sure about. Calling `retrieve()` on a non-retrievable suggestion throws a
typed exception explaining why, rather than a silent 404 or a null crash;
`retrieveBatch()` rejects the whole call if any item in the list is
non-retrievable.
## Testing your own code against this package
`HttpTransport` is exported so you can inject a fake in your own tests
instead of hitting the network:
```dart
final mapatlas = MapAtlas(token: 't', transport: myFakeTransport);
```
## Endpoint reference
This SDK is a typed wrapper — for the full parameter list, response shape,
and gateway-level gotchas each call is protecting you from, see:
- [Autocomplete (v2)](../../geocoder/v2/autocomplete.md)
- [Retrieve (v2)](../../geocoder/v2/retrieve.md)
- [Forward Geocode (v2)](../../geocoder/v2/forwardgeocode.md)
- [Reverse Geocode (v2)](../../geocoder/v2/reversegeocode.md)
- [Sessions & Billing](./sessions.md)
- [API Keys & Security](../../api-keys.md)
---
# Geocoding SDKs
https://docs.mapatlas.xyz/overview/sdk/geocoding/
# Geocoding SDKs
Four typed client packages wrap the [v2 geocoder](../../geocoder/v2/autocomplete.md)
so you don't hand-roll the autocomplete → retrieve loop, session billing, or
error parsing yourself:
| language | package | source |
|---|---|---|
| TypeScript / JavaScript | [`@mapmetrics/geocoder`](./javascript.md) | `NPM/` |
| Dart / Flutter | [`mapatlas_geocoder`](./flutter.md) | `Flutter/` |
| Swift (iOS / macOS) | [`MapAtlasGeocoder`](./swift.md) | `Swift/` |
| Kotlin (Android / JVM) | [`mapatlas-geocoder`](./kotlin.md) | `Android/` |
::: warning Not published yet
None of these packages are on npm, pub.dev, Swift Package Index, or Maven
Central yet. The install instructions on each language page show the form
that will work once they ship (or, for Swift and Kotlin, the git/composite
form that works today). Don't expect `npm install`/`dart pub add` to resolve
against a public registry right now.
:::
## Why use an SDK instead of calling the gateway directly
The raw HTTP API (documented under [Geocoder v2](../../geocoder/v2/autocomplete.md))
has a handful of sharp edges that are easy to get wrong once and then debug
for an hour, because most of them fail silently — HTTP 200 with an empty or
subtly wrong result, not an error:
- **Sessions are billed, not requests.** Autocomplete is billed per search
session. Forgetting to carry a stable `session_token` across keystrokes
doesn't error — it just charges roughly 5x more, silently. Every SDK here
manages the token for you: one session per search box interaction,
automatically.
- **Debounce is your job otherwise.** Without it, a fast typist fires a
request per keystroke. All four SDKs debounce `suggest()` calls (150ms by
default).
- **Retrieval is keyed on `ord`, not `id`.** Retrieving by `id` 404s on
every layer. Every SDK takes the suggestion object straight from
`suggest()` and reads `ord` off it for you — there's no id to get wrong.
- **Some suggestions can't be retrieved at all.** The gateway injects
locality rows (e.g. two of the fifteen results for `q=Amsterdam`) that
carry no `ord`. Every SDK exposes `isRetrievable` on a suggestion so you
can filter or disable those rows before the user taps one.
- **Batch retrieval has one specific encoding.** `/v2/retrieve-batch/`
accepts exactly one URL-encoded JSON `items` parameter — repeated
`ord=`/`ords=`/`ids=` params silently return `count: 0`. The SDKs build
that payload for you from an array of suggestion objects.
- **Errors come back as HTTP status + a code string**, not an exception
hierarchy. Each SDK turns them into typed errors/exceptions — a distinct
type per failure (`ScopeError`, `OriginRequiredError`,
`QuotaExceededError`, …) — so you can `catch`/`switch` on what actually
went wrong instead of parsing `error.status` and `error.code` yourself.
- **Response shape is easy to get wrong.** Forward/reverse geocode return a
different, flat shape unless you pass `format=pelias`. Every SDK forces
the GeoJSON envelope on every search/reverse call — you always get a
`FeatureCollection`.
None of this is exotic — it's mechanical, and it's exactly the kind of thing
worth doing once, in a typed client, instead of every app re-deriving it
from the endpoint docs.
## What's the same across all four
- A `MapAtlas` client constructed with an API key (or a `getToken` callback,
for rotating credentials).
- `client.geocoding.createSession()` → `session.suggest(query)` →
`session.retrieve(suggestion)` / `session.retrieveBatch(suggestions)`.
- A reactive controller for the common "search box" UI shape — see the
per-language page for its name and idiom (React hook, Dart `Stream`,
Swift `@Observable`, Kotlin `StateFlow`).
- One-shot `search()` / `reverse()` calls for when there's no user typing to
debounce.
- A free `tier: 'osm'` (naming varies per language) backed by the
`osm-geocode` scope, and a dedicated self-hosted constructor for
[MapMetrics/atlas-osm-geocoder](https://github.com/MapMetrics/atlas-osm-geocoder) —
see each page's "OSM tier" section.
- Typed errors/exceptions for every documented gateway failure mode —
see [API Keys & Security](../../api-keys.md) for what each one means.
## What differs
- **Routing, matrix, and isochrones** are implemented in the TypeScript and
Dart packages only. Swift and Kotlin are geocoding-only for now — see
their pages for exactly what's covered.
- Native platforms (Swift, Kotlin, Flutter) need an **unrestricted** API
key. Origin restriction only works for browsers — see
[Choosing a key for a native app](#choosing-a-key-for-native-apps) below.
## Choosing a key for native apps
Origin restriction is enforced by checking the browser's `Origin` header —
native apps, and any server-side code, send no `Origin` header at all, so an
origin-restricted key can never satisfy that check. This is the single most
common integration error a mobile developer hits with these SDKs: reuse a
web key in a Flutter/Swift/Kotlin app, and every request throws an
origin-required error.
Use an **unrestricted** key for the Flutter, Swift, and Kotlin SDKs. Origin
restriction is still the right call for a browser key used with the
TypeScript SDK. See [API Keys & Security](../../api-keys.md) for the full
scope and origin model, and [Sessions](./sessions.md) for the billing model
shared by all four SDKs.
## Next
- [Sessions & billing](./sessions.md) — the model every SDK is built around.
- [JavaScript / TypeScript](./javascript.md)
- [Flutter](./flutter.md)
- [Swift](./swift.md)
- [Kotlin](./kotlin.md)
---
# JavaScript / TypeScript SDK
https://docs.mapatlas.xyz/overview/sdk/geocoding/javascript
# JavaScript / TypeScript SDK
`@mapmetrics/geocoder` is a TypeScript client for the
[v2 geocoding API](../../geocoder/v2/autocomplete.md) — autocomplete, place
retrieval, forward/reverse geocoding, routing, and isochrones. Zero runtime
dependencies, ships ESM + CJS + types, and runs anywhere `fetch` exists:
Node 18+, browsers, and edge runtimes like Cloudflare Workers.
::: warning Not published yet
`npm install @mapmetrics/geocoder` will work once the package ships. Until
then, consume it from the `NPM/` directory of
[MapMetrics/geocoder-sdk](https://github.com/MapMetrics/geocoder-sdk)
directly (e.g. `npm install github:MapMetrics/geocoder-sdk#path:NPM` or a
local `file:` dependency).
:::
## Install
```bash
npm install @mapmetrics/geocoder
```
## Quickstart
```ts
import { MapAtlas } from '@mapmetrics/geocoder';
const mapatlas = new MapAtlas({ token: 'YOUR_API_KEY' });
// One session covers a whole search — every keystroke, then the pick.
const session = mapatlas.geocoding.createSession();
// As the user types. Debounced for you; suggestions carry no coordinates.
const results = await session.suggest('Nieuwezijds');
// When they pick one, hand back the suggestion object — not an id.
const place = await session.retrieve(results[0]);
console.log(place.latitude, place.longitude);
// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
const places = await session.retrieveBatch(results.slice(0, 3));
// One-shot lookups, when there's no user typing to debounce. Both return GeoJSON.
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', { country: 'nl' });
await mapatlas.geocoding.reverse(52.37, 4.89);
```
## Constructor options
```ts
new MapAtlas({ token: 'YOUR_API_KEY' });
// or, if you rotate/refresh tokens yourself:
new MapAtlas({ getToken: async () => await fetchFreshToken() });
```
| Option | Default | Notes |
|---|---|---|
| `token` | — | Exactly one of `token` / `getToken` is required. |
| `getToken` | — | Called fresh on every request — never cached by this package. |
| `baseUrl` | `https://gateway.mapmetrics-atlas.net` | Override for testing or a self-hosted gateway. |
| `tier` | `'v2'` | `'osm'` routes to the free, rate-limited OpenStreetMap-only tier. |
| `debounceMs` | `150` | Debounces rapid `suggest()` calls. `0` disables. |
## The reactive layer: `useAutocomplete`
`@mapmetrics/geocoder/react` is a separate subpath export with a headless
`useAutocomplete` hook — it owns the `Session` (and therefore billing),
debouncing, request cancellation, and error handling. Importing the core
`@mapmetrics/geocoder` entry never pulls React into your bundle; `react`
(>=18) is an optional peer dependency, only needed if you import this
subpath.
```bash
npm install react react-dom # if your app doesn't already have them
```
```tsx
import { useMemo, useState } from 'react';
import { MapAtlas } from '@mapmetrics/geocoder';
import { useAutocomplete } from '@mapmetrics/geocoder/react';
import type { Suggestion } from '@mapmetrics/geocoder';
function AddressField() {
const client = useMemo(() => new MapAtlas({ token: 'YOUR_API_KEY' }), []);
const [selecting, setSelecting] = useState(false);
const {
query, setQuery, // controlled input value
suggestions, // Suggestion[] — updates as the user types
isLoading, // a request is in flight
error, // typed MapAtlasError | null
select, // (s: Suggestion) => Promise
selected, // RetrievedPlace | null — last successful selection
reset, // clear query, suggestions, error and selection
} = useAutocomplete({ client, country: 'nl', minLength: 2 });
async function handleSelect(s: Suggestion) {
setSelecting(true);
try {
const place = await select(s);
console.log(place.latitude, place.longitude);
} catch {
// already surfaced via `error` below
} finally {
setSelecting(false);
}
}
return (
setQuery(e.target.value)}
placeholder="Search an address…"
/>
{isLoading &&
Searching… }
{error &&
{error.name}: {error.message} }
{suggestions.map((s) => (
handleSelect(s)}>
{s.placeName ?? s.text}
))}
{selected && (
{selected.latitude}, {selected.longitude}
Clear
)}
);
}
```
**Options:** `client` (required), `country`, `proximity`, `minLength`
(default `2` — below it, no request fires and `suggestions` is cleared),
`debounceMs` (extra debounce stacked on top of the client's own — leave
unset unless you need a delay different from `client.debounceMs`).
**Session lifecycle** matches the core `Session`: one session per `client`,
reused across every keystroke; `select()` retrieves and closes it, and the
next `setQuery()` reopens one automatically. `suggestCount` and `isOpen` on
the hook's return value mirror `Session.suggestCount` / `Session.isOpen`.
**Safety guarantees:** out-of-order responses are discarded (a slow response
for an earlier keystroke never overwrites a later one); no `setState` fires
after unmount; React 18 ``-safe (no duplicate session, no
duplicate request on double-invoked effects).
## Routing & isochrones
`mapatlas.routing` and the top-level `mapatlas.isochrone()` wrap the
gateway's Valhalla-backed endpoints — directions, matrix, map matching, and
route optimization. `costing` is a strict union
(`'auto' | 'bicycle' | 'bus' | 'truck' | 'taxi' | 'motor_scooter' |
'pedestrian' | 'bikeshare'`), not a bare string, and this client always
hits `/optimization/`, not `/optimize/` (the latter 404s). See the
[README](https://github.com/MapMetrics/geocoder-sdk/tree/main/NPM#routing--isochrones)
for the full request/response shapes.
## The OSM tier
`tier: 'osm'` talks to the free, OpenStreetMap-only endpoints
(`/osm-geocode/`, `/osm-reverse/`, `/osm-autocomplete/`) via the
`osm-geocode` scope. It has no session/retrieve flow — `search()`,
`reverse()`, and `autocomplete()` are the only three operations, results
already carry coordinates, and `createSession()` throws
`TierUnsupportedError`. Rate-limited to 10,000 requests/key/day plus a
global monthly cap; hitting it throws `QuotaExceededError`.
### Self-hosting
The free tier's engine is open source:
[MapMetrics/atlas-osm-geocoder](https://github.com/MapMetrics/atlas-osm-geocoder),
deployable on Cloudflare Workers with a prebuilt planet bundle. Point this
package at your own instance with `MapAtlas.selfHosted()`:
```ts
const mapatlas = MapAtlas.selfHosted({ baseUrl: 'https://my-worker.workers.dev' });
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147');
await mapatlas.geocoding.autocomplete('Nieuwezijds');
await mapatlas.geocoding.reverse(52.37, 4.89);
```
A self-hosted instance takes no token and uses different paths
(`/search`, `/reverse`, `/autocomplete` — no `/osm-` prefix, no `token`
parameter); `selfHosted()` never sends a credential to it, even if you pass
one by mistake. `createSession()` and session-based retrieval are
unavailable on a self-hosted client, same as on `tier: 'osm'` — there's no
session concept on this engine.
## Errors
All errors extend `MapAtlasError`:
- `TokenNotFoundError` — key was never provisioned.
- `TokenInactiveError` — key exists but is deactivated.
- `ScopeError` — key lacks the scope this operation requires.
- `OriginRequiredError` — key is origin-restricted, request sent no `Origin`
header. Native/server clients can never satisfy this — see
[Choosing a key for native apps](./index.md#choosing-a-key-for-native-apps).
- `OriginNotAllowedError` — request's `Origin` isn't on the key's allow-list.
- `QuotaExceededError` — OSM tier cap exhausted; carries `selfHostUrl`.
- `TierUnsupportedError` — thrown by `createSession()` on the `osm` tier and
on `selfHosted()` clients, and by `autocomplete()` on the `v2` tier.
- `NetworkError` — the request never completed, or the response wasn't
parseable JSON.
```ts
import { MapAtlasError, ScopeError } from '@mapmetrics/geocoder';
try {
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', { country: 'nl' });
} catch (e) {
if (e instanceof ScopeError) {
// ...
} else if (e instanceof MapAtlasError) {
// catches every other documented failure mode
} else {
throw e;
}
}
```
Prefer `e.code` / `e.name` (plain strings) over `instanceof` if your app
might load this package as both ESM and CJS in the same dependency tree —
that produces two distinct class objects for the same error, and
`instanceof` won't match across them.
::: tip Not every suggestion can be resolved
The gateway returns some rows — injected locality entries, such as the city
itself when you type "Amsterdam" — with no retrieve handle. They are still
returned so you can show them in a list, but they cannot be turned into
coordinates.
`suggestion.isRetrievable` tells you which is which, and `ord` is `null` (never
`NaN`) on those rows:
```ts
const results = await session.suggest('Amsterdam', { country: 'nl' });
const usable = results.filter((s) => s.isRetrievable);
```
Calling `retrieve()` on a non-retrievable suggestion throws
`NotRetrievableError`. `retrieveBatch()` rejects the whole call if any item is
non-retrievable, rather than silently returning fewer places than you asked
for. All four SDKs behave identically here.
:::
## Endpoint reference
This SDK is a typed wrapper — for the full parameter list, response shape,
and gateway-level gotchas each call is protecting you from, see:
- [Autocomplete (v2)](../../geocoder/v2/autocomplete.md)
- [Retrieve (v2)](../../geocoder/v2/retrieve.md)
- [Forward Geocode (v2)](../../geocoder/v2/forwardgeocode.md)
- [Reverse Geocode (v2)](../../geocoder/v2/reversegeocode.md)
- [Sessions & Billing](./sessions.md)
- [API Keys & Security](../../api-keys.md)
---
# Kotlin SDK
https://docs.mapatlas.xyz/overview/sdk/geocoding/kotlin
# Kotlin SDK
`mapatlas-geocoder` is a pure Kotlin/JVM client for the
[v2 geocoding API](../../geocoder/v2/autocomplete.md) — autocomplete, place
retrieval, and forward/reverse geocoding. Because it's plain Kotlin/JVM (not
an Android library module), the exact same artifact runs on Android and on
a server-side JVM.
::: warning Not published yet
Not on Maven Central. Consume it as a Gradle composite build or a local
Maven artifact today — see below.
:::
## Install
**As a composite build** (`settings.gradle.kts` in your app):
```kotlin
includeBuild("../Geocoder-SDK/Android") {
dependencySubstitution {
substitute(module("net.mapmetrics:mapatlas-geocoder")).using(project(":"))
}
}
```
```kotlin
// app/build.gradle.kts
dependencies {
implementation("net.mapmetrics:mapatlas-geocoder:1.0.0")
}
```
**As a local Maven artifact:**
```bash
cd Geocoder-SDK/Android
gradle publishToMavenLocal
```
```kotlin
repositories { mavenLocal() }
dependencies {
implementation("net.mapmetrics:mapatlas-geocoder:1.0.0")
}
```
**Why plain Kotlin/JVM, not an Android library module?** It uses the
`kotlin("jvm")` Gradle plugin, not the Android Gradle Plugin — no Android
SDK is required to build or unit test it. Android compatibility comes from
targeting JVM 11 bytecode and relying only on `java.net.http`, which
Android's core library desugaring supports back to `minSdk 21`. Enable
desugaring in your app module:
```kotlin
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
}
```
## Quickstart
```kotlin
import net.mapmetrics.geocoder.MapAtlas
import net.mapmetrics.geocoder.SearchOptions
import net.mapmetrics.geocoder.GeocodeSearchResult
val mapatlas = MapAtlas(token = "YOUR_API_KEY")
// One session covers a whole search — every keystroke, then the pick.
val session = mapatlas.geocoding.createSession()
// As the user types. Debounced for you; suggestions carry no coordinates.
val results = session.suggest("Nieuwezijds")
// When they pick one, hand back the suggestion — not an id.
val place = session.retrieve(results[0])
println("${place.latitude}, ${place.longitude}")
// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
val places = session.retrieveBatch(results.take(3))
// One-shot lookups, when there's no user typing to debounce.
val fwd = mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", SearchOptions(country = "nl"))
val rev = mapatlas.geocoding.reverse(latitude = 52.37, longitude = 4.89)
check(fwd is GeocodeSearchResult.Pelias) // always true on the default v2 tier
mapatlas.close() // releases the HTTP engine's threads and the coroutine scope backing Session debouncing
```
## The reactive layer: `GeocodeSearchController`
`GeocodeSearchController` exposes a single `StateFlow` — own it (and a
`MapAtlas`) in a `ViewModel`, collect it in a Compose screen, and you have a
working, correctly-billed, debounced search box. It debounces, discards
stale out-of-order responses, and turns failures into typed state rather
than thrown exceptions.
```kotlin
class AddressSearchViewModel : ViewModel() {
private val mapatlas = MapAtlas(token = "YOUR_API_KEY")
val controller = GeocodeSearchController(
client = mapatlas,
scope = viewModelScope,
country = "nl",
minLength = 2,
)
fun onQueryChanged(text: String) = controller.setQuery(text)
suspend fun onSuggestionPicked(suggestion: Suggestion): RetrievedPlace =
controller.select(suggestion)
override fun onCleared() {
controller.close()
mapatlas.close()
}
}
```
```kotlin
@Composable
fun AddressSearchScreen(viewModel: AddressSearchViewModel = viewModel()) {
val state by viewModel.controller.state.collectAsState()
val scope = rememberCoroutineScope()
Column {
TextField(
value = state.query,
onValueChange = viewModel::onQueryChanged,
placeholder = { Text("Search an address…") },
)
if (state.isLoading) CircularProgressIndicator()
state.error?.let { Text("${it::class.simpleName}: ${it.message}", color = Color.Red) }
LazyColumn {
items(state.suggestions) { suggestion ->
Text(
text = suggestion.placeName ?: suggestion.text.orEmpty(),
modifier = Modifier.clickable {
scope.launch {
val place = viewModel.onSuggestionPicked(suggestion)
// navigate / use place.latitude, place.longitude
}
},
)
}
}
state.selected?.let { place ->
Text("Selected: ${place.latitude}, ${place.longitude}")
}
}
}
```
`GeocodeSearchState` carries `query`, `suggestions`, `isLoading`, `error` (a
typed `MapAtlasException?`), and `selected`. Out-of-order responses are
discarded automatically: type "a" then quickly "ab", and a slow response for
"a" arriving after "ab" is dropped rather than flashing stale results — the
controller lets the older request finish (it doesn't race to cancel it) and
simply ignores its result once superseded.
## The OSM tier
`tier = MapAtlasTier.OSM` talks to the free, OpenStreetMap-only endpoints
via the `osm-geocode` scope. No session/retrieve flow — `search()`,
`reverse()`, and `autocomplete()` are the only three operations, returning
`GeocodeSearchResult.Osm` / `OsmResponse` (a permissive wrapper around the
raw JSON). Calling `createSession()` on this tier throws
`MapAtlasException.TierUnsupported`. Rate-limited to 10,000 requests/key/day
plus a global monthly cap; exhausting it throws
`MapAtlasException.QuotaExceeded` with a `selfHostUrl`.
### Self-hosting
The free tier's engine is open source:
[MapMetrics/atlas-osm-geocoder](https://github.com/MapMetrics/atlas-osm-geocoder),
deployable on Cloudflare Workers with a prebuilt planet bundle. Point this
package at your own instance with `MapAtlas.selfHosted()`:
```kotlin
val mapatlas = MapAtlas.selfHosted(baseUrl = "https://my-worker.workers.dev")
mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147")
mapatlas.geocoding.autocomplete("Nieuwezijds")
mapatlas.geocoding.reverse(latitude = 52.37, longitude = 4.89)
```
A self-hosted instance takes no token and uses different paths (`/search`,
`/reverse`, `/autocomplete`, no `token` parameter) — this package never
sends a credential to it. `createSession()` (and therefore
`Session.retrieve()`/`retrieveBatch()`) throw `TierUnsupported` on a
self-hosted client, exactly as on the hosted `osm` tier — this engine has no
session concept either. `QuotaExceeded` cannot occur self-hosted (no quota).
## Choosing a key for an Android app
Origin restriction is a **browser-only** feature — it works by checking the
`Origin` header a browser sends, and native apps don't send one. **This is
the error an Android developer will hit** if they reuse a browser-restricted
key: an origin-restricted key can never work from Android. Use an
unrestricted key for native/server clients. See
[API Keys & Security](../../api-keys.md) for the full model.
## Errors
All errors extend the sealed `MapAtlasException`. Kotlin's `when` is
exhaustive over a sealed class, so the compiler flags anything you miss:
- `TokenNotFound` — key was never provisioned.
- `TokenInactive` — key exists but is deactivated.
- `ScopeNotAllowed` — key is valid but not scoped for this operation.
- `OriginRequired` — key is origin-restricted, no `Origin` header sent. See
[Choosing a key for an Android app](#choosing-a-key-for-an-android-app).
- `OriginNotAllowed` — request's `Origin` isn't on the key's allow-list.
- `QuotaExceeded` — OSM tier cap exhausted; carries `selfHostUrl`.
- `TierUnsupported` — thrown by `createSession()` on the `osm` tier and on
`MapAtlas.selfHosted()` clients, and by `autocomplete()` on the `v2` tier.
- `NotRetrievable` — thrown by `retrieve()`/`retrieveBatch()` when a
suggestion has no `ord` (see below).
- `NetworkError` — the request never completed.
- `DecodingError` — a response was received but couldn't be parsed.
- `Unknown` — any other non-2xx response this package doesn't have a
specific subclass for; still carries `status`/`code` where available.
```kotlin
try {
mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", SearchOptions(country = "nl"))
} catch (e: MapAtlasException.ScopeNotAllowed) {
// ...
} catch (e: MapAtlasException) {
// catches every other documented failure mode
}
```
Every exception carries `status: Int?` and `code: String?` alongside
`message`.
### Suggestions without coordinates, or an `ord`
`Suggestion` deliberately carries **no coordinates** — only `retrieve()`/
`retrieveBatch()` return them. Separately, `ord` is nullable: the gateway
returns injected locality rows among ordinary results for some queries (two
rows for `q=Amsterdam` at last check) that carry no `ord` at all. Check
`suggestion.isRetrievable` before calling `retrieve()` on a row you're not
sure about:
```kotlin
val suggestions = session.suggest("Amsterdam")
val retrievable = suggestions.filter { it.isRetrievable }
val place = session.retrieve(retrievable.first())
```
Calling `retrieve()` on a non-retrievable suggestion throws
`MapAtlasException.NotRetrievable` with a message explaining why, rather
than crashing on a null or silently 404ing. `retrieveBatch()` rejects the
**whole call** if any item in the list is non-retrievable, rather than
silently dropping the offending item(s).
## Not implemented yet
This package covers geocoding only — routing, matrix, map matching,
optimization, and isochrones are **not** implemented here. The TypeScript
and Dart SDKs cover those endpoints; see [JavaScript](./javascript.md) or
[Flutter](./flutter.md) if you need routing from a shared backend or a
cross-platform Dart layer.
## Endpoint reference
This SDK is a typed wrapper — for the full parameter list, response shape,
and gateway-level gotchas each call is protecting you from, see:
- [Autocomplete (v2)](../../geocoder/v2/autocomplete.md)
- [Retrieve (v2)](../../geocoder/v2/retrieve.md)
- [Forward Geocode (v2)](../../geocoder/v2/forwardgeocode.md)
- [Reverse Geocode (v2)](../../geocoder/v2/reversegeocode.md)
- [Sessions & Billing](./sessions.md)
- [API Keys & Security](../../api-keys.md)
---
# Sessions & Billing
https://docs.mapatlas.xyz/overview/sdk/geocoding/sessions
# Sessions & Billing
All four geocoding SDKs are built around one billing model: the
[v2 autocomplete API](../../geocoder/v2/autocomplete.md) bills by **session**,
not by request. This page is the shared reference the per-language pages
link back to — see [Sessions (v2 Autocomplete Billing)](../../geocoder/v2/sessions.md)
for the underlying HTTP-level rules this section builds on.
## The rule
A session starts on the first `suggest()` call on a fresh session object,
and ends — closing the session — on either `retrieve()` or `retrieveBatch()`.
The next `suggest()` call after that automatically opens a new session. You
never see or manage the `session_token` yourself; every SDK mints, reuses,
and rotates it for you.
## What a session costs
Measured against the gateway (one token, counting session starts):
| sequence | sessions billed |
|---|---|
| 3 `suggest()` calls on one session | **1** |
| `suggest()` → `retrieve()` → `suggest()` | **2** |
| `suggest()` → `retrieveBatch()` → `suggest()` | **2** |
Two consequences follow directly from this table:
**Resolving picks one at a time is expensive.** Three sequential
`retrieve()` calls on three different suggestions cost **three sessions**,
because each `retrieve()` both closes the session it belongs to and — if
none is open — starts one. One `retrieveBatch()` of the same three
suggestions costs **one session**. If you're resolving more than one result
at a time — plotting a set of markers, say — always prefer the batch call.
**Omitting a stable session token costs roughly 5x.** If nothing carries a
consistent token across a search, the gateway mints a fresh session for
every single keystroke instead of one session covering the whole search.
For a typical multi-character search this multiplies cost by roughly 5x.
This is exactly the mistake the SDKs exist to prevent — a hand-rolled
`fetch`/`URLSession` call that constructs a new session (or omits
`session_token` entirely) per keystroke will hit this without any error or
warning; the gateway returns HTTP 200 every time.
## How each SDK prevents it
Every SDK ties one session object to the lifetime of one search-box
interaction:
- **JavaScript/TypeScript** — `client.geocoding.createSession()` returns a
`Session` that owns the token; `select()`/`retrieve()`/`retrieveBatch()`
close it and the next `suggest()` reopens one automatically. The
`useAutocomplete` hook owns one `Session` per component instance.
- **Dart/Flutter** — `mapatlas.geocoding.createSession()` returns a
`GeocodeSession` with the same lifecycle. `GeocodeSearchController`
creates exactly one `GeocodeSession` in its constructor and reuses it for
the controller's whole lifetime.
- **Swift** — `mapatlas.geocoding.createSession()` returns a `Session`.
`GeocodeSearchController` creates one for its lifetime as an `@Observable`
controller.
- **Kotlin** — `mapatlas.geocoding.createSession()` returns a `Session`.
`GeocodeSearchController` owns one internally and exposes it only through
`StateFlow`.
In all four, constructing a **new** session object per keystroke — e.g.
calling `createSession()` inside a text-change handler instead of once, up
front — reintroduces the roughly-5x cost the SDK is meant to prevent. The
reactive controllers exist specifically so you never have to think about
this: bind the controller to the lifetime of the search field, not the
keystroke.
## Retrievability
Not every suggestion can be resolved. The gateway injects locality rows
(e.g. two of the fifteen results for `q=Amsterdam`) alongside ordinary
results; those rows are shown in the suggestion list but carry no `ord`, so
they can't be retrieved. Every SDK exposes an `isRetrievable` check on the
suggestion — use it to disable or filter those rows before the user can tap
one. See the error-handling section of each language page for what happens
if you call `retrieve()` on a non-retrievable suggestion anyway.
## See Also
- [Autocomplete (v2)](../../geocoder/v2/autocomplete.md)
- [Retrieve (v2)](../../geocoder/v2/retrieve.md)
- [Sessions (v2 HTTP billing model)](../../geocoder/v2/sessions.md)
- [API Keys & Security](../../api-keys.md)
---
# Swift SDK
https://docs.mapatlas.xyz/overview/sdk/geocoding/swift
# Swift SDK
`MapAtlasGeocoder` is a Swift client for the
[v2 geocoding API](../../geocoder/v2/autocomplete.md) — autocomplete, place
retrieval, and forward/reverse geocoding — for iOS and macOS. Foundation
only, no third-party dependencies, `async`/`await` throughout.
::: warning Not published yet
Not on the Swift Package Index yet. Add it as a git-based Swift Package
today — see below; the coordinates don't change once it's indexed.
:::
## Install
**Platforms:** macOS 12+, iOS 15+.
### Xcode
File → Add Package Dependencies… and enter:
```
https://github.com/MapMetrics/geocoder-sdk
```
Select the `Swift` package directory and add the **MapAtlasGeocoder**
product to your target.
### Package.swift
```swift
dependencies: [
.package(url: "https://github.com/MapMetrics/geocoder-sdk", branch: "main"),
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "MapAtlasGeocoder", package: "geocoder-sdk"),
]
),
]
```
## Quickstart
```swift
import MapAtlasGeocoder
let mapatlas = MapAtlas(token: "YOUR_API_KEY")
// One session covers a whole search — every keystroke, then the pick.
let session = try mapatlas.geocoding.createSession()
// As the user types. Debounced for you; suggestions carry no coordinates.
let results = try await session.suggest("Nieuwezijds")
// When they pick one, hand back the suggestion — not an id.
let place = try await session.retrieve(results[0])
print(place.latitude, place.longitude)
// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
let places = try await session.retrieveBatch(Array(results.prefix(3)))
// One-shot lookups, when there's no user typing to debounce. Both return GeoJSON.
let forward = try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", country: "nl")
let reverse = try await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89)
```
## The reactive layer: `GeocodeSearchController`
`GeocodeSearchController` is a headless, `@Observable`, `@MainActor`
controller — bind your own markup to it. Requires macOS 14 / iOS 17 (the
rest of the package works down to macOS 12 / iOS 15 — `@Observable` alone
needs the newer OS).
```swift
import SwiftUI
import MapAtlasGeocoder
struct AddressField: View {
@State private var controller: GeocodeSearchController
var onSelect: (RetrievedPlace) -> Void
init(client: MapAtlas, onSelect: @escaping (RetrievedPlace) -> Void) {
_controller = State(initialValue: try! GeocodeSearchController(client: client, country: "nl"))
self.onSelect = onSelect
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Search an address…", text: $controller.query)
.textFieldStyle(.roundedBorder)
if controller.isLoading {
ProgressView()
}
if let error = controller.error {
Text(error.message).foregroundStyle(.red).font(.caption)
}
List(controller.suggestions, id: \.text) { suggestion in
Button(suggestion.placeName ?? suggestion.text ?? "") {
Task {
if let place = await controller.select(suggestion) {
onSelect(place)
}
}
}
// Suggestions can be non-retrievable (e.g. a bare "Amsterdam"
// locality row) — still shown, but selecting one surfaces
// `.notRetrievable` into `controller.error` instead of a crash.
.disabled(!suggestion.isRetrievable)
}
}
}
}
```
**Guarantees:** one `Session` is created for the controller's lifetime and
reused across every keystroke; `select(_:)` retrieves and closes it, and the
next `query` change reopens it automatically. `minLength` (default `2`) is
enforced locally — no request, no session activity, below it. Out-of-order
responses are discarded: each search is tagged with a sequence number, and a
slow response for an earlier keystroke arriving after a newer one is never
applied. Errors land in `controller.error`, never thrown into a view.
## The OSM tier
`tier: .osm` talks to the free, OpenStreetMap-only endpoints
(`/osm-geocode/`, `/osm-reverse/`, `/osm-autocomplete/`) via the
`osm-geocode` scope. No session/retrieve flow — `search()`, `reverse()`,
and `autocomplete(_:)` are the only three operations. Calling
`createSession()` on this tier throws `.tierUnsupported`. Rate-limited to
10,000 requests/key/day plus a global monthly cap; exhausting it throws
`.quotaExceeded` with a `selfHostURL`.
```swift
let mapatlas = MapAtlas(token: "YOUR_KEY", tier: .osm)
let result = try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147")
if case .osm(let raw) = result {
// raw: OsmResponse (= JSONValue) — narrow it yourself at the call site.
}
```
`autocomplete(_:)` on this tier is the opposite of v2 autocomplete: no
session, no `retrieve()` step, results already carry coordinates. It's only
available with `tier: .osm` — on the default `.v2` tier it throws
`.tierUnsupported` and points you at `createSession()` + `suggest(_:)`
instead.
### Self-hosting
The free tier's engine is open source:
[MapMetrics/atlas-osm-geocoder](https://github.com/MapMetrics/atlas-osm-geocoder),
deployable on Cloudflare Workers with a prebuilt planet bundle. Point this
package at your own instance with `MapAtlas.selfHosted(baseURL:)`:
```swift
let mapatlas = MapAtlas.selfHosted(baseURL: URL(string: "https://my-worker.workers.dev")!)
try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147")
try await mapatlas.geocoding.autocomplete("Nieuwezijds")
try await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89)
```
A self-hosted instance takes no token and uses different paths (`/search`,
`/reverse`, `/autocomplete`, no `token` parameter) — there is no
`token`/`getToken` option on this constructor at all, since a self-hosted
instance has no auth and this package never sends one. `createSession()`
(and session-based retrieval) throw `.tierUnsupported` here too — this
engine has no session concept at all.
## Choosing a key for a Swift app
Origin restriction is a **browser-only** feature — it's enforced by
checking the `Origin` header a browser sends, and native apps never send
one. **A native app can never satisfy an origin-restricted key** — this is
the error you'll hit if you reuse a browser-restricted key in an iOS app.
Use an unrestricted key for iOS/macOS instead. See
[API Keys & Security](../../api-keys.md) for the full model.
## Errors
`MapAtlasError` is a Swift `enum` with one case per failure mode. Switch on
it, or read `.message` / `.status` / `.code` / `.name`:
- `.tokenNotFound` — key was never provisioned.
- `.tokenInactive` — key exists but is deactivated.
- `.scopeNotAllowed` — key is valid but not scoped for this operation.
- `.originRequired` — key is origin-restricted, no `Origin` header sent.
See [Choosing a key for a Swift app](#choosing-a-key-for-a-swift-app).
- `.originNotAllowed` — request's `Origin` isn't on the key's allow-list.
- `.quotaExceeded` — OSM tier cap exhausted; carries `selfHostURL`.
- `.tierUnsupported` — thrown by `createSession()` on the `.osm` tier and on
`MapAtlas.selfHosted(baseURL:)` clients, and by `autocomplete(_:)` on the
`.v2` tier.
- `.notRetrievable` — thrown by `retrieve(_:)`/`retrieveBatch(_:)` when a
`Suggestion` has no `ord` (`isRetrievable == false`).
- `.network` — the request never completed, or the response wasn't
parseable JSON.
- `.decoding` — the response was valid JSON but didn't match the expected
shape.
- `.unknown` — any other non-2xx response, not covered above.
```swift
import MapAtlasGeocoder
do {
_ = try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", country: "nl")
} catch let error as MapAtlasError {
switch error {
case .originRequired:
// this key can't be used from a native app — swap it for an unrestricted one
break
case .quotaExceeded(_, _, _, let selfHostURL):
print("quota exceeded, self-host at:", selfHostURL as Any)
default:
print(error.name, error.message)
}
}
```
## Not implemented yet
This package covers geocoding only — routing, matrix, map matching,
optimization, and isochrones are **not** implemented here. The TypeScript
and Dart SDKs cover those endpoints; see [JavaScript](./javascript.md) or
[Flutter](./flutter.md) if you need routing from a shared backend or a
cross-platform Dart layer.
## Endpoint reference
This SDK is a typed wrapper — for the full parameter list, response shape,
and gateway-level gotchas each call is protecting you from, see:
- [Autocomplete (v2)](../../geocoder/v2/autocomplete.md)
- [Retrieve (v2)](../../geocoder/v2/retrieve.md)
- [Forward Geocode (v2)](../../geocoder/v2/forwardgeocode.md)
- [Reverse Geocode (v2)](../../geocoder/v2/reversegeocode.md)
- [Sessions & Billing](./sessions.md)
- [API Keys & Security](../../api-keys.md)
---
# SDKs & Softwares
https://docs.mapatlas.xyz/overview/sdk/
# SDKs & Softwares
## Integrating Maps in your project
MapMetrics exposes Vector Tile APIs to work with maps. To display maps, you need to use a Maps SDK.
There are already great OpenSource Maps SDKs, and we'd rather spend our nights optimizing rendering efficiency to give you the fastest maps on the market than putting our name on a brand new Maps SDK which would pretty much do the same as the others.
### Vector Tiles
Vector tiles were created after the raster tiles. These are also squares, which together form a map. This time, these squares only contain the data, so you have to associate a style to it to finally have a map. This allows for more personalization and better interactions with the map.
We would recommend using MapMetrics GL for vector tiles.
# Attribution
Your map must display the following links: © MapMetrics and © OSM contributors. This is a part or our General Terms and Conditions (5.6 Attribution Requirements).
This is our attribution template:
```html
© MapMetrics
|
© OSM contributors
```
---
# Action Journal
https://docs.mapatlas.xyz/overview/sdk/ios-native/ActionJournalExample
# Action Journal
Learn about the ``MLNMapView`` methods for logging and viewing map actions.
The Action Journal provides functionality for persistent logging of top level map events.
Its primary use case is to assist in debugging problematic sessions and crashes by offering additional insight into the actions performed by the map at the time of failure. Data is stored in human readable format, which is useful for analyzing individual cases, but can also be easily translated and aggregated into a database, allowing for efficient analysis of multiple cases and helping to identify recurring patterns (Google BigQuery, AWS Glue + S3 + Athena, etc).
We are always interested in improving observability, so if you have a special use case, feel free to [open an issue or pull request]({"contact/admin/forURLs"}) to extend the types of observability methods.
## Logging implementation details
The logging is implemented using rolling files with a size based policy:
- A new file is created when the current log size exceeds ``MLNActionJournalOptions/logFileSize``.
- When the maximum number of files exceeds ``MLNActionJournalOptions/logFileCount``:
- The oldest one is deleted.
- The remaining files are renamed sequentially to maintain the naming convention `action_journal.0.log` through `action_journal.{logFileCount - 1}.log`.
- Each file contains one event per line.
- All files are stored in an umbrella "action_journal" directory at ``MLNActionJournalOptions/path``.
See also: ``MLNSettings``, ``MLNActionJournalOptions``.
## Event format
Events are stored as JSON objects with the following format:
| Field | Type | Required | Description |
| :---- | :--: | :------: | :---------- |
| name | string | true | event name |
| time | string | true | event time ([ISO 8601]({"contact/admin/forURLs"}) with milliseconds) |
| styleName | string | false | currently loaded style name |
| styleURL | string | false | currently loaded style URL |
| clientName | string | false | |
| clientVersion | string | false | |
| event | object | false | event specific data - consists of encoded values of the parameters passed to their ``MLNMapViewDelegate`` counterparts
```
{
"name" : "onTileAction",
"time" : "2025-04-17T13:13:13.974Z",
"styleName" : "Streets",
"styleURL" : "maptiler://maps/streets",
"clientName" : "App",
"clientVersion" : "1.0",
"event" : {
"action" : "RequestedFromNetwork",
"tileX" : 0,
"tileY" : 0,
"tileZ" : 0,
"overscaledZ" : 0,
"sourceID" : "openmaptiles"
}
}
```
## Usage
Enabling the action journal.
```swift
let options = MLNMapOptions()
options.actionJournalOptions.enabled = true
options.styleURL = AMERICANA_STYLE
mapView = MLNMapView(frame: view.bounds, options: options)
```
```swift
@objc func printActionJournal() {
print("ActionJournalLog files: \(mapView.getActionJournalLogFiles())")
print("ActionJournalLog : \(mapView.getActionJournalLog())")
// print only the newest events on each call
mapView.clearActionJournalLog()
}
```
## Alternative
The implementation is kept close to the core events to minimize additional locking and avoid platform-specific conversions and calls. As a result customization options and extensibility is limited.
For greater flexibility, consider using the ``MLNMapViewDelegate`` interface. It provides hooks for most Action Journal events and allows for more customizable querying and storage of map data. However, this comes at the cost of added complexity. See [Observe Low-Level Events](./ObserverExample.md) to learn about the map events that you can listen for, which mirror the events available in the action journal.
---
# Animated Line
https://docs.mapatlas.xyz/overview/sdk/ios-native/AnimatedLineExample
# Animated Line
Add an animated line to a map
> This example uses UIKit.
Demonstrates using `MLNPolyline.polylineWithCoordinates:count:` to update an `MLNPolyline`.
```swift
class AnimatedLineExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
var timer: Timer?
var polylineSource: MLNShapeSource?
var currentIndex = 1
var allCoordinates: [CLLocationCoordinate2D]!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds, styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.setCenter(
CLLocationCoordinate2D(latitude: 45.5076, longitude: -122.6736),
zoomLevel: 11,
animated: false
)
view.addSubview(mapView)
mapView.delegate = self
allCoordinates = coordinates
}
// Wait until the map is loaded before adding to the map.
func mapViewDidFinishLoadingMap(_ mapView: MLNMapView) {
addPolyline(to: mapView.style!)
animatePolyline()
}
func addPolyline(to style: MLNStyle) {
// Add an empty MLNShapeSource, we'll keep a reference to this and add points to this later.
let source = MLNShapeSource(identifier: "polyline", shape: nil, options: nil)
style.addSource(source)
polylineSource = source
// Add a layer to style our polyline.
let layer = MLNLineStyleLayer(identifier: "polyline", source: source)
layer.lineJoin = NSExpression(forConstantValue: "round")
layer.lineCap = NSExpression(forConstantValue: "round")
layer.lineColor = NSExpression(forConstantValue: UIColor.blue)
// The line width should gradually increase based on the zoom level.
layer.lineWidth = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [14: 5, 18: 20]))
style.addLayer(layer)
}
func animatePolyline() {
currentIndex = 1
// Start a timer that will simulate adding points to our polyline. This could also represent coordinates being added to our polyline from another source, such as a CLLocationManagerDelegate.
timer = Timer.scheduledTimer(timeInterval: 0.05, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
}
@objc func tick() {
if currentIndex > allCoordinates.count {
timer?.invalidate()
timer = nil
return
}
// Create a subarray of locations up to the current index.
let coordinates = Array(allCoordinates[0 ..< currentIndex])
// Update our MLNShapeSource with the current locations.
updatePolylineWithCoordinates(coordinates: coordinates)
currentIndex += 1
}
func updatePolylineWithCoordinates(coordinates: [CLLocationCoordinate2D]) {
var mutableCoordinates = coordinates
let polyline = MLNPolylineFeature(coordinates: &mutableCoordinates, count: UInt(mutableCoordinates.count))
// Updating the MLNShapeSource's shape will have the map redraw our polyline with the current coordinates.
polylineSource?.shape = polyline
}
let coordinates = [
(-122.63748, 45.52214),
(-122.64855, 45.52218),
(-122.6545, 45.52219),
(-122.65497, 45.52196),
(-122.65631, 45.52104),
(-122.6578, 45.51935),
(-122.65867, 45.51848),
(-122.65872, 45.51293),
(-122.66576, 45.51295),
(-122.66745, 45.51252),
(-122.66813, 45.51244),
(-122.67359, 45.51385),
(-122.67415, 45.51406),
(-122.67481, 45.51484),
(-122.676, 45.51532),
(-122.68106, 45.51668),
(-122.68503, 45.50934),
(-122.68546, 45.50858),
(-122.6852, 45.50783),
(-122.68424, 45.50714),
(-122.68433, 45.50585),
(-122.68429, 45.50521),
(-122.68456, 45.50445),
(-122.68538, 45.50371),
(-122.68653, 45.50311),
(-122.68731, 45.50292),
(-122.68742, 45.50253),
(-122.6867, 45.50239),
(-122.68545, 45.5026),
(-122.68407, 45.50294),
(-122.68357, 45.50271),
(-122.68236, 45.50055),
(-122.68233, 45.49994),
(-122.68267, 45.49955),
(-122.68257, 45.49919),
(-122.68376, 45.49842),
(-122.68428, 45.49821),
(-122.68573, 45.49798),
(-122.68923, 45.49805),
(-122.68926, 45.49857),
(-122.68814, 45.49911),
(-122.68865, 45.49921),
(-122.6897, 45.49905),
(-122.69346, 45.49917),
(-122.69404, 45.49902),
(-122.69438, 45.49796),
(-122.69504, 45.49697),
(-122.69624, 45.49661),
(-122.69781, 45.4955),
(-122.69803, 45.49517),
(-122.69711, 45.49508),
(-122.69688, 45.4948),
(-122.69744, 45.49368),
(-122.69702, 45.49311),
(-122.69665, 45.49294),
(-122.69788, 45.49212),
(-122.69771, 45.49264),
(-122.69835, 45.49332),
(-122.7007, 45.49334),
(-122.70167, 45.49358),
(-122.70215, 45.49401),
(-122.70229, 45.49439),
(-122.70185, 45.49566),
(-122.70215, 45.49635),
(-122.70346, 45.49674),
(-122.70517, 45.49758),
(-122.70614, 45.49736),
(-122.70663, 45.49736),
(-122.70807, 45.49767),
(-122.70807, 45.49798),
(-122.70717, 45.49798),
(-122.70713, 45.4984),
(-122.70774, 45.49893),
].map { CLLocationCoordinate2D(latitude: $0.1, longitude: $0.0) }
}
```
---
# Custom Annotation View
https://docs.mapatlas.xyz/overview/sdk/ios-native/AnnotationViewExample
# Custom Annotation View
Add a custom annotation view
This examples shows how you can implement and use a custom ``MLNAnnotationView``.
You need to implement ``MLNMapViewDelegate/mapView:viewForAnnotation:`` of ``MLNMapViewDelegate`` which will be called when you add an ``MLNAnnotation`` to the example. In this case, three ``MLNPointAnnotation``s are added to the map. When one is selected selected ``MLNAnnotationView/setSelected:animated:`` will be called.
```swift
class AnnotationViewExample: UIViewController, MLNMapViewDelegate {
override func viewDidLoad() {
super.viewDidLoad()
let mapView = MLNMapView(frame: view.bounds)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.attributionButton.isHidden = true
mapView.tintColor = .lightGray
mapView.centerCoordinate = CLLocationCoordinate2D(latitude: 0, longitude: 66)
mapView.zoomLevel = 2
mapView.delegate = self
view.addSubview(mapView)
// Specify coordinates for our annotations.
let coordinates = [
CLLocationCoordinate2D(latitude: 0, longitude: 33),
CLLocationCoordinate2D(latitude: 0, longitude: 66),
CLLocationCoordinate2D(latitude: 0, longitude: 99),
]
// Fill an array with point annotations and add it to the map.
var pointAnnotations = [MLNPointAnnotation]()
for coordinate in coordinates {
let point = MLNPointAnnotation()
point.coordinate = coordinate
point.title = "\(coordinate.latitude), \(coordinate.longitude)"
pointAnnotations.append(point)
}
mapView.addAnnotations(pointAnnotations)
}
// MARK: - MLNMapViewDelegate methods
// This delegate method is where you tell the map to load a view for a specific annotation. To load a static MLNAnnotationImage, you would use `-mapView:imageForAnnotation:`.
func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
// This example is only concerned with point annotations.
guard annotation is MLNPointAnnotation else {
return nil
}
// Use the point annotation’s longitude value (as a string) as the reuse identifier for its view.
let reuseIdentifier = "\(annotation.coordinate.longitude)"
// For better performance, always try to reuse existing annotations.
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
// If there’s no reusable annotation view available, initialize a new one.
if annotationView == nil {
annotationView = CustomAnnotationView(reuseIdentifier: reuseIdentifier)
annotationView!.bounds = CGRect(x: 0, y: 0, width: 40, height: 40)
// Set the annotation view’s background color to a value determined by its longitude.
let hue = CGFloat(annotation.coordinate.longitude) / 100
annotationView!.backgroundColor = UIColor(hue: hue, saturation: 0.5, brightness: 1, alpha: 1)
}
return annotationView
}
func mapView(_: MLNMapView, annotationCanShowCallout _: MLNAnnotation) -> Bool {
true
}
}
//
// MLNAnnotationView subclass
class CustomAnnotationView: MLNAnnotationView {
override func layoutSubviews() {
super.layoutSubviews()
// Use CALayer’s corner radius to turn this view into a circle.
layer.cornerRadius = bounds.width / 2
layer.borderWidth = 2
layer.borderColor = UIColor.white.cgColor
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Animate the border width in/out, creating an iris effect.
let animation = CABasicAnimation(keyPath: "borderWidth")
animation.duration = 0.1
layer.borderWidth = selected ? bounds.width / 4 : 2
layer.add(animation, forKey: "borderWidth")
}
}
```

---
# Blocking Gestures
https://docs.mapatlas.xyz/overview/sdk/ios-native/BlockingGesturesExample
# Blocking Gestures
Constrain the map to a certain area.
> Note: This example uses SwiftUI.
To constrain the map to a certain area, you need to implement the ``MLNMapViewDelegate/mapView:shouldChangeFromCamera:toCamera:`` method of ``MLNMapViewDelegate``. By returning a boolean you can either allow or disallow a camera change.
```swift
// Denver, Colorado
private let center = CLLocationCoordinate2D(latitude: 39.748947, longitude: -104.995882)
// Colorado’s bounds
private let colorado = MLNCoordinateBounds(
sw: CLLocationCoordinate2D(latitude: 36.986207, longitude: -109.049896),
ne: CLLocationCoordinate2D(latitude: 40.989329, longitude: -102.062592)
)
struct BlockingGesturesExample: UIViewRepresentable {
class Coordinator: NSObject, MLNMapViewDelegate {
func mapView(_ mapView: MLNMapView, shouldChangeFrom _: MLNMapCamera, to newCamera: MLNMapCamera) -> Bool {
// Get the current camera to restore it after.
let currentCamera = mapView.camera
// From the new camera obtain the center to test if it’s inside the boundaries.
let newCameraCenter = newCamera.centerCoordinate
// Set the map’s visible bounds to newCamera.
mapView.camera = newCamera
let newVisibleCoordinates = mapView.visibleCoordinateBounds
// Revert the camera.
mapView.camera = currentCamera
// Test if the newCameraCenter and newVisibleCoordinates are inside self.colorado.
let inside = MLNCoordinateInCoordinateBounds(newCameraCenter, colorado)
let intersects = MLNCoordinateInCoordinateBounds(newVisibleCoordinates.ne, colorado) && MLNCoordinateInCoordinateBounds(newVisibleCoordinates.sw, colorado)
return inside && intersects
}
}
func makeUIView(context: Context) -> MLNMapView {
let mapView = MLNMapView(frame: .zero, styleURL: VERSATILES_COLORFUL_STYLE)
mapView.setCenter(center, zoomLevel: 10, direction: 0, animated: false)
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_: MLNMapView, context _: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
```
The style used in this example can be found here: .
---
# Fill Extrustion Layer
https://docs.mapatlas.xyz/overview/sdk/ios-native/BuildingLightExample
# Fill Extrustion Layer
Add a fill extrustion layer and adjust the light dynamically with a slider.
> Note: This example uses UIKit.
This examples adds a ``MLNFillExtrusionStyleLayer``. The to be rendered height of the buildings is read from the vector data.
The global ``MLNStyle/light`` property is adjusted as the user changes a slider, which affects the fill extrustion layer.
```swift
class BuildingLightExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
var light: MLNLight!
var slider: UISlider!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds, styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.delegate = self
// Center the map on the Flatiron Building in New York, NY.
mapView.camera = MLNMapCamera(lookingAtCenter: CLLocationCoordinate2D(latitude: 40.7411, longitude: -73.9897), altitude: 1200, pitch: 45, heading: 0)
view.addSubview(mapView)
addSlider()
}
// Add a slider to the map view. This will be used to adjust the map's light object.
func addSlider() {
slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
slider.autoresizingMask = [.flexibleTopMargin, .flexibleLeftMargin, .flexibleRightMargin]
slider.minimumValue = -180
slider.maximumValue = 180
slider.value = 0
slider.isContinuous = true
slider.addTarget(self, action: #selector(shiftLight), for: .valueChanged)
view.addSubview(slider)
NSLayoutConstraint.activate([
slider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 40.0),
slider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -40.0),
slider.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -75.0),
])
}
func mapView(_: MLNMapView, didFinishLoading style: MLNStyle) {
// Add a MLNFillExtrusionStyleLayer.
addFillExtrusionLayer(style: style)
// Create an MLNLight object.
light = MLNLight()
// Create an MLNSphericalPosition and set the radial, azimuthal, and polar values.
// Radial : Distance from the center of the base of an object to its light. Takes a CGFloat.
// Azimuthal : Position of the light relative to its anchor. Takes a CLLocationDirection.
// Polar : The height of the light. Takes a CLLocationDirection.
let position = MLNSphericalPositionMake(5, 180, 80)
light.position = NSExpression(forConstantValue: NSValue(mlnSphericalPosition: position))
// Set the light anchor to the map and add the light object to the map view's style. The light anchor can be the viewport (or rotates with the viewport) or the map (rotates with the map). To make the viewport the anchor, replace `map` with `viewport`.
light.anchor = NSExpression(forConstantValue: "map")
style.light = light
}
@objc func shiftLight() {
// Use the slider's value to change the light's polar value.
let position = MLNSphericalPositionMake(5, 180, CLLocationDirection(slider.value))
light.position = NSExpression(forConstantValue: NSValue(mlnSphericalPosition: position))
mapView.style?.light = light
}
func addFillExtrusionLayer(style: MLNStyle) {
// Access the OpenMapTiles source and use it to create a ``MLNFillExtrusionStyleLayer``. The source identifier is `openmaptiles`. Use the `sources` property on a style to verify source identifiers.
guard let source = style.source(withIdentifier: "openmaptiles") else {
print("Could not find source openmaptiles")
return
}
let layer = MLNFillExtrusionStyleLayer(identifier: "extrusion-layer", source: source)
layer.sourceLayerIdentifier = "building"
layer.fillExtrusionBase = NSExpression(forKeyPath: "render_min_height")
layer.fillExtrusionHeight = NSExpression(forKeyPath: "render_height")
layer.fillExtrusionOpacity = NSExpression(forConstantValue: 0.8)
layer.fillExtrusionColor = NSExpression(forConstantValue: UIColor.white)
// Access the map's layer with the identifier "poi" and insert the fill extrusion layer below it.
let symbolLayer = style.layer(withIdentifier: "poi")!
style.insertLayer(layer, below: symbolLayer)
}
}
```

---
# Custom Style Layers (Metal API)
https://docs.mapatlas.xyz/overview/sdk/ios-native/CustomStyleLayerExample
# Custom Style Layers (Metal API)
Creating a Custom Style Layer with Metal
Custom style layers allow you to draw directly with Metal, enabling you to render specialized shapes, custom geometry, or apply advanced visual effects that go beyond what is possible with standard style layers.
Below you can find an example of how to create a custom style layer with ``MLNCustomStyleLayer``. In this implementation, a SwiftUI view wraps an ``MLNMapView`` and appends a subclassed custom style layer once the map loads. The layer's ``MLNCustomStyleLayer/didMoveToMapView:`` method handles initialization, including compiling Metal shaders and creating a [`MTLRenderPipelineState`]({"contact/admin/forURLs"}) for subsequent draw operations. The ``MLNCustomStyleLayer/willMoveFromMapView:`` method provides a place to release or invalidate resources when the layer is removed from the map, while the ``MLNCustomStyleLayer/drawInMapView:withContext:`` method encodes the drawing commands using a [`MTLRenderCommandEncoder`]({"contact/admin/forURLs"}) and the map's projection matrix. By projecting latitude/longitude coordinates into a normalized 0–1 space and then transforming them into tile coordinates, the layer ensures that rendered geometry aligns correctly with the base map.
```swift
struct CustomStyleLayerExample: UIViewRepresentable {
func makeCoordinator() -> CustomStyleLayerExample.Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, MLNMapViewDelegate {
var control: CustomStyleLayerExample
init(_ control: CustomStyleLayerExample) {
self.control = control
}
func mapViewDidFinishLoadingMap(_ mapView: MLNMapView) {
let mapOverlay = CustomStyleLayer(identifier: "test-overlay")
let style = mapView.style!
style.layers.append(mapOverlay)
}
}
func makeUIView(context: Context) -> MLNMapView {
let mapView = MLNMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_: MLNMapView, context _: Context) {}
}
class CustomStyleLayer: MLNCustomStyleLayer {
private var pipelineState: MTLRenderPipelineState?
private var depthStencilStateWithoutStencil: MTLDepthStencilState?
override func didMove(to mapView: MLNMapView) {
#if MLN_RENDER_BACKEND_METAL
let resource = mapView.backendResource()
let shaderSource = """
#include
using namespace metal;
typedef struct
{
vector_float2 position;
vector_float4 color;
} Vertex;
struct RasterizerData
{
float4 position [[position]];
float4 color;
};
struct Uniforms
{
float4x4 matrix;
};
vertex RasterizerData
vertexShader(uint vertexID [[vertex_id]],
constant Vertex *vertices [[buffer(0)]],
constant Uniforms &uniforms [[buffer(1)]])
{
RasterizerData out;
const float4 position = uniforms.matrix * float4(float2(vertices[vertexID].position.xy), 1, 1);
out.position = position;
out.color = vertices[vertexID].color;
return out;
}
fragment float4 fragmentShader(RasterizerData in [[stage_in]])
{
return in.color;
}
"""
var error: NSError?
let device = resource.device
let library = try? device?.makeLibrary(source: shaderSource, options: nil)
assert(library != nil, "Error compiling shaders: \(String(describing: error))")
let vertexFunction = library?.makeFunction(name: "vertexShader")
let fragmentFunction = library?.makeFunction(name: "fragmentShader")
// Configure a pipeline descriptor that is used to create a pipeline state.
let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
pipelineStateDescriptor.label = "Simple Pipeline"
pipelineStateDescriptor.vertexFunction = vertexFunction
pipelineStateDescriptor.fragmentFunction = fragmentFunction
pipelineStateDescriptor.colorAttachments[0].pixelFormat = resource.mtkView.colorPixelFormat
pipelineStateDescriptor.depthAttachmentPixelFormat = .depth32Float_stencil8
pipelineStateDescriptor.stencilAttachmentPixelFormat = .depth32Float_stencil8
do {
pipelineState = try device?.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
} catch {
assertionFailure("Failed to create pipeline state: \(error)")
}
// Notice that we don't configure the stencilTest property, leaving stencil testing disabled
let depthStencilDescriptor = MTLDepthStencilDescriptor()
depthStencilDescriptor.depthCompareFunction = .always // Or another value as needed
depthStencilDescriptor.isDepthWriteEnabled = false
depthStencilStateWithoutStencil = device!.makeDepthStencilState(descriptor: depthStencilDescriptor)
#endif
}
override func willMove(from _: MLNMapView) {}
override func draw(in _: MLNMapView, with context: MLNStyleLayerDrawingContext) {
#if MLN_RENDER_BACKEND_METAL
guard let renderEncoder else { return }
// Project to 0..1.
let p1 = project(CLLocationCoordinate2D(latitude: 25.0, longitude: 12.5))
let p2 = project(CLLocationCoordinate2D(latitude: 0.0, longitude: 0.0))
let p3 = project(CLLocationCoordinate2D(latitude: 0.0, longitude: 25.0))
// Multiply by the world size so it becomes the tile coordinate system.
let worldSize = 512.0 * pow(2.0, context.zoomLevel)
let p1Tile = CGPoint(x: p1.x * worldSize, y: p1.y * worldSize)
let p2Tile = CGPoint(x: p2.x * worldSize, y: p2.y * worldSize)
let p3Tile = CGPoint(x: p3.x * worldSize, y: p3.y * worldSize)
// Then build a triangle from tile coordinates
struct Vertex { var position: vector_float2; var color: vector_float4 }
let triangleVertices: [Vertex] = [
Vertex(position: vector_float2(Float(p1Tile.x), Float(p1Tile.y)),
color: vector_float4(1, 0, 0, 1)),
Vertex(position: vector_float2(Float(p2Tile.x), Float(p2Tile.y)),
color: vector_float4(0, 1, 0, 1)),
Vertex(position: vector_float2(Float(p3Tile.x), Float(p3Tile.y)),
color: vector_float4(0, 0, 1, 1)),
]
// Use the camera's full projection matrix *unchanged*.
var matrix = convertMatrix(context.projectionMatrix)
// Encode
renderEncoder.setRenderPipelineState(pipelineState!)
renderEncoder.setDepthStencilState(depthStencilStateWithoutStencil)
renderEncoder.setVertexBytes(triangleVertices, length: MemoryLayout.size * triangleVertices.count, index: 0)
renderEncoder.setVertexBytes(&matrix, length: MemoryLayout.size, index: 1)
// Draw the triangle.
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
#endif
}
func project(_ coordinate: CLLocationCoordinate2D) -> CGPoint {
// We project the coordinates into the space 0 to 1 and then scale these when drawing based on the current zoom level
let worldSize = 1.0
let x = (180.0 + coordinate.longitude) / 360.0 * worldSize
let yi = log(tan((45.0 + coordinate.latitude / 2.0) * Double.pi / 180.0))
let y = (180.0 - yi * (180.0 / Double.pi)) / 360.0 * worldSize
return CGPoint(x: x, y: y)
}
struct MLNMatrix4f {
var m00, m01, m02, m03: Float
var m10, m11, m12, m13: Float
var m20, m21, m22, m23: Float
var m30, m31, m32, m33: Float
}
func convertMatrix(_ mat: MLNMatrix4) -> MLNMatrix4f {
MLNMatrix4f(
m00: Float(mat.m00), m01: Float(mat.m01), m02: Float(mat.m02), m03: Float(mat.m03),
m10: Float(mat.m10), m11: Float(mat.m11), m12: Float(mat.m12), m13: Float(mat.m13),
m20: Float(mat.m20), m21: Float(mat.m21), m22: Float(mat.m22), m23: Float(mat.m23),
m30: Float(mat.m30), m31: Float(mat.m31), m32: Float(mat.m32), m33: Float(mat.m33)
)
}
}
```

---
# Customizing Fonts
https://docs.mapatlas.xyz/overview/sdk/ios-native/Customizing_Fonts
# Customizing Fonts
Using custom fonts
MapMetrics Native iOS can render text that is part of an ``MLNSymbolStyleLayer`` in a font of your choice. The font customization options discussed in this document do not apply to user interface elements such as the scale bar or annotation callout views.
## Server-side fonts
By default, the map renders characters using glyphs downloaded from the server. You apply fonts when building a style with [Maputnik]({"contact/admin/forURLs"}), in the `text-font` layout property in style JSON, or in the ``MLNSymbolStyleLayer/textFontNames`` property at runtime. The values in these properties must be font display names, not font family names or PostScript names.
Each font name in the list must match a font that is present on the server; otherwise, the text will not load, even if one of the fonts is available. Each font name must be included in the `{fontstack}` portion of the JSON stylesheet’s [`glyphs`]({"contact/admin/forURLs"}) property.
[Martin]({"contact/admin/forURLs"}) can serve fonts directly. You can also generate fonts with [font-maker]({"contact/admin/forURLs"}).
## Client-side fonts
By default, Chinese hanzi, Japanese kana, and Korean hangul characters (CJK) are rendered on the client side. Client-side text rendering uses less bandwidth than server-side text rendering, especially when viewing regions of the map that feature a wide variety of CJK characters.
First, the map attempts to apply a font that you specify the same way as you would specify a server-side font: in [Maputnik]({"contact/admin/forURLs"}), in the `text-font` layout property in style JSON, or in the `MLNSymbolStyleLayer.textFontNames` property at runtime. Instead of downloading the glyphs, the map tries to find a [system font]({"contact/admin/forURLs"}) or a font [bundled with your application]({"contact/admin/forURLs"}) that matches one of these fonts based on its family name (for example, “PingFang TC”), display name (“PingFang TC Ultralight”), or PostScript name (“PingFangTC-Ultralight”).
If the symbol layer does not specify an available font that contains the required glyphs, then the map tries to find a matching font in the `MLNIdeographicFontFamilyName` [Info.plist key](doc:Info.plist_Keys). Like the ``MLNSymbolStyleLayer/textFontNames`` property, this key can contain a family name, display name, or PostScript name. This key is a global fallback that applies to all layers uniformly. It can either be a single string or an array of strings, which the map tries to apply in order from most preferred to least preferred.
Each character is rendered in the first font you specify that has a glyph for the character. If the entire list of fonts is exhausted, the map uses the system’s font cascade list, which may vary based on the device model and system language.
To disable client-side rendering of CJK characters, set the `MLNIdeographicFontFamilyName` key to the Boolean value `NO`. The map will revert to server-side font rendering.
---
# Vector Tile Sources
https://docs.mapatlas.xyz/overview/sdk/ios-native/DDSCircleLayerExample
# Vector Tile Sources
Add and style a vector tile source
> This example uses UIKit
This example shows how a vector data source can be added and a style for it can be configured dynamically.
The tiles are [tiles around Innsbruck, Austria]({"contact/admin/forURLs"}) that use the OpenMapTiles schema. We are interested in the [POIs]({"contact/admin/forURLs"}) that are in the `poi` layer, and filter this further with an `NSPredicate` to only show POIs with a `class` of shop. Each POI has a `rank` which is normally used to reduce label density. In this example, we use it to demonstrate how a numeric attribute can be used for styling with the [step]({"contact/admin/forURLs"}) expression. POIs with a rank between 0 and 10 get a red color, between 10 and 20 green, etc.
```swift
class DDSCircleLayerExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds, styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.tintColor = .darkGray
// Set the map’s center coordinate and zoom level.
let innsbruck = CLLocationCoordinate2D(latitude: 47.26497, longitude: 11.4088)
mapView.setCenter(innsbruck, animated: false)
mapView.zoomLevel = 14
mapView.delegate = self
view.addSubview(mapView)
}
// Wait until the style is loaded before modifying the map style.
func mapView(_: MLNMapView, didFinishLoading style: MLNStyle) {
let source = MLNVectorTileSource(identifier: "demotiles", configurationURL: URL(string: "{"contact/admin/forURLs"}")!)
style.addSource(source)
let layer = MLNCircleStyleLayer(identifier: "poi-shop-style", source: source)
layer.sourceLayerIdentifier = "poi"
layer.predicate = NSPredicate(format: "class == %@", "shop")
// Style the circle layer color based on the rank
layer.circleColor = NSExpression(mglJSONObject: ["step", ["get", "rank"], 0, "red", 10, "green", 20, "blue", 30, "purple", 40, "yellow"] as [Any])
layer.circleRadius = NSExpression(forConstantValue: 3)
style.addLayer(layer)
}
}
```

---
# Styles in Examples
https://docs.mapatlas.xyz/overview/sdk/ios-native/ExampleStyles
# Styles in Examples
The following styles are used in the examples:
```swift
let AMERICANA_STYLE = URL(string:
"{"contact/admin/forURLs"}")
let VERSATILES_COLORFUL_STYLE = URL(string: "{"contact/admin/forURLs"}")
```
---
# Information for Style Authors
https://docs.mapatlas.xyz/overview/sdk/ios-native/For_Style_Authors
# Information for Style Authors
## Designing for iOS
When designing your style, consider the context in which your application shows
the style. There are a number of considerations specific to iOS that may
not be obvious when designing your style with Maputnik. A map view
is essentially a graphical user interface element, so many of same issues in
user interface design also apply when designing a map style.
### Color
Ensure sufficient contrast in your application’s user interface when your map
style is present. Standard user interface elements such as toolbars, sidebars,
and sheets often overlap the map view with a translucent, blurred background, so
make sure the contents of these elements remain legible with the map view
underneath.
The user location annotation view, the attribution button, any buttons in
callout views, and any items in the navigation bar are influenced by your
application’s tint color, so choose a tint color that contrasts well with your
map style.
If you intend your style to be used in the dark, consider the impact that Night
Shift may have on your style’s colors.
### Typography and graphics
Choose font and icon sizes appropriate to iOS devices. iPhones and iPads have
smaller screens than the typical browser window, especially when multitasking is enabled. Your user's viewing distance
may be shorter than on a desktop computer. Some of your users may use the Larger
Dynamic Type and Accessibility Text features to increase the size of all text on
the device. You can use the
[runtime styling API](Manipulating-the-style-at-runtime) to adjust your style’s
font and icon sizes accordingly.
Design sprite images and choose font weights that look crisp on both
standard-resolution displays and Retina displays. This SDK supports the same
resolutions as iOS.
Standard-resolution displays are limited to older devices that your application
may or may not support, depending on its minimum deployment target.
Icon and text labels should be legible regardless of the map’s orientation.
By default, this SDK makes it easy for your users to rotate or tilt the map
using multitouch gestures.
If you do not intend your design to accommodate rotation and tilting, disable
these gestures using the `MLNMapView.rotateEnabled` and
`MLNMapView.pitchEnabled` properties, respectively, or the corresponding
inspectables in Interface Builder.
### Interactivity
Pay attention to whether elements of your style appear to be interactive.
A text label may look like a tappable button merely due to matching your
application’s tint color or the default blue tint color.
You can make an icon or text label interactive by installing a gesture
recognizer and performing feature querying (e.g.,
``MLNMapView/visibleFeaturesAtPoint:``) to get details about the selected
feature.
Make sure your users can easily distinguish any interactive elements from the
surrounding map, such as pins, the user location annotation view, or a route
line. Avoid relying on hover effects to indicate interactive elements. Leave
enough room between interactive elements to accommodate imprecise tapping
gestures.
For more information about user interface design, consult Apple’s
[_iOS Human Interface Guidelines_]({"contact/admin/forURLs"}).
To learn more about designing maps for mobile devices, see [Nathaniel Slaughter's blog post]({"contact/admin/forURLs"}) on
the subject.
## Applying your style
You set an `MLNMapView` object’s style either in code, by setting the
`MLNMapView.styleURL` property, or in Interface Builder, by setting the “Style
URL” inspectable. The URL must point to a local or remote style JSON file. The
style JSON file format is defined by the
[MapMetrics Style Spec]({"contact/admin/forURLs"}).
## Manipulating the style at runtime
The _runtime styling API_ enables you to modify every aspect of a style
dynamically as a user interacts with your application. The style itself is
represented at runtime by an ``MLNStyle`` object, which provides access to various
``MLNSource`` and ``MLNStyleLayer`` objects that represent content sources and style
layers, respectively.
To avoid conflicts with Objective-C keywords or Cocoa terminology, this SDK uses
the following terms for concepts defined in the style specification:
In the style specification | In the SDK
---------------------------|---------
bounds | coordinate bounds
filter | predicate
function type | interpolation mode
id | identifier
image | style image
layer | style layer
property | attribute
SDF icon | template image
source | content source
## Specifying the map’s content
Each source defined by a style JSON file is represented at runtime by a content
source object that you can use to initialize new style layers. The content
source object is a member of one of the following subclasses of ``MLNSource``:
In style JSON | In the SDK
--------------|-----------
`vector` | ``MLNVectorTileSource``
`raster` | ``MLNRasterTileSource``
`raster-dem` | ``MLNRasterDEMSource``
`geojson` | ``MLNShapeSource``
`image` | ``MLNImageSource``
`canvas` and `video` sources are not supported.
### Tile sources
Raster and vector tile sources may be defined in TileJSON configuration files.
This SDK supports the properties defined in the style specification, which are a
subset of the keys defined in version 2.1.0 of the
[TileJSON]({"contact/admin/forURLs"})
specification. As an alternative to authoring a custom TileJSON file, you may
supply various tile source options when creating a raster or vector tile source.
These options are detailed in the `MLNTileSourceOption` documentation:
In style JSON | In TileJSON | In the SDK
--------------|---------------|-----------
`url` | — | `configurationURL` parameter in `-[MLNTileSource initWithIdentifier:configurationURL:]`
`tiles` | `tiles` | `tileURLTemplates` parameter in `-[MLNTileSource initWithIdentifier:tileURLTemplates:options:]`
`minzoom` | `minzoom` | `MLNTileSourceOptionMinimumZoomLevel`
`maxzoom` | `maxzoom` | `MLNTileSourceOptionMaximumZoomLevel`
`bounds` | `bounds` | `MLNTileSourceOptionCoordinateBounds`
`tileSize` | — | `MLNTileSourceOptionTileSize`
`attribution` | `attribution` | `MLNTileSourceOptionAttributionHTMLString` (but consider specifying `MLNTileSourceOptionAttributionInfos` instead for improved security)
`scheme` | `scheme` | `MLNTileSourceOptionTileCoordinateSystem`
`encoding` | – | `MLNTileSourceOptionDEMEncoding`
### Shape sources
Shape sources also accept various options. These options are detailed in the
`MLNShapeSourceOption` documentation:
In style JSON | In the SDK
-----------------|-----------
`data` | `url` parameter in `-[MLNShapeSource initWithIdentifier:URL:options:]`
`maxzoom` | `MLNShapeSourceOptionMaximumZoomLevel`
`buffer` | `MLNShapeSourceOptionBuffer`
`tolerance` | `MLNShapeSourceOptionSimplificationTolerance`
`cluster` | `MLNShapeSourceOptionClustered`
`clusterRadius` | `MLNShapeSourceOptionClusterRadius`
`clusterMinPoints` | `MLNShapeSourceOptionClusterMinPoints`
`clusterMaxZoom` | `MLNShapeSourceOptionMaximumZoomLevelForClustering`
`lineMetrics` | `MLNShapeSourceOptionLineDistanceMetrics`
To create a shape source from local GeoJSON data, first
[convert the GeoJSON data into a shape](working-with-geojson-data.html#converting-geojson-data-into-shape-objects),
then use the `-[MLNShapeSource initWithIdentifier:shape:options:]` method.
### Image sources
Image sources accept a non-axis aligned quadrilateral as their geographic coordinates.
These coordinates, in `MLNCoordinateQuad`, are described in counterclockwise order,
in contrast to the clockwise order defined in the style specification.
## Configuring the map content’s appearance
Each layer defined by the style JSON file is represented at runtime by a style
layer object, which you can use to refine the map’s appearance. The style layer
object is a member of one of the following subclasses of `MLNStyleLayer`:
In style JSON | In the SDK
--------------|-----------
`background` | `MLNBackgroundStyleLayer`
`circle` | `MLNCircleStyleLayer`
`fill` | `MLNFillStyleLayer`
`fill-extrusion` | `MLNFillExtrusionStyleLayer`
`heatmap` | `MLNHeatmapStyleLayer`
`hillshade` | `MLNHillshadeStyleLayer`
`line` | `MLNLineStyleLayer`
`raster` | `MLNRasterStyleLayer`
`symbol` | `MLNSymbolStyleLayer`
You configure layout and paint attributes by setting properties on these style
layer objects. The property names generally correspond to the style JSON
properties, except for the use of camelCase instead of kebab-case. Properties
whose names differ from the style specification are listed below:
### Circle style layers
In style JSON | In Objective-C | In Swift
--------------|----------------|---------
`circle-pitch-scale` | `MLNCircleStyleLayer.circleScaleAlignment` | `MLNCircleStyleLayer.circleScaleAlignment`
`circle-translate` | `MLNCircleStyleLayer.circleTranslation` | `MLNCircleStyleLayer.circleTranslation`
`circle-translate-anchor` | `MLNCircleStyleLayer.circleTranslationAnchor` | `MLNCircleStyleLayer.circleTranslationAnchor`
### Fill style layers
In style JSON | In Objective-C | In Swift
--------------|----------------|---------
`fill-antialias` | `MLNFillStyleLayer.fillAntialiased` | `MLNFillStyleLayer.isFillAntialiased`
`fill-translate` | `MLNFillStyleLayer.fillTranslation` | `MLNFillStyleLayer.fillTranslation`
`fill-translate-anchor` | `MLNFillStyleLayer.fillTranslationAnchor` | `MLNFillStyleLayer.fillTranslationAnchor`
### Fill extrusion style layers
In style JSON | In Objective-C | In Swift
--------------|----------------|---------
`fill-extrusion-vertical-gradient` | `MLNFillExtrusionStyleLayer.fillExtrusionHasVerticalGradient` | `MLNFillExtrusionStyleLayer.fillExtrusionHasVerticalGradient`
`fill-extrusion-translate` | `MLNFillExtrusionStyleLayer.fillExtrusionTranslation` | `MLNFillExtrusionStyleLayer.fillExtrusionTranslation`
`fill-extrusion-translate-anchor` | `MLNFillExtrusionStyleLayer.fillExtrusionTranslationAnchor` | `MLNFillExtrusionStyleLayer.fillExtrusionTranslationAnchor`
### Line style layers
In style JSON | In Objective-C | In Swift
--------------|----------------|---------
`line-dasharray` | `MLNLineStyleLayer.lineDashPattern` | `MLNLineStyleLayer.lineDashPattern`
`line-translate` | `MLNLineStyleLayer.lineTranslation` | `MLNLineStyleLayer.lineTranslation`
`line-translate-anchor` | `MLNLineStyleLayer.lineTranslationAnchor` | `MLNLineStyleLayer.lineTranslationAnchor`
### Raster style layers
In style JSON | In Objective-C | In Swift
--------------|----------------|---------
`raster-brightness-max` | `MLNRasterStyleLayer.maximumRasterBrightness` | `MLNRasterStyleLayer.maximumRasterBrightness`
`raster-brightness-min` | `MLNRasterStyleLayer.minimumRasterBrightness` | `MLNRasterStyleLayer.minimumRasterBrightness`
`raster-hue-rotate` | `MLNRasterStyleLayer.rasterHueRotation` | `MLNRasterStyleLayer.rasterHueRotation`
`raster-resampling` | `MLNRasterStyleLayer.rasterResamplingMode` | `MLNRasterStyleLayer.rasterResamplingMode`
### Symbol style layers
In style JSON | In Objective-C | In Swift
--------------|----------------|---------
`icon-allow-overlap` | `MLNSymbolStyleLayer.iconAllowsOverlap` | `MLNSymbolStyleLayer.iconAllowsOverlap`
`icon-ignore-placement` | `MLNSymbolStyleLayer.iconIgnoresPlacement` | `MLNSymbolStyleLayer.iconIgnoresPlacement`
`icon-image` | `MLNSymbolStyleLayer.iconImageName` | `MLNSymbolStyleLayer.iconImageName`
`icon-optional` | `MLNSymbolStyleLayer.iconOptional` | `MLNSymbolStyleLayer.isIconOptional`
`icon-rotate` | `MLNSymbolStyleLayer.iconRotation` | `MLNSymbolStyleLayer.iconRotation`
`icon-size` | `MLNSymbolStyleLayer.iconScale` | `MLNSymbolStyleLayer.iconScale`
`icon-keep-upright` | `MLNSymbolStyleLayer.keepsIconUpright` | `MLNSymbolStyleLayer.keepsIconUpright`
`text-keep-upright` | `MLNSymbolStyleLayer.keepsTextUpright` | `MLNSymbolStyleLayer.keepsTextUpright`
`text-max-angle` | `MLNSymbolStyleLayer.maximumTextAngle` | `MLNSymbolStyleLayer.maximumTextAngle`
`text-max-width` | `MLNSymbolStyleLayer.maximumTextWidth` | `MLNSymbolStyleLayer.maximumTextWidth`
`symbol-avoid-edges` | `MLNSymbolStyleLayer.symbolAvoidsEdges` | `MLNSymbolStyleLayer.symbolAvoidsEdges`
`text-field` | `MLNSymbolStyleLayer.text` | `MLNSymbolStyleLayer.text`
`text-allow-overlap` | `MLNSymbolStyleLayer.textAllowsOverlap` | `MLNSymbolStyleLayer.textAllowsOverlap`
`text-font` | `MLNSymbolStyleLayer.textFontNames` | `MLNSymbolStyleLayer.textFontNames`
`text-size` | `MLNSymbolStyleLayer.textFontSize` | `MLNSymbolStyleLayer.textFontSize`
`text-ignore-placement` | `MLNSymbolStyleLayer.textIgnoresPlacement` | `MLNSymbolStyleLayer.textIgnoresPlacement`
`text-justify` | `MLNSymbolStyleLayer.textJustification` | `MLNSymbolStyleLayer.textJustification`
`text-optional` | `MLNSymbolStyleLayer.textOptional` | `MLNSymbolStyleLayer.isTextOptional`
`text-rotate` | `MLNSymbolStyleLayer.textRotation` | `MLNSymbolStyleLayer.textRotation`
`text-writing-mode` | `MLNSymbolStyleLayer.textWritingModes` | `MLNSymbolStyleLayer.textWritingModes`
`icon-translate` | `MLNSymbolStyleLayer.iconTranslation` | `MLNSymbolStyleLayer.iconTranslation`
`icon-translate-anchor` | `MLNSymbolStyleLayer.iconTranslationAnchor` | `MLNSymbolStyleLayer.iconTranslationAnchor`
`text-translate` | `MLNSymbolStyleLayer.textTranslation` | `MLNSymbolStyleLayer.textTranslation`
`text-translate-anchor` | `MLNSymbolStyleLayer.textTranslationAnchor` | `MLNSymbolStyleLayer.textTranslationAnchor`
## Setting attribute values
Each property representing a layout or paint attribute is set to an
`NSExpression` object. `NSExpression` objects play the same role as
[expressions in the MapMetrics Style Spec]({"contact/admin/forURLs"}),
but you create the former using a very different syntax. `NSExpression`’s format
string syntax is reminiscent of a spreadsheet formula or an expression in a
database query. See the
“[Predicates and Expressions](predicates-and-expressions.html)” guide for an
overview of the expression support in this SDK. This SDK no longer supports
style functions; use expressions instead.
### Constant values in expressions
In contrast to the JSON type that the style specification defines for each
layout or paint property, the style value object often contains a more specific
Foundation or Cocoa type. General rules for attribute types are listed below.
Pay close attention to the SDK documentation for the attribute you want to get
or set.
In style JSON | In Objective-C | In Swift
--------------|-----------------------|---------
Color | `UIColor` | `UIColor`
Enum | `NSString` | `String`
String | `NSString` | `String`
Boolean | `NSNumber.boolValue` | `NSNumber.boolValue`
Number | `NSNumber.floatValue` | `NSNumber.floatValue`
Array (`-dasharray`) | `NSArray` | `[Float]`
Array (`-font`) | `NSArray` | `[String]`
Array (`-offset`, `-translate`) | `NSValue.CGVectorValue` | `NSValue.cgVectorValue`
Array (`-padding`) | `NSValue.UIEdgeInsetsValue` | `NSValue.uiEdgeInsetsValue`
For padding attributes, note that the arguments to
`UIEdgeInsetsMake()` in Objective-C and `UIEdgeInsets(top:left:bottom:right:)`
in Swift
are specified in counterclockwise order, in contrast to the clockwise order
defined by the style specification
## Filtering sources
You can filter a shape or vector tile source by setting the
`MLNVectorStyleLayer.predicate` property to an `NSPredicate` object. Below is a
table of style JSON operators and the corresponding operators used in the
predicate format string:
In style JSON | In the format string
--------------------------|---------------------
`["has", key]` | `key != nil`
`["!has", key]` | `key == nil`
`["==", key, value]` | `key == value`
`["!=", key, value]` | `key != value`
`[">", key, value]` | `key > value`
`[">=", key, value]` | `key >= value`
`["<", key, value]` | `key < value`
`["<=", key, value]` | `key <= value`
`["in", key, v0, …, vn]` | `key IN {v0, …, vn}`
`["!in", key, v0, …, vn]` | `NOT key IN {v0, …, vn}`
`["all", f0, …, fn]` | `p0 AND … AND pn`
`["any", f0, …, fn]` | `p0 OR … OR pn`
`["none", f0, …, fn]` | `NOT (p0 OR … OR pn)`
## Specifying the text format
The following format attributes are defined as `NSString` constans that you
can use to update the formatting of `MLNSymbolStyleLayer.text` property.
In style JSON | In Objective-C | In Swift
--------------|-----------------------|---------
`text-font` | `MLNFontNamesAttribute` | `.fontNamesAttribute`
`font-scale` | `MLNFontScaleAttribute` | `.fontScaleAttribute`
`text-color` | `MLNFontColorAttribute` | `.fontColorAttribute`
See for
a full description of the supported operators and operand types.
---
# Working with GeoJSON Data
https://docs.mapatlas.xyz/overview/sdk/ios-native/GeoJSON
# Working with GeoJSON Data
## Adding a GeoJSON file to the map
MapMetrics iOS offers several ways to work with [GeoJSON]({"contact/admin/forURLs"}) files.
GeoJSON is a standard file format for representing geographic data.
You can host a GeoJSON file or bundle it with
your application, you can use a GeoJSON file as the basis of an ``MLNShapeSource``
object. Pass the file’s URL into the
``MLNShapeSource/initWithIdentifier:URL:options:`` initializer and add the
shape source to the map using the ``MLNStyle/addSource:`` method. The URL may
be a local file URL, an HTTP URL, or an HTTPS URL.
Once you’ve added the GeoJSON file to the map via an ``MLNShapeSource`` object,
you can configure the appearance of its data and control what data is visible
using ``MLNStyleLayer`` objects. You can also
[access the data programmatically](#Extracting-GeoJSON-data-from-the-map).
## Converting GeoJSON data into shape objects
If you have GeoJSON data in the form of source code (also known as “GeoJSON
text”), you can convert it into an ``MLNShape``, ``MLNFeature``, or
``MLNShapeCollectionFeature`` object that the ``MLNShapeSource`` class understands
natively. First, create an `NSData` object out of the source code string or file
contents, then pass that data object into the
``MLNShape/shapeWithData:encoding:error:`` method. Finally, you can pass the
resulting shape or feature object into the
``MLNShapeSource/initWithIdentifier:shape:options:`` initializer and add it to
the map, or you can use the object and its properties to power non-map-related
functionality in your application.
To include multiple shapes in the source, create and pass an ``MLNShapeCollection`` or
``MLNShapeCollectionFeature`` object to
``MLNShapeSource/initWithIdentifier:shape:options:``. Alternatively, use the
``MLNShapeSource/initWithIdentifier:features:options:`` or
``MLNShapeSource/initWithIdentifier:shapes:options:`` method to create a shape source
with an array. ``MLNShapeSource/initWithIdentifier:features:options:`` accepts only ``MLNFeature``
instances, such as ``MLNPointFeature`` objects, whose attributes you can use when
applying a predicate to ``MLNVectorStyleLayer`` or configuring a style layer’s
appearance.
## Extracting GeoJSON data from the map
Any ``MLNShape``, ``MLNFeature``, or ``MLNShapeCollectionFeature`` object has an
``MLNShape/geoJSONDataUsingEncoding:`` method that you can use to create a
GeoJSON source code representation of the object. You can extract a feature
object from the map using a method such as
``MLNMapView/visibleFeaturesAtPoint:``.
## About GeoJSON deserialization
The process of converting GeoJSON text into ``MLNShape``, ``MLNFeature``, or
``MLNShapeCollectionFeature`` objects is known as “GeoJSON deserialization”.
GeoJSON geometries, features, and feature collections are known in this SDK as
shapes, features, and shape collection features, respectively.
Each GeoJSON object type corresponds to a type provided by either this SDK or
the Core Location framework:
GeoJSON object type | SDK type
--------------------|---------
`Position` (longitude, latitude) | `CLLocationCoordinate2D` (latitude, longitude)
`Point` | ``MLNPointAnnotation``
`MultiPoint` | ``MLNPointCollection``
`LineString` | ``MLNPolyline``
`MultiLineString` | ``MLNMultiPolyline``
`Polygon` | ``MLNPolygon``
`MultiPolygon` | ``MLNMultiPolygon``
`GeometryCollection` | ``MLNShapeCollection``
`Feature` | ``MLNFeature``
`FeatureCollection` | ``MLNShapeCollectionFeature``
A `Feature` object in GeoJSON corresponds to an instance of an ``MLNShape``
subclass conforming to the ``MLNFeature`` protocol. There is a distinct
``MLNFeature``-conforming class for each type of geometry that a GeoJSON feature
can contain. This allows features to be used as raw shapes where convenient. For
example, some features can be added to a map view as annotations. Note that
identifiers and attributes will not be available for feature querying when a
feature is used as an annotation.
In contrast to the GeoJSON standard, it is possible for ``MLNShape`` subclasses
other than ``MLNPointAnnotation`` to straddle the antimeridian.
The following GeoJSON data types correspond straightforwardly to Foundation data
types when they occur as feature identifiers or property values:
GeoJSON data type | Objective-C representation | Swift representation
-------------------|----------------------------|---------------------
`null` | `NSNull` | `NSNull`
`true`, `false` | `NSNumber.boolValue` | `Bool`
Integer | `NSNumber.unsignedLongLongValue`, `NSNumber.longLongValue` | `UInt64`, `Int64`
Floating-point number | `NSNumber.doubleValue` | `Double`
String | `NSString` | `String`
---
# User Interactions
https://docs.mapatlas.xyz/overview/sdk/ios-native/GestureRecognizers
# User Interactions
Learn how to work with gesture recognizers
MapMetrics Native iOS provides a set of built-in gesture recognizers. You can customize or supplement these gestures according to your use case. You see what gesture recognizers are on your ``MLNMapView`` by accessing the `gestureRecognizers` property on your map.
## Configuring user interaction
Several properties on an ``MLNMapView`` provide ways to enable or disable a set of gesture recognizers. Boolean values are set to `true` by default.
- ``MLNMapView/zoomEnabled`` - Allows the user to zoom in or out by pinching two fingers, double-tapping, tapping with two fingers, or double-tapping then dragging vertically. Accepts Boolean values.
- ``MLNMapView/scrollEnabled`` - Allows the user to scroll by dragging or swiping one finger. Accepts Boolean values.
- ``MLNMapView/rotateEnabled`` - Allows the user to rotate by moving two fingers in a circular motion. Accepts Boolean values.
- ``MLNMapView/pitchEnabled`` - Allows the user to tilt the map by vertically dragging two fingers. Accepts Boolean values.
- ``MLNMapView/decelerationRate`` - Determines the rate of deceleration after the user lifts their finger. You can set the value using the ``MLNMapViewDecelerationRateNormal``, ``MLNMapViewDecelerationRateFast``, or ``MLNMapViewDecelerationRateImmediate`` constants.
## Individual gestures
|Gesture | Description | Related Property |
|:-------:|----------------| -----------|
|Pinch | Zooms in or out on the map's anchor point | ``MLNMapView/zoomEnabled`` |
|Rotation | Changes the ``MLNMapView`` direction based on the user rotating two fingers in a circular motion | ``MLNMapView/rotateEnabled`` |
|Single tap | Selects/deselects the annotation that you tap. | |
|Double tap | Zooms in on the map's anchor point | ``MLNMapView/zoomEnabled`` |
|Two-finger tap | Zooms out with the map's anchor point centered | ``MLNMapView/zoomEnabled`` |
|Pan | Scrolls across mapView (_note: if_ `MLNUserTrackingModeFollow` _is being used, it will be disabled once the user pans_)| ``MLNMapView/scrollEnabled`` |
|Two-finger drag | Adjusts the pitch of the ``MLNMapView`` | `pitchEnabled` |
|One-finger zoom | Tap twice; on second tap, hold your finger on the map and pan up to zoom in, or down to zoom out | ``MLNMapView/zoomEnabled`` |
@Video(
source: "rotation.mp4",
poster: "rotation.png",
alt: "A short video showing the gesture for rotation with two fingers.") {
Rotation with two fingers.
}
@Video(
source: "quickzoom.mp4",
poster: "quickzoom.png",
alt: "A short video showing rotation with one finger.") {
One finger zoom with a double tap.
}
## Adding custom gesture recognizers
You can add [`UIGestureRecognizer`s]({"contact/admin/forURLs"}) to your map programmatically or via storyboard. Adding custom responses to gesture recognizers can enhance your user's experience, but try to use standard gestures where possible.
The gesture recognizers that you add will take priority over the built-in gesture recognizer. You can also set up your own gesture recognizer to work simultaneously with built-in gesture recognizers by using [`gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:`]({"contact/admin/forURLs"}), allowing you to enhance already existing gesture recognizers.
You can also add gesture recognizers that are only called when the default gesture recognizer fails (and vice versa), such as when a user taps on a part of the map that is not an annotation. The documentation for ``MLNMapView`` includes an example of how to create a fallback gesture recognizer.
If you would like to disable a specific set of gesture recognizers, such as zoom, you can set the Boolean value for the appropriate property to `false`. You can then add your own gesture recognizers to perform those actions.
---
# Prerequisite
https://docs.mapatlas.xyz/overview/sdk/ios-native/GettingStarted
# Prerequisite
For all of our examples, you will need:
- an API key,
- knowledge in Swift/iOS development,
- one style (default)
## Get the library
There are several ways to get this library:
- from the github repository (https://github.com/MapMetrics/MapMetrics-iOS)
- from swift package https://github.com/MapMetrics/MapMetrics-iOS
- from cocoapods.org
## Installation (CocoaPods)
Add this to your Podfile:
```ruby
target 'YourApp' do
pod 'MapMetrics-iOS', '~> 0.0.3' # Use the latest version
# OR
pod 'MapMetrics-iOS', :git => 'https://github.com/MapMetrics/MapMetrics-iOS', :tag => '0.0.1'
end
```
Run:
```bash
pod install
```
## Required Build Settings (Sandbox Fix)
To prevent `rsync.samba deny(1)` errors, users must add these settings:
### Option A: Automatic Fix (via Podfile)
Add to your Podfile:
```ruby
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# Disable sandbox restrictions
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
end
end
end
```
Then run:
```bash
pod install
```
### Option B: Manual Fix (Xcode Settings)
1. Open your project in Xcode.
2. Go to **Target → Build Settings**.
3. Search for `ENABLE_USER_SCRIPT_SANDBOXING`.
4. Set to **NO** for all configurations (Debug/Release).
---
## Verify Installation
Import in your code:
```swift
import MapMetrics
```
Clean Build (if issues persist):
```bash
rm -rf ~/Library/Developer/Xcode/DerivedData/*
```
---
## Troubleshooting
| Issue | Solution |
|---------------------------|-----------|
| Sandbox deny(1) errors | Ensure `ENABLE_USER_SCRIPT_SANDBOXING=NO` is set. |
| `pod install` fails | Delete `Pods/` and `Podfile.lock`, then retry. |
| Version conflicts | Run `pod update MapMetrics`. |
---
## Example Podfile (Complete)
```ruby
platform :ios, '12.0'
target 'YourApp' do
use_frameworks!
pod 'MapMetrics-iOS', '~> 0.0.3'
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
end
end
end
end
```
---
## Example Usage
Here's how to integrate **MapMetrics** into your project:
```swift
class ViewController: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(
frame: view.bounds,
styleURL: URL(string: "")
)
mapView.delegate = self
view.addSubview(mapView)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.delegate = self
// This is a better starting position
let center = CLLocationCoordinate2D(latitude: 20.0, longitude: 0.0) // More centered globally
mapView.setCenter(center, zoomLevel: 2, animated: false) // Lower zoom to see more area
view.addSubview(mapView)
}
}
```
---
---
# Missing Images
https://docs.mapatlas.xyz/overview/sdk/ios-native/IMAGES-NEEDED
# Missing Images
The following images are referenced in the documentation but not yet downloaded. These images are part of the MapMetrics Native iOS documentation and can be found in the official repository or documentation site.
## Image List
1. **AddPackageDependencies.png** - Referenced in: GettingStarted.md
2. **AnnotationViewExample.png** - Referenced in: AnnotationViewExample.md
3. **BuildingLightExample.png** - Referenced in: BuildingLightExample.md
4. **CustomStyleLayerExample.png** - Referenced in: CustomStyleLayerExample.md
5. **DDSCircleLayerExample.png** - Referenced in: DDSCircleLayerExample.md
6. **DemotilesScreenshot.png** - Referenced in: (various)
7. **ImpreciseLocation.png** - Referenced in: LocationPrivacyExample.md
8. **LineStyleLayerExample.png** - Referenced in: LineStyleLayerExample.md
9. **MultipleImagesExample.png** - Referenced in: MultipleImagesExample.md
10. **PluginLayer.png** - Referenced in: PluginLayers.md
11. **PreciseLocationRequestPopup.png** - Referenced in: LocationPrivacyExample.md
12. **RenderingStatistics.png** - Referenced in: RenderingStatisticsHud.md
13. **WebAPIDataExample.png** - Referenced in: WebAPIDataExample.md
14. **pmtiles-demo.png** - Referenced in: PMTiles.md
15. **polyline.gif** - Referenced in: LineOnUserTap.md
## Where to Find Images
### Option 1: GitHub Repository (Recommended)
Visit the MapMetrics Native repository and navigate to the iOS documentation images:
```
{"contact/admin/forURLs"}
```
Look for the example app or documentation guides folders which contain screenshot images.
### Option 2: Generate from Examples
Many of these images are screenshots from the example applications. You can:
1. Clone the MapMetrics Native repository
2. Build and run the iOS example app
3. Navigate to each example
4. Take screenshots
### Option 3: Documentation Website
The rendered documentation site may have the images:
```
{"contact/admin/forURLs"}
```
However, this is a JavaScript-rendered site and images are loaded dynamically.
### Option 4: Download from Releases
Check the releases page for documentation bundles that may include images:
```
{"contact/admin/forURLs"}
```
## Currently Available
- screenshot.png (located in img/ directory)
## Notes
- All image references in the markdown files use relative paths: ``
- Images should be placed in the `img/` directory
- Some documents reference videos (e.g., gltfplugin.mp4 in PluginLayers.md)
- The markdown files are already properly formatted and ready to use once images are added
---
# Info.plist Keys
https://docs.mapatlas.xyz/overview/sdk/ios-native/Info.plist_Keys
# Info.plist Keys
MapMetrics Native for iOS supports custom `Info.plist` keys in your application in order to configure various settings.
## MLNApiKey
If it is required by the tileserver you use, set the API key to be used by all instances of ``MLNMapView`` in the current application.
## MLNAccuracyAuthorizationDescription
Set the accuracy authorization description string as an element of `NSLocationTemporaryUsageDescriptionDictionary` to be used by the map to request authorization when the `MLNLocationManager.accuracyAuthorization` is set to `CLAccuracyAuthorizationReducedAccuracy`. Requesting accuracy authorization is available for devices running iOS 14.0 and above.
Example:
```xml
NSLocationTemporaryUsageDescriptionDictionary
MLNAccuracyAuthorizationDescription
We require your precise location to help you navigate the map.
```
Remove `MLNAccuracyAuthorizationDescription` if you want to control when to request for accuracy authorization.
## MLNIdeographicFontFamilyName
This key configures a global fallback font or fonts for [client-side text rendering](doc:Customizing_Fonts) of Chinese hanzi, Japanese kana, and Korean hangul characters (CJK) that appear in text labels.
If the fonts you specify in the `MLNSymbolStyleLayer.textFontNames` property are all unavailable or lack a glyph for rendering a given CJK character, the map uses the contents of this key to choose a [system font]({"contact/admin/forURLs"}) or a font [bundled with your application]({"contact/admin/forURLs"}). This key specifies a fallback for all style layers in all map views and map snapshots. If you do not specify this key or none of the font names matches, the map applies a font from the system’s font cascade list, which may vary based on the device model and system language.
This key can either be set to a single string or an array of strings, which the map tries to apply in order from most preferred to least preferred. Each string can be a family name (for example, “PingFang TC”), display name (“PingFang TC Ultralight”), or PostScript name (“PingFangTC-Ultralight”).
To disable client-side rendering of CJK characters in favor of [server-side rendering](customizing-fonts.html#server-side-fonts), set this key to the Boolean value `NO`.
## MLNOfflineStorageDatabasePath
This key customizes the file path at which `MLNOfflineStorage` keeps the offline map database, which contains any offline packs as well as the ambient cache. Most applications should not need to customize this path; however, you could customize it to implement a migration path between different versions of your application.
The key is interpreted as either an absolute file path or a file path relative to the main bundle’s resource folder, resolving any tilde or symbolic link. The path must be writable. If a database does not exist at the path you specify, one will be created automatically.
An offline map database can consume a significant amount of the user’s bandwidth and iCloud storage due to iCloud backups. To exclude the database from backups, set the containing directory’s `NSURLIsExcludedFromBackupKey` resource property to the Boolean value `YES` using the [`NSURL/setResourceValue:forKey:error:`]({"contact/admin/forURLs"}) method. The entire directory will be affected, not just the database file. If the user restores the application from a backup, your application will need to restore any offline packs that had been previously downloaded.
At runtime, you can obtain the value of this key using the ``MLNOfflineStorage/databasePath`` and ``MLNOfflineStorage/databaseURL`` properties.
## MLNCollisionBehaviorPre4_0
If this key is set to YES (`true`), collision detection is performed only between symbol style layers based on the same source, as in versions 2.0–3.7 of the MapMetrics Native iOS. In other words, symbols in an `MLNSymbolStyleLayer` based on one source (for example, an `MLNShapeSource`) may overlap with symbols in another layer that is based on a different source. This is the case regardless of the ``MLNSymbolStyleLayer/iconAllowsOverlap``, ``MLNSymbolStyleLayer/iconIgnoresPlacement``, ``MLNSymbolStyleLayer/textAllowsOverlap``, and ``MLNSymbolStyleLayer/textIgnoresPlacement`` properties.
Beginning in version 4.0, the SDK also performs collision detection between style layers based on different sources by default. For the default behavior, omit the `MLNCollisionBehaviorPre4_0` key or set it to NO (`false`).
This property may also be set using `[[NSUserDefaults standardUserDefaults] setObject:@(YES) forKey:@"MLNCollisionBehaviorPre4_0"]`; it will override any value specified in the `Info.plist`.
---
# Add Line on User Tap
https://docs.mapatlas.xyz/overview/sdk/ios-native/LineOnUserTap
# Add Line on User Tap
Demonstrating adding ``MLNPolyline`` annotations and responding to user input.
> Note: This example uses SwiftUI.
This example draws a line from the tapped location to the center of the map. Handling the tap is done by the `Coordinator` class. It converts the location on the view to a geographic coordinate. It removes existing annotations before adding the new line.
```swift
struct LineTapMap: UIViewRepresentable {
func makeUIView(context: Context) -> MLNMapView {
let mapView = MLNMapView()
// Add a single tap gesture recognizer
let singleTap = UITapGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.handleMapTap(sender:))
)
for recognizer in mapView.gestureRecognizers! where recognizer is UITapGestureRecognizer {
singleTap.require(toFail: recognizer)
}
mapView.addGestureRecognizer(singleTap)
return mapView
}
func updateUIView(_: MLNMapView, context _: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var parent: LineTapMap
init(_ parent: LineTapMap) {
self.parent = parent
}
@objc func handleMapTap(sender: UITapGestureRecognizer) {
guard let mapView = sender.view as? MLNMapView else { return }
// Convert tap location (CGPoint) to geographic coordinate (CLLocationCoordinate2D).
let tapPoint: CGPoint = sender.location(in: mapView)
let tapCoordinate: CLLocationCoordinate2D = mapView.convert(tapPoint, toCoordinateFrom: nil)
print("You tapped at: \(tapCoordinate.latitude), \(tapCoordinate.longitude)")
// Create an array of coordinates for our polyline, starting at the center of the map and ending at the tap coordinate.
var coordinates: [CLLocationCoordinate2D] = [mapView.centerCoordinate, tapCoordinate]
// Remove any existing polyline(s) from the map.
if let existingAnnotations = mapView.annotations {
mapView.removeAnnotations(existingAnnotations)
}
// Add a polyline with the new coordinates.
let polyline = MLNPolyline(coordinates: &coordinates, count: UInt(coordinates.count))
mapView.addAnnotation(polyline)
}
}
}
```

---
# Using GeoJSON with a line style layer
https://docs.mapatlas.xyz/overview/sdk/ios-native/LineStyleLayerExample
# Using GeoJSON with a line style layer
Adding an ``MLNLineStyleLayer`` to the map using a GeoJSON file.
> Note: This example uses UIKit.
```swift
class LineStyleLayerExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds, styleURL: VERSATILES_COLORFUL_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.setCenter(
CLLocationCoordinate2D(latitude: 45.5076, longitude: -122.6736),
zoomLevel: 11,
animated: false
)
view.addSubview(mapView)
mapView.delegate = self
}
// Wait until the map is loaded before adding to the map.
func mapView(_: MLNMapView, didFinishLoading _: MLNStyle) {
loadGeoJson()
}
func loadGeoJson() {
DispatchQueue.global().async {
// Get the path for example.geojson in the app’s bundle.
guard let jsonUrl = Bundle.main.url(forResource: "example", withExtension: "geojson") else {
preconditionFailure("Failed to load local GeoJSON file")
}
guard let jsonData = try? Data(contentsOf: jsonUrl) else {
preconditionFailure("Failed to parse GeoJSON file")
}
DispatchQueue.main.async {
self.drawPolyline(geoJson: jsonData)
}
}
}
func drawPolyline(geoJson: Data) {
// Add our GeoJSON data to the map as an MLNGeoJSONSource.
// We can then reference this data from an MLNStyleLayer.
// MLNMapView.style is optional, so you must guard against it not being set.
guard let style = mapView.style else { return }
guard let shapeFromGeoJSON = try? MLNShape(data: geoJson, encoding: String.Encoding.utf8.rawValue) else {
fatalError("Could not generate MLNShape")
}
let source = MLNShapeSource(identifier: "polyline", shape: shapeFromGeoJSON, options: nil)
style.addSource(source)
// Create new layer for the line.
let layer = MLNLineStyleLayer(identifier: "polyline", source: source)
// Set the line join and cap to a rounded end.
layer.lineJoin = NSExpression(forConstantValue: "round")
layer.lineCap = NSExpression(forConstantValue: "round")
// Set the line color to a constant blue color.
layer.lineColor = NSExpression(forConstantValue: UIColor(red: 59 / 255, green: 178 / 255, blue: 208 / 255, alpha: 1))
// Use `NSExpression` to smoothly adjust the line width from 2pt to 20pt between zoom levels 14 and 18. The `interpolationBase` parameter allows the values to interpolate along an exponential curve.
layer.lineWidth = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [14: 2, 18: 20]))
// We can also add a second layer that will draw a stroke around the original line.
let casingLayer = MLNLineStyleLayer(identifier: "polyline-case", source: source)
// Copy these attributes from the main line layer.
casingLayer.lineJoin = layer.lineJoin
casingLayer.lineCap = layer.lineCap
// Line gap width represents the space before the outline begins, so should match the main line’s line width exactly.
casingLayer.lineGapWidth = layer.lineWidth
// Stroke color slightly darker than the line color.
casingLayer.lineColor = NSExpression(forConstantValue: UIColor(red: 41 / 255, green: 145 / 255, blue: 171 / 255, alpha: 1))
// Use `NSExpression` to gradually increase the stroke width between zoom levels 14 and 18.
casingLayer.lineWidth = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [14: 1, 18: 4]))
// Just for fun, let’s add another copy of the line with a dash pattern.
let dashedLayer = MLNLineStyleLayer(identifier: "polyline-dash", source: source)
dashedLayer.lineJoin = layer.lineJoin
dashedLayer.lineCap = layer.lineCap
dashedLayer.lineColor = NSExpression(forConstantValue: UIColor.white)
dashedLayer.lineOpacity = NSExpression(forConstantValue: 0.5)
dashedLayer.lineWidth = layer.lineWidth
// Dash pattern in the format [dash, gap, dash, gap, ...]. You’ll want to adjust these values based on the line cap style.
dashedLayer.lineDashPattern = NSExpression(forConstantValue: [0, 1.5])
style.addLayer(layer)
style.addLayer(dashedLayer)
style.insertLayer(casingLayer, below: layer)
}
}
```

---
# User Location & Location Privacy
https://docs.mapatlas.xyz/overview/sdk/ios-native/LocationPrivacyExample
# User Location & Location Privacy
Requesting precise location with ``MLNLocationManager``.
> Note: This example uses SwiftUI.
This example shows how to request a precise location with ``MLNLocationManager``.
Let's prepare your `Info.plist`:
First, provide a description why your app needs to access location:
```plist
NSLocationWhenInUseUsageDescription
Dummy Location When In Use Description
```
Second, add a description why your app needs precise location:
```plist
NSLocationTemporaryUsageDescriptionDictionary
MLNAccuracyAuthorizationDescription
Dummy Precise Location Description
```
Third and finally, specify your app only needs reduced location access by default (until you request precise accuracy in the example):
```plist
NSLocationDefaultAccuracyReduced
```
The `Coordinator` defined below is also the ``MLNMapViewDelegate``. When the location manager authorization changes it will call the relevant method. If precise location has not been granted, a button is shown at the bottom of the map.

When the button is pressed a pop-up with the description we set in `Info.plist` will be shown:

```swift
enum LocationAccuracyState {
case unknown
case reducedAccuracy
case fullAccuracy
}
@MainActor
class PrivacyExampleViewModel: NSObject, ObservableObject {
@Published var locationAccuracy: LocationAccuracyState = .unknown
@Published var showTemporaryLocationAuthorization = false
}
class PrivacyExampleCoordinator: NSObject, MLNMapViewDelegate {
@ObservedObject private var mapViewModel: PrivacyExampleViewModel
private var pannedToUserLocation = false
init(mapViewModel: PrivacyExampleViewModel) {
self.mapViewModel = mapViewModel
super.init()
}
@MainActor func mapView(_: MLNMapView, didChangeLocationManagerAuthorization manager: MLNLocationManager) {
guard let accuracySetting = manager.accuracyAuthorization else {
return
}
switch accuracySetting() {
case .fullAccuracy:
mapViewModel.locationAccuracy = .fullAccuracy
case .reducedAccuracy:
mapViewModel.locationAccuracy = .reducedAccuracy
@unknown default:
mapViewModel.locationAccuracy = .unknown
}
}
// when a location is available for the first time, we fly to it
func mapView(_ mapView: MLNMapView, didUpdate _: MLNUserLocation?) {
guard !pannedToUserLocation else { return }
guard let userLocation = mapView.userLocation else {
print("User location is currently not available.")
return
}
mapView.fly(to: MLNMapCamera(lookingAtCenter: userLocation.coordinate, altitude: 100_000, pitch: 0, heading: 0))
pannedToUserLocation = true
}
}
struct PrivacyExampleRepresentable: UIViewRepresentable {
@ObservedObject var mapViewModel: PrivacyExampleViewModel
func makeCoordinator() -> PrivacyExampleCoordinator {
PrivacyExampleCoordinator(mapViewModel: mapViewModel)
}
func makeUIView(context: Context) -> MLNMapView {
let mapView = MLNMapView()
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
return mapView
}
func updateUIView(_ mapView: MLNMapView, context _: Context) {
if mapViewModel.showTemporaryLocationAuthorization {
let purposeKey = "MLNAccuracyAuthorizationDescription"
mapView.locationManager.requestTemporaryFullAccuracyAuthorization?(withPurposeKey: purposeKey)
DispatchQueue.main.async {
mapViewModel.showTemporaryLocationAuthorization = false
}
}
}
}
struct LocationPrivacyExampleView: View {
@StateObject private var viewModel = PrivacyExampleViewModel()
var body: some View {
VStack {
PrivacyExampleRepresentable(mapViewModel: viewModel)
.edgesIgnoringSafeArea(.all)
if viewModel.locationAccuracy == LocationAccuracyState.reducedAccuracy {
Button("Request Precise Location") {
viewModel.showTemporaryLocationAuthorization.toggle()
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
}
```
---
# Manage Offline Regions
https://docs.mapatlas.xyz/overview/sdk/ios-native/ManageOfflineRegionsExample
# Manage Offline Regions
Query, delete and download offline regions
> Note: This example uses UIKit.
This example is similar to , but shows how offline regions can be managed.
- ``MLNOfflineStorage/addPackForRegion:withContext:completionHandler:`` is used to kick off downloads for offline regions, as before.
- ``MLNOfflineStorage/packs`` returns an array of packs that have been downloaded. In this example they are shown in an `UITableView`.
- ``MLNOfflineStorage/resetDatabaseWithCompletionHandler:`` can be used to reset the (offline) database. Note that this includes the ambient cache at the time of writing. In this example, this method is used on view initialization.
When selecting one of the packs in the table view, the map moves to the bounds of the corresponding region.
```swift
class ManageOfflineRegionsExample: UIViewController, MLNMapViewDelegate {
let jsonDecoder = JSONDecoder()
struct UserData: Codable {
var name: String
}
lazy var mapView: MLNMapView = {
let mapView = MLNMapView(frame: CGRect.zero, styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.tintColor = .gray
mapView.delegate = self
mapView.translatesAutoresizingMaskIntoConstraints = false
return mapView
}()
lazy var downloadButton: UIButton = {
let downloadButton = UIButton(frame: CGRect.zero)
downloadButton.backgroundColor = UIColor.systemBlue
downloadButton.setTitleColor(UIColor.white, for: .normal)
downloadButton.setTitle("Download Region", for: .normal)
downloadButton.addTarget(self, action: #selector(startOfflinePackDownload), for: .touchUpInside)
downloadButton.layer.cornerRadius = view.bounds.width / 30
downloadButton.translatesAutoresizingMaskIntoConstraints = false
return downloadButton
}()
lazy var tableView: UITableView = {
let tableView = UITableView(frame: CGRect.zero)
tableView.delegate = self
tableView.dataSource = self
tableView.translatesAutoresizingMaskIntoConstraints = false
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(mapView)
view.addSubview(tableView)
mapView.addSubview(downloadButton)
let centerCoordinate = CLLocationCoordinate2D(latitude: 22.27933, longitude: 114.16281)
mapView.setCenter(centerCoordinate, zoomLevel: 13, animated: false)
// Set up constraints for map view, table view, and download button.
installConstraints()
}
func setupOfflinePackHandler() {
NotificationCenter.default.addObserver(self,
selector: #selector(offlinePackProgressDidChange),
name: NSNotification.Name.MLNOfflinePackProgressChanged,
object: nil)
}
func installConstraints() {
NSLayoutConstraint.activate([
mapView.topAnchor.constraint(equalTo: view.topAnchor),
mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
mapView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5),
tableView.topAnchor.constraint(equalTo: mapView.bottomAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
downloadButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
downloadButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 5),
downloadButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.45),
downloadButton.heightAnchor.constraint(equalTo: downloadButton.widthAnchor, multiplier: 0.2),
])
}
/*
For the purposes of this example, remove any offline packs
that exist before the example is re-loaded.
*/
override func viewWillAppear(_: Bool) {
MLNOfflineStorage.shared.resetDatabase { error in
if let error {
// Handle the error here if packs can't be removed.
print(error)
} else {
MLNOfflineStorage.shared.reloadPacks()
}
}
}
@objc func startOfflinePackDownload(selector _: NSNotification) {
// Setup offline pack notification handlers.
setupOfflinePackHandler()
/**
Create a region that includes the current map camera, to be captured
in an offline map. Note: Because tile count grows exponentially as zoom level
increases, you should be conservative with your `toZoomLevel` setting.
*/
let region = MLNTilePyramidOfflineRegion(styleURL: mapView.styleURL,
bounds: mapView.visibleCoordinateBounds,
fromZoomLevel: mapView.zoomLevel,
toZoomLevel: mapView.zoomLevel + 2)
// Store some data for identification purposes alongside the offline pack.
let userInfo = UserData(name: "\(region.bounds)")
let jsonEncoder = JSONEncoder()
let optionalContext = try? jsonEncoder.encode(userInfo)
guard let context = optionalContext else {
print("Error: failed to get context")
return
}
// Create and register an offline pack with the shared offline storage object.
MLNOfflineStorage.shared.addPack(for: region, withContext: context) { pack, error in
guard error == nil else {
// Handle the error if the offline pack couldn’t be created.
print("Error: \(error?.localizedDescription ?? "unknown error")")
return
}
// Begin downloading the map for offline use.
pack!.resume()
}
}
// MARK: - MLNOfflinePack notification handlers
@objc func offlinePackProgressDidChange(notification: NSNotification) {
/**
Get the offline pack this notification is referring to,
along with its associated metadata.
*/
if let pack = notification.object as? MLNOfflinePack,
let userInfo = try? jsonDecoder.decode(UserData.self, from: pack.context)
{
// At this point, the offline pack has finished downloading.
if pack.state == .complete {
let byteCount = ByteCountFormatter.string(fromByteCount: Int64(pack.progress.countOfBytesCompleted), countStyle: ByteCountFormatter.CountStyle.memory)
let packName = userInfo.name
print("""
Offline pack “\(packName)” completed download:
- Bytes: \(byteCount)
- Resource count: \(pack.progress.countOfResourcesCompleted)")
""")
NotificationCenter.default.removeObserver(self, name: NSNotification.Name.MLNOfflinePackProgressChanged,
object: nil)
}
}
// Reload the table to update the progress percentage for each offline pack.
tableView.reloadData()
}
}
private extension MLNOfflinePackProgress {
var percentCompleted: Float {
guard countOfResourcesExpected != 0 else {
return 0
}
let percentage = Float(countOfResourcesCompleted) / Float(countOfResourcesExpected) * 100
return percentage
}
var formattedCountOfBytesCompleted: String {
ByteCountFormatter.string(fromByteCount: Int64(countOfBytesCompleted),
countStyle: .memory)
}
}
extension ManageOfflineRegionsExample: UITableViewDelegate, UITableViewDataSource {
// Create the table view which will display the downloaded regions.
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
if let packs = MLNOfflineStorage.shared.packs {
return packs.count
} else {
return 0
}
}
func tableView(_: UITableView, viewForHeaderInSection _: Int) -> UIView? {
let label = UILabel()
label.backgroundColor = UIColor.systemBlue
label.textColor = UIColor.white
label.font = UIFont.preferredFont(forTextStyle: .headline)
label.textAlignment = .center
if MLNOfflineStorage.shared.packs != nil {
label.text = "Offline maps"
} else {
label.text = "No offline maps"
}
return label
}
func tableView(_: UITableView, heightForHeaderInSection _: Int) -> CGFloat {
50.0
}
func tableView(_: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "cell")
if let packs = MLNOfflineStorage.shared.packs {
let pack = packs[indexPath.row]
cell.textLabel?.text = "Region \(indexPath.row + 1): size: \(pack.progress.formattedCountOfBytesCompleted)"
cell.detailTextLabel?.text = "Percent completion: \(pack.progress.percentCompleted)%"
}
return cell
}
func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let packs = MLNOfflineStorage.shared.packs else { return }
if let selectedRegion = packs[indexPath.row].region as? MLNTilePyramidOfflineRegion {
mapView.setVisibleCoordinateBounds(selectedRegion.bounds, animated: true)
}
}
}
```
---
# Adding Multiple Images
https://docs.mapatlas.xyz/overview/sdk/ios-native/MultipleImagesExample
# Adding Multiple Images
Adding images to the map and assigning them to POI types
> This example uses UIKit.
This example uses POIs in national parks and adds them to the map with data and icons provided by the National Park Service. Both the data and the icons are in the public domain.
## Preparation
We've done all the work for you and generated [`pois-nps.mbtiles`]({"contact/admin/forURLs"}), which you can download directly.
You can skip the rest of this section unless you're curious how we built the MBTiles from the source data.
- Data, Shapefile download: [{"contact/admin/forURLs"}]({"contact/admin/forURLs"})
- Icons: [{"contact/admin/forURLs"}]({"contact/admin/forURLs"})
Convert the data first to GeoJSON format with [`ogr2ogr`]({"contact/admin/forURLs"}) and then to GeoJSON format using [tippecanoe]({"contact/admin/forURLs"}).
```
ogr2ogr -f GeoJSON pois.json -t_srs EPSG:4326 nps-pois.shp
tippecanoe -o pois-nps.mbtiles pois.json
```
The resulting `.mbtiles` file can be hosted with a tile server such as [Martin]({"contact/admin/forURLs"}) or embedded in the app bundle as a resource. Since the file is quite small in this case we will use that last option in this example. Martin comes with a [`mbtiles` binary]({"contact/admin/forURLs"}) that allows us to inspect what the MBTiles file contains from the command line.
```
mbtiles meta-all pois-nps.mbtiles
```
The important thing to note is that there is a `pois` layer whose features include a `POITYPE` attribute. While we could add icons for all kinds of `POITYPE` values included in the dataset, we will only add icons for restrooms, trailheads and viewpoints, and leave the rest as an exercise to the reader.
The icons need to be extracted with a tool like [Inkscape]({"contact/admin/forURLs"}) because the National Park Service includes all icons in a big vector file. This is outside the scope of this example.
## Adding Icons on Style Load
The `.mbtiles` file needs to be added to the assets of the app. When the style loads we can add a ``MLNVectorTileSource`` with as URL `mbtiles://\(Bundle.main.bundlePath)/pois-nps.mbtiles"`.
The images need to be added to an imageset so they can be loaded as an `UIImage` and added to the style with ``MLNStyle/setImage:forName:`` as is shown below in the example. Note that you should set ``MLNVectorStyleLayer/sourceLayerIdentifier`` to match the layer name in the MBTiles file. Lastly a [`match` expression]({"contact/admin/forURLs"}) is used to select the correct image based on `POITYPE` attribute present in the feature.
```swift
class MultipleImagesExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds, styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.tintColor = .darkGray
let glacierPoint = CLLocationCoordinate2D(latitude: 37.72836462778902, longitude: -119.57352616838511)
mapView.setCenter(glacierPoint, animated: false)
mapView.zoomLevel = 12
mapView.delegate = self
view.addSubview(mapView)
}
// Wait until the style is loaded before modifying the map style.
func mapView(_: MLNMapView, didFinishLoading style: MLNStyle) {
let source = MLNVectorTileSource(identifier: "pois-nps", configurationURL: URL(string: "mbtiles://\(Bundle.main.bundlePath)/pois-nps.mbtiles")!)
style.addSource(source)
let imagesToAdd = [
["nps-restrooms", "restrooms"],
["nps-trailhead", "trailhead"],
["nps-viewpoint", "viewpoint"],
]
for imageInfo in imagesToAdd {
if let imageName = imageInfo.first, let imageKey = imageInfo.last {
if let image = UIImage(named: imageName) {
style.setImage(image, forName: imageKey)
} else {
print("Failed to load \(imageName)")
return
}
}
}
let imageLayer = MLNSymbolStyleLayer(identifier: "npc-poi-images", source: source)
imageLayer.sourceLayerIdentifier = "pois"
imageLayer.iconImageName = NSExpression(mglJSONObject: [
"match", ["get", "POITYPE"],
"Restroom", "restrooms",
"Trailhead", "trailhead",
"Viewpoint", "viewpoint",
"",
])
style.addLayer(imageLayer)
}
}
```

---
# Observe Low-Level Events
https://docs.mapatlas.xyz/overview/sdk/ios-native/ObserverExample
# Observe Low-Level Events
Learn about the ``MLNMapViewDelegate`` methods for observing map events.
> Warning: These methods are not thread-safe.
You can observe certain low-level events as they happen. Use these methods to collect metrics or investigate issues during map rendering. This feature is intended primarily for power users. We are always interested in improving observability, so if you have a special use case, feel free to [open an issue or pull request]({"contact/admin/forURLs"}) to extend the types of observability methods.
## Frame Events
Observe frame rendering statistics with ``MLNMapViewDelegate/mapViewDidFinishRenderingFrame:fullyRendered:renderingStats:``.
```swift
func mapViewDidFinishRenderingFrame(_: MLNMapView, fullyRendered: Bool, renderingStats: MLNRenderingStats) {
}
```
See also: ``MLNMapViewDelegate/mapViewDidFinishRenderingFrame:fullyRendered:`` and ``MLNMapViewDelegate/mapViewDidFinishRenderingFrame:fullyRendered:frameEncodingTime:frameRenderingTime:``
## Shader Events
Observe shader compilation with ``MLNMapViewDelegate/mapView:shaderWillCompile:backend:defines:`` and ``MLNMapViewDelegate/mapView:shaderDidCompile:backend:defines:``.
```swift
func mapView(_: MLNMapView, shaderWillCompile id: Int, backend: Int, defines: String) {
print("A new shader is being compiled - shaderID:\(id), backend type:\(backend), program configuration:\(defines)")
}
func mapView(_: MLNMapView, shaderDidCompile id: Int, backend: Int, defines: String) {
print("A shader has been compiled - shaderID:\(id), backend type:\(backend), program configuration:\(defines)")
}
```
See also: ``MLNMapViewDelegate/mapView:shaderDidFailCompile:backend:defines:``.
## Glyph Loading
Observe glyph loading events with ``MLNMapViewDelegate/mapView:glyphsWillLoad:range:`` and ``MLNMapViewDelegate/mapView:glyphsDidLoad:range:``.
```swift
func mapView(_: MLNMapView, glyphsWillLoad fontStack: [String], range: NSRange) {
print("Glyphs are being requested for the font stack \(fontStack), ranging from \(range.location) to \(range.location + range.length)")
}
func mapView(_: MLNMapView, glyphsDidLoad fontStack: [String], range: NSRange) {
print("Glyphs have been loaded for the font stack \(fontStack), ranging from \(range.location) to \(range.location + range.length)")
}
```
See also: ``MLNMapViewDelegate/mapView:glyphsDidError:range:``.
## Tile Events
Monitor tile-related actions using the delegate method ``MLNMapViewDelegate/mapView:tileDidTriggerAction:x:y:z:wrap:overscaledZ:sourceID:`` with the ``MLNTileOperation`` type.
```swift
func mapView(_: MLNMapView, tileDidTriggerAction operation: MLNTileOperation,
x: Int,
y: Int,
z: Int,
wrap: Int,
overscaledZ: Int,
sourceID: String)
{
let tileStr = String(format: "(x: %ld, y: %ld, z: %ld, wrap: %ld, overscaledZ: %ld, sourceID: %@)",
x, y, z, wrap, overscaledZ, sourceID)
switch operation {
case MLNTileOperation.requestedFromCache:
print("Requesting tile \(tileStr) from cache")
case MLNTileOperation.requestedFromNetwork:
print("Requesting tile \(tileStr) from network")
case MLNTileOperation.loadFromCache:
print("Loading tile \(tileStr), requested from the cache")
case MLNTileOperation.loadFromNetwork:
print("Loading tile \(tileStr), requested from the network")
case MLNTileOperation.startParse:
print("Parsing tile \(tileStr)")
case MLNTileOperation.endParse:
print("Completed parsing tile \(tileStr)")
case MLNTileOperation.error:
print("An error occured during proccessing for tile \(tileStr)")
case MLNTileOperation.cancelled:
print("Pending work on tile \(tileStr)")
case MLNTileOperation.nullOp:
print("An unknown tile operation was emitted for tile \(tileStr)")
@unknown default:
assertionFailure()
}
}
```
## Sprite Loading
Observe sprite loading events with ``MLNMapViewDelegate/mapView:spriteWillLoad:url:`` and ``MLNMapViewDelegate/mapView:spriteDidLoad:url:``.
```swift
func mapView(_: MLNMapView, spriteWillLoad id: String, url: String) {
print("The sprite \(id) has been requested from \(url)")
}
func mapView(_: MLNMapView, spriteDidLoad id: String, url: String) {
print("The sprite \(id) has been loaded from \(url)")
}
```
See also: ``MLNMapViewDelegate/mapView:spriteDidError:url:``.
---
# PMTiles
https://docs.mapatlas.xyz/overview/sdk/ios-native/PMTiles
# PMTiles
Working with PMTiles
Starting MapMetrics iOS 6.10.0, using [PMTiles](https://protomaps.com/docs/pmtiles) as a data source is supported. You can prefix your vector tile source with `pmtiles://` to load a PMTiles file. The rest of the URL continue with be `https://` to load a remote PMTiles file, `asset://` to load an asset or `file://` to load a local PMTiles file.
> Note: PMTiles sources currently do not support caching or offline pack downloads.
Oliver Wipfli has made a style available that combines a [Protomaps](https://protomaps.com) basemap together with Foursquare's POI dataset. It is available in the [wipfli/foursquare-os-places-pmtiles](https://github.com/wipfli/foursquare-os-places-pmtiles) repository on GitHub. The style to use is
```
https://raw.githubusercontent.com/wipfli/foursquare-os-places-pmtiles/main/style.json
```
The neat thing about this style is that it only uses PMTiles vector sources. PMTiles can be hosted with a relatively simple file server (or file hosting service) instead of a more complex specialized tile server.

---
# POI Along a Route
https://docs.mapatlas.xyz/overview/sdk/ios-native/POIAlongRouteExample
# POI Along a Route
Use an `NSPredicate` to show POI and road labels along a route.
> This example uses UIKit.
This example adds a dynamically styled GeoJSON route to the map, similar to . However, two existing layers: the `poi` layer and the `road_label` part of the Americana style are adjusted as well. The contents of these layers are shown or hidden, based on whether they lay inside a polygon around the route. In this example, both the route and the area of the polygon along the route are hardcoded.
The route is styled with three `MLNLineStyleLayer`s. We make use of the [interpolate expression]({"contact/admin/forURLs"}) to set the widths of these line layers at various zoom levels.
```swift
class POIAlongRouteExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds, styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.setCenter(
CLLocationCoordinate2D(latitude: 45.52214, longitude: -122.63748),
zoomLevel: 18,
animated: false
)
view.addSubview(mapView)
mapView.delegate = self
}
// Wait until the map is loaded before adding to the map.
func mapView(_: MLNMapView, didFinishLoading _: MLNStyle) {
loadGeoJson()
restrictPOIVisibleShape()
setCamera()
}
func loadGeoJson() {
DispatchQueue.global().async {
// Get the path for example.geojson in the app’s bundle.
guard let jsonUrl = Bundle.main.url(forResource: "example", withExtension: "geojson") else {
preconditionFailure("Failed to load local GeoJSON file")
}
guard let jsonData = try? Data(contentsOf: jsonUrl) else {
preconditionFailure("Failed to parse GeoJSON file")
}
DispatchQueue.main.async {
self.drawPolyline(geoJson: jsonData)
}
}
}
func setCamera() {
let camera = mapView.camera
camera.heading = 249.37706203842038
camera.pitch = 60
camera.centerCoordinate.latitude = 45.52199780570582
camera.centerCoordinate.longitude = -122.6418837958432
mapView.setCamera(camera, animated: false)
mapView.setZoomLevel(15.062187320447523, animated: false)
}
func drawPolyline(geoJson: Data) {
// Add our GeoJSON data to the map as an MLNGeoJSONSource.
// We can then reference this data from an MLNStyleLayer.
// MLNMapView.style is optional, so you must guard against it not being set.
guard let style = mapView.style else { return }
guard let shapeFromGeoJSON = try? MLNShape(data: geoJson, encoding: String.Encoding.utf8.rawValue) else {
fatalError("Could not generate MLNShape")
}
let source = MLNShapeSource(identifier: "polyline", shape: shapeFromGeoJSON, options: nil)
style.addSource(source)
// Create new layer for the line.
let layer = MLNLineStyleLayer(identifier: "polyline", source: source)
// Set the line join and cap to a rounded end.
layer.lineJoin = NSExpression(forConstantValue: "round")
layer.lineCap = NSExpression(forConstantValue: "round")
// Set the line color to a constant blue color.
layer.lineColor = NSExpression(forConstantValue: UIColor(red: 59 / 255, green: 178 / 255, blue: 208 / 255, alpha: 1))
// Use expression to smoothly adjust the line width from 2pt to 20pt between zoom levels 14 and 18.
layer.lineWidth = NSExpression(mglJSONObject: ["interpolate", ["linear"], ["zoom"], 14, 2, 18, 20])
// We can also add a second layer that will draw a stroke around the original line.
let casingLayer = MLNLineStyleLayer(identifier: "polyline-case", source: source)
// Copy these attributes from the main line layer.
casingLayer.lineJoin = layer.lineJoin
casingLayer.lineCap = layer.lineCap
// Line gap width represents the space before the outline begins, so should match the main line’s line width exactly.
casingLayer.lineGapWidth = layer.lineWidth
// Stroke color slightly darker than the line color.
casingLayer.lineColor = NSExpression(forConstantValue: UIColor(red: 41 / 255, green: 145 / 255, blue: 171 / 255, alpha: 1))
// Use expression to gradually increase the stroke width between zoom levels 14 and 18.
casingLayer.lineWidth = NSExpression(mglJSONObject: ["interpolate", ["linear"], ["zoom"], 14, 1, 18, 4])
// Just for fun, let’s add another copy of the line with a dash pattern.
let dashedLayer = MLNLineStyleLayer(identifier: "polyline-dash", source: source)
dashedLayer.lineJoin = layer.lineJoin
dashedLayer.lineCap = layer.lineCap
dashedLayer.lineColor = NSExpression(forConstantValue: UIColor.white)
dashedLayer.lineOpacity = NSExpression(forConstantValue: 0.5)
dashedLayer.lineWidth = layer.lineWidth
// Dash pattern in the format [dash, gap, dash, gap, ...]. You’ll want to adjust these values based on the line cap style.
dashedLayer.lineDashPattern = NSExpression(forConstantValue: [0, 1.5])
guard let poiLayer = mapView.style?.layer(withIdentifier: "poi") as? MLNSymbolStyleLayer else {
print("Could not find poi layer")
return
}
style.insertLayer(layer, below: poiLayer)
style.insertLayer(dashedLayer, above: layer)
style.insertLayer(casingLayer, below: layer)
}
func restrictPOIVisibleShape() {
// find poi-label layer
guard let poiLayer = mapView.style?.layer(withIdentifier: "poi") as? MLNSymbolStyleLayer else {
print("Could not find poi layer")
return
}
// find road-label layer
guard let roadLabelLayer = mapView.style?.layer(withIdentifier: "road_label") as? MLNSymbolStyleLayer else {
print("Could not find road_label layer")
return
}
// show the POI and road that is within this polygon
let polygonShape = [
[-122.63730626171188, 45.52288837762333],
[-122.65455070022612, 45.52299746891552],
[-122.65747018755947, 45.52177017968134],
[-122.65992255691913, 45.51931552089448],
[-122.66015611590598, 45.513696676587045],
[-122.66696825301655, 45.51375123117057],
[-122.6672018120034, 45.51222368283956],
[-122.6571977020749, 45.51225096085216],
[-122.6570419960839, 45.51822452705878],
[-122.65392787626189, 45.52106106703124],
[-122.63567134880579, 45.52114288817623],
[-122.63657745074761, 45.52288036393409],
[-122.6373404839605, 45.52291377640398],
]
// create a polygon class
let coordinates = polygonShape.map { CLLocationCoordinate2D(latitude: $0[1], longitude: $0[0]) }
let bufferedRoutePolygon = MLNPolygon(coordinates: coordinates, count: UInt(coordinates.count), interiorPolygons: nil)
// apply predicates to these two layers
poiLayer.predicate = NSPredicate(format: "SELF IN %@", bufferedRoutePolygon)
roadLabelLayer.predicate = NSPredicate(format: "SELF IN %@", bufferedRoutePolygon)
}
}
```
---
# Plugin Layers
https://docs.mapatlas.xyz/overview/sdk/ios-native/PluginLayers
# Plugin Layers
Plugin Layers are a way to add layer types that render themselves into the style parsing engine at runtime. It is a way to dynamically link new layer types to the MapMetrics core -- and styling language -- without having to compile them into the library itself.
Because the layers are bound at runtime, one or more different types of specialized layers can be added and different versions of layer types can be added.
Currently Plugin Layers are only available on iOS/Darwin using the Metal Rendering pipeline.
## Creating a Plugin Layer
Plugin Layers can be created by creating a descendant from MLNPluginLayer and add the class type to an instance of MLNMapView. The layer is self describing (e.g. the layer type, what properties are available, etc) and that information will be registered with the MapMetrics core. When the plugin layer type is found in the style, the core will automatically instantiate it, pass along initial properties and manage the rendering of the layer.
### Defining a layer's capabilities
The newly created layer class should override the layerCapabilites class method. Please note that this is a class method and not an instance method, so make sure it's prefaced with a + and not a -.
The object that is returned from this method defines the layer type and the propeties that the layer expects.
The triangle example (platform/darwin/app/PluginLayerExampleMetalRendering.mm) defines it's layer type "plugin-layer-metal-rendering" and two paint properties (scale and fill-color). These properties can be expression based. It's important to define the type of property (single float or color) and a default value. The properties returned by layerCapabilities will correspond to any properties in the "paint": part of the style.
Initialization properties can also be added to the "properties": section of the layer style.
```objc
+(MLNPluginLayerCapabilities *)layerCapabilities {
MLNPluginLayerCapabilities *tempResult = [[MLNPluginLayerCapabilities alloc] init];
tempResult.layerID = @"plugin-layer-metal-rendering";
tempResult.requiresPass3D = YES;
// Define the paint properties that this layer implements and
// what types they are
tempResult.layerProperties = @[
// The scale property
[MLNPluginLayerProperty propertyWithName:@"scale"
propertyType:MLNPluginLayerPropertyTypeSingleFloat
defaultValue:@(1.0)],
// The fill color property
[MLNPluginLayerProperty propertyWithName:@"fill-color"
propertyType:MLNPluginLayerPropertyTypeColor
defaultValue:[UIColor blueColor]]
];
return tempResult;
}
```
For the triangle example, there are two initialization properties (offset-x and offset-y) for positioning and two paint properties, scale and fill-color. Here are three different instances of the plugin layer being added to the style. One that has a static scale (and uses the default color), one that has an expression based scale (and uses the default scale) and one that has an expression based scale and fill color.
```json
{
"id": "metal-rendering-layer-1",
"type": "plugin-layer-metal-rendering",
"properties": {
"offset-x": -300,
"offset-y": -300
},
"paint": {
"scale": 2.5
}
},
{
"id": "metal-rendering-layer-2",
"type": "plugin-layer-metal-rendering",
"properties": {
"offset-x": -300
},
"paint": {
"scale": [
"interpolate",
[
"linear"
],
[
"zoom"
],
5,
0.5,
15,
3
]
}
},
{
"id": "metal-rendering-layer-3",
"type": "plugin-layer-metal-rendering",
"properties": {
"color1": "#DDAAFF",
"offset-x": 300
},
"paint": {
"scale": [
"interpolate",
[
"linear"
],
[
"zoom"
],
5,
3,
15,
0.5
],
"fill-color": [
"interpolate",
[
"linear"
],
[
"zoom"
],
1,
"#ff0000",
22,
"#00ff00"
]
}
}
```

## Example 2: GLTF Plugin Layer
In [this repository]({"contact/admin/forURLs"}) you can find an example of a plugin layer that allows adding GLTF models. The following JSON is added to the style.
```
{
"id": "model-layer",
"type": "hudhud::gltf-model-layer",
"properties": {
"model-source-resource":"models.json"
},
"paint": {
"scale": 1.0
}
}
```
@Video(
source: "gltfplugin.mp4",
poster: "gltfplugin.png",
alt: "GLTF Plugin in action.") {
GLTF Plugin showing 3D models of the Arc de Triomphe and the Eifel Tower.
}
---
# Predicates and expressions
https://docs.mapatlas.xyz/overview/sdk/ios-native/Predicates_and_Expressions
# Predicates and expressions
Using `NSPredicate` with MapMetrics iOS
Style layers use predicates and expressions to determine what to display and how
to format it. _Predicates_ are represented by the same `NSPredicate` class that
filters results from Core Data or items in an `NSArray` in Objective-C.
Predicates are based on _expressions_, represented by the `NSExpression` class.
Somewhat unusually, style layers also use expressions on their own.
This document discusses the specific subset of the predicate and expression
syntax supported by this SDK. For a more general introduction to predicates and
expressions, consult the
_[Predicate Programming Guide]({"contact/admin/forURLs"})_
in Apple developer documentation. For additional detail on how this SDK has
extended the `NSExpression` class, see the [`NSExpression+MLNAdditions.h`]({"contact/admin/forURLs"}) header.
## Using predicates to filter vector data
Most style layer classes display `MLNFeature` objects that you can show or hide
based on the feature’s attributes. Use the `MLNVectorStyleLayer.predicate`
property to include only the features in the source layer that satisfy a
condition that you define.
### Operators
The following comparison operators are supported:
`NSPredicateOperatorType` | Format string syntax
----------------------------------------------|---------------------
`NSEqualToPredicateOperatorType` | `key = value` `key == value`
`NSGreaterThanOrEqualToPredicateOperatorType` | `key >= value` `key => value`
`NSLessThanOrEqualToPredicateOperatorType` | `key <= value` `key =< value`
`NSGreaterThanPredicateOperatorType` | `key > value`
`NSLessThanPredicateOperatorType` | `key < value`
`NSNotEqualToPredicateOperatorType` | `key != value` `key <> value`
`NSBetweenPredicateOperatorType` | `key BETWEEN { 32, 212 }`
To test whether a feature has or lacks a specific attribute, compare the
attribute to `NULL` or `NIL`. Predicates created using the
`+[NSPredicate predicateWithValue:]` method are also supported. String
operators and custom operators are not supported.
The following compound operators are supported:
`NSCompoundPredicateType` | Format string syntax
--------------------------|---------------------
`NSAndPredicateType` | `predicate1 AND predicate2` `predicate1 && predicate2`
`NSOrPredicateType` | `predicate1 OR predicate2`predicate1 || predicate2
`NSNotPredicateType` | `NOT predicate` `!predicate`
The following aggregate operators are supported:
`NSPredicateOperatorType` | Format string syntax
----------------------------------|---------------------
`NSInPredicateOperatorType` | `key IN { 'iOS', 'macOS', 'tvOS', 'watchOS' }`
`NSContainsPredicateOperatorType` | `{ 'iOS', 'macOS', 'tvOS', 'watchOS' } CONTAINS key`
You can use the `IN` and `CONTAINS` operators to test whether a value appears in a collection, whether a string is a substring of a larger string, or whether the evaluated feature (`SELF`) lies within a given `MLNShape` or `MLNFeature`. For example, to show one delicious local chain of sandwich shops, but not similarly named steakhouses and pizzerias:
```objc
MLNPolygon *cincinnati = [MLNPolygon polygonWithCoordinates:cincinnatiCoordinates count:sizeof(cincinnatiCoordinates) / sizeof(cincinnatiCoordinates[0])];
deliLayer.predicate = [NSPredicate predicateWithFormat:@"class = 'food_and_drink' AND name CONTAINS 'Izzy' AND SELF IN %@", cincinnati];
```
```swift
let cincinnati = MLNPolygon(coordinates: &cincinnatiCoordinates, count: UInt(cincinnatiCoordinates.count))
deliLayer.predicate = NSPredicate(format: "class = 'food_and_drink' AND name CONTAINS 'Izzy' AND SELF IN %@", cincinnati)
```
The following combinations of comparison operators and modifiers are supported:
`NSComparisonPredicateModifier` | `NSPredicateOperatorType` | Format string syntax
--------------------------------|-------------------------------------|---------------------
`NSAllPredicateModifier` | `NSNotEqualToPredicateOperatorType` | `ALL haystack != needle`
`NSAnyPredicateModifier` | `NSEqualToPredicateOperatorType` | `ANY haystack = needle` `SOME haystack = needle`
The following comparison predicate options are supported for comparison and
aggregate operators that are used in the predicate:
`NSComparisonPredicateOptions` | Format string syntax
----------------------------------------|---------------------
`NSCaseInsensitivePredicateOption` | `'QUEBEC' =[c] 'Quebec'`
`NSDiacriticInsensitivePredicateOption` | `'Québec' =[d] 'Quebec'`
Other comparison predicate options are unsupported, namely `l`
(for locale sensitivity) and `n` (for normalization). A comparison is
locale-sensitive as long as it is case- or diacritic-insensitive. Comparison
predicate options are not supported in conjunction with comparison modifiers
like `ALL` and `ANY`.
### Operands
Operands in predicates can be [variables](#variables), [key paths](#key-paths),
or almost anything else that can appear
[inside an expression](#using-expressions-to-configure-layout-and-paint-attributes).
Automatic type casting is not performed. Therefore, a feature only matches a
predicate if its value for the attribute in question is of the same type as the
value specified in the predicate. Use the `CAST()` operator to convert a key
path or variable into a matching type:
* To cast a value to a number, use `CAST(key, 'NSNumber')`.
* To cast a value to a string, use `CAST(key, 'NSString')`.
* To cast a value to a color, use `CAST(key, 'UIColor')` on iOS and `CAST(key, 'NSColor')` on macOS.
* To cast an `NSColor` or `UIColor` object to an array, use `CAST(noindex(color), 'NSArray')`.
For details about the predicate format string syntax, consult the “Predicate
Format String Syntax” chapter of the
_[Predicate Programming Guide]({"contact/admin/forURLs"})_
in Apple developer documentation.
## Using expressions to configure layout and paint attributes
An expression can contain subexpressions of various types. Each of the supported
types of expressions is discussed below.
### Constant values
A constant value can be of any of the following types:
In Objective-C | In Swift
----------------------|---------
`NSColor` (macOS) `UIColor` (iOS) | `NSColor` (macOS) `UIColor` (iOS)
`NSString` | `String`
`NSString` | `String`
`NSNumber.boolValue` | `NSNumber.boolValue`
`NSNumber.doubleValue` | `NSNumber.doubleValue`
`NSArray` | `[Float]`
`NSArray` | `[String]`
`NSValue.CGVectorValue` (iOS) `NSValue` containing `CGVector` (macOS) | `NSValue.cgVectorValue` (iOS) `NSValue` containing `CGVector` (macOS)
`NSValue.UIEdgeInsetsValue` (iOS) `NSValue.edgeInsetsValue` (macOS) | `NSValue.uiEdgeInsetsValue` (iOS) `NSValue.edgeInsetsValue` (macOS)
For literal floating-point values, use `-[NSNumber numberWithDouble:]` instead
of `-[NSNumber numberWithFloat:]` to avoid precision issues.
### Key paths
A key path expression refers to an attribute of the `MLNFeature` object being
evaluated for display. For example, if a polygon’s `MLNFeature.attributes`
dictionary contains the `floorCount` key, then the key path `floorCount` refers
to the value of the `floorCount` attribute when evaluating that particular
polygon.
The following special attributes are also available on features that are produced
as a result of clustering multiple point features together in a shape source:
| Attribute | Type | Meaning |
|-------------|--------|------------------------------------------------------------------------------------------------------------------------------------------|
| cluster | Bool | True if the feature is a point cluster. If the attribute is false (or not present) then the feature should not be considered a cluster. |
| cluster_id | Number | Identifier for the point cluster. |
| point_count | Number | The number of point features in a given cluster. |
Some characters may not be used directly as part of a key path in a format
string. For example, if a feature’s attribute is named `ISO 3166-1:2006`, an
expression format string of `lowercase(ISO 3166-1:2006)` or a predicate format
string of `ISO 3166-1:2006 == 'US-OH'` would raise an exception. Instead, use a
`%K` placeholder or the `+[NSExpression expressionForKeyPath:]` initializer:
```objc
[NSPredicate predicateWithFormat:@"%K == 'US-OH'", @"ISO 3166-1:2006"];
[NSExpression expressionForFunction:@"lowercase:"
arguments:@[[NSExpression expressionForKeyPath:@"ISO 3166-1:2006"]]]
```
```swift
NSPredicate(format: "%K == 'US-OH'", "ISO 3166-1:2006")
NSExpression(forFunction: "lowercase:",
arguments: [NSExpression(forKeyPath: "ISO 3166-1:2006")])
```
### Functions
Of the
[functions predefined]({"contact/admin/forURLs"})
by the
[`+[NSExpression expressionForFunction:arguments:]` method]({"contact/admin/forURLs"}),
the following subset is supported in layer attribute values:
Initializer parameter | Format string syntax
----------------------|---------------------
`average:` | `average({1, 2, 2, 3, 4, 7, 9})`
`sum:` | `sum({1, 2, 2, 3, 4, 7, 9})`
`count:` | `count({1, 2, 2, 3, 4, 7, 9})`
`min:` | `min({1, 2, 2, 3, 4, 7, 9})`
`max:` | `max({1, 2, 2, 3, 4, 7, 9})`
`add:to:` | `1 + 2`
`from:subtract:` | `2 - 1`
`multiply:by:` | `1 * 2`
`divide:by:` | `1 / 2`
`modulus:by:` | `modulus:by:(1, 2)`
`sqrt:` | `sqrt(2)`
`log:` | `log(10)`
`ln:` | `ln(2)`
`raise:toPower:` | `2 ** 2`
`exp:` | `exp(0)`
`ceiling:` | `ceiling(0.99999)`
`abs:` | `abs(-1)`
`trunc:` | `trunc(6378.1370)`
`floor:` | `floor(-0.99999)`
`uppercase:` | `uppercase('Elysian Fields')`
`lowercase:` | `lowercase('DOWNTOWN')`
`noindex:` | `noindex(0 + 2 + c)`
`length:` | `length('Wapakoneta')`
`castObject:toType:` | `CAST(ele, 'NSString')` `CAST(ele, 'NSNumber')`
A number of [MapMetrics-specific functions](#MapMetrics-specific-functions) are also
available.
The following predefined functions are **not** supported:
Initializer parameter | Format string syntax
----------------------|---------------------
`median:` | `median({1, 2, 2, 3, 4, 7, 9})`
`mode:` | `mode({1, 2, 2, 3, 4, 7, 9})`
`stddev:` | `stddev({1, 2, 2, 3, 4, 7, 9})`
`random` | `random()`
`randomn:` | `randomn(10)`
`now` | `now()`
`bitwiseAnd:with:` | `bitwiseAnd:with:(5, 3)`
`bitwiseOr:with:` | `bitwiseOr:with:(5, 3)`
`bitwiseXor:with:` | `bitwiseXor:with:(5, 3)`
`leftshift:by:` | `leftshift:by:(23, 1)`
`rightshift:by:` | `rightshift:by:(23, 1)`
`onesComplement:` | `onesComplement(255)`
`distanceToLocation:fromLocation:` | `distanceToLocation:fromLocation:(there, here)`
### Conditionals
Conditionals are supported via the built-in
`+[NSExpression expressionForConditional:trueExpression:falseExpression:]`
method and `TERNARY()` operator. If you need to express multiple cases
(“else-if”), you can either nest a conditional within a conditional or use the
[`MLN_IF()`](#code-mgl_if-code) or [`MLN_MATCH()`](#code-mgl_match-code) function.
### Aggregates
Aggregate expressions can contain arrays of expressions. In some cases, it is
possible to use the array itself instead of wrapping the array in an aggregate
expression.
### Variables
The following variables are defined by this SDK for use with style layers:
| Variable | Type | Meaning |
| --- | --- | --- |
| `$featureIdentifier` | Any GeoJSON data type | A value that uniquely identifies the feature in the containing source. This variable corresponds to the `NSExpression.featureIdentifierVariableExpression` property. |
| `$geometryType` | String | The type of geometry represented by the feature. A feature’s type is one of the following strings: * `Point` for point features, corresponding to the `MLNPointAnnotation` class * `LineString` for polyline features, corresponding to the ``MLNPolyline`` class * `Polygon` for polygon features, corresponding to the ``MLNPolygon`` class This variable corresponds to the `NSExpression.geometryTypeVariableExpression` property. |
| `$heatmapDensity` | Number | The [kernel density estimation]({"contact/admin/forURLs"}) of a screen point in a heatmap layer; in other words, a relative measure of how many data points are crowded around a particular pixel. This variable can only be used with the `heatmapColor` property. This variable corresponds to the `NSExpression.heatmapDensityVariableExpression` property. |
| `$zoomLevel` | Number | The current zoom level. In style layout and paint properties, this variable may only appear as the target of a top-level interpolation or step expression. This variable corresponds to the `NSExpression.zoomLevelVariableExpression` property. |
| `$lineProgress` | Number | A number that indicates the relative distance along a line at a given point along the line. This variable evaluates to 0 at the beginning of the line and 1 at the end of the line. It can only be used with the ``MLNLineStyleLayer/lineGradient`` property. It corresponds to the `NSExpression.lineProgressVariableExpression` property. |
In addition to these variables, you can define your own variables and refer to
them elsewhere in the expression. The syntax for defining a variable makes use
of a [MapMetrics-specific function](#MapMetrics-specific-functions) that takes an
`NSDictionary` as an argument:
```objc
[NSExpression expressionWithFormat:@"MLN_LET('floorCount', 2, $floorCount + 1)"];
```
```swift
NSExpression(format: "MLN_LET(floorCount, 2, $floorCount + 1)")
```
## MapMetrics-specific functions
> Warning: Due to a change in iOS 15.5, some of these stopped working. See [#331]({"contact/admin/forURLs"}) for more information and workarounds.
For compatibility with the MapMetrics Style Spec, the following functions
are defined by this SDK. When setting a style layer property, you can call these
functions just like the predefined functions above, using either the
`+[NSExpression expressionForFunction:arguments:]` method or a convenient format
string syntax:
### mgl_does:have:
**Selector:** `mgl_does:have:`
**Format string syntax:** `mgl_does:have:(SELF, '🧀🍔')` or `mgl_does:have:(%@, '🧀🍔')`
Returns a Boolean value indicating whether the dictionary has a value for the
key or whether the evaluated object (`SELF`) has a value for the feature
attribute. Compared to the [`mgl_has:`](#code-mgl_has-code) custom function,
that function's target is instead passed in as the first argument to this
function. Both functions are equivalent to the syntax `key != NIL` or
`%@[key] != NIL` but can be used outside of a predicate.
### mgl_interpolate:withCurveType:parameters:stops:
**Selector:** `mgl_interpolate:withCurveType:parameters:stops:`
**Format string syntax:** `mgl_interpolate:withCurveType:parameters:stops:(x, 'linear', nil, %@)`
Produces continuous, smooth results by interpolating between pairs of input and
output values ("stops"). Compared to the
[`mgl_interpolateWithCurveType:parameters:stops:`](#code-mgl_interpolatewithcurvetype-parameters-stops-code)
custom function, the input expression (that function's target) is instead passed
in as the first argument to this function.
### mgl_step:from:stops:
**Selector:** `mgl_step:from:stops:`
**Format string syntax:** `mgl_step:from:stops:(x, 11, %@)`
Produces discrete, stepped results by evaluating a piecewise-constant function
defined by pairs of input and output values ("stops"). Compared to the
[`mgl_stepWithMinimum:stops:`](#code-mgl_stepwithminimum-stops-code) custom
function, the input expression (that function's target) is instead passed in as
the first argument to this function.
### mgl_join:
**Selector:** `mgl_join:`
**Format string syntax:** `mgl_join({'Old', 'MacDonald'})`
Returns the result of concatenating together all the elements of an array in
order. Compared to the
[`stringByAppendingString:`](#code-stringbyappendingstring-code) custom
function, this function takes only one argument, which is an aggregate
expression containing the strings to concatenate.
### mgl_acos:
**Selector:** `mgl_acos:`
**Format string syntax:** `mgl_acos(1)`
Returns the arccosine of the number.
This function corresponds to the
[`acos`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_asin:
**Selector:** `mgl_asin:`
**Format string syntax:** `mgl_asin(0)`
Returns the arcsine of the number.
This function corresponds to the
[`asin`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_atan:
**Selector:** `mgl_atan:`
**Format string syntax:** `mgl_atan(20)`
Returns the arctangent of the number.
This function corresponds to the
[`atan`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_cos:
**Selector:** `mgl_cos:`
**Format string syntax:** `mgl_cos(0)`
Returns the cosine of the number.
This function corresponds to the
[`cos`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_log2:
**Selector:** `mgl_log2:`
**Format string syntax:** `mgl_log2(1024)`
Returns the base-2 logarithm of the number.
This function corresponds to the
[`log2`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_round:
**Selector:** `mgl_round:`
**Format string syntax:** `mgl_round(1.5)`
Returns the number rounded to the nearest integer. If the number is halfway
between two integers, this function rounds it away from zero.
This function corresponds to the
[`round`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_sin:
**Selector:** `mgl_sin:`
**Format string syntax:** `mgl_sin(0)`
Returns the sine of the number.
This function corresponds to the
[`sin`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_tan:
**Selector:** `mgl_tan:`
**Format string syntax:** `mgl_tan(0)`
Returns the tangent of the number.
This function corresponds to the
[`tan`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_distanceFrom:
**Selector:** `mgl_distanceFrom:`
**Format string syntax:** `mgl_distanceFrom(%@)` with an `MLNShape`
Returns the straight-line distance from the evaluated object to the given shape.
This function corresponds to the
[`distance`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_coalesce:
**Selector:** `mgl_coalesce:`
**Format string syntax:** `mgl_coalesce({x, y, z})`
Returns the first non-`nil` value from an array of expressions.
This function corresponds to the
[`coalesce`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### mgl_attributed:
**Selector:** `mgl_attributed:`
**Format string syntax:** `mgl_attributed({x, y, z})`
Concatenates and returns the array of `MLNAttributedExpression` objects, for use
with the `MLNSymbolStyleLayer.text` property.
`MLNAttributedExpression.attributes` valid attributes.
Key | Value Type
--- | ---
`MLNFontNamesAttribute` | An `NSExpression` evaluating to an `NSString` array.
`MLNFontScaleAttribute` | An `NSExpression` evaluating to an `NSNumber` value.
`MLNFontColorAttribute` | An `NSExpression` evaluating to an `UIColor` (iOS) or `NSColor` (macOS).
This function corresponds to the
[`format`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### MLN_LET
**Selector:** `MLN_LET:`
**Format string syntax:** `MLN_LET('age', uppercase('old'), 'name', uppercase('MacDonald'), mgl_join({$age, $name}))`
**Arguments:** Any number of variable names interspersed with their assigned
`NSExpression` values, followed by an `NSExpression`
that may contain references to those variables.
Returns the result of evaluating an expression with the given variable values.
Compared to the
[`mgl_expressionWithContext:`](#code-mgl_expressionwithcontext-code) custom
function, this function takes the variable names and values inline before the
expression that contains references to those variables.
### MLN_MATCH
**Selector:** `MLN_MATCH:`
**Format string syntax:** `MLN_MATCH(x, 0, 'zero match', 1, 'one match', 2, 'two match', 'default')`
**Arguments:** An input expression, then any number of argument pairs, followed by a default
expression. Each argument pair consists of a constant value followed by an
expression to produce as a result of matching that constant value.
If the input value is an aggregate expression, then any of the constant values within
that aggregate expression result in the following argument. This is shorthand for
specifying an argument pair for each of the constant values within that aggregate
expression. It is not possible to match the aggregate expression itself.
Returns the result of matching the input expression against the given constant
values.
This function corresponds to the
`+[NSExpression(MLNAdditions) mgl_expressionForMatchingExpression:inDictionary:defaultExpression:]`
method and the
[`match`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### MLN_IF
**Selector:** `MLN_IF:`
**Format string syntax:** `MLN_IF(1 = 2, YES, 2 = 2, YES, NO)`
**Arguments:** Alternating `NSPredicate` conditionals and resulting expressions,
followed by a default expression.
Returns the first expression that meets the condition; otherwise, the default
value. Unlike
`+[NSExpression expressionForConditional:trueExpression:falseExpression:]` or
the `TERNARY()` syntax, this function can accept multiple "if else" conditions
and is supported on iOS 8._x_ and macOS 10.10._x_; however, each conditional
passed into this function must be wrapped in a constant expression.
This function corresponds to the
`+[NSExpression(MLNAdditions) mgl_expressionForConditional:trueExpression:falseExpresssion:]`
method and the
[`case`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### MLN_FUNCTION
**Selector:** `MLN_FUNCTION:`
**Format string syntax:** `MLN_FUNCTION('typeof', mystery)`
**Arguments:** Any arguments required by the expression operator.
An expression exactly as defined by the
[MapMetrics Style Spec]({"contact/admin/forURLs"}).
## Custom functions
The following custom functions are also available with the
`+[NSExpression expressionForFunction:selectorName:arguments:]` method or the
`FUNCTION()` format string syntax.
Some of these functions are defined as methods on their respective target
classes, but you should not call them directly outside the context of an
expression, because the result may differ from the evaluated expression's result
or may result in undefined behavior.
The MapMetrics Style Spec defines some operators for which no custom
function is available. To use these operators in an `NSExpression`, call the
[`MLN_FUNCTION()`](#code-mgl_function-code) function with the same arguments
that the operator expects.
### boolValue
**Selector:** `boolValue`
**Format string syntax:** `FUNCTION(1, 'boolValue')`
**Target:** An `NSExpression` that evaluates to a number or string.
**Arguments:** None.
A Boolean representation of the target: `FALSE` when then input is an
empty string, 0, `FALSE`, `NIL`, or `NaN`, otherwise `TRUE`.
### mgl_has:
**Selector:** `mgl_has:`
**Format string syntax:** `FUNCTION($featureAttributes, 'mgl_has:', '🧀🍔')`
**Target:** An `NSExpression` that evaluates to an `NSDictionary`
or the evaluated object (`SELF`).
**Arguments:** An `NSExpression` that evaluates to an `NSString`
representing the key to look up in the dictionary or the feature attribute to
look up in the evaluated object (see `MLNFeature.attributes`).
`true` if the dictionary has a value for the key or if the evaluated
object has a value for the feature attribute.
This function corresponds to the
[`has`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec. See also the
[`mgl_does:have:`](#code-mgl_does-have-code) function, which is used on its own
without the `FUNCTION()` operator. You can also check whether an object has an
attribute by comparing the key path to `NIL`, for example `cheeseburger != NIL`
or `burger.cheese != NIL`
### mgl_expressionWithContext:
**Selector:** `mgl_expressionWithContext:`
**Format string syntax:** `FUNCTION($ios + $macos, 'mgl_expressionWithContext:', %@)` with
a dictionary containing `ios` and `macos` keys
**Target:** An `NSExpression` that may contain references to the variables
defined in the context dictionary.
**Arguments:** An `NSDictionary` with `NSString`s as keys and
`NSExpression`s as values. Each key is a variable name and each
value is the variable's value within the target expression.
The target expression with variable subexpressions replaced with the values
defined in the context dictionary.
This function corresponds to the
[`let`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec. See also the
[`MLN_LET`](#code-mgl_let-code) function, which is used on its own without the
`FUNCTION()` operator.
### mgl_interpolateWithCurveType:parameters:stops:
**Selector:** `mgl_interpolateWithCurveType:parameters:stops:`
**Format string syntax:** `FUNCTION($zoomLevel, 'mgl_interpolateWithCurveType:parameters:stops:', 'linear', NIL, %@)`
with a dictionary containing zoom levels or other constant values as keys
**Target:** An `NSExpression` that evaluates to a number and contains a
variable or key path expression.
**Arguments:** The first argument is one of the following strings denoting curve types:
`linear`, `exponential`, or `cubic-bezier`.
The second argument is an expression providing parameters for the curve:
* If the curve type is `linear`, the argument is `NIL`.
* If the curve type is `exponential`, the argument is an
expression that evaluates to a number, specifying the base of the
exponential interpolation.
* If the curve type is `cubic-bezier`, the argument is an
array or aggregate expression containing four expressions, each
evaluating to a number. The four numbers are control points for the
cubic Bézier curve.
The third argument is an `NSDictionary` object representing the
interpolation's stops, with numeric zoom levels as keys and expressions as
values.
A value interpolated along the continuous mathematical function defined by the
arguments, with the target as the input to the function.
The input expression is matched against the keys in the stop dictionary. The
keys may be feature attribute values, zoom levels, or heatmap densities. The
values may be constant values or `NSExpression` objects. For example, you can
use a stop dictionary with the zoom levels 0, 10, and 20 as keys and the colors
yellow, orange, and red as the values.
This function corresponds to the
`+[NSExpression(MLNAdditions) mgl_expressionForInterpolatingExpression:withCurveType:parameters:stops:]`
method and the
[`interpolate`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec. See also the
[`mgl_interpolate:withCurveType:parameters:stops:`](#code-mgl_interpolate-withcurvetype-parameters-stops-code)
function, which is used on its own without the `FUNCTION()` operator.
### mgl_numberWithFallbackValues:
**Selector:** `mgl_numberWithFallbackValues:`,
`doubleValue`,
`floatValue`, or
`decimalValue`
**Format string syntax:** `FUNCTION(ele, 'mgl_numberWithFallbackValues:', 0)`
**Target:** An `NSExpression` that evaluates to a Boolean value, number, or
string.
**Arguments:** Zero or more `NSExpression`s, each evaluating to a Boolean value
or string.
A numeric representation of the target:
* If the target is `NIL` or `FALSE`, the result is 0.
* If the target is true, the result is 1.
* If the target is a string, it is converted to a number as specified by the
**Selector:**
`mgl_numberWithFallbackValues:`,
`doubleValue`,
`floatValue`, or
`decimalValue`
**Format string syntax:**
`FUNCTION(ele, 'mgl_numberWithFallbackValues:', 0)`
**Target:**
An `NSExpression` that evaluates to a Boolean value, number, or
string.
**Arguments:**
Zero or more `NSExpression`s, each evaluating to a Boolean value
or string.
A numeric representation of the target:
* If the target is `NIL` or `FALSE`, the result is 0.
* If the target is true, the result is 1.
* If the target is a string, it is converted to a number as specified by the
"[ToNumber Applied to the String Type]({"contact/admin/forURLs"})"
algorithm of the ECMAScript Language Specification.
* If multiple values are provided, each one is evaluated in order until the
first successful conversion is obtained.
This function corresponds to the
[`to-number`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec. You can also cast a value to a
number by passing the value and the string `NSNumber` into the `CAST()`
operator.
### mgl_stepWithMinimum:stops:
**Selector:**
`mgl_stepWithMinimum:stops:`
**Format string syntax:**
`FUNCTION($zoomLevel, 'mgl_stepWithMinimum:stops:', 0, %@)` with
a dictionary with zoom levels or other constant values as keys
**Target:**
An `NSExpression` that evaluates to a number and contains a
variable or key path expression.
**Arguments:**
The first argument is an expression that evaluates to a number, specifying
the minimum value in case the target is less than any of the stops in the
second argument.
The second argument is an `NSDictionary` object representing the
interpolation's stops, with numeric zoom levels as keys and expressions as
values.
The output value of the stop whose key is just less than the evaluated target,
or the minimum value if the target is less than the least of the stops' keys.
The input expression is matched against the keys in the stop dictionary. The
keys may be feature attribute values, zoom levels, or heatmap densities. The
values may be constant values or `NSExpression` objects. For example, you can
use a stop dictionary with the zoom levels 0, 10, and 20 as keys and the colors
yellow, orange, and red as the values.
This function corresponds to the
`+[NSExpression(MLNAdditions) mgl_expressionForSteppingExpression:fromExpression:stops:]`
method and the
[`step`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec.
### stringByAppendingString:
**Selector:**
`stringByAppendingString:`
**Format string syntax:**
`FUNCTION('Old', 'stringByAppendingString:', 'MacDonald')`
**Target:**
An `NSExpression` that evaluates to a string.
**Arguments:**
One or more `NSExpression`s, each evaluating to a string.
The target string with each of the argument strings appended in order.
This function corresponds to the
`-[NSExpression(MLNAdditions) mgl_expressionByAppendingExpression:]`
method and is similar to the
[`concat`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec. See also the
[`mgl_join:`](#code-mgl_join-code) function, which concatenates multiple
expressions and is used on its own without the `FUNCTION()` operator.
### stringValue
**Selector:**
`stringValue`
**Format string syntax:**
`FUNCTION(ele, 'stringValue')`
**Target:**
An `NSExpression` that evaluates to a Boolean value, number, or
string.
**Arguments:**
None.
A string representation of the target:
* If the target is NIL, the result is the empty string.
* If the target is a Boolean value, the result is the string `true` or `false`.
* If the target is a number, it is converted to a string as specified by the
“[NumberToString]({"contact/admin/forURLs"})”
algorithm of the ECMAScript Language Specification.
* If the target is a color, it is converted to a string of the form
`rgba(r,g,b,a)`, where r , g , and b are
numerals ranging from 0 to 255 and a ranges from 0 to 1.
* Otherwise, the target is converted to a string in the format specified by the
[`JSON.stringify()`]({"contact/admin/forURLs"})
function of the ECMAScript Language Specification.
This function corresponds to the
[`to-string`]({"contact/admin/forURLs"})
operator in the MapMetrics Style Spec. You can also cast a value to a
string by passing the value and the string `NSString` into the `CAST()`
operator.
---
# Rendering Statistics HUD
https://docs.mapatlas.xyz/overview/sdk/ios-native/RenderingStatisticsHud
# Rendering Statistics HUD
Show rendering statistics on the map
Enable the rendering HUD with:
```swift
mapView.enableRenderingStatsView(true)
```

---
# Making Snapshots
https://docs.mapatlas.xyz/overview/sdk/ios-native/StaticSnapshotExample
# Making Snapshots
Use ``MLNMapSnapshotter`` to create snapshots of the map.
> This example uses UIKit.
This example demonstrates how snapshots can be made of the map. The result is an `UIImage` that you can let the user save to their camera roll or share with a friend. In this demo, the resulting image is shown back with an `UIImageView`. Attribution is read from the sources and added to the snapshot (when applicable). By default a MapMetrics logo is included on the image, but it is not required to show it. You can use ``MLNMapSnapshotOptions/showsLogo`` to configure whether to include the logo. Check with your tile provider if you need to show their logo.
```swift
class StaticSnapshotExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
var button: UIButton!
var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height / 2), styleURL: AMERICANA_STYLE)
mapView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
// Center map on the Giza Pyramid Complex in Egypt.
let center = CLLocationCoordinate2D(latitude: 29.9773, longitude: 31.1325)
mapView.setCenter(center, zoomLevel: 14, animated: false)
view.addSubview(mapView)
// Create a button to take a map snapshot.
button = UIButton(frame: CGRect(x: mapView.bounds.width / 2 - 40, y: mapView.bounds.height - 40, width: 80, height: 30))
button.layer.cornerRadius = 15
button.backgroundColor = UIColor(red: 0.96, green: 0.65, blue: 0.14, alpha: 1.0)
button.setImage(UIImage(named: "camera"), for: .normal)
button.addTarget(self, action: #selector(createSnapshot), for: .touchUpInside)
view.addSubview(button)
// Create a UIImageView that will store the map snapshot.
imageView = UIImageView(frame: CGRect(x: 0, y: view.bounds.height / 2, width: view.bounds.width, height: view.bounds.height / 2))
imageView.backgroundColor = .black
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(imageView)
}
@objc func createSnapshot() {
// Use the map's style, camera, size, and zoom level to set the snapshot's options.
let options = MLNMapSnapshotOptions(styleURL: mapView.styleURL, camera: mapView.camera, size: mapView.bounds.size)
options.zoomLevel = mapView.zoomLevel
// Add an activity indicator to show that the snapshot is loading.
let indicator = UIActivityIndicatorView(frame: CGRect(x: imageView.center.x - 30, y: imageView.center.y - 30, width: 60, height: 60))
view.addSubview(indicator)
indicator.startAnimating()
// Create the map snapshot.
let snapshotter: MLNMapSnapshotter? = MLNMapSnapshotter(options: options)
snapshotter?.start { snapshot, error in
if error != nil {
print("Unable to create a map snapshot.")
} else if let snapshot {
// Add the map snapshot's image to the image view.
indicator.stopAnimating()
self.imageView.image = snapshot.image
}
}
}
}
```
---
# Tile URL Templates
https://docs.mapatlas.xyz/overview/sdk/ios-native/Tile_URL_Templates
# Tile URL Templates
Using URL Templates when defining tile sources
``MLNTileSource`` objects, specifically ``MLNRasterTileSource`` and
``MLNVectorTileSource`` objects, can be created using an initializer that accepts
an array of tile URL templates. Tile URL templates are strings that specify the
URLs of the vector tiles or raster tile images to load. A template resembles an
absolute URL, but with any number of placeholder strings that the source
evaluates based on the tile it needs to load. For example:
* `{"contact/admin/forURLs"} could be
evaluated as `{"contact/admin/forURLs"}
* `{"contact/admin/forURLs"} could be
evaluated as `{"contact/admin/forURLs"}
Tile URL templates are also used to define tilesets in TileJSON manifests or
[`raster`]({"contact/admin/forURLs"})
and
[`vector`]({"contact/admin/forURLs"})
sources in style JSON files. See the
[TileJSON specification]({"contact/admin/forURLs"})
for information about tile URL templates in the context of a TileJSON or style
JSON file.
Tile sources support the following placeholder strings in tile URL templates,
all of which are optional:
| Placeholder string | Description |
| --- | --- |
| `{x}` | The index of the tile along the map’s x axis according to Spherical Mercator projection. If the value is 0, the tile’s left edge corresponds to the 180th meridian west. If the value is 2^z−1, the tile’s right edge corresponds to the 180th meridian east. |
| `{y}` | The index of the tile along the map’s y axis according to Spherical Mercator projection. If the value is 0, the tile’s tile edge corresponds to arctan(sinh(π)), or approximately 85.0511 degrees north. If the value is 2^z−1, the tile’s bottom edge corresponds to −arctan(sinh(π)), or approximately 85.0511 degrees south. The y axis is inverted if the options parameter contains ``MLNTileSourceOptionTileCoordinateSystem`` with a value of ``MLNTileCoordinateSystem/MLNTileCoordinateSystemTMS``. |
| `{z}` | The tile’s zoom level. At zoom level 0, each tile covers the entire world map; at zoom level 1, it covers ¼ of the world; at zoom level 2, 1⁄16 of the world, and so on. For tiles loaded by a ``MLNRasterTileSource`` object, whether the tile zoom level matches the map’s current zoom level depends on the value of the source’s tile size as specified in the ``MLNTileSourceOptionTileSize`` key of the options parameter. |
| `{bbox-epsg-3857}` | The tile's bounding box, expressed as a comma-separated list of the tile's western, southern, eastern, and northern extents according to Spherical Mercator (EPSG:3857) projection. The bounding box is typically used with map services conforming to the Web Map Service protocol. |
| `{quadkey}` | A quadkey indicating both the tile's location and its zoom level. The quadkey is typically used with Bing Maps. |
| `{ratio}` | A suffix indicating the resolution of the tile image. The suffix is the empty string for standard resolution displays and `@2x` for Retina displays, including displays for which `UIScreen.scale` is 3. |
| `{prefix}` | Two hexadecimal digits chosen such that each visible tile has a different prefix. The prefix is typically used for domain sharding. |
For more information about the `{x}`, `{y}`, and `{z}` placeholder strings,
refer to the tile URL template documentation.
---
# Showing data from an API
https://docs.mapatlas.xyz/overview/sdk/ios-native/WebAPIDataExample
# Showing data from an API
Showing data from an API with custom styling and interaction
> Note: This example uses UIKit.
This example loads lighthouses in the United States from [WikiData]({"contact/admin/forURLs"}). It adds points to the map and applies dynamic styling to these points. When zooming in the dots become larger circles with a custom icon and the name of the lighthouse shown next to it. When tapping a callout is shown with the name of the lighthouse that was tapped on.
```swift
class WebAPIDataExample: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(frame: view.bounds)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.setCenter(CLLocationCoordinate2D(latitude: 37.090240, longitude: -95.712891), zoomLevel: 2, animated: false)
mapView.delegate = self
mapView.attributionButton.isHidden = true
view.addSubview(mapView)
// Add a single tap gesture recognizer. This gesture requires the built-in MLNMapView tap gestures (such as those for zoom and annotation selection) to fail.
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleMapTap(sender:)))
for recognizer in mapView.gestureRecognizers! where recognizer is UITapGestureRecognizer {
singleTap.require(toFail: recognizer)
}
mapView.addGestureRecognizer(singleTap)
}
func mapView(_: MLNMapView, didFinishLoading _: MLNStyle) {
fetchPoints { [weak self] features in
self?.addItemsToMap(features: features)
}
}
func addItemsToMap(features: [MLNPointFeature]) {
// MLNMapView.style is optional, so you must guard against it not being set.
guard let style = mapView.style else { return }
// You can add custom UIImages to the map style.
// These can be referenced by an MLNSymbolStyleLayer’s iconImage property.
style.setImage(UIImage(named: "lighthouse")!, forName: "lighthouse")
// Add the features to the map as a shape source.
let source = MLNShapeSource(identifier: "us-lighthouses", features: features, options: nil)
style.addSource(source)
let lighthouseColor = UIColor(red: 0.08, green: 0.44, blue: 0.96, alpha: 1.0)
// Use MLNCircleStyleLayer to represent the points with simple circles.
// In this case, we can use style functions to gradually change properties between zoom level 2 and 7: the circle opacity from 50% to 100% and the circle radius from 2pt to 3pt.
let circles = MLNCircleStyleLayer(identifier: "lighthouse-circles", source: source)
circles.circleColor = NSExpression(forConstantValue: lighthouseColor)
// The circles should increase in opacity from 0.5 to 1 based on zoom level.
circles.circleOpacity = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [2: 0.5, 7: 1]))
circles.circleRadius = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [2: 2, 7: 3]))
// Use MLNSymbolStyleLayer for more complex styling of points including custom icons and text rendering.
let symbols = MLNSymbolStyleLayer(identifier: "lighthouse-symbols", source: source)
symbols.iconImageName = NSExpression(forConstantValue: "lighthouse")
symbols.iconColor = NSExpression(forConstantValue: lighthouseColor)
symbols.iconScale = NSExpression(forConstantValue: 0.5)
symbols.iconHaloColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.5))
symbols.iconHaloWidth = NSExpression(forConstantValue: 1)
symbols.iconOpacity = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [5.9: 0, 6: 1]))
// "name" references the "name" key in an MLNPointFeature’s attributes dictionary.
symbols.text = NSExpression(forKeyPath: "name")
symbols.textColor = symbols.iconColor
symbols.textFontSize = NSExpression(forMLNInterpolating: NSExpression.zoomLevelVariable, curveType: MLNExpressionInterpolationMode.linear, parameters: nil, stops: NSExpression(forConstantValue: [10: 10, 16: 16]))
symbols.textTranslation = NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: 15, dy: 0)))
symbols.textOpacity = symbols.iconOpacity
symbols.textHaloColor = symbols.iconHaloColor
symbols.textHaloWidth = symbols.iconHaloWidth
symbols.textJustification = NSExpression(forConstantValue: NSValue(mlnTextJustification: .left))
symbols.textAnchor = NSExpression(forConstantValue: NSValue(mlnTextAnchor: .left))
style.addLayer(circles)
style.addLayer(symbols)
}
// MARK: - Feature interaction
@IBAction func handleMapTap(sender: UITapGestureRecognizer) {
if sender.state == .ended {
// Limit feature selection to just the following layer identifiers.
let layerIdentifiers: Set = ["lighthouse-symbols", "lighthouse-circles"]
// Try matching the exact point first.
let point = sender.location(in: sender.view!)
for feature in mapView.visibleFeatures(at: point, styleLayerIdentifiers: layerIdentifiers)
where feature is MLNPointFeature
{
guard let selectedFeature = feature as? MLNPointFeature else {
fatalError("Failed to cast selected feature as MLNPointFeature")
}
showCallout(feature: selectedFeature)
return
}
let touchCoordinate = mapView.convert(point, toCoordinateFrom: sender.view!)
let touchLocation = CLLocation(latitude: touchCoordinate.latitude, longitude: touchCoordinate.longitude)
// Otherwise, get all features within a rect the size of a touch (44x44).
let touchRect = CGRect(origin: point, size: .zero).insetBy(dx: -22.0, dy: -22.0)
let possibleFeatures = mapView.visibleFeatures(in: touchRect, styleLayerIdentifiers: Set(layerIdentifiers)).filter { $0 is MLNPointFeature }
// Select the closest feature to the touch center.
let closestFeatures = possibleFeatures.sorted(by: {
CLLocation(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude).distance(from: touchLocation) < CLLocation(latitude: $1.coordinate.latitude, longitude: $1.coordinate.longitude).distance(from: touchLocation)
})
if let feature = closestFeatures.first {
guard let closestFeature = feature as? MLNPointFeature else {
fatalError("Failed to cast selected feature as MLNPointFeature")
}
showCallout(feature: closestFeature)
return
}
// If no features were found, deselect the selected annotation, if any.
mapView.deselectAnnotation(mapView.selectedAnnotations.first, animated: true)
}
}
func showCallout(feature: MLNPointFeature) {
let point = MLNPointFeature()
point.title = feature.attributes["name"] as? String
point.coordinate = feature.coordinate
// Selecting an feature that doesn’t already exist on the map will add a new annotation view.
// We’ll need to use the map’s delegate methods to add an empty annotation view and remove it when we’re done selecting it.
mapView.selectAnnotation(point, animated: true, completionHandler: nil)
}
// MARK: - MLNMapViewDelegate
func mapView(_: MLNMapView, annotationCanShowCallout _: MLNAnnotation) -> Bool {
true
}
func mapView(_ mapView: MLNMapView, didDeselect annotation: MLNAnnotation) {
mapView.removeAnnotations([annotation])
}
func mapView(_: MLNMapView, viewFor _: MLNAnnotation) -> MLNAnnotationView? {
// Create an empty view annotation. Set a frame to offset the callout.
MLNAnnotationView(frame: CGRect(x: 0, y: 0, width: 20, height: 20))
}
// MARK: - Data fetching and parsing
func fetchPoints(withCompletion completion: @escaping (([MLNPointFeature]) -> Void)) {
// Wikidata query for all lighthouses in the United States: {"contact/admin/forURLs"}
let query = "SELECT DISTINCT ?item " +
"?itemLabel ?coor ?image " +
"WHERE " +
"{ " +
"?item wdt:P31 wd:Q39715 . " +
"?item wdt:P17 wd:Q30 . " +
"?item wdt:P625 ?coor . " +
"OPTIONAL { ?item wdt:P18 ?image } . " +
"SERVICE wikibase:label { bd:serviceParam wikibase:language \"en\" } " +
"} " +
"ORDER BY ?itemLabel"
let characterSet = NSMutableCharacterSet()
characterSet.formUnion(with: CharacterSet.urlQueryAllowed)
characterSet.removeCharacters(in: "?")
characterSet.removeCharacters(in: "&")
characterSet.removeCharacters(in: ":")
let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: characterSet as CharacterSet)!
let request = URLRequest(url: URL(string: "{"contact/admin/forURLs"})&format=json")!)
URLSession.shared.dataTask(with: request, completionHandler: { data, _, error in
guard error == nil else {
preconditionFailure("Failed to load GeoJSON data: \(error!)")
}
guard
let data,
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: AnyObject],
let results = json["results"] as? [String: AnyObject],
let items = results["bindings"] as? [[String: AnyObject]]
else {
preconditionFailure("Failed to parse GeoJSON data")
}
DispatchQueue.main.async {
completion(self.parseJSONItems(items: items))
}
}).resume()
}
func parseJSONItems(items: [[String: AnyObject]]) -> [MLNPointFeature] {
var features = [MLNPointFeature]()
for item in items {
guard let label = item["itemLabel"] as? [String: AnyObject],
let title = label["value"] as? String else { continue }
guard let coor = item["coor"] as? [String: AnyObject],
let point = coor["value"] as? String else { continue }
let parsedPoint = point.replacingOccurrences(of: "Point(", with: "").replacingOccurrences(of: ")", with: "")
let pointComponents = parsedPoint.components(separatedBy: " ")
let coordinate = CLLocationCoordinate2D(latitude: Double(pointComponents[1])!, longitude: Double(pointComponents[0])!)
let feature = MLNPointFeature()
feature.coordinate = coordinate
feature.title = title
// A feature’s attributes can used by runtime styling for things like text labels.
feature.attributes = [
"name": title,
]
features.append(feature)
}
return features
}
}
```

---
# mapmetrics-ios-Map-add markers
https://docs.mapatlas.xyz/overview/sdk/ios-native/mapmetrics-ios-Map-add markers
## 📍 MapMetrics iOS Map Features Guide
This guide covers how to:
Add interactive markers
## 🔧 Prerequisites
Xcode installed (version 13 or newer recommended)
A new or existing iOS project
Add MapMetrics SDK to your project (via SPM or manual integration)
### Example via Swift Package Manager:
: https://github.com/MapMetrics/MapMetrics-iOS
## Setting Up the Map
In your ViewController.swift:
```swift
class ViewController: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
var selectedAnnotation: MLNPointAnnotation?
var isMarkerSelected = false
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(
frame: view.bounds,
styleURL: URL(string: "")
)
mapView.delegate = self
view.addSubview(mapView)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.delegate = self
// This is a better starting position
let center = CLLocationCoordinate2D(latitude: 20.0, longitude: 0.0) // More centered globally
mapView.setCenter(center, zoomLevel: 2, animated: false) // Lower zoom to see more area
view.addSubview(mapView)
}
}
```
## Add Tap-to-Drop Marker with Editable Title
```swift
override func viewDidLoad() {
...
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
mapView.addGestureRecognizer(tapGesture)
}
@objc func mapTapped(_ sender: UITapGestureRecognizer) {
let location = sender.location(in: mapView)
let coordinate = mapView.convert(location, toCoordinateFrom: mapView)
let marker = MLNPointAnnotation()
marker.coordinate = coordinate
marker.title = "Tap to add a name"
mapView.addAnnotation(marker)
}
```
### Add support for selecting and editing markers:
```swift
func mapView(_ mapView: MLNMapView, didSelect annotation: MLNAnnotation) {
guard let point = annotation as? MLNPointAnnotation else { return }
selectedAnnotation = point
}
// Handle tap gesture on the map
@objc func mapTapped(_ sender: UITapGestureRecognizer) {
// Only add a marker if no marker is currently selected
guard !isMarkerSelected else { return }
let location = sender.location(in: mapView)
let coordinates = mapView.convert(location, toCoordinateFrom: mapView)
addMarker(at: coordinates)
}
// Add marker to the map at specific coordinates
func addMarker(at coordinates: CLLocationCoordinate2D) {
let marker = MLNPointAnnotation()
marker.coordinate = coordinates
marker.title = "Tap to add a name" // Default title
// Add the marker to the map
mapView.addAnnotation(marker)
}
// Handle deselecting a marker (hide info view)
func mapView(_ mapView: MLNMapView, didDeselect annotation: MLNAnnotation) {
if annotation === selectedAnnotation {
isMarkerSelected = false
selectedAnnotation = nil
}
}
// MARK: - UITextFieldDelegate
extension ViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
// When the user presses "done", update the marker's title with the text entered
if let selectedAnnotation = selectedAnnotation, let newName = textField.text, !newName.isEmpty {
selectedAnnotation.title = newName
}
return true
}
}
```
---
# mapmetrics-ios-Map-add-Clusters
https://docs.mapatlas.xyz/overview/sdk/ios-native/mapmetrics-ios-Map-add-Clusters
## 📍 MapMetrics iOS Map Features Guide
This guide covers how to:
Add Clusters
## 🔧 Prerequisites
Xcode installed (version 13 or newer recommended)
A new or existing iOS project
Add MapMetrics SDK to your project (via SPM or manual integration)
### Example via Swift Package Manager:
: https://github.com/MapMetrics/MapMetrics-iOS
## 1️⃣ Setting Up the Map
In your ViewController.swift:
```swift
class ViewController: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
var selectedAnnotation: MLNPointAnnotation?
var isMarkerSelected = false
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(
frame: view.bounds,
styleURL: URL(string: "")
)
mapView.delegate = self
view.addSubview(mapView)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.delegate = self
// This is a better starting position
let center = CLLocationCoordinate2D(latitude: 20.0, longitude: 0.0) // More centered globally
mapView.setCenter(center, zoomLevel: 2, animated: false) // Lower zoom to see more area
view.addSubview(mapView)
}
}
```
## Displaying Clusters from GeoJSON
###Step 1: Create the source
```swift
let url = URL(string: "https://cdn.mapmetrics-atlas.net/Images/heatmap.geojson")!
let source = MLNShapeSource(
identifier: "clusteredEarthquakes",
url: url,
options: [
.clustered: true,
.clusterRadius: 30
]
)
style.addSource(source)
```
## Step 2: Add the cluster layers
```swift
private func addClusterLayers(source: MLNShapeSource, to style: MLNStyle) throws {
// Cluster layer
let clusterLayer = MLNCircleStyleLayer(identifier: "clusters", source: source)
clusterLayer.circleColor = NSExpression(format: "mgl_step:from:stops:(point_count, %@, %@)",
UIColor.systemTeal,
[100: UIColor.systemYellow, 750: UIColor.systemPink])
clusterLayer.circleRadius = NSExpression(format: "mgl_step:from:stops:(point_count, 20, %@)",
[100: 30, 750: 40])
clusterLayer.predicate = NSPredicate(format: "point_count >= 0")
style.addLayer(clusterLayer)
// Count layer
let countLayer = MLNSymbolStyleLayer(identifier: "cluster-count", source: source)
countLayer.text = NSExpression(format: "CAST(point_count_abbreviated, 'NSString')")
countLayer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Medium"])
countLayer.textFontSize = NSExpression(forConstantValue: 12)
countLayer.predicate = NSPredicate(format: "point_count >= 0")
style.addLayer(countLayer)
// Unclustered point layer
let pointLayer = MLNCircleStyleLayer(identifier: "unclustered-point", source: source)
pointLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue)
pointLayer.circleRadius = NSExpression(forConstantValue: 4)
pointLayer.circleStrokeWidth = NSExpression(forConstantValue: 1)
pointLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white)
pointLayer.predicate = NSPredicate(format: "point_count == nil")
style.addLayer(pointLayer)
}
```
### Complete Setup
```swift
func setupClusters() {
print("🟢 Starting cluster setup")
guard let style = mapView.style else {
print("🔴 CRITICAL ERROR: Map style is nil")
return
}
guard let url = URL(string: "https://cdn.mapmetrics-atlas.net/Images/heatmap.geojson") else {
print("🔴 ERROR: Invalid GeoJSON URL")
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
print("✅ GeoJSON Size: \(data.count) bytes")
if let geoJSON = String(data: data, encoding: .utf8) {
print("📦 Preview GeoJSON:\n\(geoJSON.prefix(500))")
}
} else {
print("❌ Failed to fetch GeoJSON: \(error?.localizedDescription ?? "Unknown error")")
}
}
task.resume()
if let layer = style.layer(withIdentifier: "earthquakes-heat") as? MLNVectorStyleLayer {
print("Heatmap filter: \(String(describing: layer.predicate))")
}
do {
// Create source without custom cluster properties first
let source = MLNShapeSource(
identifier: "clusteredEarthquakes",
url: url,
options: [
.clustered: true,
.clusterRadius: 30
]
)
if let shapeCollection = source.shape as? MLNShapeCollectionFeature {
var minLat = 90.0
var maxLat = -90.0
var minLon = 180.0
var maxLon = -180.0
for feature in shapeCollection.shapes {
if let point = feature as? MLNPointFeature {
let coord = point.coordinate
minLat = min(minLat, coord.latitude)
maxLat = max(maxLat, coord.latitude)
minLon = min(minLon, coord.longitude)
maxLon = max(maxLon, coord.longitude)
}
// Optionally handle more geometry types like MGLPolylineFeature or MGLPolygonFeature here
}
let sw = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
let ne = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
let bounds = MLNCoordinateBounds(sw: sw, ne: ne)
let camera = mapView.cameraThatFitsCoordinateBounds(bounds, edgePadding: .init(top: 40, left: 20, bottom: 40, right: 20))
mapView.setCamera(camera, animated: true)
}
style.addSource(source)
print("🟢 Source added successfully")
// Create and add layers...
try addClusterLayers(source: source, to: style)
} catch {
print("🔴 ERROR: \(error.localizedDescription)")
if let nsError = error as NSError? {
print("User Info: \(nsError.userInfo)")
}
}
}
private func addClusterLayers(source: MLNShapeSource, to style: MLNStyle) throws {
// Cluster layer
let clusterLayer = MLNCircleStyleLayer(identifier: "clusters", source: source)
clusterLayer.circleColor = NSExpression(format: "mgl_step:from:stops:(point_count, %@, %@)",
UIColor.systemTeal,
[100: UIColor.systemYellow, 750: UIColor.systemPink])
clusterLayer.circleRadius = NSExpression(format: "mgl_step:from:stops:(point_count, 20, %@)",
[100: 30, 750: 40])
clusterLayer.predicate = NSPredicate(format: "point_count >= 0")
style.addLayer(clusterLayer)
// Count layer
let countLayer = MLNSymbolStyleLayer(identifier: "cluster-count", source: source)
countLayer.text = NSExpression(format: "CAST(point_count_abbreviated, 'NSString')")
countLayer.textFontNames = NSExpression(forConstantValue: ["Noto Sans Medium"])
countLayer.textFontSize = NSExpression(forConstantValue: 12)
countLayer.predicate = NSPredicate(format: "point_count >= 0")
style.addLayer(countLayer)
// Unclustered point layer
let pointLayer = MLNCircleStyleLayer(identifier: "unclustered-point", source: source)
pointLayer.circleColor = NSExpression(forConstantValue: UIColor.systemBlue)
pointLayer.circleRadius = NSExpression(forConstantValue: 4)
pointLayer.circleStrokeWidth = NSExpression(forConstantValue: 1)
pointLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white)
pointLayer.predicate = NSPredicate(format: "point_count == nil")
style.addLayer(pointLayer)
}
```
---
# mapmetrics-ios-Map-add-Heatmap
https://docs.mapatlas.xyz/overview/sdk/ios-native/mapmetrics-ios-Map-add-Heatmap
## 📍 MapMetrics iOS Map Features Guide
This guide covers how to:
Add HeatMap
## 🔧 Prerequisites
Xcode installed (version 13 or newer recommended)
A new or existing iOS project
Add MapMetrics SDK to your project (via SPM or manual integration)
### Example via Swift Package Manager:
: https://github.com/MapMetrics/MapMetrics-iOS
## Setting Up the Map
In your ViewController.swift:
```swift
class ViewController: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
var selectedAnnotation: MLNPointAnnotation?
var isMarkerSelected = false
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(
frame: view.bounds,
styleURL: URL(string: "")
)
mapView.delegate = self
view.addSubview(mapView)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.delegate = self
// This is a better starting position
let center = CLLocationCoordinate2D(latitude: 20.0, longitude: 0.0) // More centered globally
mapView.setCenter(center, zoomLevel: 2, animated: false) // Lower zoom to see more area
view.addSubview(mapView)
}
}
```
## Displaying Heatmap from GeoJSON
###Step 1: Add heatmap source
```swift
let heatmapSource = MLNShapeSource(
identifier: "earthquakes",
url: URL(string: "https://cdn.mapmetrics-atlas.net/Images/heatmap.geojson")!,
options: [.clustered: false]
)
style.addSource(heatmapSource)
```
###Step 2: Add heatmap layer
```swift
let heatmap = MLNHeatmapStyleLayer(identifier: "earthquakes-heat", source: heatmapSource)
heatmap.heatmapWeight = ...
heatmap.heatmapColor = ...
heatmap.heatmapRadius = ...
heatmap.heatmapOpacity = NSExpression(forConstantValue: 0.8)
heatmap.isVisible = false // initially hidden
style.addLayer(heatmap)
```
### Complete Func
```swift
func setupHeatmap() {
guard let style = mapView.style else {
print("🔴 Map style not available")
return
}
// 1. Remove any existing source/layer to avoid duplicates
if let existingSource = style.source(withIdentifier: "earthquakes") {
style.removeSource(existingSource)
}
if let existingLayer = style.layer(withIdentifier: "earthquakes-heat") {
style.removeLayer(existingLayer)
}
// 2. Create the source with proper options
let url = URL(string: "https://cdn.mapmetrics-atlas.net/Images/heatmap.geojson")!
let source: MLNShapeSource
do {
source = MLNShapeSource(
identifier: "earthquakes",
url: url,
options: [.clustered: false] // Important for heatmap
)
style.addSource(source)
print("🟢 Heatmap source added successfully")
// 3. Create the heatmap layer with proper configuration
let heatmap = MLNHeatmapStyleLayer(identifier: "earthquakes-heat", source: source)
// Weight based on magnitude with exponential scaling
heatmap.heatmapWeight = NSExpression(
forMLNInterpolating: NSExpression(forKeyPath: "mag"),
curveType: .exponential,
parameters: NSExpression(forConstantValue: 1.5),
stops: NSExpression(forConstantValue: [
0: 0,
1: 0.2,
3: 0.4,
5: 1.0
])
)
// Dynamic intensity based on zoom level
heatmap.heatmapIntensity = NSExpression(
forMLNInterpolating: .zoomLevelVariable,
curveType: .linear,
parameters: nil,
stops: NSExpression(forConstantValue: [
0: 0.5,
5: 1.5,
10: 3.0
])
)
// Color gradient from cool to hot
heatmap.heatmapColor = NSExpression(
forMLNInterpolating: NSExpression(forVariable: "heatmapDensity"),
curveType: .linear,
parameters: nil,
stops: NSExpression(forConstantValue: [
0: UIColor.blue.withAlphaComponent(0),
0.2: UIColor.blue,
0.4: UIColor.cyan,
0.6: UIColor.green,
0.8: UIColor.yellow,
1.0: UIColor.red
])
)
// Radius that changes with zoom
heatmap.heatmapRadius = NSExpression(
forMLNInterpolating: .zoomLevelVariable,
curveType: .linear,
parameters: nil,
stops: NSExpression(forConstantValue: [
0: 5,
5: 10,
10: 20
])
)
heatmap.heatmapOpacity = NSExpression(forConstantValue: 0.8)
heatmap.isVisible = false // Start hidden
// 4. Add the layer in the correct position (above base but below labels)
if let waterLayer = style.layer(withIdentifier: "water") {
style.insertLayer(heatmap, above: waterLayer)
} else {
style.addLayer(heatmap)
}
print("🟢 Heatmap layer added successfully")
}
}
```
---
# Prerequisite
https://docs.mapatlas.xyz/overview/sdk/ios-native/mapmetrics-ios-add-Map-Basic-guide
# Prerequisite
For all of our examples, you will need:
- an API key,
- knowledge in Swift/iOS development,
- one style (default)
## Get the library
There are several ways to get this library:
- from the github repository (https://github.com/MapMetrics/MapMetrics-iOS)
- from swift package https://github.com/MapMetrics/MapMetrics-iOS
- from cocoapods.org
## Installation (CocoaPods)
Add this to your Podfile:
```ruby
target 'YourApp' do
pod 'MapMetrics-iOS', '~> 0.0.3' # Use the latest version
# OR
pod 'MapMetrics-iOS', :git => 'https://github.com/MapMetrics/MapMetrics-iOS', :tag => '0.0.1'
end
```
Run:
```bash
pod install
```
## Required Build Settings (Sandbox Fix)
To prevent `rsync.samba deny(1)` errors, users must add these settings:
### Option A: Automatic Fix (via Podfile)
Add to your Podfile:
```ruby
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# Disable sandbox restrictions
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
end
end
end
```
Then run:
```bash
pod install
```
### Option B: Manual Fix (Xcode Settings)
1. Open your project in Xcode.
2. Go to **Target → Build Settings**.
3. Search for `ENABLE_USER_SCRIPT_SANDBOXING`.
4. Set to **NO** for all configurations (Debug/Release).
---
## Verify Installation
Import in your code:
```swift
import MapMetrics
```
Clean Build (if issues persist):
```bash
rm -rf ~/Library/Developer/Xcode/DerivedData/*
```
---
## Troubleshooting
| Issue | Solution |
|---------------------------|-----------|
| Sandbox deny(1) errors | Ensure `ENABLE_USER_SCRIPT_SANDBOXING=NO` is set. |
| `pod install` fails | Delete `Pods/` and `Podfile.lock`, then retry. |
| Version conflicts | Run `pod update MapMetrics`. |
---
## Example Podfile (Complete)
```ruby
platform :ios, '12.0'
target 'YourApp' do
use_frameworks!
pod 'MapMetrics-iOS', '~> 0.0.3'
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
end
end
end
end
```
---
## Example Usage
Here's how to integrate **MapMetrics** into your project:
```swift
class ViewController: UIViewController, MLNMapViewDelegate {
var mapView: MLNMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MLNMapView(
frame: view.bounds,
styleURL: URL(string: "")
)
mapView.delegate = self
view.addSubview(mapView)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.delegate = self
// This is a better starting position
let center = CLLocationCoordinate2D(latitude: 20.0, longitude: 0.0) // More centered globally
mapView.setCenter(center, zoomLevel: 2, animated: false) // Lower zoom to see more area
view.addSubview(mapView)
}
}
```
---
---
# Mapbox migration guide
https://docs.mapatlas.xyz/overview/sdk/mapbox-migration-guide
# Mapbox migration guide
This part of the docs is dedicated to the migration from `mapbox-gl` to MapMetrics GL.
This guide might not be accurate depending on the current version of `mapbox-gl` but should be fairly straight forward.
The libraries are very similar but diverge with newer features happening from v2 in both libraries where Mapbox turned proprietary.
The overall migration is: uninstall `mapbox-gl`, install `@mapmetrics/mapmetrics-gl`
(or use the CDN links below), then replace `mapboxgl` with `mapmetricsgl`
throughout your TypeScript, JavaScript and HTML/CSS.
::: warning The global is `mapmetricsgl`, not `mapmetrics`
The UMD bundle defines exactly one global, `mapmetricsgl`. Writing
`new mapmetrics.Map(...)` throws `mapmetrics is not defined`. The CSS classes
follow the same name — `mapmetricsgl-ctrl`, not `mapmetrics-ctrl`.
:::
```bash
npm uninstall mapbox-gl
npm install @mapmetrics/mapmetrics-gl
```
```dif
- var map = new mapboxgl.Map({
+ var map = new mapmetricsgl.Map({
-
+
```
For an ES module build, the import is:
```js
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
```
### CDN Links
> Mapmetrics gives you custom links
```dif
-
-
+
+
```
---
# Prerequisite
https://docs.mapatlas.xyz/overview/sdk/mapmetrics-andriod
# Prerequisite
For all of our examples, you will need:
- an API key,
- knowledge in Java/Kotlin and Android development
- one style (default)
## Get the library
Add the mavenCentral() repository in your build.gradle.kts or settings.gradle.kts:
```js
repositories {
...
mavenCentral()
...
}
```
Then add the dependency in your dependencies { ... }:
```js
implementation("org.maplibre.gl:android-sdk:11.5.1");
```
## Simple Map (Light Mode)
```text
package io.jawg.example.maplibre
import android.app.Activity
import android.os.Bundle
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
class SimpleMapActivity : Activity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Init MapLibre
MapLibre.getInstance(this)
// Then set the activity layout
setContentView(R.layout.activity_simple_map)
val accessToken = getString(R.string.mapmetrics_access_token)
val styleUrl = "https://gateway.mapmetrics-atlas.net/styles/light.json?token=$accessToken"
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
map.setStyle(styleUrl)
}
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
}
```
## Simple Map (Dark Mode)
```text
package io.jawg.example.maplibre
import android.app.Activity
import android.os.Bundle
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
class SimpleMapActivity : Activity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Init MapLibre
MapLibre.getInstance(this)
// Then set the activity layout
setContentView(R.layout.activity_simple_map)
val accessToken = getString(R.string.mapmetrics_access_token)
val styleUrl = "https://gateway.mapmetrics-atlas.net/styles/dark.json?token=$accessToken"
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
map.setStyle(styleUrl)
}
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
}
```
## Add a Marker
```text
package io.jawg.example.maplibre
import android.app.Activity
import android.os.Bundle
import android.widget.Toast
import androidx.core.content.res.ResourcesCompat
import org.maplibre.android.MapLibre
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.plugins.annotation.SymbolManager
import org.maplibre.android.plugins.annotation.SymbolOptions
import org.maplibre.android.utils.BitmapUtils
class MarkerMapActivity : Activity() {
companion object {
private const val MARKER_NAME = "marker-pin"
}
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val accessToken = getString(R.string.mapmetrics_access_token)
val styleUrl = "https://gateway.mapmetrics-atlas.net/styles/dark.json?token=$accessToken"
// Init MapLibre
MapLibre.getInstance(this)
// Then set the activity layout
// We use the same layout as SimpleMapActivity
setContentView(R.layout.activity_simple_map)
// We get the map view to set its style with the desired Jawg URL.
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
map.setStyle(styleUrl) { style ->
// Choose logo to display
val drawable = ResourcesCompat.getDrawable(
this.resources,
R.drawable.ic_marker_default,
null
)
style.addImage(MARKER_NAME, BitmapUtils.getBitmapFromDrawable(drawable)!!)
// Create a SymbolManager
val symbolManager = SymbolManager(mapView, map, style)
// Disable symbol collisions
symbolManager.iconAllowOverlap = true
symbolManager.iconIgnorePlacement = true
// Add a new symbol at specified lat/lon.
val symbol = symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(-33.85416325, 151.20916))
.withIconImage(MARKER_NAME)
.withIconSize(1.25f)
.withIconAnchor("bottom")
)
symbolManager.update(symbol)
// Add a listener to trigger markers clicks.
symbolManager.addClickListener {
// Display information
Toast.makeText(this, "Opera house", Toast.LENGTH_LONG).show();
true
}
}
}
}
override fun onStart() {
super.onStart()
mapView.onStart()
}
override fun onResume() {
super.onResume()
mapView.onResume()
}
override fun onPause() {
super.onPause()
mapView.onPause()
}
override fun onStop() {
super.onStop()
mapView.onStop()
}
override fun onLowMemory() {
super.onLowMemory()
mapView.onLowMemory()
}
override fun onDestroy() {
super.onDestroy()
mapView.onDestroy()
}
}
```
---
# MapMetrics Flutter SDK
https://docs.mapatlas.xyz/overview/sdk/mapmetrics-flutter
# MapMetrics Flutter SDK
::: tip Available
The MapMetrics Flutter SDK is available on [pub.dev](https://pub.dev/packages/mapmetrics). API documentation can be found [here](https://pub.dev/documentation/mapmetrics/latest/).
:::
## Overview
The MapMetrics Flutter SDK provides a cross-platform solution for integrating MapMetrics Atlas maps into your Flutter applications, supporting both iOS and Android platforms.
## Features
- Cross-platform support (iOS & Android)
- Interactive map rendering
- Marker and annotation support
- Custom styling and themes
- Gesture controls and camera animations
- GeoJSON layer support
- Real-time data visualization
## Quick Start
Add the dependency to your `pubspec.yaml`:
```yaml
dependencies:
mapmetrics: ^1.0.6
```
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MapScreen extends StatefulWidget {
@override
_MapScreenState createState() => _MapScreenState();
}
class _MapScreenState extends State {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('MapMetrics Map')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(40.7128, -74.0060),
zoom: 10.0,
),
),
);
}
}
```
## Tutorials
Get started with our step-by-step guides:
- [Flutter MapMetrics Introduction](/sdk/examples/flutter-mapmetrics-intro) - Overview and prerequisites
- [Setup Guide](/sdk/examples/flutter-setup) - Project creation, dependencies, and platform configuration
- [Basic Map](/sdk/examples/flutter-basic-map) - Interactive map with zoom, location tracking, and camera controls
- [Markers & Annotations](/sdk/examples/flutter-markers) - Adding markers to your map
- [Custom Styling](/sdk/examples/flutter-custom-styling) - Customize your map appearance
- [Map Interactions](/sdk/examples/flutter-interactions) - Handle taps, gestures, and events
- [Add a Popup](/sdk/examples/flutter-add-a-popup) - Display popups and info windows on markers
- [Add a Polyline](/sdk/examples/flutter-add-a-polyline) - Draw lines and routes on the map
- [Add a Polygon](/sdk/examples/flutter-add-a-polygon) - Draw filled shapes and zones
- [Draw a Circle](/sdk/examples/flutter-draw-a-circle) - Draw circular areas and radius zones
- [Draggable Marker](/sdk/examples/flutter-draggable-marker) - Let users drag markers to pick locations
- [Fly to a Location](/sdk/examples/flutter-fly-to-location) - Smooth camera animations to any place
- [Jump to Locations](/sdk/examples/flutter-jump-to-locations) - Navigate through a series of locations
- [Locate the User](/sdk/examples/flutter-locate-user) - Show the user's GPS location on the map
- [Fullscreen Map](/sdk/examples/flutter-fullscreen-map) - Immersive fullscreen map experience
- [Set Pitch and Bearing](/sdk/examples/flutter-set-pitch-and-bearing) - 3D perspective and rotation controls
- [Add Image Marker](/sdk/examples/flutter-add-image-marker) - Custom image icons for markers
- [Navigation Controls](/sdk/examples/flutter-navigation-controls) - Zoom, compass, and location buttons
- [Disable Scroll Zoom](/sdk/examples/flutter-disable-scroll-zoom) - Control zoom gestures for embedded maps
- [Fit to Bounding Box](/sdk/examples/flutter-fit-to-bounding-box) - Zoom to fit markers or regions
- [Animate Camera Around Point](/sdk/examples/flutter-animate-camera-around-point) - Orbiting camera animation
- [Restrict Map Panning](/sdk/examples/flutter-restrict-map-panning) - Limit map to a specific area
- [Multiple Geometries](/sdk/examples/flutter-multiple-geometries) - Markers, lines, polygons, and circles together
- [Animate a Marker](/sdk/examples/flutter-animate-marker) - Move markers along a route
- [Measure Distances](/sdk/examples/flutter-measure-distances) - Tap-to-measure with Haversine formula
- [Toggle Interactions](/sdk/examples/flutter-toggle-interactions) - Enable/disable individual gestures
- [Add Clusters](/sdk/examples/flutter-add-a-cluster) - Group markers into clusters for large datasets
- [Add a Heatmap](/sdk/examples/flutter-add-a-heatmap) - Visualize data density with color gradients
- [Popup on Click](/sdk/examples/flutter-popup-on-click) - Show detail cards when tapping markers
- [Animate a Line](/sdk/examples/flutter-animate-a-line) - Animate a route being drawn on the map
- [Filter Markers](/sdk/examples/flutter-filter-markers) - Filter by category or search text
- [Sync Multiple Maps](/sdk/examples/flutter-sync-multiple-maps) - Side-by-side synced map comparison
- [Slowly Fly to Location](/sdk/examples/flutter-slowly-fly-to-location) - Cinematic slow camera flights
- [Show Polygon Info on Click](/sdk/examples/flutter-show-polygon-info-on-click) - Tap polygons to see zone info
- [Game-Style Controls](/sdk/examples/flutter-game-controls) - D-pad and button navigation
- [Migrate from Google Maps](/sdk/examples/flutter-google-map-migration) - Switch from google_maps_flutter
## Other SDKs
- [iOS SDK](/overview/sdk/ios-native/GettingStarted) - For native iOS applications
- [Android SDK](/overview/sdk/android-native/getting-started) - For native Android applications
- [Web SDK](/overview/sdk/mapmetrics) - For web and hybrid applications
## Questions?
If you have specific requirements or questions about Flutter support, please reach out to our team.
---
# MapMetrics GL (Web)
https://docs.mapatlas.xyz/overview/sdk/mapmetrics
# MapMetrics GL (Web)
MapMetrics GL is the browser map library: it renders vector tiles on a
`` with WebGL, and gives you markers, popups, geometry, clustering and
heatmaps on top. Use it for maps in a web page or web app. For mobile, see the
[Android](/overview/sdk/android-native/getting-started),
[iOS](/overview/sdk/ios-native/GettingStarted) or
[Flutter](/overview/sdk/mapmetrics-flutter) SDKs instead; to turn text into
coordinates, see the [geocoding SDKs](/overview/sdk/geocoding/).
Source: [github.com/MapMetrics/mapmetrics-gl](https://github.com/MapMetrics/mapmetrics-gl)
## Prerequisites
For all of our examples, you will need:
- an API key,
- basic knowledge of JavaScript and HTML,
- one style (default)
## Get the library
```html
```
## Examples
- [Simple Map (CDN)](/sdk/examples/simple-map-cdn "Simple Map (CDN)")
- [Simple Map (NPM)](/sdk/examples/simple-map-npm "Simple Map (NPM)")
- [Add a Marker](/sdk/examples/add-a-marker "Add a Marker")
- [Add a Geometry](/sdk/examples/add-a-geometry "Add a Geometry")
- [Create and style clusters](/sdk/examples/add-a-cluster "Create and style clusters")
- [Display the whole world](/sdk/examples/display-whole-world "Display the whole world")
- [3D Building](/sdk/examples/3d-building "3D Building")
- [Create a Heatmap Globe](/sdk/examples/create-heatmap-globe "Create a Heatmap Globe")
---
# 3D Building Visualization
https://docs.mapatlas.xyz/sdk/examples/3d-building
---
title: "3D Building Visualization"
category: "visualization"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "Popup", "queryRenderedFeatures"]
tags: ["3d", "buildings", "vector-tiles", "fill-extrusion", "popup"]
description: "Display 3D buildings on a map using vector tiles and fill-extrusion layers with interactive popups"
---
## 3D Building
Buildings: 0
Status: Loading...
```html
3D Buildings
3D Buildings
Buildings: 0
Status: Loading...
```
```jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const Building3D = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const [buildingCount, setBuildingCount] = useState(0);
const [status, setStatus] = useState('Loading...');
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
const token = '';
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${token}`,
center: [5.47, 51.49],
zoom: 16,
pitch: 60,
bearing: -30
});
map.on('load', () => {
console.log('Map loaded');
setStatus('Map loaded');
// Add the 3D buildings source
map.addSource('buildings', {
type: 'vector',
tiles: ['https://building.mapmetrics-atlas.net/data/buildings/{z}/{x}/{y}.pbf'],
minzoom: 13,
maxzoom: 16
});
// Add 3D buildings layer
map.addLayer({
id: 'buildings-3d',
type: 'fill-extrusion',
source: 'buildings',
'source-layer': 'building',
paint: {
'fill-extrusion-color': '#aaa',
'fill-extrusion-height': [
'case',
['has', 'height'],
['get', 'height'],
['has', 'estimated_height'],
['get', 'estimated_height'],
['has', 'building:levels'],
['*', ['to-number', ['get', 'building:levels']], 3],
['has', 'floors'],
['*', ['to-number', ['get', 'floors']], 3],
10
],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.9
}
});
updateBuildingCount();
});
const updateBuildingCount = () => {
const features = map.queryRenderedFeatures({ layers: ['buildings-3d'] });
setBuildingCount(features.length);
};
// Update on map movement
map.on('moveend', () => {
updateBuildingCount();
});
// Add popup on click for building info
map.on('click', 'buildings-3d', (e) => {
const coordinates = e.lngLat;
const properties = e.features[0].properties;
const actualHeight = properties.height || properties.estimated_height ||
(properties['building:levels'] ? parseInt(properties['building:levels']) * 3 : null) ||
(properties.floors ? parseInt(properties.floors) * 3 : null) || 10;
const popupContent = `
Building Info:
Height: ${actualHeight}m
Levels: ${properties['building:levels'] || 'N/A'}
Type: ${properties['building'] || properties['building:type'] || 'N/A'}
Name: ${properties.name || 'N/A'}
`;
new mapmetricsgl.Popup()
.setLngLat(coordinates)
.setHTML(popupContent)
.addTo(map);
});
// Change cursor on hover
map.on('mouseenter', 'buildings-3d', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'buildings-3d', () => {
map.getCanvas().style.cursor = '';
});
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
Buildings: {buildingCount}
Status: {status}
);
};
export default Building3D;
```
---
# 3d-buildingWithShadow
https://docs.mapatlas.xyz/sdk/examples/3d-buildingWithShadow
## 3D Building with Shadow
Shadow Length: 1.0 x
Buildings: 0
Status: Loading...
```html
3D Buildings with Shadows
Shadow Length: 1.0 x
Buildings: 0
Status: Loading...
```
---
# Add a 3D Model using Three.js
https://docs.mapatlas.xyz/sdk/examples/3d-model-threejs
---
title: "Add a 3D Model using Three.js"
category: "3d"
platform: ["web"]
difficulty: "advanced"
apis: ["Map", "addLayer", "custom layer", "triggerRepaint"]
tags: ["3d", "threejs", "gltf", "custom layer", "model", "three.js", "globe", "mercator"]
description: "Use a custom style layer with Three.js to render a 3D GLTF model on the map"
---
# Add a 3D Model using Three.js
Use a **custom style layer** with [Three.js](https://threejs.org/) to load and render a 3D GLTF model directly on the map. The model is georeferenced using the map's own projection matrix, so it stays anchored to a real-world coordinate.
> **Three.js is loaded via CDN** — no build step needed for this example.
Toggle Globe / Mercator
## How It Works
A **custom layer** gives you full access to the map's WebGL context. Three.js shares that context so both the map and your 3D model render on the same canvas.
```javascript
const customLayer = {
id: '3d-model',
type: 'custom',
renderingMode: '3d', // required for correct depth buffer
onAdd(map, gl) {
this.camera = new THREE.Camera();
this.scene = new THREE.Scene();
// Load GLTF model
const loader = new GLTFLoader();
loader.load('path/to/model.gltf', (gltf) => {
this.scene.add(gltf.scene);
});
// Share the map's WebGL canvas and context
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
antialias: true
});
this.renderer.autoClear = false;
},
render(gl, args) {
// Georeference the model using map's projection matrix
const modelMatrix = map.transform.getMatrixForModel([lng, lat], altitude);
const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
const l = new THREE.Matrix4().fromArray(modelMatrix).scale(new THREE.Vector3(scale, scale, scale));
this.camera.projectionMatrix = m.multiply(l);
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
this.map.triggerRepaint(); // keep re-rendering
}
};
map.on('style.load', () => {
map.addLayer(customLayer);
});
```
## Import Three.js
Use an importmap to load Three.js and GLTFLoader from CDN — no npm needed:
```html
```
## Georeference the Model
`map.transform.getMatrixForModel()` converts a `[lng, lat]` coordinate into the correct 4×4 matrix for the current projection (globe or Mercator):
```javascript
const modelMatrix = map.transform.getMatrixForModel(
[148.9819, -35.39847], // [lng, lat]
0 // altitude in meters
);
```
Scale the model up if needed — GLTF models are often designed at real-world scale (meters), but at global zoom you need a large multiplier:
```javascript
.scale(new THREE.Vector3(10_000, 10_000, 10_000))
```
## Toggle Globe vs Mercator
```javascript
const current = map.getProjection();
map.setProjection({
type: current.type === 'globe' ? 'mercator' : 'globe'
});
```
## Complete Example
```html
Toggle Globe / Mercator
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# 3D Terrain
https://docs.mapatlas.xyz/sdk/examples/3d-terrain
---
title: "3D Terrain"
category: "3d"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "terrain", "raster-dem", "TerrainControl", "NavigationControl"]
tags: ["3D", "terrain", "elevation", "DEM", "raster-dem", "exaggeration", "mountain"]
description: "Render a 3D terrain map by adding a raster-dem source and enabling the terrain property"
---
# 3D Terrain
Make your map look like a real landscape by enabling **3D terrain**. The map reads elevation data from a special tile source (`raster-dem`) and pushes mountains up into 3D.
> **No external libraries needed.** This is built directly into MapMetrics GL — no three.js, no plugins.
Drag to pan · Hold right-click and drag to tilt · Use the terrain button (mountain icon) to toggle 3D
## How It Works
3D terrain needs two things:
1. **A `raster-dem` source** — tiles containing elevation data for every point on the map
2. **The `terrain` style property** — tells the map to use that elevation data to push the land surface up into 3D
```javascript
const map = new mapmetricsgl.Map({
container: 'map',
// 1. Your regular base map — here a MapMetrics vector style
style: 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE.json&token=YOUR_TOKEN',
center: [11.39085, 47.27574],
zoom: 12,
pitch: 70, // Tilt the camera to see the 3D effect
maxPitch: 85,
});
// 2. Wait for the vector style to finish loading, then add elevation
map.on('load', () => {
// Elevation data source (free AWS terrain tiles — no API key needed)
map.addSource('terrainSource', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
encoding: 'terrarium',
maxzoom: 15,
});
// 3. Enable 3D terrain
map.setTerrain({ source: 'terrainSource', exaggeration: 1.5 });
});
```
## Exaggeration
`exaggeration` controls how dramatic the 3D effect looks:
| Value | Effect |
|---|---|
| `0` | Flat map (terrain disabled) |
| `1` | Real-world scale |
| `1.5` | Slightly dramatic (good default) |
| `3` | Very exaggerated mountains |
## Add a Terrain Toggle Button
```javascript
map.addControl(new mapmetricsgl.TerrainControl({
source: 'terrainSource',
exaggeration: 1.5,
}), 'top-right');
```
This adds a mountain icon button that toggles 3D terrain on/off.
## Tips
- Set `pitch: 70` or higher to see the 3D effect clearly
- Set `maxPitch: 85` to allow steep camera angles
- Add a `sky` layer for a realistic atmospheric background
- Mountains and valleys look best at zoom 8–14
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
// Replace with your own MapMetrics style URL + token
const STYLE_URL = 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE.json&token=YOUR_TOKEN';
const Terrain3D = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: STYLE_URL,
center: [11.39085, 47.27574],
zoom: 12,
pitch: 70,
maxPitch: 85,
});
map.current.on('load', () => {
map.current.addSource('terrainSource', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
encoding: 'terrarium',
maxzoom: 15,
});
map.current.setTerrain({ source: 'terrainSource', exaggeration: 1.5 });
map.current.addLayer({ id: 'sky', type: 'sky', paint: { 'sky-type': 'atmosphere' } });
});
map.current.addControl(new mapmetricsgl.NavigationControl({ visualizePitch: true }), 'top-right');
map.current.addControl(new mapmetricsgl.TerrainControl({ source: 'terrainSource', exaggeration: 1.5 }), 'top-right');
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default Terrain3D;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Create and Style Clusters
https://docs.mapatlas.xyz/sdk/examples/add-a-cluster
---
title: "Create and Style Clusters"
category: "data-visualization"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "getClusterExpansionZoom", "easeTo"]
tags: ["clustering", "geojson", "data-aggregation", "performance"]
description: "Group multiple markers into clusters for better performance and visualization with large datasets"
---
# Create and style clusters
##
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
// Module-level flag to prevent multiple RTL plugin loads
let rtlPluginLoaded = false;
const AddCluster = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
// Load RTL plugin only once
if (!rtlPluginLoaded) {
mapmetricsgl.setRTLTextPlugin(
"https://cdn.mapmetrics-atlas.net/basemaps-assets/js/mapmetrics-gl-rtl-text.min.js"
);
rtlPluginLoaded = true;
}
const accessToken = "";
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${accessToken}`,
center: [-103.59179687498357, 40.66995747013945],
zoom: 3,
});
map.on("load", () => {
map.addSource("earthquakes", {
type: "geojson",
data: "https://cdn.mapmetrics-atlas.net/Images/heatmap.geojson",
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
});
map.addLayer({
id: "clusters",
type: "circle",
source: "earthquakes",
filter: ["has", "point_count"],
paint: {
"circle-color": [
"step",
["get", "point_count"],
"#51bbd6",
100,
"#f1f075",
750,
"#f28cb1",
],
"circle-radius": [
"step",
["get", "point_count"],
20,
100,
30,
750,
40,
],
},
});
map.addLayer({
id: "cluster-count",
type: "symbol",
source: "earthquakes",
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["Noto Sans Medium"],
"text-size": 12,
},
});
map.addLayer({
id: "unclustered-point",
type: "circle",
source: "earthquakes",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": "#11b4da",
"circle-radius": 4,
"circle-stroke-width": 1,
"circle-stroke-color": "#fff",
},
});
map.on("click", "clusters", async (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ["clusters"],
});
const clusterId = features[0].properties.cluster_id;
const zoom = await map
.getSource("earthquakes")
.getClusterExpansionZoom(clusterId);
map.easeTo({
center: features[0].geometry.coordinates,
zoom,
});
});
map.on("click", "unclustered-point", (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const mag = e.features[0].properties.mag;
let tsunami;
if (e.features[0].properties.tsunami === 1) {
tsunami = "yes";
} else {
tsunami = "no";
}
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
new mapmetricsgl.Popup()
.setLngLat(coordinates)
.setHTML(`magnitude: ${mag} Was there a tsunami?: ${tsunami}`)
.addTo(map);
});
map.on("mouseenter", "clusters", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "clusters", () => {
map.getCanvas().style.cursor = "";
});
});
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
);
};
export default AddCluster;
```
---
# Add a Color Relief Layer
https://docs.mapatlas.xyz/sdk/examples/add-a-color-relief-layer
---
title: "Add a Color Relief Layer"
category: "3d"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "raster-dem", "addLayer", "fill-extrusion", "color-relief"]
tags: ["color relief", "terrain", "elevation", "DEM", "raster-dem", "hypsometric", "altitude color"]
description: "Color the map surface by elevation using a raster-dem source and elevation-based color expressions"
---
# Add a Color Relief Layer
**Color relief** (also called hypsometric tinting) paints the map with colors based on elevation — lowlands are green, mid-elevations are brown, mountain peaks are white. This makes elevation immediately visible at a glance.
> **No three.js or external libraries needed.** This uses a standard raster layer with a color expression.
Color Relief
OSM Only
## How Color Relief Works
Color relief paints the terrain surface with colors that represent altitude:
```
🌊 Water / low → Deep blue / dark green
🌿 Plains → Light green
🌄 Hills → Yellow / brown
⛰️ Mountains → Dark brown / grey
🏔️ Peaks → White / light grey
```
The simplest approach is to use a `hillshade` layer with carefully chosen shadow/highlight colors, combined with a semi-transparent base map.
## Adding Color Relief with Hillshade
```javascript
map.addSource('dem', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
encoding: 'terrarium',
maxzoom: 15,
});
map.addLayer({
id: 'color-relief',
type: 'hillshade',
source: 'dem',
paint: {
'hillshade-shadow-color': '#2d6a4f', // low elevation → green
'hillshade-highlight-color': '#f8f9fa', // high elevation → white
'hillshade-accent-color': '#8B4513', // steep slopes → brown
'hillshade-exaggeration': 0.8,
'hillshade-illumination-anchor': 'viewport',
},
});
```
## Tweak the Relief Opacity
Adjust how strongly the color relief overlays the base map by changing its `hillshade-exaggeration` or by inserting the layer at a different position with `map.addLayer(..., 'beforeLayerId')`:
```javascript
// Softer relief
map.setPaintProperty('color-relief', 'hillshade-exaggeration', 0.4);
// Stronger relief
map.setPaintProperty('color-relief', 'hillshade-exaggeration', 1);
// Hide / show the relief layer entirely
map.setLayoutProperty('color-relief', 'visibility', 'none');
map.setLayoutProperty('color-relief', 'visibility', 'visible');
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const ColorReliefLayer = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
zoom: 5,
center: [13, 47],
// Replace with your own MapMetrics style URL + token
style: 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE.json&token=YOUR_TOKEN',
});
map.current.on('load', () => {
map.current.addSource('dem', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
encoding: 'terrarium',
maxzoom: 15,
});
map.current.addLayer({
id: 'color-relief',
type: 'hillshade',
source: 'dem',
paint: {
'hillshade-shadow-color': '#2d6a4f',
'hillshade-highlight-color': '#f8f9fa',
'hillshade-accent-color': '#8B4513',
'hillshade-exaggeration': 0.8,
'hillshade-illumination-anchor': 'viewport',
},
});
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default ColorReliefLayer;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a Geometry
https://docs.mapatlas.xyz/sdk/examples/add-a-geometry
# Add a Geometry
##
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AddGeometry = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
const accessToken = "";
const geoJsonFeature = {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: [
[2.319887, 48.90046],
[2.329981, 48.901163],
[2.38515, 48.902008],
[2.394906, 48.898444],
[2.397627, 48.894578],
[2.398846, 48.887109],
[2.408308, 48.880409],
[2.41327, 48.872892],
[2.413838, 48.864376],
[2.416341, 48.849234],
[2.412246, 48.834539],
[2.422139, 48.835798],
[2.41939, 48.842577],
[2.42813, 48.841528],
[2.447699, 48.844818],
[2.463438, 48.842089],
[2.467426, 48.838891],
[2.467582, 48.833133],
[2.462696, 48.81906],
[2.458705, 48.81714],
[2.438448, 48.818232],
[2.421462, 48.824054],
[2.406032, 48.827615],
[2.390939, 48.826079],
[2.379296, 48.821214],
[2.363947, 48.816314],
[2.345958, 48.816036],
[2.331898, 48.817011],
[2.332461, 48.818247],
[2.292196, 48.827142],
[2.279052, 48.83249],
[2.272793, 48.82792],
[2.263174, 48.83398],
[2.255144, 48.83481],
[2.251709, 48.838822],
[2.250612, 48.845555],
[2.239978, 48.849702],
[2.224219, 48.853517],
[2.228225, 48.865183],
[2.231736, 48.869069],
[2.245678, 48.876435],
[2.25541, 48.874264],
[2.258467, 48.880387],
[2.277487, 48.877968],
[2.282327, 48.883923],
[2.291507, 48.889472],
[2.319887, 48.90046],
],
},
};
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${accessToken}`,
zoom: 11,
center: [2.349902, 48.852966],
});
map.addControl(new mapmetricsgl.NavigationControl(), "top-right");
mapmetricsgl.setRTLTextPlugin(
"https://cdn.mapmetrics-atlas.net/basemaps-assets/js/mapmetrics-gl-rtl-text.min.js"
);
map.on("load", () => {
map.addLayer({
id: "route",
type: "line",
source: {
type: "geojson",
data: geoJsonFeature,
},
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "steelblue",
"line-width": 4,
},
});
});
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
);
};
export default AddGeometry;
```
---
# Add a Heatmap Layer
https://docs.mapatlas.xyz/sdk/examples/add-a-heatmap
---
title: "Add a Heatmap Layer"
category: "data-visualization"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer"]
tags: ["heatmap", "density", "visualization", "geojson"]
description: "Create heatmap visualizations to show data density and patterns on your map"
---
# Add a Heatmap
##
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AddHeatmap = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
const accessToken = "";
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${accessToken}`,
center: [-120, 50],
zoom: 2,
});
map.addControl(new mapmetricsgl.NavigationControl(), "top-right");
map.on("load", () => {
mapmetricsgl.setRTLTextPlugin(
"https://cdn.mapmetrics-atlas.net/basemaps-assets/js/mapmetrics-gl-rtl-text.min.js"
);
// Add a geojson point source
map.addSource("earthquakes", {
type: "geojson",
data: "https://cdn.mapmetrics-atlas.net/Images/heatmap.geojson",
});
// Add the heatmap layer
map.addLayer(
{
id: "earthquakes-heat",
type: "heatmap",
source: "earthquakes",
maxzoom: 9,
paint: {
"heatmap-weight": [
"interpolate",
["linear"],
["get", "mag"],
0,
0,
6,
1,
],
"heatmap-intensity": 1,
"heatmap-color": [
"interpolate",
["linear"],
["heatmap-density"],
0,
"rgba(33,102,172,0)",
0.2,
"rgb(103,169,207)",
0.4,
"rgb(209,229,240)",
0.6,
"rgb(253, 199, 199)",
0.8,
"rgb(239, 98, 98)",
1,
"rgb(178, 24, 24)",
],
"heatmap-radius": [
"interpolate",
["linear"],
["zoom"],
0,
2,
9,
20,
],
"heatmap-opacity": 1,
},
},
map.getStyle().layers[map.getStyle().layers.length - 1].id
);
// Add the point layer for earthquakes
map.addLayer(
{
id: "earthquakes-point",
type: "circle",
source: "earthquakes",
minzoom: 7,
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
7,
["interpolate", ["linear"], ["get", "mag"], 1, 1, 6, 4],
16,
["interpolate", ["linear"], ["get", "mag"], 1, 5, 6, 50],
],
"circle-color": [
"interpolate",
["linear"],
["get", "mag"],
1,
"rgba(33,102,172,0)",
2,
"rgb(103,169,207)",
3,
"rgb(209,229,240)",
4,
"rgb(251, 199, 199)",
5,
"rgb(239, 98, 98)",
6,
"rgb(234, 43, 43)",
],
"circle-stroke-color": "white",
"circle-stroke-width": 1,
"circle-opacity": [
"interpolate",
["linear"],
["zoom"],
7,
0,
8,
1,
],
},
},
map.getStyle().layers[map.getStyle().layers.length - 1].id
);
});
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
);
};
export default AddHeatmap;
```
---
# Add a Hillshade Layer
https://docs.mapatlas.xyz/sdk/examples/add-a-hillshade-layer
---
title: "Add a Hillshade Layer"
category: "3d"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "hillshade", "raster-dem", "addLayer"]
tags: ["hillshade", "terrain", "elevation", "shadow", "DEM", "raster-dem", "shading"]
description: "Add a hillshade layer to show terrain shadows and elevation relief on a flat map"
---
# Add a Hillshade Layer
A **hillshade layer** simulates how sunlight would fall on terrain — mountains cast shadows, valleys stay dark. This makes elevation visible on an otherwise flat 2D map.
> **No three.js or external libraries needed.** Hillshade is a built-in layer type in MapMetrics GL.
Show Hillshade
Hide Hillshade
## What is a Hillshade Layer?
Imagine shining a light from the top-left corner of the map. Mountains facing the light become bright; valleys and north-facing slopes fall into shadow. That's what a hillshade layer does — it gives a flat 2D map a sense of depth and elevation.
## Adding the Hillshade Layer
You need two things:
1. A `raster-dem` source (elevation data tiles)
2. A `hillshade` layer that references it
```javascript
// Step 1: Add the elevation data source (free AWS terrain tiles — no API key needed)
map.addSource('dem', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
encoding: 'terrarium',
maxzoom: 15,
});
// Step 2: Add the hillshade layer
map.addLayer({
id: 'hillshade',
type: 'hillshade',
source: 'dem',
paint: {
'hillshade-shadow-color': '#473B24', // shadow color
'hillshade-highlight-color': '#ffffff', // lit color
'hillshade-illumination-direction': 335, // sun angle 0–360°
'hillshade-exaggeration': 0.5, // 0 = flat, 1 = maximum contrast
},
});
```
## Paint Properties
| Property | What it does |
|---|---|
| `hillshade-shadow-color` | Color of shadowed slopes |
| `hillshade-highlight-color` | Color of sunlit slopes |
| `hillshade-accent-color` | Color of very steep slopes |
| `hillshade-illumination-direction` | Direction of the sun (0° = North) |
| `hillshade-exaggeration` | Shadow intensity (0–1) |
## Show/Hide at Runtime
```javascript
// Show
map.setLayoutProperty('hillshade', 'visibility', 'visible');
// Hide
map.setLayoutProperty('hillshade', 'visibility', 'none');
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const HillshadeLayer = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
zoom: 7,
center: [11.39085, 47.27574],
// Replace with your own MapMetrics style URL + token
style: 'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE.json&token=YOUR_TOKEN',
});
map.current.on('load', () => {
map.current.addSource('dem', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
encoding: 'terrarium',
maxzoom: 15,
});
map.current.addLayer({
id: 'hillshade',
type: 'hillshade',
source: 'dem',
paint: {
'hillshade-shadow-color': '#473B24',
'hillshade-highlight-color': '#ffffff',
'hillshade-illumination-direction': 335,
'hillshade-exaggeration': 0.5,
},
});
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default HillshadeLayer;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add Markers to Map
https://docs.mapatlas.xyz/sdk/examples/add-a-marker
---
title: "Add Markers to Map"
category: "markers"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "Marker", "setLngLat", "addTo"]
tags: ["markers", "pins", "draggable", "custom-marker"]
description: "Learn how to add interactive markers to your map, including draggable markers"
---
# Adding Markers to Your Map
## The red marker is dragable
This guide demonstrates how to add markers to your MapMetrics map. Follow these steps to get started.
## Prerequisites
Before you begin, ensure you have:
- **Generated a style and API key:** [How to create an API key](./key-creation.md) and [How to create a map style](./style-creation.md).
## Adding a Marker
To add a marker to your map, you can use the `Marker` class provided by MapMetrics-gl. Here's how to do it:
### Example: Adding a Marker
```javascript
const marker = new mapmetricsgl.Marker()
.setLngLat([2.349902, 48.852966]) // Set the marker's position
.addTo(map); // Add the marker to the map
```
### Customizing Markers
You can customize the appearance of markers by setting properties such as color, size, and icon. For example:
```javascript
const marker = new mapmetricsgl.Marker({
color: "#FF0000", // Red color
scale: 1.5, // Increase the size
})
.setLngLat([2.349902, 48.852966])
.addTo(map);
```
### Creating a Draggable Marker with Custom Color
You can create a marker with a custom color and make it draggable by specifying the `color` and `draggable` options in the marker constructor:
```javascript
const marker = new mapmetricsgl.Marker({
color: "#FFFFFF", // White marker
draggable: true // Make the marker draggable
})
.setLngLat([30.5, 50.5])
.addTo(map);
```
You can also listen for drag events to get the marker's new position:
```javascript
marker.on('dragend', function() {
const lngLat = marker.getLngLat();
console.log('Marker dropped at', lngLat);
});
```
## Complete Example
Here's a complete example of how to add a marker to your map:
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AddMarker = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
const accessToken = "";
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${accessToken}`,
zoom: 11,
center: [2.349902, 48.852966],
});
mapRef.current = map;
map.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
// Add a marker with custom icon
new mapmetricsgl.Marker({
icon: "https://cdn.mapmetrics-atlas.net/Images/car.png"
})
.setLngLat([2.349902, 48.852966])
.addTo(map);
// Add a red draggable marker
new mapmetricsgl.Marker({
color: "#FF0000",
draggable: true
})
.setLngLat([2.349902, 48.841066])
.addTo(map);
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
);
};
export default AddMarker;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
```
---
# Drawing a Polyline on Your Map
https://docs.mapatlas.xyz/sdk/examples/add-a-polyline
# Drawing a Polyline on Your Map
This guide demonstrates how to display a polyline (LineString) on your MapMetrics map. Polylines are useful for showing routes, paths, or any sequence of connected points.
## Prerequisites
Before you begin, ensure you have:
- **Generated a style and API key:** [How to create an API key](./key-creation.md) and [How to create a map style](./style-creation.md).
## Adding a Polyline
To add a polyline, you need to:
1. Define the coordinates for your line.
2. Add a GeoJSON source to the map.
3. Add a new layer of type `line` that uses this source.
### Example: Adding a Polyline
```javascript
// Define the coordinates for your polyline
const lineCoordinates = [
[
2.3398344221314176,
48.85894283778356
],
[
2.349092133374455,
48.856611153804636
],
[
2.3539379666036098,
48.85466006946356
],
[
2.3610259017737576,
48.85189986905755
],
[
2.3589284515699944,
48.84828282438352
],
[
2.3505386507564197,
48.851566731130646
],
[
2.3433783897170883,
48.85399382812608
],
[
2.3386048823571173,
48.85732494614973
],
[
2.334409981949733,
48.85789597269732
],
[
2.3399067480006295,
48.85899042203965
]
];
// Add the source and layer when the map is loaded
map.on('load', function () {
map.addSource('route', {
'type': 'geojson',
'data': {
'type': 'Feature',
'properties': {},
'geometry': {
'type': 'LineString',
'coordinates': lineCoordinates
}
}
});
map.addLayer({
'id': 'route',
'type': 'line',
'source': 'route',
'layout': {
'line-join': 'round',
'line-cap': 'round'
},
'paint': {
'line-color': '#FF0000',
'line-width': 4
}
});
});
```
## Complete Example
Below is a complete example of how to add a polyline to your map:
```html
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
## Polyline Example (Vue)
Below is a Vue-compatible example that displays a polyline (LineString) on the map:
---
# Add Popup to Marker
https://docs.mapatlas.xyz/sdk/examples/add-a-popup
---
title: "Add Popup to Marker"
category: "popups"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "Marker", "Popup", "setPopup", "setHTML"]
tags: ["popup", "marker", "info-window", "tooltip"]
description: "Display interactive popups on markers with custom HTML content"
---
# Add a Popup
## Click on the marker for a popup
## Prerequisites
Before you begin, ensure you have:
- **Generated a style and API key:** [How to create an API key](./key-creation.md) and [How to create a map style](./style-creation.md).
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AddPopup = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
const accessToken = "";
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${accessToken}`,
zoom: 11,
center: [2.349902, 48.852966],
});
map.addControl(
new mapmetricsgl.NavigationControl(),
'top-right'
);
// Create popup
const popup = new mapmetricsgl.Popup().setHTML(
`Hello Mapmetrics A good coffee shop
`
);
// Add marker with popup
new mapmetricsgl.Marker()
.setLngLat([2.349902, 48.852966])
.setPopup(popup)
.addTo(map);
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
);
};
export default AddPopup;
```
---
# Add an Icon to the Map
https://docs.mapatlas.xyz/sdk/examples/add-an-icon-to-the-map
---
title: "Add an Icon to the Map"
category: "icons-images"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "loadImage", "addImage", "addSource", "addLayer", "icon-image"]
tags: ["icon", "symbol", "image", "marker", "loadImage", "addImage", "sprite", "point"]
description: "Load an external image and use it as a map icon in a symbol layer"
---
# Add an Icon to the Map
Load an image from a URL and display it as an icon on the map using a `symbol` layer.
## Load an Image with `loadImage`
```javascript
map.on('load', () => {
map.loadImage('https://example.com/icon.png', (error, image) => {
if (error) throw error;
// Register the image with a name
map.addImage('my-icon', image);
// Use it in a symbol layer
map.addLayer({
id: 'icon-layer',
type: 'symbol',
source: 'my-source',
layout: {
'icon-image': 'my-icon',
'icon-size': 1.0,
'icon-allow-overlap': true,
}
});
});
});
```
## Symbol Layer Icon Properties
```javascript
map.addLayer({
id: 'icons',
type: 'symbol',
source: 'points',
layout: {
'icon-image': 'my-icon', // name registered with addImage()
'icon-size': 0.5, // scale factor (1.0 = original size)
'icon-anchor': 'center', // 'center', 'top', 'bottom', 'left', 'right'
'icon-allow-overlap': true, // show icon even if it overlaps others
'icon-ignore-placement': true, // don't block other features
'icon-offset': [0, -10], // [x, y] pixel offset
}
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const pointsData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-74.0, 40.7] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [2.35, 48.85] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [139.7, 35.7] }, properties: {} },
]
};
const AddIconToMap = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [0, 20],
zoom: 1.5
});
map.current.on('load', () => {
map.current.loadImage('https://example.com/icon.png', (error, image) => {
if (error) throw error;
map.current.addImage('my-icon', image);
map.current.addSource('points', { type: 'geojson', data: pointsData });
map.current.addLayer({
id: 'icon-layer',
type: 'symbol',
source: 'points',
layout: {
'icon-image': 'my-icon',
'icon-size': 0.5,
'icon-allow-overlap': true,
}
});
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default AddIconToMap;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add an Animated Icon to the Map
https://docs.mapatlas.xyz/sdk/examples/add-animated-icon
---
title: "Add an Animated Icon to the Map"
category: "icons-images"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addImage", "updateImage", "addSource", "addLayer", "requestAnimationFrame"]
tags: ["icon", "animated", "pulse", "canvas", "animation", "requestAnimationFrame", "symbol", "addImage", "updateImage"]
description: "Create a pulsing animated icon using Canvas and requestAnimationFrame"
---
# Add an Animated Icon to the Map
Create a pulsing icon animation by updating a canvas-based image on every animation frame using `map.updateImage()`.
## How It Works
The animation uses three steps:
1. Create a canvas and draw an initial frame
2. Register the image with `map.addImage()`
3. On each `requestAnimationFrame`, redraw and call `map.updateImage()` to push the new frame
## Draw and Register the Animated Icon
```javascript
const size = 80;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
let t = 0;
function drawFrame() {
ctx.clearRect(0, 0, size, size);
t += 0.04;
const pulse = (Math.sin(t) + 1) / 2; // oscillates 0 → 1
// Expanding ring
ctx.strokeStyle = `rgba(59, 130, 246, ${1 - pulse})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(size / 2, size / 2, 15 + pulse * 20, 0, Math.PI * 2);
ctx.stroke();
// Core dot
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.arc(size / 2, size / 2, 8, 0, Math.PI * 2);
ctx.fill();
// Push updated frame to the map
if (map.hasImage('pulse-icon')) {
map.updateImage('pulse-icon', ctx.getImageData(0, 0, size, size));
}
requestAnimationFrame(drawFrame);
}
map.on('load', () => {
// Register the initial frame
map.addImage('pulse-icon', ctx.getImageData(0, 0, size, size));
// ... addSource, addLayer with 'icon-image': 'pulse-icon'
drawFrame(); // start animation loop
});
```
## Key APIs
| API | Description |
|-----|-------------|
| `map.addImage(name, data)` | Register the initial image frame |
| `map.updateImage(name, data)` | Update an existing image each frame |
| `map.hasImage(name)` | Check if an image is registered before updating |
| `requestAnimationFrame(fn)` | Browser animation loop (~60fps) |
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const pointsData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-74, 40.7] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [2.35, 48.85] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [139.7, 35.7] }, properties: {} },
]
};
const AddAnimatedIcon = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const animId = useRef(null);
useEffect(() => {
if (map.current) return;
const size = 80;
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
let t = 0;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [0, 20],
zoom: 1.5
});
const drawFrame = () => {
ctx.clearRect(0, 0, size, size);
t += 0.04;
const pulse = (Math.sin(t) + 1) / 2;
ctx.strokeStyle = `rgba(59,130,246,${1 - pulse})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(size / 2, size / 2, 15 + pulse * 20, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.arc(size / 2, size / 2, 8, 0, Math.PI * 2);
ctx.fill();
if (map.current?.hasImage('pulse')) {
map.current.updateImage('pulse', ctx.getImageData(0, 0, size, size));
}
animId.current = requestAnimationFrame(drawFrame);
};
map.current.on('load', () => {
map.current.addImage('pulse', ctx.getImageData(0, 0, size, size));
map.current.addSource('pts', { type: 'geojson', data: pointsData });
map.current.addLayer({
id: 'pts',
type: 'symbol',
source: 'pts',
layout: { 'icon-image': 'pulse', 'icon-allow-overlap': true }
});
drawFrame();
});
return () => {
if (animId.current) cancelAnimationFrame(animId.current);
map.current?.remove(); map.current = null;
};
}, []);
return
;
};
export default AddAnimatedIcon;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add Contour Lines
https://docs.mapatlas.xyz/sdk/examples/add-contour-lines
---
title: "Add Contour Lines"
category: "3d"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "addSource", "addLayer", "line", "GeoJSON"]
tags: ["contour", "elevation", "terrain", "lines", "topographic", "altitude", "isoline"]
description: "Draw contour lines on the map to show elevation levels using GeoJSON line features"
---
# Add Contour Lines
**Contour lines** are lines that connect points of the same elevation — like the rings you see on a topographic map. They show you how steep or flat the terrain is without needing 3D.
> **No three.js or external libraries needed.** Contour lines are drawn using a standard GeoJSON `line` layer.
Contour lines with elevation labels — thin lines every 100m, thick lines every 200m
## How Contour Lines Work
Contour lines are just line features in a GeoJSON source. Each line has an `elevation` property. You add two layers:
- **Minor lines** (thin) — every 100m interval
- **Major lines** (thick) — every 200m or 500m interval
```javascript
map.addSource('contours', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { elevation: 500 },
geometry: {
type: 'LineString',
coordinates: [[lng1, lat1], [lng2, lat2], ...]
}
},
// more contour lines...
]
}
});
// Thin lines for all contours
map.addLayer({
id: 'contour-lines',
type: 'line',
source: 'contours',
paint: {
'line-color': '#8B4513',
'line-width': 0.8,
'line-opacity': 0.6,
}
});
// Thick lines for major intervals (every 200m)
map.addLayer({
id: 'contour-major',
type: 'line',
source: 'contours',
filter: ['==', ['%', ['get', 'elevation'], 200], 0],
paint: {
'line-color': '#5c3317',
'line-width': 2,
}
});
```
## Add Elevation Labels
Place labels along the contour lines using `symbol-placement: 'line'`:
```javascript
map.addLayer({
id: 'contour-labels',
type: 'symbol',
source: 'contours',
layout: {
'text-field': ['concat', ['to-string', ['get', 'elevation']], 'm'],
'symbol-placement': 'line', // follow the line path
'text-size': 11,
},
paint: {
'text-color': '#5c3317',
'text-halo-color': '#fff',
'text-halo-width': 1.5,
}
});
```
## Real Contour Data Sources
In production, use a real contour data source:
- **MapTiler Contours** — vector tiles with elevation data
- **OpenTopoData API** — free elevation API
- **Mapbox Terrain** — `contour` source layer in Mapbox terrain tiles
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const contourData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { elevation: 800 }, geometry: { type: 'LineString', coordinates: [[11.36,47.26],[11.38,47.275],[11.42,47.26],[11.38,47.248],[11.36,47.26]] } },
{ type: 'Feature', properties: { elevation: 1000 }, geometry: { type: 'LineString', coordinates: [[11.375,47.268],[11.390,47.272],[11.40,47.256],[11.375,47.258],[11.375,47.268]] } },
]
};
const ContourLines = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [11.39085, 47.27574],
zoom: 11,
});
map.current.on('load', () => {
map.current.addSource('contours', { type: 'geojson', data: contourData });
map.current.addLayer({
id: 'contours', type: 'line', source: 'contours',
paint: { 'line-color': '#8B4513', 'line-width': 1, 'line-opacity': 0.7 }
});
map.current.addLayer({
id: 'contour-labels', type: 'symbol', source: 'contours',
layout: {
'text-field': ['concat', ['to-string', ['get', 'elevation']], 'm'],
'symbol-placement': 'line',
'text-size': 11,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
},
paint: { 'text-color': '#5c3317', 'text-halo-color': '#fff', 'text-halo-width': 1.5 }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default ContourLines;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add Custom Icons with Markers
https://docs.mapatlas.xyz/sdk/examples/add-custom-icons-markers
---
title: "Add Custom Icons with Markers"
category: "icons-images"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "Marker", "setLngLat", "addTo", "getElement"]
tags: ["marker", "custom", "icon", "html", "css", "emoji", "image", "div", "getElement"]
description: "Create Markers with custom HTML elements as icons instead of the default pin"
---
# Add Custom Icons with Markers
Use the `Marker` API with a custom HTML element to display any icon — emoji, SVG, image, or styled div — as a map marker.
## Default vs Custom Marker
```javascript
// Default marker (blue pin)
new mapmetricsgl.Marker()
.setLngLat([lng, lat])
.addTo(map);
// Custom HTML marker
const el = document.createElement('div');
el.innerHTML = '⭐';
el.style.fontSize = '32px';
el.style.cursor = 'pointer';
new mapmetricsgl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
```
## Create a Styled Div Marker
```javascript
function createCustomMarker(emoji, color) {
const el = document.createElement('div');
el.style.cssText = `
width: 44px;
height: 44px;
background: ${color};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
cursor: pointer;
`;
el.textContent = emoji;
return el;
}
const el = createCustomMarker('🏪', '#3b82f6');
new mapmetricsgl.Marker({ element: el })
.setLngLat([-74, 40.7])
.addTo(map);
```
## Use an Image as the Marker
```javascript
const el = document.createElement('img');
el.src = 'https://example.com/icon.png';
el.style.width = '40px';
el.style.height = '40px';
new mapmetricsgl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
```
## Marker with Popup
```javascript
const popup = new mapmetricsgl.Popup({ offset: 25 })
.setHTML('My Location Description here
');
new mapmetricsgl.Marker({ element: el })
.setLngLat([lng, lat])
.setPopup(popup)
.addTo(map);
```
## Marker Options
```javascript
new mapmetricsgl.Marker({
element: el, // custom HTML element
anchor: 'center', // 'center', 'top', 'bottom', 'left', 'right', 'top-left', etc.
offset: [0, -20], // [x, y] pixel offset
draggable: true, // allow the user to drag the marker
})
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const places = [
{ coords: [-74, 40.7], emoji: '🗽', color: '#3b82f6', name: 'New York' },
{ coords: [2.35, 48.85], emoji: '🗼', color: '#ef4444', name: 'Paris' },
{ coords: [139.7, 35.7], emoji: '⛩️', color: '#f59e0b', name: 'Tokyo' },
];
const AddCustomIconsMarkers = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 30],
zoom: 1.5
});
map.current.on('load', () => {
places.forEach(({ coords, emoji, color, name }) => {
const el = document.createElement('div');
Object.assign(el.style, {
width: '44px', height: '44px',
background: color, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '22px', border: '2px solid #fff',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)', cursor: 'pointer'
});
el.textContent = emoji;
const marker = new mapmetricsgl.Marker({ element: el })
.setLngLat(coords)
.setPopup(new mapmetricsgl.Popup({ offset: 25 }).setHTML(`${name} `))
.addTo(map.current);
markers.current.push(marker);
});
});
return () => {
markers.current.forEach(m => m.remove());
map.current?.remove(); map.current = null;
};
}, []);
return
;
};
export default AddCustomIconsMarkers;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a Generated Icon to the Map
https://docs.mapatlas.xyz/sdk/examples/add-generated-icon
---
title: "Add a Generated Icon to the Map"
category: "icons-images"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addImage", "addSource", "addLayer", "icon-image", "Canvas"]
tags: ["icon", "generated", "canvas", "symbol", "programmatic", "custom", "image", "addImage"]
description: "Generate a custom icon programmatically using the Canvas API and add it to the map"
---
# Add a Generated Icon to the Map
Create a custom icon programmatically using the Canvas 2D API and register it with `map.addImage()`.
★ Star
● Dot
📍 Pin
## Generate an Icon with Canvas API
```javascript
function generateIcon(color) {
const size = 48;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Draw a circle
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2);
ctx.fill();
// White border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.stroke();
return ctx.getImageData(0, 0, size, size);
}
```
## Register and Use the Icon
```javascript
map.on('load', () => {
const iconData = generateIcon('#3b82f6');
// Register with map
map.addImage('generated-icon', iconData);
// Use in a symbol layer
map.addLayer({
id: 'icons',
type: 'symbol',
source: 'my-source',
layout: {
'icon-image': 'generated-icon',
'icon-size': 1.0,
'icon-allow-overlap': true,
}
});
});
```
## Switch Icon at Runtime
```javascript
// Update which image is used for the layer
map.setLayoutProperty('icons', 'icon-image', 'new-icon-name');
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
function makeCircleIcon(color, size = 40) {
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.stroke();
return ctx.getImageData(0, 0, size, size);
}
const pointsData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-74, 40.7] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [2.35, 48.85] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [139.7, 35.7] }, properties: {} },
]
};
const AddGeneratedIcon = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 30],
zoom: 1.5
});
map.current.on('load', () => {
map.current.addImage('blue-dot', makeCircleIcon('#3b82f6'));
map.current.addSource('points', { type: 'geojson', data: pointsData });
map.current.addLayer({
id: 'icons',
type: 'symbol',
source: 'points',
layout: { 'icon-image': 'blue-dot', 'icon-size': 0.8, 'icon-allow-overlap': true }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default AddGeneratedIcon;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a GeoJSON Line
https://docs.mapatlas.xyz/sdk/examples/add-geojson-line
---
title: "Add a GeoJSON Line"
category: "geometry"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "addSource", "addLayer", "LineString"]
tags: ["line", "geojson", "linestring", "polyline", "route", "path", "layer"]
description: "Add a GeoJSON LineString to the map as a styled line layer"
---
# Add a GeoJSON Line
Add a GeoJSON LineString source and display it as a styled line layer on the map.
## Add a GeoJSON Line
```javascript
map.on('load', () => {
map.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[lng1, lat1],
[lng2, lat2],
[lng3, lat3]
]
}
}
});
map.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': '#3b82f6',
'line-width': 4,
'line-opacity': 0.9
}
});
});
```
## Line Style Options
```javascript
paint: {
'line-color': '#3b82f6',
'line-width': 4,
'line-opacity': 0.9,
'line-dasharray': [2, 4], // dashed line
'line-blur': 1, // soft edge
'line-gap-width': 2 // gap between double lines
}
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const GeoJSONLine = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 4
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
map.current.on('load', () => {
map.current.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [[2.349902, 48.852966], [-0.1276, 51.5074], [13.405, 52.52]]
}
}
});
map.current.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#3b82f6', 'line-width': 4 }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default GeoJSONLine;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a GeoJSON Polygon
https://docs.mapatlas.xyz/sdk/examples/add-geojson-polygon
---
title: "Add a GeoJSON Polygon"
category: "geometry"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "addSource", "addLayer", "Polygon", "fill"]
tags: ["polygon", "geojson", "fill", "area", "shape", "layer", "geometry"]
description: "Add a GeoJSON Polygon to the map as a filled area with an outline"
---
# Add a GeoJSON Polygon
Add a GeoJSON Polygon and display it as a filled area with an outline border.
## Add a Polygon
```javascript
map.on('load', () => {
map.addSource('polygon', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[
[lng1, lat1],
[lng2, lat2],
[lng3, lat3],
[lng1, lat1] // close the ring
]]
}
}
});
// Filled area
map.addLayer({
id: 'polygon-fill',
type: 'fill',
source: 'polygon',
paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.3 }
});
// Border outline
map.addLayer({
id: 'polygon-outline',
type: 'line',
source: 'polygon',
paint: { 'line-color': '#1d4ed8', 'line-width': 2 }
});
});
```
## Polygon with a Hole
```javascript
coordinates: [
// Outer ring
[[-5, 40], [10, 40], [10, 50], [-5, 50], [-5, 40]],
// Inner ring (hole)
[[0, 44], [5, 44], [5, 47], [0, 47], [0, 44]]
]
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const GeoJSONPolygon = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 4
});
map.current.on('load', () => {
map.current.addSource('polygon', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[[-4.5, 48.0], [8.2, 48.0], [7.5, 43.5], [3.0, 42.5], [-1.8, 43.4], [-4.5, 48.0]]]
}
}
});
map.current.addLayer({ id: 'polygon-fill', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.3 } });
map.current.addLayer({ id: 'polygon-outline', type: 'line', source: 'polygon', paint: { 'line-color': '#1d4ed8', 'line-width': 2 } });
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default GeoJSONPolygon;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a Image as a Marker
https://docs.mapatlas.xyz/sdk/examples/add-image-marker
# Add a Image as a Marker
##
## Prerequisites
Before you begin, ensure you have:
- **Generated a style and API key:** [How to create an API key](./key-creation.md) and [How to create a map style](./style-creation.md).
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AddImageMarker = () => {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
const accessToken = "";
const geojson = {
'type': 'FeatureCollection',
'features': [
{
'type': 'Feature',
'properties': {
'message': 'Foo',
'iconSize': [60, 60]
},
'geometry': {
'type': 'Point',
'coordinates': [-66.324462890625, -16.024695711685304]
}
},
{
'type': 'Feature',
'properties': {
'message': 'Bar',
'iconSize': [50, 50]
},
'geometry': {
'type': 'Point',
'coordinates': [-61.2158203125, -15.97189158092897]
}
},
{
'type': 'Feature',
'properties': {
'message': 'Baz',
'iconSize': [40, 40]
},
'geometry': {
'type': 'Point',
'coordinates': [-63.29223632812499, -18.28151823530889]
}
}
]
};
const map = new mapmetricsgl.Map({
container: mapContainerRef.current,
style: `${accessToken}`,
center: [-65.017, -16.457],
zoom: 5,
});
map.addControl(new mapmetricsgl.NavigationControl(), "top-right");
mapmetricsgl.setRTLTextPlugin(
"https://cdn.mapmetrics-atlas.net/basemaps-assets/js/mapmetrics-gl-rtl-text.min.js"
);
// Add markers with custom images
geojson.features.forEach((markerData) => {
const el = document.createElement('div');
el.className = 'marker';
// Apply styles using an object for cleaner code
Object.assign(el.style, {
backgroundImage: `url(https://picsum.photos/${markerData.properties.iconSize.join('/')}/)`,
width: `${markerData.properties.iconSize[0]}px`,
height: `${markerData.properties.iconSize[1]}px`,
backgroundSize: 'cover',
borderRadius: '50%',
cursor: 'pointer'
});
el.addEventListener('click', () => {
window.alert(markerData.properties.message);
});
new mapmetricsgl.Marker({ element: el })
.setLngLat(markerData.geometry.coordinates)
.addTo(map);
});
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return (
);
};
export default AddImageMarker;
```
---
# Add a New Layer Below Labels
https://docs.mapatlas.xyz/sdk/examples/add-layer-below-labels
---
title: "Add a New Layer Below Labels"
category: "styling"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addLayer", "beforeId", "addSource"]
tags: ["layer", "order", "labels", "beforeId", "z-order", "below", "insert"]
description: "Insert a new layer below existing map labels so labels remain visible on top"
---
# Add a New Layer Below Labels
Use the `beforeId` parameter in `addLayer()` to insert layers below map labels so they stay readable.
## Insert a Layer Below Labels
```javascript
map.on('load', () => {
// Find the first symbol (label) layer in the style
const layers = map.getStyle().layers;
let firstSymbolId;
for (const layer of layers) {
if (layer.type === 'symbol') {
firstSymbolId = layer.id;
break;
}
}
map.addSource('my-source', { type: 'geojson', data: myGeoJSON });
// Pass firstSymbolId as the second argument to insert below labels
map.addLayer({
id: 'my-fill',
type: 'fill',
source: 'my-source',
paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.4 }
}, firstSymbolId);
});
```
## Insert Before a Specific Layer
```javascript
// Insert before a known layer ID
map.addLayer({ id: 'my-layer', type: 'fill', source: 'my-source', paint: {} }, 'road-label');
// Insert at the very bottom (no beforeId = on top)
map.addLayer({ id: 'my-layer', type: 'fill', source: 'my-source', paint: {} });
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const LayerBelowLabels = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({ container: mapContainer.current, style: '', center: [2.35, 48.85], zoom: 4 });
map.current.on('load', () => {
const layers = map.current.getStyle().layers;
const firstSymbolId = layers.find(l => l.type === 'symbol')?.id;
map.current.addSource('polygon', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[-4.5,48],[8.2,48],[7.5,43.5],[3,42.5],[-1.8,43.4],[-4.5,48]]] } }
});
map.current.addLayer({ id: 'polygon-fill', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.4 } }, firstSymbolId);
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default LayerBelowLabels;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a Pattern to a Polygon
https://docs.mapatlas.xyz/sdk/examples/add-pattern-to-polygon
---
title: "Add a Pattern to a Polygon"
category: "lines-polygons"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "loadImage", "addImage", "fill-pattern"]
tags: ["pattern", "polygon", "fill", "texture", "image", "fill-pattern", "hatch", "repeat"]
description: "Fill a polygon with a repeating image pattern using fill-pattern and loadImage"
---
# Add a Pattern to a Polygon
Fill a polygon with a repeating image texture using `fill-pattern` and `map.loadImage()`.
Pattern Fill
Solid Fill
## How It Works
1. Load or create an image to use as the tile pattern
2. Register it with `map.addImage('name', imageData)`
3. Apply it using `fill-pattern: 'name'` in the layer paint
## Using a Remote Image
```javascript
map.on('load', () => {
map.loadImage('https://example.com/pattern.png', (error, image) => {
if (error) throw error;
map.addImage('my-pattern', image);
map.addLayer({
id: 'polygon-fill',
type: 'fill',
source: 'polygon',
paint: {
'fill-pattern': 'my-pattern'
}
});
});
});
```
## Using a Canvas-Generated Pattern
```javascript
// Create pattern programmatically with Canvas API
const size = 16;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Draw diagonal lines
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, size);
ctx.lineTo(size, 0);
ctx.stroke();
// Register as a map image
const imageData = ctx.getImageData(0, 0, size, size);
map.addImage('hatch', imageData);
map.addLayer({
id: 'fill',
type: 'fill',
source: 'polygon',
paint: { 'fill-pattern': 'hatch' }
});
```
## Pattern Layer Properties
```javascript
map.addLayer({
id: 'polygon-fill',
type: 'fill',
source: 'polygon',
paint: {
'fill-pattern': 'my-pattern', // image name from addImage()
'fill-opacity': 0.9, // overall opacity
// Note: fill-color is ignored when fill-pattern is set
}
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const polygonData = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[[0, 44], [12, 44], [12, 54], [0, 54], [0, 44]]]
}
};
const AddPatternToPolygon = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [5, 48],
zoom: 4
});
map.current.on('load', () => {
// Create hatch pattern
const size = 16;
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, size); ctx.lineTo(size, 0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-size, size); ctx.lineTo(size, -size); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, 2 * size); ctx.lineTo(2 * size, 0); ctx.stroke();
map.current.addImage('hatch', ctx.getImageData(0, 0, size, size));
map.current.addSource('polygon', { type: 'geojson', data: polygonData });
map.current.addLayer({
id: 'polygon-fill',
type: 'fill',
source: 'polygon',
paint: { 'fill-pattern': 'hatch', 'fill-opacity': 0.8 }
});
map.current.addLayer({
id: 'polygon-outline',
type: 'line',
source: 'polygon',
paint: { 'line-color': '#1d4ed8', 'line-width': 2 }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default AddPatternToPolygon;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Add a Stretchable Image to the Map
https://docs.mapatlas.xyz/sdk/examples/add-stretchable-image
---
title: "Add a Stretchable Image to the Map"
category: "icons-images"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addImage", "addSource", "addLayer", "stretchX", "stretchY", "content", "icon-text-fit"]
tags: ["stretchable", "nine-patch", "icon", "image", "addImage", "stretchX", "stretchY", "content", "label", "icon-text-fit"]
description: "Use a stretchable (nine-patch) image as an icon that scales without distortion"
---
# Add a Stretchable Image to the Map
A stretchable image (nine-patch) allows certain regions to stretch while keeping corners and edges crisp. Use `stretchX`, `stretchY`, and `content` in `map.addImage()` to define which parts can stretch and where text is placed.
## What is a Stretchable Image?
A stretchable image is similar to Android's **nine-patch** format. You define:
- `stretchX` — pixel ranges along the X axis that can be stretched
- `stretchY` — pixel ranges along the Y axis that can be stretched
- `content` — the bounding box `[left, top, right, bottom]` where text/icon content is placed
This lets the image scale gracefully without distorting corners or borders.
## Create and Register a Stretchable Image
```javascript
const width = 60, height = 24;
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d');
// Draw a pill shape (rounded rect)
const r = height / 2;
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.moveTo(r, 0);
ctx.arcTo(width, 0, width, height, r);
ctx.arcTo(width, height, 0, height, r);
ctx.arcTo(0, height, 0, 0, r);
ctx.arcTo(0, 0, width, 0, r);
ctx.closePath();
ctx.fill();
const imageData = ctx.getImageData(0, 0, width, height);
map.addImage('stretchable-pill', imageData, {
stretchX: [[8, width - 8]], // horizontal stretch zone
stretchY: [[4, height - 4]], // vertical stretch zone
content: [8, 4, width - 8, height - 4], // where text goes
pixelRatio: 1
});
```
## Use with `icon-text-fit`
```javascript
map.addLayer({
id: 'labels',
type: 'symbol',
source: 'points',
layout: {
'icon-image': 'stretchable-pill',
'icon-text-fit': 'both', // 'none' | 'width' | 'height' | 'both'
'icon-text-fit-padding': [4, 8, 4, 8], // padding inside the content box
'icon-allow-overlap': true,
}
});
```
## `stretchX` / `stretchY` Format
```javascript
// Array of [from, to] pixel ranges (0-indexed)
stretchX: [[8, 52]] // pixels 8–52 can stretch horizontally
stretchY: [[4, 20]] // pixels 4–20 can stretch vertically
// Multiple stretch zones
stretchX: [[4, 12], [48, 56]]
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const citiesData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-74, 40.7] }, properties: { label: 'New York' } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [2.35, 48.85] }, properties: { label: 'Paris' } },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [139.7, 35.7] }, properties: { label: 'Tokyo' } },
]
};
const AddStretchableImage = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 30],
zoom: 2
});
map.current.on('load', () => {
const w = 60, h = 24, r = h / 2;
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.moveTo(r, 0);
ctx.arcTo(w, 0, w, h, r); ctx.arcTo(w, h, 0, h, r);
ctx.arcTo(0, h, 0, 0, r); ctx.arcTo(0, 0, w, 0, r);
ctx.closePath(); ctx.fill();
map.current.addImage('pill', ctx.getImageData(0, 0, w, h), {
stretchX: [[8, w - 8]],
stretchY: [[4, h - 4]],
content: [8, 4, w - 8, h - 4]
});
map.current.addSource('cities', { type: 'geojson', data: citiesData });
map.current.addLayer({
id: 'labels',
type: 'symbol',
source: 'cities',
layout: {
'icon-image': 'pill',
'icon-text-fit': 'both',
'icon-text-fit-padding': [4, 8, 4, 8],
'icon-allow-overlap': true
}
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default AddStretchableImage;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Animate a Line
https://docs.mapatlas.xyz/sdk/examples/animate-a-line
---
title: "Animate a Line"
category: "lines-polygons"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "setData", "requestAnimationFrame"]
tags: ["line", "animate", "animation", "lineString", "draw", "route", "path", "requestAnimationFrame"]
description: "Animate a line being drawn on the map step by step using requestAnimationFrame"
---
# Animate a Line
Draw a line on the map that animates progressively, revealing a path step by step.
▶ Start
↺ Reset
## How It Works
Use `setData()` on a GeoJSON source to progressively add coordinates to a LineString, driven by `requestAnimationFrame` or `setTimeout`.
## Key Pattern
```javascript
// 1. Add source with empty line
map.addSource('route', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
});
// 2. Add line layer
map.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
paint: { 'line-color': '#3b82f6', 'line-width': 3 }
});
// 3. Animate by adding one coordinate at a time
let step = 0;
const coordinates = [[lng1, lat1], [lng2, lat2], ...];
function animate() {
if (step >= coordinates.length) return;
step++;
map.getSource('route').setData({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coordinates.slice(0, step) }
});
setTimeout(() => requestAnimationFrame(animate), 300);
}
animate();
```
## Smooth Animation with Interpolation
For very smooth animation between two points:
```javascript
const from = [0, 0];
const to = [10, 10];
const steps = 100;
let i = 0;
function animate() {
if (i > steps) return;
const t = i / steps;
const lng = from[0] + (to[0] - from[0]) * t;
const lat = from[1] + (to[1] - from[1]) * t;
map.getSource('route').setData({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [from, [lng, lat]] }
});
i++;
requestAnimationFrame(animate);
}
```
## Complete Example
```html
▶ Animate
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const coordinates = [
[2.349902, 48.852966],
[-0.1276, 51.5074],
[13.405, 52.52],
[16.3738, 48.2082],
[12.4964, 41.9028],
];
const AnimateLine = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const timer = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 4
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
map.current.on('load', () => {
map.current.addSource('route', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
});
map.current.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
paint: { 'line-color': '#3b82f6', 'line-width': 3 }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const startAnimation = () => {
let step = 0;
const animate = () => {
if (step >= coordinates.length) return;
step++;
map.current?.getSource('route')?.setData({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coordinates.slice(0, step) }
});
timer.current = setTimeout(() => requestAnimationFrame(animate), 400);
};
animate();
};
return (
);
};
export default AnimateLine;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Animate Camera Around a Point
https://docs.mapatlas.xyz/sdk/examples/animate-camera-around-point
---
title: "Animate Camera Around a Point"
category: "camera"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "easeTo", "getBearing"]
tags: ["camera", "animation", "rotate", "orbit", "bearing"]
description: "Continuously rotate the map camera around a fixed point to create an orbiting animation"
---
# Animate Camera Around a Point
Continuously rotate the map camera around a fixed point to create a smooth orbiting animation.
▶ Start Rotation
⏹ Stop Rotation
## How It Works
The animation works by repeatedly calling `map.easeTo()` with an incremented bearing on each animation frame using `requestAnimationFrame`.
## Basic Usage
```javascript
let rotating = false;
let animationId = null;
function rotateCamera() {
if (!rotating) return;
// Increment bearing by a small amount each frame
map.easeTo({
bearing: map.getBearing() + 0.3,
duration: 0,
easing: (t) => t
});
// Request next frame
animationId = requestAnimationFrame(rotateCamera);
}
// Start rotation
function startRotation() {
rotating = true;
rotateCamera();
}
// Stop rotation
function stopRotation() {
rotating = false;
if (animationId) cancelAnimationFrame(animationId);
}
```
## Customization
```javascript
// Faster rotation
map.easeTo({ bearing: map.getBearing() + 1.0, duration: 0 });
// Slower rotation
map.easeTo({ bearing: map.getBearing() + 0.1, duration: 0 });
// Rotate AND change pitch for a dynamic effect
map.easeTo({
bearing: map.getBearing() + 0.5,
pitch: 60,
duration: 0
});
```
## Complete Example
```html
▶ Start Rotation
⏹ Stop Rotation
```
```jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AnimateCameraAround = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const rotating = useRef(false);
const animationId = useRef(null);
const [isRotating, setIsRotating] = useState(false);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 14,
pitch: 60,
bearing: 0
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
return () => {
rotating.current = false;
if (animationId.current) cancelAnimationFrame(animationId.current);
map.current?.remove();
map.current = null;
};
}, []);
const rotateCamera = () => {
if (!rotating.current) return;
map.current?.easeTo({ bearing: map.current.getBearing() + 0.3, duration: 0, easing: t => t });
animationId.current = requestAnimationFrame(rotateCamera);
};
const startRotation = () => {
rotating.current = true;
setIsRotating(true);
rotateCamera();
};
const stopRotation = () => {
rotating.current = false;
setIsRotating(false);
if (animationId.current) cancelAnimationFrame(animationId.current);
};
return (
{!isRotating ? (
▶ Start Rotation
) : (
⏹ Stop Rotation
)}
);
};
export default AnimateCameraAround;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Animate a Marker
https://docs.mapatlas.xyz/sdk/examples/animate-marker
---
title: "Animate a Marker"
category: "animations"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "Marker", "setLngLat", "requestAnimationFrame"]
tags: ["animate", "marker", "animation", "moving", "requestAnimationFrame", "pulse"]
description: "Animate a marker moving on the map using requestAnimationFrame"
---
# Animate a Marker
Animate a Marker smoothly across the map using `requestAnimationFrame` and `setLngLat()`.
▶ Start
⏹ Stop
## Animate a Marker with requestAnimationFrame
```javascript
const marker = new mapmetricsgl.Marker({ color: '#3b82f6' })
.setLngLat([0, 0])
.addTo(map);
let t = 0;
function animate() {
t += 0.005;
// Move in a sine wave pattern
const lng = (t * 30) % 360 - 180;
const lat = Math.sin(t) * 40;
marker.setLngLat([lng, lat]);
requestAnimationFrame(animate);
}
animate();
```
## Animate Along Waypoints
```javascript
const waypoints = [[2.35, 48.85], [-0.12, 51.50], [13.40, 52.52]];
let step = 0;
const stepsPerSegment = 100;
let subStep = 0;
function animate() {
const from = waypoints[step];
const to = waypoints[(step + 1) % waypoints.length];
const t = subStep / stepsPerSegment;
marker.setLngLat([
from[0] + (to[0] - from[0]) * t,
from[1] + (to[1] - from[1]) * t
]);
subStep++;
if (subStep > stepsPerSegment) {
subStep = 0;
step = (step + 1) % waypoints.length;
}
requestAnimationFrame(animate);
}
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AnimateMarker = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const animId = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({ container: mapContainer.current, style: '', center: [0, 20], zoom: 1.5 });
const marker = new mapmetricsgl.Marker({ color: '#3b82f6' }).setLngLat([0, 20]).addTo(map.current);
let t = 0;
const animate = () => {
t += 0.005;
marker.setLngLat([(t * 30) % 360 - 180, Math.sin(t) * 40]);
animId.current = requestAnimationFrame(animate);
};
map.current.on('load', animate);
return () => {
if (animId.current) cancelAnimationFrame(animId.current);
map.current?.remove(); map.current = null;
};
}, []);
return
;
};
export default AnimateMarker;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Animate a Point Along a Route
https://docs.mapatlas.xyz/sdk/examples/animate-point-along-route
---
title: "Animate a Point Along a Route"
category: "lines-polygons"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "setData", "requestAnimationFrame"]
tags: ["animate", "point", "route", "path", "moving-marker", "animation", "lineString", "symbol"]
description: "Animate a point marker moving along a defined route path on the map"
---
# Animate a Point Along a Route
Move a point marker smoothly along a route path using frame-by-frame animation.
▶ Start
⏹ Stop
↺ Reset
## How It Works
1. Add a GeoJSON `Point` source at the starting position
2. Add a `circle` (or `symbol`) layer to display the point
3. On each animation frame, update the point's coordinates using `setData()`
## Key Pattern
```javascript
// 1. Add point source at start position
map.addSource('point', {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'Point', coordinates: route[0] }
}
});
// 2. Display as circle
map.addLayer({
id: 'moving-point',
type: 'circle',
source: 'point',
paint: {
'circle-radius': 10,
'circle-color': '#3b82f6',
'circle-stroke-width': 3,
'circle-stroke-color': '#fff'
}
});
// 3. Animate along route
let step = 0;
function animate() {
if (step >= route.length) return;
map.getSource('point').setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: route[step] }
});
step++;
requestAnimationFrame(animate);
}
animate();
```
## Interpolation for Smooth Movement
Interpolate between waypoints to get smooth frame-by-frame movement:
```javascript
function interpolate(from, to, t) {
return [
from[0] + (to[0] - from[0]) * t,
from[1] + (to[1] - from[1]) * t
];
}
// Generate 60 steps between each pair of waypoints
for (let i = 0; i < waypoints.length - 1; i++) {
for (let s = 0; s < 60; s++) {
const t = s / 60;
points.push(interpolate(waypoints[i], waypoints[i + 1], t));
}
}
```
## Complete Example
```html
▶ Start
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const waypoints = [
[2.349902, 48.852966],
[-0.1276, 51.5074],
[13.405, 52.52],
[12.4964, 41.9028],
];
// Build interpolated route
const route = [];
for (let i = 0; i < waypoints.length - 1; i++) {
for (let s = 0; s < 60; s++) {
const t = s / 60;
route.push([
waypoints[i][0] + (waypoints[i+1][0] - waypoints[i][0]) * t,
waypoints[i][1] + (waypoints[i+1][1] - waypoints[i][1]) * t,
]);
}
}
route.push(waypoints[waypoints.length - 1]);
const AnimatePoint = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const animId = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 4
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
map.current.on('load', () => {
map.current.addSource('route-line', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: waypoints } }
});
map.current.addLayer({
id: 'route', type: 'line', source: 'route-line',
paint: { 'line-color': '#94a3b8', 'line-width': 2, 'line-dasharray': [2, 2] }
});
map.current.addSource('point', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: waypoints[0] } }
});
map.current.addLayer({
id: 'moving-point', type: 'circle', source: 'point',
paint: { 'circle-radius': 10, 'circle-color': '#3b82f6', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const start = () => {
let step = 0;
const animate = () => {
if (step >= route.length) return;
map.current?.getSource('point')?.setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: route[step++] }
});
animId.current = requestAnimationFrame(animate);
};
animate();
};
return (
);
};
export default AnimatePoint;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Animate a Point
https://docs.mapatlas.xyz/sdk/examples/animate-point
---
title: "Animate a Point"
category: "special"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "addSource", "addLayer", "setData", "requestAnimationFrame"]
tags: ["animate", "point", "circle", "moving", "orbit", "sine", "cosine", "requestAnimationFrame"]
description: "Animate a point on the map using requestAnimationFrame to create smooth movement"
---
# Animate a Point
Move a point continuously on the map using `requestAnimationFrame` for smooth, frame-based animation.
▶ Start
⏹ Stop
## How It Works
Use `requestAnimationFrame` to call a function on every browser repaint (~60fps). On each frame, compute the new position and call `setData()` to move the point.
## Key Pattern
```javascript
// 1. Add point source
map.addSource('point', {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'Point', coordinates: [0, 0] },
properties: {}
}
});
// 2. Display as circle layer
map.addLayer({
id: 'point',
type: 'circle',
source: 'point',
paint: { 'circle-radius': 12, 'circle-color': '#f59e0b' }
});
// 3. Animate position on each frame
let t = 0;
function animate() {
t += 0.01;
const lng = Math.cos(t) * 60;
const lat = Math.sin(t) * 30;
map.getSource('point').setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {}
});
requestAnimationFrame(animate);
}
animate();
```
## Stop and Resume Animation
```javascript
let animId = null;
let running = false;
function start() {
if (running) return;
running = true;
function tick() {
if (!running) return;
// update position...
animId = requestAnimationFrame(tick);
}
tick();
}
function stop() {
running = false;
if (animId) cancelAnimationFrame(animId);
}
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const AnimatePoint = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const animId = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [0, 0],
zoom: 1.5
});
map.current.on('load', () => {
map.current.addSource('point', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: {} }
});
map.current.addLayer({
id: 'point',
type: 'circle',
source: 'point',
paint: { 'circle-radius': 12, 'circle-color': '#f59e0b', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' }
});
let t = 0;
const animate = () => {
t += 0.015;
map.current?.getSource('point')?.setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: [Math.cos(t) * 60, Math.sin(t * 0.7) * 30] },
properties: {}
});
animId.current = requestAnimationFrame(animate);
};
animate();
});
return () => {
if (animId.current) cancelAnimationFrame(animId.current);
map.current?.remove();
map.current = null;
};
}, []);
return
;
};
export default AnimatePoint;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Arc Layer — Flight Routes
https://docs.mapatlas.xyz/sdk/examples/arc-layer
---
title: "Arc Layer — Flight Routes"
category: "data-visualization"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "setData"]
tags: ["arc", "line", "flow", "animation", "geojson", "flight", "routes", "connections"]
description: "Visualize connections between locations using animated curved arc lines — great for flight routes, migration flows, and network links"
---
# Arc Layer — Flight Routes
Visualize connections between cities using animated curved arcs. Each arc is a bezier curve drawn between an origin and destination, colored by direction and animated with a flowing dash effect.
⏸ Pause
Line width
2px
## How It Works
Each arc is a **quadratic bezier curve** computed in JavaScript between two `[lng, lat]` coordinates. The curve is added as a GeoJSON `LineString` source, then rendered with a `line` layer. A flowing animation is achieved by cycling through `line-dasharray` values each frame using `requestAnimationFrame`.
### Step 1 — Generate bezier arc coordinates
```javascript
function bezierArc(from, to, steps = 80) {
const coords = [];
const midLng = (from[0] + to[0]) / 2;
const midLat = (from[1] + to[1]) / 2;
const dist = Math.sqrt(Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2));
const ctrl = [midLng, midLat + dist * 0.25]; // control point lifted upward
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const lng = (1-t)*(1-t)*from[0] + 2*(1-t)*t*ctrl[0] + t*t*to[0];
const lat = (1-t)*(1-t)*from[1] + 2*(1-t)*t*ctrl[1] + t*t*to[1];
coords.push([lng, lat]);
}
return coords;
}
```
### Step 2 — Add source and layer
```javascript
map.addSource('arc-0', {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'LineString', coordinates: bezierArc(from, to) }
}
});
map.addLayer({
id: 'arc-line-0',
type: 'line',
source: 'arc-0',
paint: {
'line-color': '#ef4444',
'line-width': 2,
'line-opacity': 0.9,
'line-dasharray': [0, 4, 3],
}
});
```
### Step 3 — Animate the dash
```javascript
const dashArraySequence = [
[0, 4, 3], [0.5, 4, 2.5], [1, 4, 2], /* ... */
];
let step = 0;
function animate(timestamp) {
const newStep = Math.floor((timestamp / 80) % dashArraySequence.length);
if (newStep !== step) {
step = newStep;
map.setPaintProperty('arc-line-0', 'line-dasharray', dashArraySequence[step]);
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
```
## Complete Example
```html
⏸ Pause
Width
2px
```
```jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const routes = [
{ from: [-73.9857, 40.7484], to: [-0.1276, 51.5074], color: '#ef4444', label: 'NYC → London' },
{ from: [-73.9857, 40.7484], to: [2.3490, 48.8530], color: '#f97316', label: 'NYC → Paris' },
{ from: [-73.9857, 40.7484], to: [13.4050, 52.5200], color: '#eab308', label: 'NYC → Berlin' },
{ from: [-73.9857, 40.7484], to: [37.6173, 55.7558], color: '#22c55e', label: 'NYC → Moscow' },
{ from: [-73.9857, 40.7484], to: [139.6917, 35.6895], color: '#06b6d4', label: 'NYC → Tokyo' },
{ from: [-73.9857, 40.7484], to: [103.8198, 1.3521], color: '#8b5cf6', label: 'NYC → Singapore' },
{ from: [-73.9857, 40.7484], to: [151.2093, -33.8688], color: '#ec4899', label: 'NYC → Sydney' },
{ from: [-73.9857, 40.7484], to: [-43.1729, -22.9068], color: '#14b8a6', label: 'NYC → Rio' },
{ from: [-73.9857, 40.7484], to: [18.4241, -33.9249], color: '#a855f7', label: 'NYC → Cape Town' },
{ from: [-73.9857, 40.7484], to: [72.8777, 19.0760], color: '#f59e0b', label: 'NYC → Mumbai' },
];
function bezierArc(from, to, steps = 80) {
const coords = [];
const midLng = (from[0] + to[0]) / 2;
const midLat = (from[1] + to[1]) / 2;
const dist = Math.sqrt(Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2));
const ctrl = [midLng, midLat + dist * 0.25];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
coords.push([
(1-t)*(1-t)*from[0] + 2*(1-t)*t*ctrl[0] + t*t*to[0],
(1-t)*(1-t)*from[1] + 2*(1-t)*t*ctrl[1] + t*t*to[1],
]);
}
return coords;
}
const dashArraySequence = [
[0,4,3],[0.5,4,2.5],[1,4,2],[1.5,4,1.5],[2,4,1],[2.5,4,0.5],[3,4,0],
[0,0.5,3,3.5],[0,1,3,3],[0,1.5,3,2.5],[0,2,3,2],[0,2.5,3,1.5],
[0,3,3,1],[0,3.5,3,0.5],[0,4,3,0],
];
const ArcLayer = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const rafId = useRef(null);
const [running, setRunning] = useState(true);
const runningRef = useRef(true);
const [lineWidth, setLineWidth] = useState(2);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: 'YOUR_STYLE_URL_WITH_TOKEN',
center: [-30, 30],
zoom: 1.5,
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
map.current.on('load', () => {
routes.forEach((route, i) => {
map.current.addSource(`arc-${i}`, {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: bezierArc(route.from, route.to) } }
});
map.current.addLayer({
id: `arc-glow-${i}`, type: 'line', source: `arc-${i}`,
paint: { 'line-color': route.color, 'line-width': 6, 'line-opacity': 0.15, 'line-blur': 4 }
});
map.current.addLayer({
id: `arc-line-${i}`, type: 'line', source: `arc-${i}`,
paint: { 'line-color': route.color, 'line-width': 2, 'line-opacity': 0.9, 'line-dasharray': [0, 4, 3] }
});
});
new mapmetricsgl.Marker({ color: '#ffffff', scale: 1.2 })
.setLngLat([-73.9857, 40.7484])
.setPopup(new mapmetricsgl.Popup({ offset: 20 }).setHTML('New York City Origin hub'))
.addTo(map.current);
routes.forEach(route => {
new mapmetricsgl.Marker({ color: route.color, scale: 0.7 })
.setLngLat(route.to)
.setPopup(new mapmetricsgl.Popup({ offset: 15 }).setHTML(`${route.label} `))
.addTo(map.current);
});
let step = 0;
const animate = (timestamp) => {
if (!runningRef.current) return;
const newStep = Math.floor((timestamp / 80) % dashArraySequence.length);
if (newStep !== step) {
step = newStep;
routes.forEach((_, i) => {
map.current?.setPaintProperty(`arc-line-${i}`, 'line-dasharray', dashArraySequence[step]);
});
}
rafId.current = requestAnimationFrame(animate);
};
rafId.current = requestAnimationFrame(animate);
});
return () => {
if (rafId.current) cancelAnimationFrame(rafId.current);
map.current?.remove();
map.current = null;
};
}, []);
const toggle = () => {
runningRef.current = !runningRef.current;
setRunning(runningRef.current);
if (runningRef.current) rafId.current = requestAnimationFrame(() => {});
};
const handleWidthChange = (e) => {
const w = parseFloat(e.target.value);
setLineWidth(w);
routes.forEach((_, i) => {
map.current?.setPaintProperty(`arc-line-${i}`, 'line-width', w);
map.current?.setPaintProperty(`arc-glow-${i}`, 'line-width', w * 3);
});
};
return (
);
};
export default ArcLayer;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Change Building Color Based on Zoom Level
https://docs.mapatlas.xyz/sdk/examples/building-color-zoom
---
title: "Change Building Color Based on Zoom Level"
category: "labels"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addLayer", "setPaintProperty", "fill-extrusion-color", "interpolate", "zoom"]
tags: ["building", "3D", "zoom", "color", "fill-extrusion", "interpolate", "zoom-based", "style"]
description: "Style 3D buildings with colors that change dynamically based on the current zoom level"
---
# Change Building Color Based on Zoom Level
Use the `interpolate` expression with `zoom` to change 3D building colors as the user zooms in and out.
Zoom in to see building colors transition from grey → blue → gold.
## Zoom-Based Color with `interpolate`
Use the `interpolate` expression with `['zoom']` as the input to smoothly transition colors between zoom levels:
```javascript
// Add your building polygons as a GeoJSON source
map.addSource('buildings', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { height: 80 }, geometry: { type: 'Polygon', coordinates: [[[lng1,lat1],[lng2,lat1],[lng2,lat2],[lng1,lat2],[lng1,lat1]]] } },
// ... more building polygons
]
}
});
map.addLayer({
id: 'buildings-3d',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.85,
// Color changes based on zoom
'fill-extrusion-color': [
'interpolate',
['linear'],
['zoom'],
13, '#94a3b8', // grey at zoom 13
15, '#3b82f6', // blue at zoom 15
17, '#f59e0b', // gold at zoom 17+
],
}
});
```
## Step-Based Color (Discrete Jumps)
Use `step` for discrete color jumps instead of smooth transitions:
```javascript
'fill-extrusion-color': [
'step',
['zoom'],
'#94a3b8', // default (zoom < 14)
14, '#3b82f6', // zoom >= 14 → blue
16, '#f59e0b', // zoom >= 16 → gold
18, '#ef4444', // zoom >= 18 → red
]
```
## Update Color at Runtime
```javascript
map.setPaintProperty('buildings-3d', 'fill-extrusion-color', [
'interpolate', ['linear'], ['zoom'],
13, '#e2e8f0',
16, '#22c55e',
]);
```
## Also Apply to Height and Opacity
```javascript
paint: {
// Height from data
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'min_height'],
// Fade in as zoom increases
'fill-extrusion-opacity': [
'interpolate', ['linear'], ['zoom'],
13, 0, // invisible at zoom 13
14, 0.85 // fully visible at zoom 14
],
// Color by zoom
'fill-extrusion-color': [
'interpolate', ['linear'], ['zoom'],
14, '#94a3b8',
17, '#3b82f6',
],
}
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const BuildingColorZoom = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.3522, 48.8566],
zoom: 14,
pitch: 45,
bearing: -17
});
map.current.on('load', () => {
const geojsonBuildings = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { height: 80 }, geometry: { type: 'Polygon', coordinates: [[[2.348,48.858],[2.350,48.858],[2.350,48.860],[2.348,48.860],[2.348,48.858]]] } },
{ type: 'Feature', properties: { height: 120 }, geometry: { type: 'Polygon', coordinates: [[[2.351,48.857],[2.353,48.857],[2.353,48.860],[2.351,48.860],[2.351,48.857]]] } },
{ type: 'Feature', properties: { height: 200 }, geometry: { type: 'Polygon', coordinates: [[[2.344,48.856],[2.347,48.856],[2.347,48.859],[2.344,48.859],[2.344,48.856]]] } },
{ type: 'Feature', properties: { height: 50 }, geometry: { type: 'Polygon', coordinates: [[[2.354,48.858],[2.356,48.858],[2.356,48.859],[2.354,48.859],[2.354,48.858]]] } },
]
};
map.current.addSource('buildings', { type: 'geojson', data: geojsonBuildings });
map.current.addLayer({
id: 'buildings-3d',
type: 'fill-extrusion',
source: 'buildings',
paint: {
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': 0,
'fill-extrusion-opacity': 0.85,
'fill-extrusion-color': [
'interpolate', ['linear'], ['zoom'],
13, '#94a3b8',
15, '#3b82f6',
17, '#f59e0b',
],
}
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default BuildingColorZoom;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Change the Case of Labels
https://docs.mapatlas.xyz/sdk/examples/change-label-case
---
title: "Change the Case of Labels"
category: "labels"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "setLayoutProperty", "text-transform", "uppercase", "lowercase", "none"]
tags: ["label", "case", "text-transform", "uppercase", "lowercase", "capitalize", "symbol", "text"]
description: "Transform map label text to uppercase, lowercase, or default case using text-transform"
---
# Change the Case of Labels
Use the `text-transform` layout property to render map labels in uppercase, lowercase, or default case.
Default Case
UPPERCASE
lowercase
## `text-transform` Property
Set `text-transform` in a symbol layer's `layout` to control label casing:
```javascript
map.addLayer({
id: 'my-labels',
type: 'symbol',
source: 'my-source',
layout: {
'text-field': ['get', 'name'],
'text-transform': 'uppercase', // 'none' | 'uppercase' | 'lowercase'
}
});
```
## Update at Runtime
```javascript
// Apply to a specific layer
map.setLayoutProperty('my-labels', 'text-transform', 'uppercase');
// Apply to ALL symbol layers in the style
map.getStyle().layers.forEach(layer => {
if (layer.type === 'symbol') {
map.setLayoutProperty(layer.id, 'text-transform', 'uppercase');
}
});
```
## Values
| Value | Effect |
|---|---|
| `'none'` | Use the text as-is from the data (default) |
| `'uppercase'` | Convert all characters to uppercase |
| `'lowercase'` | Convert all characters to lowercase |
## Data-Driven Case (per feature)
```javascript
'text-transform': [
'match',
['get', 'type'],
'highway', 'uppercase',
'suburb', 'lowercase',
'none' // default
]
```
## Complete Example
```html
Default
UPPERCASE
lowercase
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const ChangeLabelCase = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 50],
zoom: 4
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const setCase = (val) => {
if (!map.current) return;
map.current.getStyle().layers.forEach(layer => {
if (layer.type === 'symbol') {
map.current.setLayoutProperty(layer.id, 'text-transform', val);
}
});
};
const btnStyle = (bg) => ({
padding: '8px 14px', background: bg, color: 'white',
border: 'none', borderRadius: 6, cursor: 'pointer'
});
return (
setCase('none')} style={btnStyle('#6b7280')}>Default
setCase('uppercase')} style={btnStyle('#3b82f6')}>UPPERCASE
setCase('lowercase')} style={btnStyle('#8b5cf6')}>lowercase
);
};
export default ChangeLabelCase;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Change a Layer's Color with Buttons
https://docs.mapatlas.xyz/sdk/examples/change-layer-color
---
title: "Change a Layer's Color with Buttons"
category: "styling"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "setPaintProperty", "addLayer", "addSource"]
tags: ["color", "setPaintProperty", "style", "layer", "dynamic", "buttons", "theme"]
description: "Dynamically change a map layer's color at runtime using setPaintProperty"
---
# Change a Layer's Color with Buttons
Use `setPaintProperty()` to update a layer's color dynamically without reloading the map.
Blue
Red
Green
Purple
Orange
## setPaintProperty
```javascript
// Change fill color
map.setPaintProperty('my-layer', 'fill-color', '#ef4444');
// Change line color and width
map.setPaintProperty('my-line-layer', 'line-color', '#22c55e');
map.setPaintProperty('my-line-layer', 'line-width', 5);
// Change circle radius and color
map.setPaintProperty('my-circle-layer', 'circle-color', '#8b5cf6');
map.setPaintProperty('my-circle-layer', 'circle-radius', 15);
// Change opacity
map.setPaintProperty('my-layer', 'fill-opacity', 0.8);
```
## setLayoutProperty
For layout properties (visibility, text, icon):
```javascript
// Toggle layer visibility
map.setLayoutProperty('my-layer', 'visibility', 'none'); // hide
map.setLayoutProperty('my-layer', 'visibility', 'visible'); // show
```
## Complete Example
```html
Blue
Red
Green
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const colors = ['#3b82f6', '#ef4444', '#22c55e', '#8b5cf6', '#f59e0b'];
const ChangeLayerColor = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({ container: mapContainer.current, style: '', center: [2.35, 48.85], zoom: 4 });
map.current.on('load', () => {
map.current.addSource('polygon', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[-4.5,48],[8.2,48],[7.5,43.5],[3,42.5],[-1.8,43.4],[-4.5,48]]] } } });
map.current.addLayer({ id: 'polygon-fill', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.5 } });
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const setColor = (color) => {
map.current?.setPaintProperty('polygon-fill', 'fill-color', color);
};
return (
{colors.map(c => (
setColor(c)} style={{ padding: '8px 16px', background: c, color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
{c}
))}
);
};
export default ChangeLayerColor;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Customize Camera Animations
https://docs.mapatlas.xyz/sdk/examples/customize-camera-animations
---
title: "Customize Camera Animations"
category: "camera"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "flyTo", "easeTo", "jumpTo", "AnimationOptions", "CameraOptions"]
tags: ["camera", "animation", "flyTo", "easeTo", "jumpTo", "duration", "easing", "curve", "speed"]
description: "Control camera animation speed, easing curves, and behavior with flyTo and easeTo options"
---
# Customize Camera Animations
Fine-tune how the map camera moves using animation options like `duration`, `easing`, `curve`, and `speed` with `flyTo()` and `easeTo()`.
Fast Fly
Slow Fly
Ease To
Jump To
Linear Ease
## `flyTo` — Flight Animation
`flyTo` zooms out, pans, and zooms back in, simulating a flight arc.
```javascript
map.flyTo({
center: [lng, lat],
zoom: 12,
speed: 1.2, // how fast to fly (default 1.2)
curve: 1.42, // arc height (higher = more zoom-out)
duration: 3000, // ms (overrides speed if set)
essential: true, // not affected by prefers-reduced-motion
});
```
## `easeTo` — Smooth Pan/Zoom
`easeTo` smoothly transitions all camera properties simultaneously.
```javascript
map.easeTo({
center: [lng, lat],
zoom: 10,
bearing: 45,
pitch: 30,
duration: 2000,
easing: t => t * (2 - t), // ease-out quadratic
});
```
## `jumpTo` — Instant Move
`jumpTo` moves the camera immediately with no animation.
```javascript
map.jumpTo({
center: [lng, lat],
zoom: 12,
bearing: 0,
pitch: 0,
});
```
## Custom Easing Functions
The `easing` option takes a function `(t) => number` where `t` goes from 0 to 1:
```javascript
// Linear (no easing)
easing: t => t
// Ease-in (slow start)
easing: t => t * t
// Ease-out (slow end)
easing: t => t * (2 - t)
// Ease-in-out
easing: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
// Bounce
easing: t => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) { t -= 1.5 / 2.75; return 7.5625 * t * t + 0.75; }
if (t < 2.5 / 2.75) { t -= 2.25 / 2.75; return 7.5625 * t * t + 0.9375; }
t -= 2.625 / 2.75;
return 7.5625 * t * t + 0.984375;
}
```
## Animation Events
```javascript
map.on('movestart', () => console.log('camera started moving'));
map.on('move', () => console.log('camera moving'));
map.on('moveend', () => console.log('camera stopped'));
map.on('zoomend', () => console.log('zoom finished'));
```
## Complete Example
```html
Fast Fly
Slow Fly
Ease To
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const CustomizeCameraAnimations = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.35, 48.85],
zoom: 4
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const flyFast = () => map.current?.flyTo({ center: [-74, 40.7], zoom: 10, speed: 3 });
const flySlow = () => map.current?.flyTo({ center: [139.7, 35.7], zoom: 10, speed: 0.3, duration: 5000 });
const easeTo = () => map.current?.easeTo({
center: [2.35, 48.85], zoom: 10,
duration: 2000,
easing: t => t * (2 - t)
});
const jumpTo = () => map.current?.jumpTo({ center: [-43.2, -22.9], zoom: 10 });
return (
Fast Fly
Slow Fly
Ease To
Jump To
);
};
export default CustomizeCameraAnimations;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Customize the Map Transform Constrain
https://docs.mapatlas.xyz/sdk/examples/customize-map-transform-constrain
---
title: "Customize the Map Transform Constrain"
category: "camera"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "transformRequest", "transformCameraUpdate", "maxBounds", "setMaxBounds", "setMinZoom", "setMaxZoom"]
tags: ["constrain", "transform", "bounds", "zoom", "maxBounds", "transformCameraUpdate", "restrict", "limit"]
description: "Constrain the map camera to specific bounds, zoom levels, and custom transform rules"
---
# Customize the Map Transform Constrain
Control how and where the map camera can move by setting bounds, zoom limits, and using `transformCameraUpdate` to enforce custom constraints.
Lock to Europe
Unlock
Limit Zoom 3–8
Reset Zoom
## Set Max Bounds
Restrict panning so the camera cannot move outside a bounding box:
```javascript
// At initialization
const map = new mapmetricsgl.Map({
container: 'map',
style: '',
maxBounds: [[-25, 34], [45, 72]], // [SW, NE]
});
// At runtime
map.setMaxBounds([[-25, 34], [45, 72]]);
// Remove constraint
map.setMaxBounds(null);
```
## Limit Zoom Levels
```javascript
// At initialization
const map = new mapmetricsgl.Map({
minZoom: 3,
maxZoom: 18,
// ...
});
// At runtime
map.setMinZoom(3);
map.setMaxZoom(12);
// Remove limits
map.setMinZoom(null);
map.setMaxZoom(null);
```
## Limit Pitch and Bearing
```javascript
const map = new mapmetricsgl.Map({
minPitch: 0,
maxPitch: 60, // default 60
// ...
});
```
## `transformCameraUpdate` — Custom Constraints
Use `transformCameraUpdate` to intercept and modify every camera update before it is applied:
```javascript
const map = new mapmetricsgl.Map({
// ...
transformCameraUpdate: ({ center, zoom, pitch, bearing, elevation, padding }) => {
// Example: keep latitude above the equator
return {
center: {
lng: center.lng,
lat: Math.max(0, center.lat), // clamp lat to >= 0
},
zoom,
pitch,
bearing,
};
}
});
```
## Complete Example
```html
Lock to Europe
Unlock
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const europeBounds = [[-25, 34], [45, 72]];
const CustomizeMapTransformConstrain = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 50],
zoom: 4,
minZoom: 2,
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const lock = () => {
map.current?.setMaxBounds(europeBounds);
map.current?.fitBounds(europeBounds, { padding: 20 });
};
const unlock = () => map.current?.setMaxBounds(null);
return (
);
};
export default CustomizeMapTransformConstrain;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Style Lines with a Data-Driven Property
https://docs.mapatlas.xyz/sdk/examples/data-driven-lines
---
title: "Style Lines with a Data-Driven Property"
category: "lines-polygons"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "line-color", "line-width", "get expression"]
tags: ["line", "data-driven", "style", "expression", "geojson", "layer", "properties", "color", "width"]
description: "Style map lines dynamically based on feature properties using data-driven expressions"
---
# Style Lines with a Data-Driven Property
Change line color and width based on feature data properties using MapMetrics GL expressions.
Lines are styled by speed property: 🔴 fast highway · 🟡 medium road · 🟢 slow street
## How It Works
Use MapMetrics GL **expressions** in layer paint properties to read feature properties and apply styles dynamically.
## Color by Property Value
### `match` expression — exact value matching
```javascript
'line-color': [
'match', ['get', 'type'],
'highway', '#ef4444', // red for highways
'road', '#eab308', // yellow for roads
'street', '#22c55e', // green for streets
'#94a3b8' // default (gray)
]
```
### `step` expression — numeric ranges
```javascript
'line-color': [
'step', ['get', 'speed'],
'#22c55e', // default (speed < 60)
60, '#eab308', // speed >= 60
100, '#ef4444' // speed >= 100
]
```
### `interpolate` expression — smooth gradient
```javascript
'line-color': [
'interpolate', ['linear'], ['get', 'speed'],
0, '#22c55e', // green at 0 km/h
60, '#eab308', // yellow at 60 km/h
130, '#ef4444' // red at 130 km/h
]
```
## Width by Property
```javascript
'line-width': [
'interpolate', ['linear'], ['get', 'speed'],
0, 1, // thin at slow speed
130, 8 // thick at high speed
]
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const roadsData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Highway', type: 'highway', speed: 130 },
geometry: { type: 'LineString', coordinates: [[2.35, 48.85], [5.0, 47.5]] } },
{ type: 'Feature', properties: { name: 'Road', type: 'road', speed: 80 },
geometry: { type: 'LineString', coordinates: [[2.35, 48.85], [2.5, 47.0]] } },
{ type: 'Feature', properties: { name: 'Street', type: 'street', speed: 30 },
geometry: { type: 'LineString', coordinates: [[2.35, 48.85], [1.0, 49.5]] } },
]
};
const DataDrivenLines = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 5
});
map.current.on('load', () => {
map.current.addSource('roads', { type: 'geojson', data: roadsData });
map.current.addLayer({
id: 'roads-layer',
type: 'line',
source: 'roads',
paint: {
'line-color': ['step', ['get', 'speed'], '#22c55e', 60, '#eab308', 100, '#ef4444'],
'line-width': ['match', ['get', 'type'], 'highway', 5, 'road', 3, 'street', 1.5, 2]
}
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default DataDrivenLines;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Disable Map Rotation
https://docs.mapatlas.xyz/sdk/examples/disable-map-rotation
---
title: "Disable Map Rotation"
category: "controls"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "dragRotate", "touchZoomRotate", "keyboard"]
tags: ["rotation", "disable", "lock", "bearing", "dragRotate", "touchZoomRotate", "controls"]
description: "Disable map rotation from mouse drag, touch gestures, and keyboard shortcuts"
---
# Disable Map Rotation
Prevent users from rotating the map by disabling drag-rotate, touch-rotate, and keyboard rotation.
Try right-click dragging or using the compass — rotation is disabled on this map.
## How to Disable Rotation
Call these three methods after map initialization to fully disable rotation from all input sources:
```javascript
// Disable right-click drag rotation (mouse)
map.dragRotate.disable();
// Disable two-finger rotation (touch devices)
map.touchZoomRotate.disableRotation();
// Disable keyboard rotation (Alt + Arrow keys)
map.keyboard.disable();
```
## Disable at Initialization
You can also disable rotation when creating the map:
```javascript
const map = new mapmetricsgl.Map({
container: 'map',
style: '',
center: [0, 20],
zoom: 2,
dragRotate: false // disable drag rotation at init
});
// Still need to disable touch rotation separately
map.touchZoomRotate.disableRotation();
```
## Re-enable Rotation
```javascript
// Re-enable rotation later if needed
map.dragRotate.enable();
map.touchZoomRotate.enableRotation();
map.keyboard.enable();
```
## Hide the Compass
If rotation is disabled, you can also hide the compass from `NavigationControl`:
```javascript
map.addControl(new mapmetricsgl.NavigationControl({
showCompass: false // hide compass since rotation is disabled
}), 'top-right');
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const DisableRotation = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 5
});
map.current.addControl(new mapmetricsgl.NavigationControl({
showCompass: false
}), 'top-right');
// Disable all rotation
map.current.dragRotate.disable();
map.current.touchZoomRotate.disableRotation();
map.current.keyboard.disable();
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default DisableRotation;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Disable Scroll Zoom
https://docs.mapatlas.xyz/sdk/examples/disable-scroll-zoom
---
title: "Disable Scroll Zoom"
category: "controls"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "scrollZoom", "doubleClickZoom", "touchZoomRotate"]
tags: ["scroll", "zoom", "disable", "scrollZoom", "touch", "interaction", "controls"]
description: "Disable scroll wheel zoom and touch pinch zoom to prevent accidental map zooming"
---
# Disable Scroll Zoom
Disable scroll wheel zooming to prevent the map from accidentally zooming when users scroll the page.
Try scrolling over the map — scroll zoom is disabled. Use the +/− buttons or pinch to zoom.
## Disable Scroll Zoom
```javascript
// Disable scroll wheel zoom
map.scrollZoom.disable();
// Re-enable later if needed
map.scrollZoom.enable();
```
## Disable at Initialization
```javascript
const map = new mapmetricsgl.Map({
container: 'map',
style: '',
center: [0, 20],
zoom: 2,
scrollZoom: false // disable scroll zoom at init
});
```
## Disable All Zoom Interactions
```javascript
// Disable scroll wheel zoom
map.scrollZoom.disable();
// Disable double-click zoom
map.doubleClickZoom.disable();
// Disable touch pinch zoom (keeps rotation)
map.touchZoomRotate.disable();
```
## Common Use Case: Embed in a Scrollable Page
When embedding a map in a long scrollable page, scroll zoom causes a frustrating experience. The recommended pattern is to only enable scroll zoom when the user clicks/focuses the map:
```javascript
map.scrollZoom.disable();
// Enable on map focus
map.getCanvas().addEventListener('focus', () => {
map.scrollZoom.enable();
});
// Disable when leaving map
map.getCanvas().addEventListener('blur', () => {
map.scrollZoom.disable();
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const DisableScrollZoom = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 5,
scrollZoom: false
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
const canvas = map.current.getCanvas();
canvas.addEventListener('focus', () => map.current.scrollZoom.enable());
canvas.addEventListener('blur', () => map.current.scrollZoom.disable());
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default DisableScrollZoom;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Display a Popup
https://docs.mapatlas.xyz/sdk/examples/display-popup
---
title: "Display a Popup"
category: "popups"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "Popup", "setLngLat", "setHTML", "addTo", "remove"]
tags: ["popup", "tooltip", "click", "marker", "info", "overlay", "html"]
description: "Display a popup at a specific location on the map with custom HTML content"
---
# Display a Popup
Show a popup with custom HTML at a specific map location, or trigger it from a marker or click event.
Paris
London
Berlin
Close Popup
## Basic Popup
```javascript
const popup = new mapmetricsgl.Popup({ closeButton: true, closeOnClick: true })
.setLngLat([2.3499, 48.853])
.setHTML('Paris Capital of France')
.addTo(map);
// Remove programmatically
popup.remove();
```
## Popup Options
```javascript
new mapmetricsgl.Popup({
closeButton: true, // Show × button (default: true)
closeOnClick: true, // Close when map is clicked (default: true)
anchor: 'bottom', // Anchor: 'top' | 'bottom' | 'left' | 'right' | 'center'
offset: [0, -10], // Pixel offset [x, y]
maxWidth: '300px', // Max popup width
})
```
## Popup with Text vs HTML
```javascript
// Plain text (safe, auto-escaped)
popup.setText('Hello World');
// HTML content
popup.setHTML('Title Description
');
// Set coordinates
popup.setLngLat([lng, lat]);
```
## Attach Popup to Marker
```javascript
const marker = new mapmetricsgl.Marker()
.setLngLat([2.3499, 48.853])
.setPopup(
new mapmetricsgl.Popup().setHTML('Paris ')
)
.addTo(map);
// Open/close programmatically
marker.togglePopup();
```
## Popup on Feature Click
```javascript
map.on('click', 'my-layer', (e) => {
const feature = e.features[0];
new mapmetricsgl.Popup()
.setLngLat(e.lngLat)
.setHTML(`${feature.properties.name} `)
.addTo(map);
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const DisplayPopup = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const popup = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.3499, 48.853],
zoom: 5
});
map.current.on('load', () => {
// Show popup at a fixed location
popup.current = new mapmetricsgl.Popup({ closeButton: true })
.setLngLat([2.3499, 48.853])
.setHTML('Paris Capital of France')
.addTo(map.current);
// Popup on click
map.current.on('click', (e) => {
if (popup.current) popup.current.remove();
popup.current = new mapmetricsgl.Popup()
.setLngLat(e.lngLat)
.setHTML(`Lng: ${e.lngLat.lng.toFixed(4)}, Lat: ${e.lngLat.lat.toFixed(4)}`)
.addTo(map.current);
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default DisplayPopup;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Display a Remote SVG Symbol
https://docs.mapatlas.xyz/sdk/examples/display-remote-svg-symbol
---
title: "Display a Remote SVG Symbol"
category: "icons-images"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addImage", "addSource", "addLayer", "fetch", "Image", "icon-image"]
tags: ["svg", "symbol", "remote", "icon", "fetch", "image", "addImage", "url", "external"]
description: "Fetch an SVG from a URL and render it as a map icon using a canvas"
---
# Display a Remote SVG Symbol
Fetch an SVG from a remote URL, render it onto a canvas using an `Image` element, and register it with `map.addImage()` for use in symbol layers.
## Approach: SVG → Blob → Image → Canvas → addImage
SVG images cannot be passed directly to `map.addImage()`. The recommended workflow is:
1. Get the SVG string (inline or fetched)
2. Create a `Blob` and object URL from the SVG
3. Draw it onto a `` via an `Image` element
4. Extract `ImageData` and register with `map.addImage()`
```javascript
function svgToImageData(svgString, size) {
return new Promise((resolve, reject) => {
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image(size, size);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
canvas.getContext('2d').drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
resolve(canvas.getContext('2d').getImageData(0, 0, size, size));
};
img.onerror = reject;
img.src = url;
});
}
```
## Fetch SVG from a Remote URL
```javascript
async function loadRemoteSvg(url, size) {
const response = await fetch(url);
const svgText = await response.text();
return svgToImageData(svgText, size);
}
map.on('load', async () => {
const imageData = await loadRemoteSvg('https://example.com/pin.svg', 48);
map.addImage('remote-svg', imageData);
// Use in a symbol layer
map.addLayer({
id: 'svg-layer',
type: 'symbol',
source: 'my-source',
layout: {
'icon-image': 'remote-svg',
'icon-size': 1.0,
'icon-allow-overlap': true,
}
});
});
```
## Using an Inline SVG String
```javascript
const svg = `
`;
svgToImageData(svg, 48).then(imageData => {
map.addImage('dot-icon', imageData);
});
```
## CORS Note
When fetching SVGs from external servers, the server must send `Access-Control-Allow-Origin: *`. If CORS is blocked, use an inline SVG string or a data URI instead.
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
function svgToImageData(svgString, size) {
return new Promise((resolve) => {
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image(size, size);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
canvas.getContext('2d').drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
resolve(canvas.getContext('2d').getImageData(0, 0, size, size));
};
img.src = url;
});
}
const svg = `
`;
const pointsData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [-74, 40.7] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [2.35, 48.85] }, properties: {} },
{ type: 'Feature', geometry: { type: 'Point', coordinates: [139.7, 35.7] }, properties: {} },
]
};
const DisplayRemoteSvgSymbol = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 30],
zoom: 1.5
});
map.current.on('load', async () => {
const imageData = await svgToImageData(svg, 48);
map.current.addImage('pin', imageData);
map.current.addSource('pts', { type: 'geojson', data: pointsData });
map.current.addLayer({
id: 'pins',
type: 'symbol',
source: 'pts',
layout: { 'icon-image': 'pin', 'icon-size': 1, 'icon-allow-overlap': true, 'icon-anchor': 'bottom' }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default DisplayRemoteSvgSymbol;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Display the Globe
https://docs.mapatlas.xyz/sdk/examples/display-whole-world
# Display the Globe
This example demonstrates how to display a 3D globe view with an optional animated starfield background. You can toggle the stars effect on/off using the button below.
## Interactive Demo
---
## Code Implementation
Below are two implementation examples: one with the starfield effect and one without.
### Option 1: Globe with Stars Background (Full Interactive Experience)
This implementation includes:
- Animated starfield background with multiple layers
- Stars that rotate and move with globe interactions
- Parallax mouse effect for depth
- Toggle button to show/hide stars
```html
Hide Stars
```
### Option 2: Simple Globe (No Stars)
This is a basic implementation without the starfield effect - just the globe:
```html
```
---
## Key Features Explained
### Toggle Button
- **Location**: Top-right corner of the map
- **Initial State**: Stars are visible by default
- **Functionality**: Click to show/hide the starfield background
- **Button Text**: Changes between "Hide Stars" and "Show Stars"
### Stars Animation
The starfield includes:
1. **Multiple Star Layers**: Large bright stars, medium stars, and tiny distant stars
2. **Color Variety**: White, blue, yellow, orange, and other star colors for realism
3. **Twinkling Effect**: Stars gently pulse with opacity changes
4. **Interactive Rotation**: Stars rotate as you move the globe
5. **Smooth Transitions**: 0.3s fade effect when toggling visibility
### Globe Projection
- Uses `map.setProjection({ type: "globe" })` to display Earth in 3D
- Interactive rotation, pitch, and zoom
### Customization Options
**Change toggle button position:**
```css
#toggleStars {
top: 20px; /* Distance from top */
right: 20px; /* Distance from right */
}
```
**Adjust star intensity:**
```css
#stars::before {
opacity: 0.8; /* Reduce for dimmer stars */
}
```
---
## Browser Compatibility
This example works in all modern browsers that support:
- CSS animations
- CSS filter effects
- WebGL (for globe rendering)
- ES6 JavaScript
Tested on: Chrome, Firefox, Safari, Edge
---
# Create a Draggable Marker
https://docs.mapatlas.xyz/sdk/examples/draggable-marker
---
title: "Create a Draggable Marker"
category: "interaction"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "Marker", "on('dragend')", "getLngLat"]
tags: ["marker", "draggable", "drag", "dragend", "interaction", "lngLat"]
description: "Create markers that users can drag to new locations, and capture the new position"
---
# Create a Draggable Marker
Create markers that users can drag to any location on the map, and capture the updated coordinates.
🔵 Drag the blue marker to see its new coordinates
## How It Works
Pass `draggable: true` to the `Marker` constructor, then listen to `drag` and `dragend` events to get the new position.
## Basic Draggable Marker
```javascript
const marker = new mapmetricsgl.Marker({
color: '#3b82f6',
draggable: true // Enable dragging
})
.setLngLat([2.349902, 48.852966])
.addTo(map);
// Get new position when drag ends
marker.on('dragend', () => {
const { lng, lat } = marker.getLngLat();
console.log(`Marker dropped at: ${lng}, ${lat}`);
});
```
## Listen to All Drag Events
```javascript
// Fires when drag starts
marker.on('dragstart', () => {
console.log('Started dragging');
});
// Fires continuously while dragging
marker.on('drag', () => {
const pos = marker.getLngLat();
console.log(`Dragging: ${pos.lng}, ${pos.lat}`);
});
// Fires when drag ends
marker.on('dragend', () => {
const pos = marker.getLngLat();
console.log(`Dropped at: ${pos.lng}, ${pos.lat}`);
});
```
## Use Case: Pick-up / Drop-off Points
```javascript
const pickup = new mapmetricsgl.Marker({ color: '#22c55e', draggable: true })
.setLngLat([2.34, 48.85])
.addTo(map);
const dropoff = new mapmetricsgl.Marker({ color: '#ef4444', draggable: true })
.setLngLat([2.36, 48.86])
.addTo(map);
function getRoute() {
const from = pickup.getLngLat();
const to = dropoff.getLngLat();
console.log('From:', from, 'To:', to);
// Call Directions API with these coordinates
}
pickup.on('dragend', getRoute);
dropoff.on('dragend', getRoute);
```
## Complete Example
```html
🔵 Drag the marker to see coordinates
```
```jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const DraggableMarker = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const [position, setPosition] = useState(null);
const [dragging, setDragging] = useState(false);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 11
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
const marker = new mapmetricsgl.Marker({ color: '#3b82f6', draggable: true })
.setLngLat([2.349902, 48.852966])
.addTo(map.current);
marker.on('dragstart', () => setDragging(true));
marker.on('drag', () => {
const pos = marker.getLngLat();
setPosition({ lng: pos.lng.toFixed(5), lat: pos.lat.toFixed(5) });
});
marker.on('dragend', () => {
setDragging(false);
const pos = marker.getLngLat();
setPosition({ lng: pos.lng.toFixed(5), lat: pos.lat.toFixed(5) });
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return (
{position
? <>{dragging ? '✋ Dragging...' : '📍 Dropped at:'} Lng: {position.lng} | Lat: {position.lat} >
: '🔵 Drag the marker to see its coordinates'
}
);
};
export default DraggableMarker;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Create a Draggable Point
https://docs.mapatlas.xyz/sdk/examples/draggable-point
---
title: "Create a Draggable Point"
category: "user-interaction"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "setData", "mousedown", "mousemove", "mouseup"]
tags: ["drag", "draggable", "point", "geojson", "interactive", "mouse", "move", "edit"]
description: "Create a draggable GeoJSON point that the user can move by clicking and dragging"
---
# Create a Draggable Point
Allow users to drag a GeoJSON point on the map using mouse events.
Click and drag the point to move it
## How It Works
1. Add a GeoJSON `Point` source and display it as a `circle` layer
2. Listen for `mousedown` on the layer to start dragging
3. On `mousemove`, update the point position with `setData()`
4. On `mouseup`, end the drag and re-enable map panning
## Key Pattern
```javascript
let isDragging = false;
// Start drag when user clicks the point
map.on('mousedown', 'my-point', (e) => {
e.preventDefault();
isDragging = true;
map.dragPan.disable(); // prevent map from panning
map.getCanvas().style.cursor = 'grabbing';
});
// Move point while dragging
map.on('mousemove', (e) => {
if (!isDragging) return;
map.getSource('my-point').setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] },
properties: {}
});
});
// End drag
map.on('mouseup', () => {
isDragging = false;
map.dragPan.enable();
map.getCanvas().style.cursor = '';
});
```
## Complete Example
```html
Drag the point
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const DraggablePoint = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const isDragging = useRef(false);
const [coords, setCoords] = React.useState([0, 20]);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [0, 20],
zoom: 2
});
map.current.on('load', () => {
map.current.addSource('point', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 20] }, properties: {} }
});
map.current.addLayer({
id: 'point',
type: 'circle',
source: 'point',
paint: { 'circle-radius': 10, 'circle-color': '#3b82f6', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' }
});
map.current.on('mousedown', 'point', (e) => {
e.preventDefault();
isDragging.current = true;
map.current.dragPan.disable();
map.current.getCanvas().style.cursor = 'grabbing';
});
map.current.on('mousemove', (e) => {
if (!isDragging.current) return;
const newCoords = [e.lngLat.lng, e.lngLat.lat];
map.current.getSource('point').setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: newCoords },
properties: {}
});
setCoords(newCoords);
});
map.current.on('mouseup', () => {
isDragging.current = false;
map.current.dragPan.enable();
map.current.getCanvas().style.cursor = '';
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return (
Position: [{coords[0].toFixed(4)}, {coords[1].toFixed(4)}]
);
};
export default DraggablePoint;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Draw a Circle
https://docs.mapatlas.xyz/sdk/examples/draw-a-circle
---
title: "Draw a Circle"
category: "lines-polygons"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "addSource", "addLayer", "circle-radius", "circle-color", "fill"]
tags: ["circle", "polygon", "draw", "radius", "geojson", "layer", "shape", "area"]
description: "Draw circles and filled areas on the map using circle layers or polygon GeoJSON"
---
# Draw a Circle
Draw circles on the map using the `circle` layer type or as filled polygon areas.
Click the map to add a circle at that location.
## Method 1: Circle Layer (Pixel-based)
The simplest way — uses pixel radius, so the circle size stays constant regardless of zoom level:
```javascript
map.addSource('points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'Point', coordinates: [2.35, 48.85] }, properties: {} }
]
}
});
map.addLayer({
id: 'circle-layer',
type: 'circle',
source: 'points',
paint: {
'circle-radius': 20, // pixels
'circle-color': '#3b82f6',
'circle-opacity': 0.5,
'circle-stroke-width': 2,
'circle-stroke-color': '#1d4ed8'
}
});
```
## Method 2: Polygon Circle (Geographic radius)
For a circle with a real-world radius (e.g., 5km), generate polygon coordinates:
```javascript
function createGeoCircle(center, radiusKm, points = 64) {
const coords = [];
for (let i = 0; i < points; i++) {
const angle = (i / points) * 2 * Math.PI;
const dx = radiusKm / 111.32; // degrees longitude
const dy = radiusKm / 110.574; // degrees latitude
coords.push([
center[0] + dx * Math.cos(angle),
center[1] + dy * Math.sin(angle)
]);
}
coords.push(coords[0]); // close the ring
return coords;
}
map.addSource('area', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [createGeoCircle([2.35, 48.85], 50)] // 50km radius
}
}
});
map.addLayer({
id: 'area-fill',
type: 'fill',
source: 'area',
paint: {
'fill-color': '#3b82f6',
'fill-opacity': 0.2
}
});
map.addLayer({
id: 'area-outline',
type: 'line',
source: 'area',
paint: {
'line-color': '#3b82f6',
'line-width': 2
}
});
```
## Data-Driven Circle Size
```javascript
map.addLayer({
id: 'circles',
type: 'circle',
source: 'points',
paint: {
'circle-radius': ['get', 'radius'], // from feature property
'circle-color': ['get', 'color'], // from feature property
'circle-opacity': 0.6
}
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const DrawCircle = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 5
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
const data = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [2.349902, 48.852966] } },
{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-0.1276, 51.5074] } },
]
};
map.current.on('load', () => {
map.current.addSource('points', { type: 'geojson', data });
map.current.addLayer({
id: 'circles',
type: 'circle',
source: 'points',
paint: {
'circle-radius': 20,
'circle-color': '#3b82f6',
'circle-opacity': 0.5,
'circle-stroke-width': 2,
'circle-stroke-color': '#1d4ed8'
}
});
map.current.on('click', (e) => {
data.features.push({
type: 'Feature', properties: {},
geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] }
});
map.current.getSource('points').setData({ ...data });
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default DrawCircle;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Draw GeoJSON Points
https://docs.mapatlas.xyz/sdk/examples/draw-geojson-points
---
title: "Draw GeoJSON Points"
category: "geometry"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "addSource", "addLayer", "circle", "Point", "FeatureCollection"]
tags: ["points", "geojson", "circle", "dot", "marker", "layer", "data"]
description: "Draw multiple points on the map from a GeoJSON FeatureCollection using a circle layer"
---
# Draw GeoJSON Points
Render multiple points from a GeoJSON FeatureCollection using a circle layer — efficient for large datasets.
## GeoJSON Points vs Markers
| | GeoJSON + Circle Layer | Marker |
|---|---|---|
| **Performance** | Excellent (WebGL rendered) | Good (DOM elements) |
| **Best for** | Hundreds/thousands of points | Few points with rich HTML |
| **Clustering** | Built-in support | Manual |
| **Custom HTML** | No | Yes |
## Draw Points from GeoJSON
```javascript
map.addSource('points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'Paris' },
geometry: { type: 'Point', coordinates: [2.35, 48.85] }
},
{
type: 'Feature',
properties: { name: 'London' },
geometry: { type: 'Point', coordinates: [-0.12, 51.50] }
}
]
}
});
map.addLayer({
id: 'points-layer',
type: 'circle',
source: 'points',
paint: {
'circle-radius': 8,
'circle-color': '#3b82f6',
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const pointsData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Paris' }, geometry: { type: 'Point', coordinates: [2.349902, 48.852966] } },
{ type: 'Feature', properties: { name: 'London' }, geometry: { type: 'Point', coordinates: [-0.1276, 51.5074] } },
{ type: 'Feature', properties: { name: 'Berlin' }, geometry: { type: 'Point', coordinates: [13.405, 52.52] } },
]
};
const GeoJSONPoints = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [10, 50],
zoom: 3
});
map.current.on('load', () => {
map.current.addSource('points', { type: 'geojson', data: pointsData });
map.current.addLayer({
id: 'points-layer',
type: 'circle',
source: 'points',
paint: { 'circle-radius': 8, 'circle-color': '#3b82f6', 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default GeoJSONPoints;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Filter Symbols by Text Input
https://docs.mapatlas.xyz/sdk/examples/filter-by-text-input
---
title: "Filter Symbols by Text Input"
category: "filtering"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "setFilter", "addSource", "addLayer"]
tags: ["filter", "search", "text", "input", "setFilter", "layer", "dynamic"]
description: "Filter map features in real time as the user types in a search input"
---
# Filter Symbols by Text Input
Filter map features dynamically as the user types in a search box using `setFilter()`.
## Filter by Text Input
```javascript
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim().toLowerCase();
if (!query) {
map.setFilter('my-layer', null); // show all
return;
}
// Filter: feature name contains the query string
map.setFilter('my-layer', [
'in',
query,
['downcase', ['get', 'name']]
]);
});
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const citiesData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Paris' }, geometry: { type: 'Point', coordinates: [2.349902, 48.852966] } },
{ type: 'Feature', properties: { name: 'London' }, geometry: { type: 'Point', coordinates: [-0.1276, 51.5074] } },
{ type: 'Feature', properties: { name: 'Berlin' }, geometry: { type: 'Point', coordinates: [13.405, 52.52] } },
]
};
const FilterByText = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const [query, setQuery] = useState('');
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({ container: mapContainer.current, style: '', center: [10, 50], zoom: 3 });
map.current.on('load', () => {
map.current.addSource('cities', { type: 'geojson', data: citiesData });
map.current.addLayer({ id: 'cities-layer', type: 'circle', source: 'cities', paint: { 'circle-radius': 9, 'circle-color': '#3b82f6' } });
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const handleSearch = (e) => {
const q = e.target.value.toLowerCase();
setQuery(q);
if (!map.current) return;
map.current.setFilter('cities-layer', q ? ['in', q, ['downcase', ['get', 'name']]] : null);
};
return (
);
};
export default FilterByText;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Filter Features by Toggle List
https://docs.mapatlas.xyz/sdk/examples/filter-by-toggle-list
---
title: "Filter Features by Toggle List"
category: "user-interaction"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "setFilter", "filter expression", "in expression"]
tags: ["filter", "toggle", "checkbox", "category", "buttons", "setFilter", "layer", "interactive"]
description: "Filter map features by toggling category buttons that show or hide specific feature types"
---
# Filter Features by Toggle List
Toggle category buttons to show or hide specific feature types on the map using `setFilter()`.
Toggle categories:
🍕 Restaurant
☕ Cafe
🏨 Hotel
🏛️ Museum
🌿 Park
## Filter by Active Categories
Track which categories are selected, then build a filter expression:
```javascript
const activeCategories = new Set(['restaurant', 'cafe', 'hotel']);
function applyFilter() {
const active = [...activeCategories];
if (active.length === 0) {
// Hide everything
map.setFilter('places-layer', ['==', '1', '0']);
} else {
// Show features whose category is in the active list
map.setFilter('places-layer', [
'in',
['get', 'category'],
['literal', active]
]);
}
}
// Toggle a category on/off
function toggleCategory(category) {
if (activeCategories.has(category)) {
activeCategories.delete(category);
} else {
activeCategories.add(category);
}
applyFilter();
}
```
## Filter Expressions
```javascript
// Show only restaurants
map.setFilter('layer', ['==', ['get', 'category'], 'restaurant']);
// Show restaurants OR cafes
map.setFilter('layer', [
'in',
['get', 'category'],
['literal', ['restaurant', 'cafe']]
]);
// Remove filter (show all)
map.setFilter('layer', null);
// Hide all
map.setFilter('layer', ['==', '1', '0']);
```
## Complete Example
```html
Restaurant
Cafe
Hotel
```
```jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const categories = ['restaurant', 'cafe', 'hotel'];
const placesData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { category: 'restaurant' }, geometry: { type: 'Point', coordinates: [2.344, 48.856] } },
{ type: 'Feature', properties: { category: 'cafe' }, geometry: { type: 'Point', coordinates: [2.333, 48.854] } },
{ type: 'Feature', properties: { category: 'hotel' }, geometry: { type: 'Point', coordinates: [2.337, 48.860] } },
]
};
const FilterByToggleList = () => {
const mapContainer = useRef(null);
const map = useRef(null);
const [active, setActive] = useState(new Set(categories));
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.3499, 48.853],
zoom: 13
});
map.current.on('load', () => {
map.current.addSource('places', { type: 'geojson', data: placesData });
map.current.addLayer({
id: 'places-layer', type: 'circle', source: 'places',
paint: { 'circle-radius': 9, 'circle-color': '#3b82f6', 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' }
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
useEffect(() => {
if (!map.current) return;
const list = [...active];
map.current.setFilter?.('places-layer', list.length
? ['in', ['get', 'category'], ['literal', list]]
: ['==', '1', '0']
);
}, [active]);
const toggle = (cat) => {
setActive(prev => {
const next = new Set(prev);
next.has(cat) ? next.delete(cat) : next.add(cat);
return next;
});
};
return (
{categories.map(cat => (
toggle(cat)}
style={{
padding: '6px 12px',
background: active.has(cat) ? '#3b82f6' : 'white',
color: active.has(cat) ? 'white' : '#3b82f6',
border: '2px solid #3b82f6',
borderRadius: '20px',
cursor: 'pointer'
}}
>
{cat}
))}
);
};
export default FilterByToggleList;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Filter Features Within a Layer
https://docs.mapatlas.xyz/sdk/examples/filter-within-layer
---
title: "Filter Features Within a Layer"
category: "lines-polygons"
platform: ["web", "react"]
difficulty: "intermediate"
apis: ["Map", "addSource", "addLayer", "setFilter", "filter"]
tags: ["filter", "layer", "setFilter", "geojson", "expression", "query", "search", "dynamic"]
description: "Dynamically filter which features are displayed in a map layer using setFilter"
---
# Filter Features Within a Layer
Show or hide specific map features dynamically using `setFilter()` without reloading data.
All Cities
Capitals Only
Port Cities
Pop > 1M
## How It Works
`setFilter()` applies a filter expression to a layer — only features that match the expression are displayed. The data stays in the source; only the rendering changes.
## Basic Filter Patterns
```javascript
// Show all features (clear filter)
map.setFilter('my-layer', null);
// Match exact string value
map.setFilter('my-layer', ['==', ['get', 'type'], 'capital']);
// Match boolean property
map.setFilter('my-layer', ['==', ['get', 'port'], true]);
// Numeric comparison
map.setFilter('my-layer', ['>=', ['get', 'population'], 1000000]);
// Multiple conditions (AND)
map.setFilter('my-layer', [
'all',
['==', ['get', 'type'], 'capital'],
['>=', ['get', 'population'], 1000000]
]);
// Multiple conditions (OR)
map.setFilter('my-layer', [
'any',
['==', ['get', 'type'], 'capital'],
['==', ['get', 'port'], true]
]);
```
## Filter by Text Search
```javascript
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
if (!query) {
map.setFilter('cities-layer', null);
return;
}
// Filter by name containing the search text
map.setFilter('cities-layer', [
'in',
query,
['downcase', ['get', 'name']]
]);
});
```
## Complete Example
```html
All
Capitals
Cities
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const citiesData = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Paris', type: 'capital' }, geometry: { type: 'Point', coordinates: [2.349902, 48.852966] } },
{ type: 'Feature', properties: { name: 'London', type: 'capital' }, geometry: { type: 'Point', coordinates: [-0.1276, 51.5074] } },
{ type: 'Feature', properties: { name: 'Hamburg', type: 'city' }, geometry: { type: 'Point', coordinates: [9.9937, 53.5511] } },
{ type: 'Feature', properties: { name: 'Lyon', type: 'city' }, geometry: { type: 'Point', coordinates: [4.8357, 45.764] } },
]
};
const FilterLayer = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [5, 48],
zoom: 4
});
map.current.on('load', () => {
map.current.addSource('cities', { type: 'geojson', data: citiesData });
map.current.addLayer({
id: 'cities-layer',
type: 'circle',
source: 'cities',
paint: {
'circle-radius': 10,
'circle-color': ['match', ['get', 'type'], 'capital', '#ef4444', '#3b82f6'],
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const filterBy = (type) => {
if (!map.current) return;
if (type === 'all') {
map.current.setFilter('cities-layer', null);
} else {
map.current.setFilter('cities-layer', ['==', ['get', 'type'], type]);
}
};
return (
filterBy('all')} style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>All
filterBy('capital')} style={{ padding: '8px 16px', background: '#ef4444', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Capitals
filterBy('city')} style={{ padding: '8px 16px', background: '#22c55e', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Cities
);
};
export default FilterLayer;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Fit a Map to a Bounding Box
https://docs.mapatlas.xyz/sdk/examples/fit-to-bounding-box
---
title: "Fit a Map to a Bounding Box"
category: "camera"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "fitBounds", "LngLatBounds"]
tags: ["camera", "bounds", "fitBounds", "bounding-box", "zoom"]
description: "Automatically fit the map view to show all markers or a region using fitBounds"
---
# Fit a Map to a Bounding Box
Automatically adjust the map camera to fit any set of coordinates or a region into view using `fitBounds`.
Fit Europe
Fit USA
Fit Paris
## How It Works
Use `map.fitBounds()` to fit the map view to any rectangular area defined by two corners `[southwest, northeast]`.
## Basic Usage
```javascript
// Fit to a bounding box [southwest corner, northeast corner]
map.fitBounds([
[-74.1, 40.6], // Southwest corner [lng, lat]
[-73.9, 40.9] // Northeast corner [lng, lat]
], {
padding: 40 // Padding in pixels around the bounds
});
```
## Fit to a Set of Markers
```javascript
// Calculate bounds from a set of points
const bounds = new mapmetricsgl.LngLatBounds();
const points = [
[-74.006, 40.7128], // New York
[-0.1276, 51.5074], // London
[2.349902, 48.852966] // Paris
];
points.forEach(point => bounds.extend(point));
map.fitBounds(bounds, { padding: 60 });
```
## Options
| Option | Type | Description |
|--------|------|-------------|
| `padding` | `number` or `object` | Padding in pixels. Can be `{ top, bottom, left, right }` |
| `maxZoom` | `number` | Maximum zoom level to use |
| `animate` | `boolean` | Whether to animate (default: `true`) |
| `duration` | `number` | Animation duration in milliseconds |
| `bearing` | `number` | Map bearing after fitting |
| `pitch` | `number` | Map pitch after fitting |
## Complete Example
```html
Fit to All Markers
Fit Europe
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const locations = [
{ center: [-74.006, 40.7128], label: 'New York' },
{ center: [-0.1276, 51.5074], label: 'London' },
{ center: [2.349902, 48.852966], label: 'Paris' },
];
const FitToBounds = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [2.349902, 48.852966],
zoom: 3
});
map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
// Add markers for each location
locations.forEach(loc => {
new mapmetricsgl.Marker()
.setLngLat(loc.center)
.setPopup(new mapmetricsgl.Popup().setHTML(`${loc.label} `))
.addTo(map.current);
});
return () => { map.current?.remove(); map.current = null; };
}, []);
const fitToMarkers = () => {
const bounds = new mapmetricsgl.LngLatBounds();
locations.forEach(loc => bounds.extend(loc.center));
map.current?.fitBounds(bounds, { padding: 60 });
};
const fitEurope = () => {
map.current?.fitBounds([[-10, 35], [30, 70]], { padding: 40 });
};
return (
Fit to All Markers
Fit Europe
);
};
export default FitToBounds;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# Fit Map to a LineString
https://docs.mapatlas.xyz/sdk/examples/fit-to-linestring
---
title: "Fit Map to a LineString"
category: "camera"
platform: ["web", "react"]
difficulty: "beginner"
apis: ["Map", "fitBounds", "addSource", "addLayer", "LngLatBounds"]
tags: ["fitBounds", "linestring", "bounds", "zoom", "camera", "route", "auto-fit", "padding"]
description: "Automatically fit the map view to the bounds of a LineString or any set of coordinates"
---
# Fit Map to a LineString
Automatically zoom and pan to fit a LineString or route using `fitBounds()`.
Europe Route
World Route
Local Route
## Fit to a Set of Coordinates
Use `LngLatBounds` to compute the bounding box, then call `fitBounds()`:
```javascript
const coordinates = [
[2.3499, 48.853], // Paris
[-0.1276, 51.5074], // London
[13.405, 52.52], // Berlin
];
// Build bounds from all coordinates
const bounds = coordinates.reduce(
(bounds, coord) => bounds.extend(coord),
new mapmetricsgl.LngLatBounds(coordinates[0], coordinates[0])
);
// Fit map to bounds
map.fitBounds(bounds, {
padding: 60, // pixels of padding on each side
duration: 1000, // animation duration in ms
});
```
## fitBounds Options
```javascript
map.fitBounds(bounds, {
padding: { top: 50, bottom: 50, left: 50, right: 50 }, // or just a number
maxZoom: 15, // don't zoom in more than this
duration: 1000, // animation ms (0 = instant)
bearing: 0, // target bearing
pitch: 0, // target pitch
});
```
## From a GeoJSON Feature
```javascript
// Given a LineString feature
const feature = {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [[lng1, lat1], [lng2, lat2], ...]
}
};
const bounds = feature.geometry.coordinates.reduce(
(b, c) => b.extend(c),
new mapmetricsgl.LngLatBounds(
feature.geometry.coordinates[0],
feature.geometry.coordinates[0]
)
);
map.fitBounds(bounds, { padding: 60 });
```
## Complete Example
```html
```
```jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';
const route = [
[2.3499, 48.853],
[-0.1276, 51.5074],
[13.405, 52.52],
[16.3738, 48.2082],
[12.4964, 41.9028],
];
const FitToLineString = () => {
const mapContainer = useRef(null);
const map = useRef(null);
useEffect(() => {
if (map.current) return;
map.current = new mapmetricsgl.Map({
container: mapContainer.current,
style: '',
center: [5, 50],
zoom: 2
});
map.current.on('load', () => {
map.current.addSource('route', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: route } }
});
map.current.addLayer({
id: 'route',
type: 'line',
source: 'route',
paint: { 'line-color': '#3b82f6', 'line-width': 3 }
});
// Fit the map to the route
const bounds = route.reduce(
(b, c) => b.extend(c),
new mapmetricsgl.LngLatBounds(route[0], route[0])
);
map.current.fitBounds(bounds, { padding: 60 });
});
return () => { map.current?.remove(); map.current = null; };
}, []);
return
;
};
export default FitToLineString;
```
---
For more information, visit the [MapMetrics GitHub repository](https://github.com/MapMetrics/mapmetrics-gl).
---
# 3D Buildings with Shadow in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-3d-buildings-with-shadow
# 3D Buildings with Shadow in Flutter
This tutorial shows how to display 3D extruded buildings with realistic shadow effects based on a simulated light source — adding depth and realism to your map.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## 3D Buildings with Light and Shadow
Add extruded buildings with shadow color and adjustable light position:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BuildingsWithShadowScreen extends StatefulWidget {
@override
_BuildingsWithShadowScreenState createState() =>
_BuildingsWithShadowScreenState();
}
class _BuildingsWithShadowScreenState
extends State {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('3D Buildings with Shadow')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8606, 2.3376), // Louvre area
zoom: 16.5,
tilt: 60.0,
bearing: -30.0,
),
onStyleLoaded: () {
_addBuildingsWithShadow();
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton.small(
heroTag: 'morning',
onPressed: () => _setLightAngle(90.0, 30.0),
child: Icon(Icons.wb_twilight),
tooltip: 'Morning Light',
),
SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'noon',
onPressed: () => _setLightAngle(180.0, 80.0),
child: Icon(Icons.wb_sunny),
tooltip: 'Noon Light',
),
SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'evening',
onPressed: () => _setLightAngle(270.0, 20.0),
child: Icon(Icons.nights_stay),
tooltip: 'Evening Light',
),
],
),
);
}
void _addBuildingsWithShadow() {
// Set the global light source for shadow casting
mapController?.setLight(
anchor: 'viewport',
color: '#ffffff',
intensity: 0.4,
position: [1.5, 180.0, 40.0], // [radial, azimuthal, polar]
);
// Add 3D buildings with shadow-aware colors
mapController?.addFillExtrusionLayer(
'3d-buildings-shadow',
'composite',
sourceLayer: 'building',
fillExtrusionColor: '#b0b0b0',
fillExtrusionOpacity: 0.85,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
fillExtrusionVerticalGradient: true, // Darker at base, lighter at top
minZoom: 14.0,
);
}
void _setLightAngle(double azimuthal, double polar) {
mapController?.setLight(
anchor: 'viewport',
color: '#ffffff',
intensity: 0.4,
position: [1.5, azimuthal, polar],
);
}
}
```
## Time-of-Day Shadow Simulation
Simulate how building shadows change throughout the day:
```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ShadowTimeScreen extends StatefulWidget {
@override
_ShadowTimeScreenState createState() => _ShadowTimeScreenState();
}
class _ShadowTimeScreenState extends State {
MapMetricsController? mapController;
double timeOfDay = 12.0; // 0-24 hours
Timer? animTimer;
bool isAnimating = false;
// Map hour to light settings
Map _lightForHour(double hour) {
// Azimuthal: sun moves east (90°) → south (180°) → west (270°)
final azimuthal = 90.0 + (hour - 6) * 15.0; // 6AM=90°, noon=180°, 6PM=270°
// Polar: low at sunrise/sunset, high at noon
final noonDist = (hour - 12).abs();
final polar = 80.0 - noonDist * 8.0; // 80° at noon, ~32° at 6AM/6PM
// Intensity: brighter midday, dimmer morning/evening
final intensity = 0.2 + (1.0 - noonDist / 6.0).clamp(0.0, 1.0) * 0.3;
// Light color: warm at sunrise/sunset, white at noon
String color;
if (hour < 7 || hour > 19) {
color = '#FF8C00'; // Deep orange
} else if (hour < 9 || hour > 17) {
color = '#FFB74D'; // Warm orange
} else {
color = '#FFFFFF'; // White
}
// Building color: warmer tones at golden hour
String buildingColor;
if (hour < 7 || hour > 19) {
buildingColor = '#8B6914'; // Dark warm
} else if (hour < 9 || hour > 17) {
buildingColor = '#C0A060'; // Warm
} else {
buildingColor = '#b0b0b0'; // Neutral grey
}
return {
'azimuthal': azimuthal.clamp(45.0, 315.0),
'polar': polar.clamp(10.0, 80.0),
'intensity': intensity,
'color': color,
'buildingColor': buildingColor,
};
}
String _hourLabel(double hour) {
final h = hour.toInt();
final m = ((hour - h) * 60).toInt();
final period = h >= 12 ? 'PM' : 'AM';
final displayH = h > 12 ? h - 12 : (h == 0 ? 12 : h);
return '${displayH}:${m.toString().padLeft(2, '0')} $period';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Shadow Time Simulation')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8606, 2.3376),
zoom: 16.5,
tilt: 55.0,
bearing: -20.0,
),
onStyleLoaded: () {
_setupBuildings();
_updateLight();
},
),
// Time controls
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_hourLabel(timeOfDay),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed: isAnimating
? _stopAnimation
: _startAnimation,
icon: Icon(isAnimating
? Icons.pause
: Icons.play_arrow),
label:
Text(isAnimating ? 'Pause' : 'Animate'),
),
],
),
Slider(
value: timeOfDay,
min: 5.0,
max: 21.0,
divisions: 64,
label: _hourLabel(timeOfDay),
onChanged: (val) {
setState(() => timeOfDay = val);
_updateLight();
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('5 AM', style: TextStyle(fontSize: 11, color: Colors.grey)),
Text('Noon', style: TextStyle(fontSize: 11, color: Colors.grey)),
Text('9 PM', style: TextStyle(fontSize: 11, color: Colors.grey)),
],
),
],
),
),
),
),
],
),
);
}
void _setupBuildings() {
final settings = _lightForHour(timeOfDay);
mapController?.setLight(
anchor: 'viewport',
color: settings['color'],
intensity: settings['intensity'],
position: [1.5, settings['azimuthal'], settings['polar']],
);
mapController?.addFillExtrusionLayer(
'3d-buildings',
'composite',
sourceLayer: 'building',
fillExtrusionColor: settings['buildingColor'],
fillExtrusionOpacity: 0.85,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
fillExtrusionVerticalGradient: true,
minZoom: 14.0,
);
}
void _updateLight() {
final settings = _lightForHour(timeOfDay);
mapController?.setLight(
anchor: 'viewport',
color: settings['color'],
intensity: settings['intensity'],
position: [1.5, settings['azimuthal'], settings['polar']],
);
mapController?.setPaintProperty(
'3d-buildings',
'fill-extrusion-color',
settings['buildingColor'],
);
}
void _startAnimation() {
setState(() {
isAnimating = true;
if (timeOfDay >= 21.0) timeOfDay = 5.0;
});
animTimer = Timer.periodic(Duration(milliseconds: 80), (_) {
if (timeOfDay >= 21.0) {
_stopAnimation();
return;
}
setState(() {
timeOfDay += 0.05;
});
_updateLight();
});
}
void _stopAnimation() {
animTimer?.cancel();
setState(() => isAnimating = false);
}
@override
void dispose() {
animTimer?.cancel();
super.dispose();
}
}
```
## Light Properties
| Property | Type | Description |
|----------|------|-------------|
| `anchor` | `String` | `viewport` (relative to camera) or `map` (fixed direction) |
| `color` | `String` | Light color hex string |
| `intensity` | `double` | Brightness (0.0 - 1.0) |
| `position` | `List` | `[radial, azimuthal, polar]` — distance, compass angle, elevation |
| `fillExtrusionVerticalGradient` | `bool` | Darker at base, lighter at top |
## Next Steps
- [3D Buildings](./flutter-3d-buildings) — Basic 3D building setup
- [Building Color by Zoom](./flutter-building-color-zoom) — Zoom-dependent colors
- [Sky, Fog & Terrain](./flutter-sky-fog-terrain) — Atmospheric effects
---
**Tip**: Use `anchor: 'viewport'` so shadows rotate with the camera (feels natural), or `anchor: 'map'` so shadows stay fixed to compass direction (geographically accurate). The time-of-day animation makes a great demo for real estate or urban planning apps.
---
# 3D Building Visualization in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-3d-buildings
# 3D Building Visualization in Flutter
This tutorial shows how to display 3D extruded buildings on your MapMetrics Flutter map — great for urban planning, real estate, and city exploration apps.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Enable 3D Buildings
Display 3D buildings by tilting the camera and adding a fill-extrusion layer:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class Buildings3DScreen extends StatefulWidget {
@override
_Buildings3DScreenState createState() => _Buildings3DScreenState();
}
class _Buildings3DScreenState extends State {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('3D Buildings')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8584, 2.2945), // Eiffel Tower area
zoom: 16.0,
tilt: 60.0, // Tilt to see buildings in 3D
bearing: 45.0, // Rotate for a better perspective
),
onStyleLoaded: () {
_add3DBuildings();
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: 'tilt_up',
onPressed: () => _setTilt(60.0),
child: Icon(Icons.landscape),
tooltip: '3D View',
),
SizedBox(height: 8),
FloatingActionButton(
heroTag: 'tilt_down',
onPressed: () => _setTilt(0.0),
child: Icon(Icons.map),
tooltip: '2D View',
),
],
),
);
}
void _add3DBuildings() {
// Add a 3D fill-extrusion layer for buildings
mapController?.addFillExtrusionLayer(
'3d-buildings',
'composite', // source (depends on your style)
sourceLayer: 'building',
fillExtrusionColor: '#aaaaaa',
fillExtrusionOpacity: 0.6,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
minZoom: 14.0,
);
}
void _setTilt(double tilt) async {
final position = await mapController?.getCameraPosition();
if (position != null) {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: position.target,
zoom: position.zoom,
tilt: tilt,
bearing: position.bearing,
),
),
);
}
}
}
```
## 3D Buildings with Custom Colors
Color buildings based on their height for a dramatic visualization:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ColoredBuildings3DScreen extends StatefulWidget {
@override
_ColoredBuildings3DScreenState createState() =>
_ColoredBuildings3DScreenState();
}
class _ColoredBuildings3DScreenState extends State {
MapMetricsController? mapController;
String colorScheme = 'height'; // 'height', 'blue', 'warm'
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Colored 3D Buildings')),
body: Column(
children: [
// Color scheme selector
Container(
padding: EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_schemeChip('Height', 'height'),
_schemeChip('Cool Blue', 'blue'),
_schemeChip('Warm Sunset', 'warm'),
],
),
),
Expanded(
child: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8606, 2.3376), // Louvre area
zoom: 16.0,
tilt: 55.0,
bearing: -20.0,
),
onStyleLoaded: () {
_applyColorScheme();
},
),
),
],
),
);
}
Widget _schemeChip(String label, String scheme) {
return ChoiceChip(
label: Text(label),
selected: colorScheme == scheme,
onSelected: (selected) {
if (selected) {
setState(() => colorScheme = scheme);
_applyColorScheme();
}
},
);
}
void _applyColorScheme() {
String color;
switch (colorScheme) {
case 'blue':
color = '#4a90d9';
break;
case 'warm':
color = '#e8775a';
break;
default: // height-based
color = '#8a8a8a';
}
// Remove existing layer if present
mapController?.removeLayer('3d-buildings');
mapController?.addFillExtrusionLayer(
'3d-buildings',
'composite',
sourceLayer: 'building',
fillExtrusionColor: color,
fillExtrusionOpacity: 0.7,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
minZoom: 14.0,
);
}
}
```
## Interactive 3D Building Explorer
Tap on buildings to see their details, rotate around them:
```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BuildingExplorerScreen extends StatefulWidget {
@override
_BuildingExplorerScreenState createState() =>
_BuildingExplorerScreenState();
}
class _BuildingExplorerScreenState extends State {
MapMetricsController? mapController;
Timer? rotationTimer;
double currentBearing = 0.0;
bool isRotating = false;
final List> landmarks = [
{
'name': 'Eiffel Tower Area',
'lat': 48.8584,
'lng': 2.2945,
'zoom': 17.0,
'tilt': 60.0,
},
{
'name': 'Louvre Area',
'lat': 48.8606,
'lng': 2.3376,
'zoom': 16.5,
'tilt': 55.0,
},
{
'name': 'Notre-Dame Area',
'lat': 48.8530,
'lng': 2.3499,
'zoom': 17.0,
'tilt': 65.0,
},
{
'name': 'Opera Area',
'lat': 48.8720,
'lng': 2.3316,
'zoom': 16.5,
'tilt': 55.0,
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('3D Explorer')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8584, 2.2945),
zoom: 17.0,
tilt: 60.0,
bearing: 0.0,
),
onStyleLoaded: () {
mapController?.addFillExtrusionLayer(
'3d-buildings',
'composite',
sourceLayer: 'building',
fillExtrusionColor: '#667799',
fillExtrusionOpacity: 0.7,
fillExtrusionHeight: ['get', 'height'],
fillExtrusionBase: ['get', 'min_height'],
minZoom: 14.0,
);
},
),
// Landmark buttons
Positioned(
bottom: 24,
left: 8,
right: 8,
child: SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: landmarks.length,
separatorBuilder: (_, __) => SizedBox(width: 8),
itemBuilder: (context, i) {
final lm = landmarks[i];
return ElevatedButton(
onPressed: () {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: LatLng(lm['lat'], lm['lng']),
zoom: lm['zoom'],
tilt: lm['tilt'],
bearing: currentBearing,
),
),
);
},
child: Text(lm['name'], style: TextStyle(fontSize: 12)),
);
},
),
),
),
// Rotate toggle
Positioned(
top: 16,
right: 16,
child: FloatingActionButton.small(
onPressed: _toggleRotation,
child: Icon(isRotating ? Icons.pause : Icons.rotate_right),
tooltip: 'Auto Rotate',
),
),
],
),
);
}
void _toggleRotation() {
if (isRotating) {
rotationTimer?.cancel();
setState(() => isRotating = false);
} else {
setState(() => isRotating = true);
rotationTimer = Timer.periodic(Duration(milliseconds: 50), (_) async {
currentBearing = (currentBearing + 0.5) % 360;
final pos = await mapController?.getCameraPosition();
if (pos != null) {
mapController?.moveCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: pos.target,
zoom: pos.zoom,
tilt: pos.tilt,
bearing: currentBearing,
),
),
);
}
});
}
}
@override
void dispose() {
rotationTimer?.cancel();
super.dispose();
}
}
```
## 3D Building Properties
| Property | Description |
|----------|-------------|
| `fillExtrusionColor` | Building wall/roof color |
| `fillExtrusionOpacity` | Transparency (0.0 - 1.0) |
| `fillExtrusionHeight` | Building height in meters |
| `fillExtrusionBase` | Base height (for elevated structures) |
| `minZoom` | Minimum zoom level to show buildings |
## Next Steps
- [3D Terrain](./flutter-3d-terrain) — Enable terrain elevation
- [Set Pitch and Bearing](./flutter-set-pitch-and-bearing) — Control 3D camera angles
- [Animate Camera Around Point](./flutter-animate-camera-around-point) — Orbit around buildings
---
**Tip**: 3D buildings look best at zoom levels 15-18 with a tilt of 45-65 degrees. Combine with a bearing rotation for dramatic fly-through effects.
---
# 3D Terrain in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-3d-terrain
# 3D Terrain in Flutter
This tutorial shows how to enable 3D terrain elevation on your MapMetrics Flutter map — hills, mountains, and valleys rendered in 3D for immersive map experiences.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Enable 3D Terrain
Activate terrain elevation with a tilted camera to see mountains in 3D:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class Terrain3DScreen extends StatefulWidget {
@override
_Terrain3DScreenState createState() => _Terrain3DScreenState();
}
class _Terrain3DScreenState extends State {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('3D Terrain')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(46.8182, 8.2275), // Swiss Alps
zoom: 10.0,
tilt: 60.0,
bearing: 30.0,
),
onStyleLoaded: () {
_enableTerrain();
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
heroTag: '3d',
onPressed: () => _setView(60.0),
child: Icon(Icons.terrain),
tooltip: '3D View',
),
SizedBox(height: 8),
FloatingActionButton(
heroTag: '2d',
onPressed: () => _setView(0.0),
child: Icon(Icons.map),
tooltip: '2D View',
),
],
),
);
}
void _enableTerrain() {
// Add terrain source
mapController?.addRasterDemSource(
'terrain-source',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
// Enable terrain with exaggeration
mapController?.setTerrain('terrain-source', exaggeration: 1.5);
}
void _setView(double tilt) async {
final pos = await mapController?.getCameraPosition();
if (pos != null) {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: pos.target,
zoom: pos.zoom,
tilt: tilt,
bearing: pos.bearing,
),
),
);
}
}
}
```
## Mountain Explorer with Location Buttons
Jump between famous mountain locations:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MountainExplorerScreen extends StatefulWidget {
@override
_MountainExplorerScreenState createState() =>
_MountainExplorerScreenState();
}
class _MountainExplorerScreenState extends State {
MapMetricsController? mapController;
final List> mountains = [
{
'name': 'Swiss Alps',
'lat': 46.8182,
'lng': 8.2275,
'zoom': 10.0,
'bearing': 30.0,
},
{
'name': 'Mont Blanc',
'lat': 45.8326,
'lng': 6.8652,
'zoom': 12.0,
'bearing': 150.0,
},
{
'name': 'Matterhorn',
'lat': 45.9763,
'lng': 7.6586,
'zoom': 13.0,
'bearing': 220.0,
},
{
'name': 'Dolomites',
'lat': 46.4102,
'lng': 11.8440,
'zoom': 11.0,
'bearing': 90.0,
},
{
'name': 'Pyrenees',
'lat': 42.6953,
'lng': 0.0414,
'zoom': 10.0,
'bearing': 45.0,
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Mountain Explorer')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(46.8182, 8.2275),
zoom: 10.0,
tilt: 60.0,
bearing: 30.0,
),
onStyleLoaded: () {
mapController?.addRasterDemSource(
'terrain-source',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
mapController?.setTerrain('terrain-source', exaggeration: 1.5);
},
),
// Mountain selector
Positioned(
bottom: 16,
left: 8,
right: 8,
child: SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: mountains.length,
separatorBuilder: (_, __) => SizedBox(width: 8),
itemBuilder: (context, i) {
final m = mountains[i];
return ElevatedButton.icon(
onPressed: () {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: LatLng(m['lat'], m['lng']),
zoom: m['zoom'],
tilt: 60.0,
bearing: m['bearing'],
),
),
);
},
icon: Icon(Icons.terrain, size: 16),
label: Text(m['name'], style: TextStyle(fontSize: 12)),
);
},
),
),
),
],
),
);
}
}
```
## Terrain with Exaggeration Slider
Let users control how dramatically terrain is displayed:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class TerrainSliderScreen extends StatefulWidget {
@override
_TerrainSliderScreenState createState() => _TerrainSliderScreenState();
}
class _TerrainSliderScreenState extends State {
MapMetricsController? mapController;
double exaggeration = 1.5;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Terrain Exaggeration')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(46.8182, 8.2275),
zoom: 10.0,
tilt: 60.0,
),
onStyleLoaded: () {
mapController?.addRasterDemSource(
'terrain-source',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
mapController?.setTerrain(
'terrain-source', exaggeration: exaggeration);
},
),
// Exaggeration slider
Positioned(
bottom: 24,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Exaggeration: ${exaggeration.toStringAsFixed(1)}x',
style: TextStyle(fontWeight: FontWeight.bold),
),
Slider(
value: exaggeration,
min: 0.0,
max: 3.0,
divisions: 30,
label: '${exaggeration.toStringAsFixed(1)}x',
onChanged: (value) {
setState(() {
exaggeration = value;
});
mapController?.setTerrain(
'terrain-source',
exaggeration: value,
);
},
),
Text(
'0x = flat | 1x = real | 3x = dramatic',
style: TextStyle(color: Colors.grey, fontSize: 11),
),
],
),
),
),
),
],
),
);
}
}
```
## Terrain Parameters
| Parameter | Range | Description |
|-----------|-------|-------------|
| `exaggeration` | 0.0 - 3.0 | Multiplier for terrain height. 1.0 = real scale |
| `tileSize` | 256 / 512 | Resolution of terrain tiles |
| `tilt` | 0 - 85 | Camera tilt angle for 3D effect |
## Next Steps
- [3D Buildings](./flutter-3d-buildings) — Add extruded buildings
- [Satellite Terrain](./flutter-satellite-terrain) — Satellite imagery with elevation
- [Set Pitch and Bearing](./flutter-set-pitch-and-bearing) — Camera angle controls
---
**Tip**: Set exaggeration to 1.5 for a good balance between realism and visibility. Values above 2.0 create a dramatic effect but can distort distances at close zoom levels.
---
# Add Clusters in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-a-cluster
# Add Clusters in Flutter
This tutorial shows how to group nearby markers into clusters for better performance and readability when you have many data points on the map.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Clustering
When you have many markers close together, clustering groups them into a single icon showing the count. As the user zooms in, clusters break apart into individual markers:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
import 'dart:math';
class ClusterExampleScreen extends StatefulWidget {
@override
_ClusterExampleScreenState createState() => _ClusterExampleScreenState();
}
class _ClusterExampleScreenState extends State {
MapMetricsController? mapController;
Set markers = {};
double currentZoom = 12.0;
// Sample data: 100 random points around Paris
late final List allPoints;
@override
void initState() {
super.initState();
final random = Random(42);
allPoints = List.generate(100, (_) {
return LatLng(
48.82 + random.nextDouble() * 0.08, // Lat range around Paris
2.28 + random.nextDouble() * 0.14, // Lng range around Paris
);
});
_updateMarkers();
}
void _updateMarkers() {
// Simple clustering: group nearby points based on zoom level
final double gridSize = 0.01 * pow(2, 15 - currentZoom).toDouble();
final Map> grid = {};
for (final point in allPoints) {
final key =
'${(point.latitude / gridSize).floor()}_${(point.longitude / gridSize).floor()}';
grid.putIfAbsent(key, () => []).add(point);
}
final Set newMarkers = {};
for (final entry in grid.entries) {
final points = entry.value;
// Calculate center of the group
final avgLat = points.map((p) => p.latitude).reduce((a, b) => a + b) / points.length;
final avgLng = points.map((p) => p.longitude).reduce((a, b) => a + b) / points.length;
final center = LatLng(avgLat, avgLng);
if (points.length == 1) {
// Single point — show as regular marker
newMarkers.add(
Marker(
markerId: MarkerId(entry.key),
position: center,
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
infoWindow: InfoWindow(
title: 'Point',
snippet:
'${center.latitude.toStringAsFixed(4)}, ${center.longitude.toStringAsFixed(4)}',
),
),
);
} else {
// Cluster — show count
newMarkers.add(
Marker(
markerId: MarkerId('cluster_${entry.key}'),
position: center,
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue),
infoWindow: InfoWindow(
title: '${points.length} points',
snippet: 'Zoom in to see details',
),
),
);
}
}
setState(() {
markers = newMarkers;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Clustered Markers')),
body: Column(
children: [
Container(
padding: EdgeInsets.all(10),
color: Colors.grey[100],
child: Text(
'${allPoints.length} total points | ${markers.length} visible markers | Zoom: ${currentZoom.toStringAsFixed(1)}',
style: TextStyle(fontSize: 13),
),
),
Expanded(
child: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapController = controller,
onCameraIdle: () async {
final position = await mapController?.getCameraPosition();
if (position != null && position.zoom != currentZoom) {
currentZoom = position.zoom;
_updateMarkers();
}
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 12.0,
),
markers: markers,
),
),
],
),
);
}
}
```
## Cluster with Custom Widget Icons
Create visually distinct cluster icons that show the count and change color based on size:
```dart
Future _createClusterIcon(int count) async {
Color color;
double size;
if (count < 10) {
color = Colors.blue;
size = 40;
} else if (count < 50) {
color = Colors.orange;
size = 50;
} else {
color = Colors.red;
size = 60;
}
return await BitmapDescriptor.fromWidget(
Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
),
alignment: Alignment.center,
child: Text(
'$count',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: size * 0.35,
),
),
),
);
}
```
## Tap Cluster to Zoom In
Zoom into a cluster when tapped:
```dart
Marker(
markerId: MarkerId('cluster_${entry.key}'),
position: center,
icon: clusterIcon,
onTap: () {
// Zoom in to break apart the cluster
mapController?.animateCamera(
CameraUpdate.newLatLngZoom(center, currentZoom + 2),
);
},
)
```
## Cluster Size Colors
| Count | Color | Size |
|-------|-------|------|
| 1–9 | Blue | Small (40px) |
| 10–49 | Orange | Medium (50px) |
| 50+ | Red | Large (60px) |
## Next Steps
- [Add a Heatmap](./flutter-add-a-heatmap) — Density visualization alternative to clusters
- [Markers and Annotations](./flutter-markers) — Basic marker features
- [Fit to Bounding Box](./flutter-fit-to-bounding-box) — Zoom to show all clusters
---
**Tip**: Recalculate clusters on `onCameraIdle` (not `onCameraMove`) to avoid excessive recomputation during panning.
---
# Add a Heatmap in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-a-heatmap
# Add a Heatmap in Flutter
This tutorial shows how to create a heatmap overlay to visualize data density on your MapMetrics Flutter map.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Heatmap with Weighted Circles
Flutter doesn't have a built-in heatmap layer, but you can simulate one effectively using semi-transparent, overlapping circles:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
import 'dart:math';
class HeatmapExampleScreen extends StatefulWidget {
@override
_HeatmapExampleScreenState createState() => _HeatmapExampleScreenState();
}
class _HeatmapExampleScreenState extends State {
MapMetricsController? mapController;
// Sample data: locations with intensity (weight)
final List> heatData = [
{'position': LatLng(48.8584, 2.2945), 'weight': 0.9}, // Eiffel Tower
{'position': LatLng(48.8606, 2.3376), 'weight': 0.85}, // Louvre
{'position': LatLng(48.8530, 2.3499), 'weight': 0.8}, // Notre-Dame
{'position': LatLng(48.8867, 2.3431), 'weight': 0.7}, // Sacré-Cœur
{'position': LatLng(48.8738, 2.2950), 'weight': 0.75}, // Arc de Triomphe
{'position': LatLng(48.8620, 2.3520), 'weight': 0.5},
{'position': LatLng(48.8450, 2.3400), 'weight': 0.4},
{'position': LatLng(48.8700, 2.3100), 'weight': 0.6},
{'position': LatLng(48.8550, 2.3700), 'weight': 0.3},
{'position': LatLng(48.8480, 2.3200), 'weight': 0.35},
{'position': LatLng(48.8650, 2.3300), 'weight': 0.55},
{'position': LatLng(48.8580, 2.3050), 'weight': 0.45},
{'position': LatLng(48.8750, 2.3500), 'weight': 0.5},
{'position': LatLng(48.8500, 2.2800), 'weight': 0.25},
{'position': LatLng(48.8800, 2.3200), 'weight': 0.4},
];
Color _getHeatColor(double weight) {
// Gradient: blue (cold) → green → yellow → red (hot)
if (weight > 0.75) return Colors.red.withOpacity(0.4);
if (weight > 0.5) return Colors.orange.withOpacity(0.35);
if (weight > 0.25) return Colors.yellow.withOpacity(0.3);
return Colors.green.withOpacity(0.25);
}
Set get heatCircles => heatData.asMap().entries.map((entry) {
final data = entry.value;
final double weight = data['weight'];
return Circle(
circleId: CircleId('heat_${entry.key}'),
center: data['position'],
radius: 300 + (weight * 500), // 300–800m radius based on weight
strokeWidth: 0,
fillColor: _getHeatColor(weight),
);
}).toSet();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Heatmap')),
body: Stack(
children: [
MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapController = controller,
initialCameraPosition: CameraPosition(
target: LatLng(48.8600, 2.3300),
zoom: 13.0,
),
circles: heatCircles,
),
// Legend
Positioned(
bottom: 24,
right: 16,
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Intensity',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
SizedBox(height: 8),
_legendRow(Colors.red, 'High'),
_legendRow(Colors.orange, 'Medium-High'),
_legendRow(Colors.yellow, 'Medium-Low'),
_legendRow(Colors.green, 'Low'),
],
),
),
),
],
),
);
}
Widget _legendRow(Color color, String label) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 14, height: 14,
decoration: BoxDecoration(
color: color.withOpacity(0.5),
shape: BoxShape.circle,
),
),
SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12)),
],
),
);
}
}
```
## Generate Heatmap from Random Data
Create a realistic heatmap from a large dataset:
```dart
List> _generateHeatData(LatLng center, int count) {
final random = Random(42);
return List.generate(count, (_) {
// Cluster points near the center with normal-like distribution
final lat = center.latitude + (random.nextDouble() - 0.5) * 0.05;
final lng = center.longitude + (random.nextDouble() - 0.5) * 0.08;
// Weight higher for points closer to center
final dist = sqrt(pow(lat - center.latitude, 2) + pow(lng - center.longitude, 2));
final weight = max(0.1, 1.0 - dist * 30);
return {
'position': LatLng(lat, lng),
'weight': weight,
};
});
}
```
## Toggle Heatmap and Points
Switch between heatmap view and individual point markers:
```dart
bool showHeatmap = true;
// In your build method:
MapMetrics(
// ... config
circles: showHeatmap ? heatCircles : {},
markers: showHeatmap ? {} : pointMarkers,
)
// Toggle button:
FloatingActionButton(
onPressed: () => setState(() => showHeatmap = !showHeatmap),
child: Icon(showHeatmap ? Icons.location_on : Icons.blur_on),
tooltip: showHeatmap ? 'Show Points' : 'Show Heatmap',
)
```
## Heatmap Color Scales
| Scale | Colors | Best For |
|-------|--------|----------|
| Traffic | Green → Yellow → Red | Congestion, density |
| Temperature | Blue → Green → Yellow → Red | Weather, temperature data |
| Monochrome | Light blue → Dark blue | Clean, minimal design |
| Viridis | Purple → Blue → Green → Yellow | Scientific data |
## Next Steps
- [Add Clusters](./flutter-add-a-cluster) — Group markers instead of overlapping
- [Multiple Geometries](./flutter-multiple-geometries) — Combine heatmap with other layers
- [Draw a Circle](./flutter-draw-a-circle) — More about circle styling
---
**Tip**: For the best visual result, use `strokeWidth: 0` on heatmap circles so there are no visible borders, and use generous opacity overlap between neighboring circles.
---
# Add a Polygon in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-a-polygon
# Add a Polygon in Flutter
This tutorial shows how to draw filled polygons on your MapMetrics Flutter map. Polygons are useful for highlighting areas, zones, or boundaries.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Polygon
Draw a simple polygon by providing a list of `LatLng` points. The shape will automatically close by connecting the last point to the first:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class PolygonExampleScreen extends StatefulWidget {
@override
_PolygonExampleScreenState createState() => _PolygonExampleScreenState();
}
class _PolygonExampleScreenState extends State {
MapMetricsController? mapController;
final Set polygons = {
Polygon(
polygonId: PolygonId('manhattan'),
points: [
LatLng(40.800, -73.958),
LatLng(40.800, -74.020),
LatLng(40.700, -74.020),
LatLng(40.700, -73.970),
],
strokeWidth: 2,
strokeColor: Colors.blue,
fillColor: Colors.blue.withOpacity(0.2),
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Polygon Example')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(40.750, -73.990),
zoom: 12.0,
),
polygons: polygons,
),
);
}
}
```
## Multiple Colored Zones
Show different areas with distinct colors:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ColoredZonesScreen extends StatefulWidget {
@override
_ColoredZonesScreenState createState() => _ColoredZonesScreenState();
}
class _ColoredZonesScreenState extends State {
MapMetricsController? mapController;
final Set zones = {
// Zone A - Green (safe zone)
Polygon(
polygonId: PolygonId('zone_a'),
points: [
LatLng(48.870, 2.330),
LatLng(48.870, 2.350),
LatLng(48.860, 2.350),
LatLng(48.860, 2.330),
],
strokeWidth: 2,
strokeColor: Colors.green,
fillColor: Colors.green.withOpacity(0.25),
),
// Zone B - Orange (caution zone)
Polygon(
polygonId: PolygonId('zone_b'),
points: [
LatLng(48.860, 2.330),
LatLng(48.860, 2.350),
LatLng(48.850, 2.350),
LatLng(48.850, 2.330),
],
strokeWidth: 2,
strokeColor: Colors.orange,
fillColor: Colors.orange.withOpacity(0.25),
),
// Zone C - Red (restricted zone)
Polygon(
polygonId: PolygonId('zone_c'),
points: [
LatLng(48.850, 2.330),
LatLng(48.850, 2.350),
LatLng(48.840, 2.350),
LatLng(48.840, 2.330),
],
strokeWidth: 2,
strokeColor: Colors.red,
fillColor: Colors.red.withOpacity(0.25),
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Colored Zones')),
body: Stack(
children: [
MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapController = controller,
initialCameraPosition: CameraPosition(
target: LatLng(48.855, 2.340),
zoom: 14.0,
),
polygons: zones,
),
// Legend
Positioned(
top: 16,
right: 16,
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_legendItem(Colors.green, 'Safe Zone'),
SizedBox(height: 6),
_legendItem(Colors.orange, 'Caution Zone'),
SizedBox(height: 6),
_legendItem(Colors.red, 'Restricted Zone'),
],
),
),
),
],
),
);
}
Widget _legendItem(Color color, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color.withOpacity(0.3),
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(3),
),
),
SizedBox(width: 8),
Text(label, style: TextStyle(fontSize: 13)),
],
);
}
}
```
## Tappable Polygons
Make polygons respond to taps:
```dart
Polygon(
polygonId: PolygonId('tappable_area'),
points: [
LatLng(48.870, 2.330),
LatLng(48.870, 2.360),
LatLng(48.850, 2.360),
LatLng(48.850, 2.330),
],
strokeWidth: 2,
strokeColor: Colors.purple,
fillColor: Colors.purple.withOpacity(0.2),
consumeTapEvents: true,
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Area Selected'),
content: Text('You tapped on the highlighted area.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
),
);
},
)
```
## Polygon Properties
| Property | Type | Description |
|----------|------|-------------|
| `polygonId` | `PolygonId` | Unique identifier for the polygon |
| `points` | `List` | Vertices of the polygon (auto-closed) |
| `strokeWidth` | `int` | Border width in pixels |
| `strokeColor` | `Color` | Border color |
| `fillColor` | `Color` | Fill color (use `withOpacity` for transparency) |
| `visible` | `bool` | Whether the polygon is visible |
| `zIndex` | `int` | Drawing order relative to other overlays |
| `consumeTapEvents` | `bool` | If `true`, tap events are consumed by the polygon |
| `onTap` | `VoidCallback` | Called when the polygon is tapped |
## Next Steps
- [Draw a Circle](./flutter-draw-a-circle) — Draw circular areas on the map
- [Add a Polyline](./flutter-add-a-polyline) — Draw lines and routes
- [Markers and Annotations](./flutter-markers) — Add markers to your polygons
---
**Tip**: Use `withOpacity` on your fill colors to keep the polygon semi-transparent so the map underneath remains visible.
---
# Add a Polyline in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-a-polyline
# Add a Polyline in Flutter
This tutorial shows how to draw polylines (lines connecting multiple points) on your MapMetrics Flutter map. Polylines are useful for showing routes, paths, or borders.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Polyline
Draw a simple polyline by providing a list of `LatLng` points:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class PolylineExampleScreen extends StatefulWidget {
@override
_PolylineExampleScreenState createState() => _PolylineExampleScreenState();
}
class _PolylineExampleScreenState extends State {
MapMetricsController? mapController;
final Set polylines = {
Polyline(
polylineId: PolylineId('paris_route'),
points: [
LatLng(48.8589, 2.3398),
LatLng(48.8566, 2.3491),
LatLng(48.8547, 2.3539),
LatLng(48.8519, 2.3610),
LatLng(48.8483, 2.3589),
LatLng(48.8516, 2.3505),
LatLng(48.8540, 2.3434),
LatLng(48.8573, 2.3386),
LatLng(48.8579, 2.3344),
LatLng(48.8590, 2.3399),
],
color: Colors.red,
width: 4,
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Polyline Example')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8540, 2.3470),
zoom: 14.0,
),
polylines: polylines,
),
);
}
}
```
## Styled Polylines
Customize the look of your polylines with color, width, and patterns:
```dart
final Set styledPolylines = {
// Solid thick line
Polyline(
polylineId: PolylineId('thick_line'),
points: [
LatLng(48.860, 2.330),
LatLng(48.855, 2.345),
LatLng(48.850, 2.360),
],
color: Colors.blue,
width: 6,
),
// Dotted line
Polyline(
polylineId: PolylineId('dotted_line'),
points: [
LatLng(48.858, 2.330),
LatLng(48.853, 2.345),
LatLng(48.848, 2.360),
],
color: Colors.orange,
width: 3,
patterns: [PatternItem.dot],
),
// Dashed line
Polyline(
polylineId: PolylineId('dashed_line'),
points: [
LatLng(48.856, 2.330),
LatLng(48.851, 2.345),
LatLng(48.846, 2.360),
],
color: Colors.green,
width: 3,
patterns: [PatternItem.dash(20), PatternItem.gap(10)],
),
};
```
## Multiple Routes
Show several routes with different colors:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MultipleRoutesScreen extends StatefulWidget {
@override
_MultipleRoutesScreenState createState() => _MultipleRoutesScreenState();
}
class _MultipleRoutesScreenState extends State {
MapMetricsController? mapController;
final Set routes = {
// Route 1: New York → Philadelphia → Washington DC
Polyline(
polylineId: PolylineId('east_coast'),
points: [
LatLng(40.7128, -74.0060), // New York
LatLng(39.9526, -75.1652), // Philadelphia
LatLng(38.9072, -77.0369), // Washington DC
],
color: Colors.blue,
width: 4,
),
// Route 2: Los Angeles → Las Vegas → Phoenix
Polyline(
polylineId: PolylineId('southwest'),
points: [
LatLng(34.0522, -118.2437), // Los Angeles
LatLng(36.1699, -115.1398), // Las Vegas
LatLng(33.4484, -112.0740), // Phoenix
],
color: Colors.red,
width: 4,
),
};
// Markers at each city
final Set cityMarkers = {
Marker(
markerId: MarkerId('nyc'),
position: LatLng(40.7128, -74.0060),
infoWindow: InfoWindow(title: 'New York'),
),
Marker(
markerId: MarkerId('philly'),
position: LatLng(39.9526, -75.1652),
infoWindow: InfoWindow(title: 'Philadelphia'),
),
Marker(
markerId: MarkerId('dc'),
position: LatLng(38.9072, -77.0369),
infoWindow: InfoWindow(title: 'Washington DC'),
),
Marker(
markerId: MarkerId('la'),
position: LatLng(34.0522, -118.2437),
infoWindow: InfoWindow(title: 'Los Angeles'),
),
Marker(
markerId: MarkerId('vegas'),
position: LatLng(36.1699, -115.1398),
infoWindow: InfoWindow(title: 'Las Vegas'),
),
Marker(
markerId: MarkerId('phoenix'),
position: LatLng(33.4484, -112.0740),
infoWindow: InfoWindow(title: 'Phoenix'),
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Multiple Routes')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(37.0902, -95.7129), // Center of USA
zoom: 4.0,
),
polylines: routes,
markers: cityMarkers,
),
);
}
}
```
## Polyline Properties
| Property | Type | Description |
|----------|------|-------------|
| `polylineId` | `PolylineId` | Unique identifier for the polyline |
| `points` | `List` | List of coordinates that form the line |
| `color` | `Color` | Line color (default: black) |
| `width` | `int` | Line width in pixels (default: 10) |
| `patterns` | `List` | Dash/dot/gap pattern for the line |
| `geodesic` | `bool` | If `true`, line follows Earth's curvature |
| `visible` | `bool` | Whether the polyline is visible |
| `zIndex` | `int` | Drawing order relative to other overlays |
## Next Steps
- [Add a Polygon](./flutter-add-a-polygon) — Draw filled shapes on the map
- [Draw a Circle](./flutter-draw-a-circle) — Draw circular areas
- [Add a Popup](./flutter-add-a-popup) — Show popups on markers
---
**Tip**: Combine polylines with markers at key points to create an interactive route map. Use `geodesic: true` for long-distance routes so the line follows the curvature of the Earth.
---
# Add a Popup in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-a-popup
# Add a Popup in Flutter
This tutorial shows how to display popups (info windows) on markers in your MapMetrics Flutter map. Popups are great for showing extra information when a user taps on a marker.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Popup with InfoWindow
The simplest way to show a popup is by using the `InfoWindow` property on a `Marker`:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class PopupExampleScreen extends StatefulWidget {
@override
_PopupExampleScreenState createState() => _PopupExampleScreenState();
}
class _PopupExampleScreenState extends State {
MapMetricsController? mapController;
final Set markers = {
Marker(
markerId: MarkerId('paris'),
position: LatLng(48.852966, 2.349902),
infoWindow: InfoWindow(
title: 'Hello MapMetrics',
snippet: 'A good coffee shop',
),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Popup Example')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.852966, 2.349902),
zoom: 13.0,
),
markers: markers,
),
);
}
}
```
Tap on the marker to see the popup with the title and snippet text.
## Multiple Markers with Popups
Add several markers, each with their own popup content:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MultiplePopupsScreen extends StatefulWidget {
@override
_MultiplePopupsScreenState createState() => _MultiplePopupsScreenState();
}
class _MultiplePopupsScreenState extends State {
MapMetricsController? mapController;
final Set markers = {
Marker(
markerId: MarkerId('eiffel_tower'),
position: LatLng(48.8584, 2.2945),
infoWindow: InfoWindow(
title: 'Eiffel Tower',
snippet: 'Iconic iron lattice tower on the Champ de Mars',
),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue),
),
Marker(
markerId: MarkerId('louvre'),
position: LatLng(48.8606, 2.3376),
infoWindow: InfoWindow(
title: 'Louvre Museum',
snippet: 'World\'s largest art museum and historic monument',
),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
),
Marker(
markerId: MarkerId('notre_dame'),
position: LatLng(48.8530, 2.3499),
infoWindow: InfoWindow(
title: 'Notre-Dame Cathedral',
snippet: 'Medieval Catholic cathedral on the Île de la Cité',
),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueOrange),
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Paris Landmarks')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8566, 2.3222),
zoom: 13.0,
),
markers: markers,
),
);
}
}
```
## Custom Popup with Bottom Sheet
For richer popup content beyond a simple title and snippet, you can show a bottom sheet when a marker is tapped:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class CustomPopupScreen extends StatefulWidget {
@override
_CustomPopupScreenState createState() => _CustomPopupScreenState();
}
class _CustomPopupScreenState extends State {
MapMetricsController? mapController;
final Map> markerData = {
'cafe': {
'title': 'Le Petit Café',
'description': 'A cozy Parisian café with excellent croissants and espresso.',
'hours': 'Mon-Sat: 7:00 AM - 9:00 PM',
'rating': '4.5',
},
'bookstore': {
'title': 'Shakespeare & Company',
'description': 'Historic English-language bookstore on the Left Bank.',
'hours': 'Daily: 10:00 AM - 10:00 PM',
'rating': '4.8',
},
};
Set get markers => {
Marker(
markerId: MarkerId('cafe'),
position: LatLng(48.8530, 2.3470),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueOrange),
),
Marker(
markerId: MarkerId('bookstore'),
position: LatLng(48.8526, 2.3471),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueViolet),
),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Custom Popups')),
body: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
onMarkerTapped: (MarkerId markerId) {
_showCustomPopup(markerId.value);
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8530, 2.3470),
zoom: 17.0,
),
markers: markers,
),
);
}
void _showCustomPopup(String markerId) {
final data = markerData[markerId];
if (data == null) return;
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data['title']!,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
data['description']!,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
SizedBox(height: 12),
Row(
children: [
Icon(Icons.access_time, size: 16, color: Colors.grey),
SizedBox(width: 4),
Text(data['hours']!, style: TextStyle(fontSize: 13)),
],
),
SizedBox(height: 8),
Row(
children: [
Icon(Icons.star, size: 16, color: Colors.amber),
SizedBox(width: 4),
Text(data['rating']!, style: TextStyle(fontSize: 13)),
],
),
SizedBox(height: 16),
],
),
);
},
);
}
}
```
## Add Popup on Map Tap
You can also show a popup when the user taps anywhere on the map:
```dart
MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapController = controller,
onMapClick: (Point point, LatLng coordinates) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Location'),
content: Text(
'Lat: ${coordinates.latitude.toStringAsFixed(6)}\n'
'Lng: ${coordinates.longitude.toStringAsFixed(6)}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
),
);
},
initialCameraPosition: CameraPosition(
target: LatLng(48.852966, 2.349902),
zoom: 13.0,
),
)
```
## Next Steps
- [Add a Polyline](./flutter-add-a-polyline) — Draw lines and routes on the map
- [Fly to a Location](./flutter-fly-to-location) — Animate the camera to different places
- [Draggable Marker](./flutter-draggable-marker) — Let users drag markers around the map
---
**Tip**: For simple information, use `InfoWindow`. For richer content with images, buttons, or custom layouts, use a bottom sheet or dialog triggered by `onMarkerTapped`.
---
# Add an Animated Icon to the Map in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-animated-icon
# Add an Animated Icon to the Map in Flutter
This tutorial shows how to create animated marker icons — rotating, scaling, or changing appearance over time — for live tracking, alerts, or eye-catching points of interest.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Rotating Icon Marker
Create a marker icon that rotates continuously (e.g., a compass or loading indicator):
```dart
import 'dart:math';
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class RotatingIconScreen extends StatefulWidget {
@override
_RotatingIconScreenState createState() => _RotatingIconScreenState();
}
class _RotatingIconScreenState extends State
with SingleTickerProviderStateMixin {
MapMetricsController? mapController;
late AnimationController _animController;
BitmapDescriptor? currentIcon;
final LatLng position = LatLng(48.8584, 2.2945);
@override
void initState() {
super.initState();
_animController = AnimationController(
duration: Duration(seconds: 3),
vsync: this,
)..repeat();
_animController.addListener(() {
_generateRotatedIcon(_animController.value * 2 * pi);
});
}
Future _generateRotatedIcon(double angle) async {
final size = 64.0;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// Background circle
canvas.drawCircle(
Offset(size / 2, size / 2),
size / 2,
Paint()..color = Colors.blue,
);
// Rotating arrow
canvas.save();
canvas.translate(size / 2, size / 2);
canvas.rotate(angle);
final arrowPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeCap = StrokeCap.round;
// Arrow pointing up
canvas.drawLine(Offset(0, 12), Offset(0, -12), arrowPaint);
canvas.drawLine(Offset(0, -12), Offset(-6, -4), arrowPaint);
canvas.drawLine(Offset(0, -12), Offset(6, -4), arrowPaint);
canvas.restore();
final picture = recorder.endRecording();
final image = await picture.toImage(size.toInt(), size.toInt());
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
if (mounted) {
setState(() {
currentIcon = BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Rotating Icon')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: position,
zoom: 15.0,
),
markers: currentIcon != null
? {
Marker(
markerId: MarkerId('rotating'),
position: position,
icon: currentIcon!,
anchor: Offset(0.5, 0.5),
),
}
: {},
),
);
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
}
```
## Color-Cycling Alert Icon
A marker that cycles through colors to draw attention:
```dart
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class AlertIconScreen extends StatefulWidget {
@override
_AlertIconScreenState createState() => _AlertIconScreenState();
}
class _AlertIconScreenState extends State
with SingleTickerProviderStateMixin {
MapMetricsController? mapController;
late AnimationController _animController;
late Animation _colorAnimation;
BitmapDescriptor? currentIcon;
final List alertLocations = [
LatLng(48.860, 2.340),
LatLng(48.855, 2.350),
LatLng(48.865, 2.325),
];
@override
void initState() {
super.initState();
_animController = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
_colorAnimation = ColorTween(
begin: Colors.red,
end: Colors.yellow,
).animate(_animController);
_animController.addListener(() {
_generateColoredIcon(_colorAnimation.value!);
});
}
Future _generateColoredIcon(Color color) async {
final size = 48.0;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// Outer glow
canvas.drawCircle(
Offset(size / 2, size / 2),
size / 2,
Paint()..color = color.withOpacity(0.3),
);
// Inner circle
canvas.drawCircle(
Offset(size / 2, size / 2),
size / 3,
Paint()..color = color,
);
// White border
canvas.drawCircle(
Offset(size / 2, size / 2),
size / 3,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
// Exclamation mark
final textPainter = TextPainter(
text: TextSpan(
text: '!',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset((size - textPainter.width) / 2, (size - textPainter.height) / 2),
);
final picture = recorder.endRecording();
final image = await picture.toImage(size.toInt(), size.toInt());
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
if (mounted) {
setState(() {
currentIcon = BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Alert Icons')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.858, 2.340),
zoom: 14.0,
),
markers: currentIcon != null
? alertLocations.asMap().entries.map((e) {
return Marker(
markerId: MarkerId('alert_${e.key}'),
position: e.value,
icon: currentIcon!,
anchor: Offset(0.5, 0.5),
infoWindow: InfoWindow(title: 'Alert ${e.key + 1}'),
);
}).toSet()
: {},
),
);
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
}
```
## Frame-by-Frame Sprite Animation
Cycle through pre-made icon frames for sprite-sheet style animation:
```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mapmetrics/mapmetrics.dart';
class SpriteAnimationScreen extends StatefulWidget {
@override
_SpriteAnimationScreenState createState() => _SpriteAnimationScreenState();
}
class _SpriteAnimationScreenState extends State {
MapMetricsController? mapController;
Timer? frameTimer;
int currentFrame = 0;
List frames = [];
@override
void initState() {
super.initState();
_loadFrames();
}
Future _loadFrames() async {
// Load animation frames from assets
// e.g., assets/anim/frame_0.png, frame_1.png, frame_2.png...
final loadedFrames = [];
for (int i = 0; i < 8; i++) {
final icon = await BitmapDescriptor.fromAssetImage(
ImageConfiguration(size: Size(48, 48)),
'assets/anim/frame_$i.png',
);
loadedFrames.add(icon);
}
setState(() {
frames = loadedFrames;
});
// Start frame cycling
frameTimer = Timer.periodic(Duration(milliseconds: 150), (_) {
setState(() {
currentFrame = (currentFrame + 1) % frames.length;
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Sprite Animation')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8584, 2.2945),
zoom: 15.0,
),
markers: frames.isNotEmpty
? {
Marker(
markerId: MarkerId('animated'),
position: LatLng(48.8584, 2.2945),
icon: frames[currentFrame],
),
}
: {},
),
);
}
@override
void dispose() {
frameTimer?.cancel();
super.dispose();
}
}
```
Declare frames in `pubspec.yaml`:
```yaml
flutter:
assets:
- assets/anim/
```
## Animation Approaches
| Approach | FPS | Best For |
|----------|-----|----------|
| Canvas drawing + Timer | 15-30 | Rotating/color-changing icons |
| Pre-loaded sprite frames | 10-15 | Complex custom animations |
| AnimationController | 60 | Smooth transitions |
## Next Steps
- [Animate a Point](./flutter-animate-point) — Moving/bouncing markers
- [Add Icon to Map](./flutter-add-icon-to-map) — Static custom icons
- [Add Custom Icons with Markers](./flutter-add-custom-icons-markers) — Custom marker styles
---
**Tip**: Generating icons on every animation frame is CPU-intensive. For production apps, pre-generate all frames during `initState` and store them in a list, then just swap the `icon` property each frame — this is much more efficient than drawing on every tick.
---
# Add a Color Relief Layer in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-color-relief-layer
# Add a Color Relief Layer in Flutter
This tutorial shows how to add a color relief (hypsometric tinting) layer to your MapMetrics Flutter map — coloring terrain by elevation bands from green lowlands to white mountain peaks.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Color Relief
Color the map terrain based on elevation bands using a raster DEM source:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ColorReliefScreen extends StatefulWidget {
@override
_ColorReliefScreenState createState() => _ColorReliefScreenState();
}
class _ColorReliefScreenState extends State {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Color Relief Layer')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(46.8182, 8.2275), // Swiss Alps
zoom: 8.0,
),
onStyleLoaded: () {
_addColorRelief();
},
),
// Elevation legend
Positioned(
bottom: 16,
left: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Elevation',
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 12)),
SizedBox(height: 6),
_elevRow(Color(0xFF1a6e1a), '0 - 200m'),
_elevRow(Color(0xFF4ca64c), '200 - 500m'),
_elevRow(Color(0xFFb3d98c), '500 - 1000m'),
_elevRow(Color(0xFFe6d96e), '1000 - 2000m'),
_elevRow(Color(0xFFc9854c), '2000 - 3000m'),
_elevRow(Color(0xFF8c5a2e), '3000 - 4000m'),
_elevRow(Color(0xFFffffff), '4000m+'),
],
),
),
),
),
],
),
);
}
Widget _elevRow(Color color, String label) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 12,
decoration: BoxDecoration(
color: color,
border: Border.all(color: Colors.grey[400]!, width: 0.5),
),
),
SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 11)),
],
),
);
}
void _addColorRelief() {
// Add terrain DEM source
mapController?.addRasterDemSource(
'terrain-dem',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
// Add color relief raster layer with elevation-based color ramp
mapController?.addRasterLayer(
'color-relief',
'terrain-dem',
rasterColor: [
'interpolate',
['linear'],
['raster-value'],
0.0, '#1a6e1a', // Deep green (sea level)
0.05, '#4ca64c', // Green (200m)
0.125, '#b3d98c', // Light green (500m)
0.25, '#e6d96e', // Yellow-green (1000m)
0.5, '#c9854c', // Brown (2000m)
0.75, '#8c5a2e', // Dark brown (3000m)
1.0, '#ffffff', // White (4000m+)
],
rasterOpacity: 0.6,
);
}
}
```
## Color Relief with Hillshade
Combine elevation coloring with hillshade for a professional topographic look:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ReliefHillshadeScreen extends StatefulWidget {
@override
_ReliefHillshadeScreenState createState() =>
_ReliefHillshadeScreenState();
}
class _ReliefHillshadeScreenState extends State {
MapMetricsController? mapController;
bool showRelief = true;
bool showHillshade = true;
double reliefOpacity = 0.5;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Relief + Hillshade')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(45.9763, 7.6586), // Matterhorn
zoom: 10.0,
),
onStyleLoaded: () {
_addLayers();
},
),
// Controls
Positioned(
top: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Layers',
style: TextStyle(fontWeight: FontWeight.bold)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: showRelief,
onChanged: (val) {
setState(() => showRelief = val!);
mapController?.setLayerVisibility(
'color-relief', val!);
},
),
Text('Color Relief', style: TextStyle(fontSize: 13)),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: showHillshade,
onChanged: (val) {
setState(() => showHillshade = val!);
mapController?.setLayerVisibility(
'hillshade', val!);
},
),
Text('Hillshade', style: TextStyle(fontSize: 13)),
],
),
SizedBox(height: 4),
Text('Opacity', style: TextStyle(fontSize: 12)),
SizedBox(
width: 150,
child: Slider(
value: reliefOpacity,
min: 0.1,
max: 1.0,
onChanged: (val) {
setState(() => reliefOpacity = val);
mapController?.setPaintProperty(
'color-relief',
'raster-opacity',
val,
);
},
),
),
],
),
),
),
),
// Location presets
Positioned(
bottom: 16,
left: 8,
right: 8,
child: SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_locationChip('Matterhorn', 45.976, 7.659, 10.0),
SizedBox(width: 6),
_locationChip('Mont Blanc', 45.833, 6.865, 10.0),
SizedBox(width: 6),
_locationChip('Swiss Alps', 46.818, 8.228, 8.0),
SizedBox(width: 6),
_locationChip('Dolomites', 46.410, 11.844, 10.0),
SizedBox(width: 6),
_locationChip('Pyrenees', 42.695, 0.041, 8.0),
],
),
),
),
],
),
);
}
Widget _locationChip(String name, double lat, double lng, double zoom) {
return ActionChip(
avatar: Icon(Icons.terrain, size: 14),
label: Text(name, style: TextStyle(fontSize: 12)),
onPressed: () {
mapController?.animateCamera(
CameraUpdate.newLatLngZoom(LatLng(lat, lng), zoom),
);
},
);
}
void _addLayers() {
mapController?.addRasterDemSource(
'terrain-dem',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
// Hillshade layer (below relief for shadow effect)
mapController?.addHillshadeLayer(
'hillshade',
'terrain-dem',
hillshadeExaggeration: 0.5,
hillshadeIlluminationDirection: 315.0,
);
// Color relief on top with transparency
mapController?.addRasterLayer(
'color-relief',
'terrain-dem',
rasterColor: [
'interpolate',
['linear'],
['raster-value'],
0.0, '#1a6e1a',
0.05, '#4ca64c',
0.125, '#b3d98c',
0.25, '#e6d96e',
0.5, '#c9854c',
0.75, '#8c5a2e',
1.0, '#ffffff',
],
rasterOpacity: reliefOpacity,
);
}
}
```
## Color Scheme Switcher
Switch between different elevation color palettes:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ReliefSchemesScreen extends StatefulWidget {
@override
_ReliefSchemesScreenState createState() => _ReliefSchemesScreenState();
}
class _ReliefSchemesScreenState extends State {
MapMetricsController? mapController;
String activeScheme = 'natural';
final Map> schemes = {
'natural': {
'name': 'Natural',
'colors': [
0.0, '#1a6e1a', 0.05, '#4ca64c', 0.125, '#b3d98c',
0.25, '#e6d96e', 0.5, '#c9854c', 0.75, '#8c5a2e', 1.0, '#ffffff',
],
},
'ocean': {
'name': 'Ocean Blue',
'colors': [
0.0, '#0d47a1', 0.1, '#1565c0', 0.25, '#42a5f5',
0.5, '#90caf9', 0.75, '#bbdefb', 1.0, '#e3f2fd',
],
},
'thermal': {
'name': 'Thermal',
'colors': [
0.0, '#00008B', 0.15, '#0000FF', 0.3, '#00FFFF',
0.5, '#00FF00', 0.7, '#FFFF00', 0.85, '#FF8C00', 1.0, '#FF0000',
],
},
'grayscale': {
'name': 'Grayscale',
'colors': [
0.0, '#1a1a1a', 0.25, '#4d4d4d', 0.5, '#808080',
0.75, '#b3b3b3', 1.0, '#ffffff',
],
},
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Relief Color Schemes')),
body: Column(
children: [
Container(
padding: EdgeInsets.all(8),
child: Wrap(
spacing: 8,
children: schemes.entries.map((entry) {
return ChoiceChip(
label: Text(entry.value['name']),
selected: activeScheme == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => activeScheme = entry.key);
_applyScheme(entry.value['colors']);
}
},
);
}).toList(),
),
),
Expanded(
child: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(46.818, 8.228),
zoom: 8.0,
),
onStyleLoaded: () {
_setupRelief();
},
),
),
],
),
);
}
void _setupRelief() {
mapController?.addRasterDemSource(
'terrain-dem',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
final colors = schemes[activeScheme]!['colors'] as List;
mapController?.addRasterLayer(
'color-relief',
'terrain-dem',
rasterColor: [
'interpolate',
['linear'],
['raster-value'],
...colors,
],
rasterOpacity: 0.6,
);
}
void _applyScheme(List colors) {
// Remove and re-add with new color ramp
mapController?.removeLayer('color-relief');
mapController?.addRasterLayer(
'color-relief',
'terrain-dem',
rasterColor: [
'interpolate',
['linear'],
['raster-value'],
...colors,
],
rasterOpacity: 0.6,
);
}
}
```
## Color Relief Properties
| Property | Type | Description |
|----------|------|-------------|
| `rasterColor` | `List` | Color interpolation expression mapping elevation to colors |
| `rasterOpacity` | `double` | Layer transparency (0.0 - 1.0) |
| `rasterValue` | Expression | Normalized elevation value from DEM (0.0 - 1.0) |
## Standard Elevation Color Bands
| Elevation | Color | Terrain Type |
|-----------|-------|-------------|
| 0 - 200m | Dark green | Lowlands, valleys |
| 200 - 500m | Green | Hills, foothills |
| 500 - 1000m | Light green | Uplands |
| 1000 - 2000m | Yellow-green | Mountains |
| 2000 - 3000m | Brown | High mountains |
| 3000 - 4000m | Dark brown | Alpine zone |
| 4000m+ | White | Glaciers, snow |
## Next Steps
- [Add a Hillshade Layer](./flutter-add-hillshade-layer) — Shaded relief effect
- [Add Contour Lines](./flutter-add-contour-lines) — Elevation contour lines
- [3D Terrain](./flutter-3d-terrain) — Full 3D elevation view
---
**Tip**: Combine color relief + hillshade + contour lines for a complete topographic map. Set the relief layer opacity to 0.4-0.6 so the base map labels remain readable underneath.
---
# Add Contour Lines in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-contour-lines
# Add Contour Lines in Flutter
This tutorial shows how to add elevation contour lines to your MapMetrics Flutter map — essential for topographic maps, hiking apps, and geographic analysis.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Basic Contour Lines
Add contour lines from a vector tile source:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ContourLinesScreen extends StatefulWidget {
@override
_ContourLinesScreenState createState() => _ContourLinesScreenState();
}
class _ContourLinesScreenState extends State {
MapMetricsController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Contour Lines')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(46.8182, 8.2275), // Swiss Alps
zoom: 11.0,
),
onStyleLoaded: () {
_addContourLines();
},
),
);
}
void _addContourLines() {
// Add contour source (vector tiles with elevation data)
mapController?.addVectorSource(
'contour-source',
'https://gateway.mapmetrics.org/contours/{z}/{x}/{y}.pbf',
);
// Add thin contour lines (every 100m)
mapController?.addLineLayer(
'contour-lines',
'contour-source',
sourceLayer: 'contour',
lineColor: '#8B4513',
lineWidth: 0.5,
lineOpacity: 0.5,
);
// Add thicker lines for major contours (every 500m)
mapController?.addLineLayer(
'contour-lines-major',
'contour-source',
sourceLayer: 'contour',
lineColor: '#8B4513',
lineWidth: 1.5,
lineOpacity: 0.7,
filter: ['==', ['%', ['get', 'ele'], 500], 0],
);
// Add elevation labels on major contours
mapController?.addSymbolLayer(
'contour-labels',
'contour-source',
sourceLayer: 'contour',
textField: '{ele}m',
textSize: 10.0,
textColor: '#8B4513',
symbolPlacement: 'line',
filter: ['==', ['%', ['get', 'ele'], 500], 0],
);
}
}
```
## Contour Lines with Hillshade
Combine contour lines with hillshade for a classic topographic map:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class TopoMapScreen extends StatefulWidget {
@override
_TopoMapScreenState createState() => _TopoMapScreenState();
}
class _TopoMapScreenState extends State {
MapMetricsController? mapController;
bool showContours = true;
bool showHillshade = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Topographic Map')),
body: Stack(
children: [
MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(45.9763, 7.6586), // Matterhorn
zoom: 12.0,
),
onStyleLoaded: () {
_addLayers();
},
),
// Layer toggles
Positioned(
top: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: showHillshade,
onChanged: (val) {
setState(() => showHillshade = val!);
_toggleLayer('hillshade-layer', val!);
},
),
Text('Hillshade', style: TextStyle(fontSize: 13)),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: showContours,
onChanged: (val) {
setState(() => showContours = val!);
_toggleLayer('contour-lines', val!);
_toggleLayer('contour-lines-major', val!);
_toggleLayer('contour-labels', val!);
},
),
Text('Contours', style: TextStyle(fontSize: 13)),
],
),
],
),
),
),
),
],
),
);
}
void _addLayers() {
// Hillshade
mapController?.addRasterDemSource(
'dem-source',
'https://gateway.mapmetrics.org/terrain/{z}/{x}/{y}.png',
tileSize: 256,
);
mapController?.addHillshadeLayer(
'hillshade-layer',
'dem-source',
hillshadeExaggeration: 0.4,
hillshadeIlluminationDirection: 315.0,
);
// Contour lines
mapController?.addVectorSource(
'contour-source',
'https://gateway.mapmetrics.org/contours/{z}/{x}/{y}.pbf',
);
mapController?.addLineLayer(
'contour-lines',
'contour-source',
sourceLayer: 'contour',
lineColor: '#8B4513',
lineWidth: 0.5,
lineOpacity: 0.4,
);
mapController?.addLineLayer(
'contour-lines-major',
'contour-source',
sourceLayer: 'contour',
lineColor: '#8B4513',
lineWidth: 1.2,
lineOpacity: 0.6,
filter: ['==', ['%', ['get', 'ele'], 500], 0],
);
mapController?.addSymbolLayer(
'contour-labels',
'contour-source',
sourceLayer: 'contour',
textField: '{ele}m',
textSize: 9.0,
textColor: '#654321',
symbolPlacement: 'line',
filter: ['==', ['%', ['get', 'ele'], 500], 0],
);
}
void _toggleLayer(String layerId, bool visible) {
mapController?.setLayerVisibility(layerId, visible);
}
}
```
## Contour Line Properties
| Property | Value | Description |
|----------|-------|-------------|
| `lineColor` | `#8B4513` | Brown for topographic convention |
| `lineWidth` | 0.5 (minor) / 1.5 (major) | Thicker for index contours |
| `lineOpacity` | 0.4 - 0.7 | Semi-transparent to not obscure the base map |
| `filter` | `['==', ['%', ['get', 'ele'], 500], 0]` | Show only every 500m |
## Next Steps
- [Add a Hillshade Layer](./flutter-add-hillshade-layer) — Shaded relief effect
- [3D Terrain](./flutter-3d-terrain) — Full 3D elevation
- [Satellite Terrain](./flutter-satellite-terrain) — Satellite with terrain
---
**Tip**: Use brown (`#8B4513`) for contour lines — it's the cartographic standard. Show minor contours (every 100m) as thin/light lines and major contours (every 500m) as thick/labeled for readability.
---
# Add Custom Icons with Markers in Flutter
https://docs.mapatlas.xyz/sdk/examples/flutter-add-custom-icons-markers
# Add Custom Icons with Markers in Flutter
This tutorial shows how to create markers with custom icons — using asset images, network images, or Flutter widgets — instead of the default pin.
## Prerequisites
Before you begin, ensure you have:
- Completed the [Flutter Setup Guide](./flutter-setup)
- A MapMetrics API key and style URL from the [MapMetrics Portal](https://portal.mapmetrics.org)
## Custom Asset Icon Markers
Use a local image asset as a marker icon:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class CustomIconMarkerScreen extends StatefulWidget {
@override
_CustomIconMarkerScreenState createState() => _CustomIconMarkerScreenState();
}
class _CustomIconMarkerScreenState extends State {
MapMetricsController? mapController;
Set markers = {};
@override
void initState() {
super.initState();
_loadCustomMarkers();
}
Future _loadCustomMarkers() async {
// Load a custom icon from assets
final customIcon = await BitmapDescriptor.fromAssetImage(
ImageConfiguration(size: Size(48, 48)),
'assets/icons/custom_pin.png',
);
setState(() {
markers = {
Marker(
markerId: MarkerId('paris'),
position: LatLng(48.8566, 2.3522),
icon: customIcon,
infoWindow: InfoWindow(title: 'Paris'),
),
Marker(
markerId: MarkerId('london'),
position: LatLng(51.5074, -0.1276),
icon: customIcon,
infoWindow: InfoWindow(title: 'London'),
),
Marker(
markerId: MarkerId('berlin'),
position: LatLng(52.52, 13.405),
icon: customIcon,
infoWindow: InfoWindow(title: 'Berlin'),
),
};
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Custom Icon Markers')),
body: MapMetrics(
styleUrl:
'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (MapMetricsController controller) {
mapController = controller;
},
initialCameraPosition: CameraPosition(
target: LatLng(50.0, 5.0),
zoom: 4.0,
),
markers: markers,
),
);
}
}
```
Make sure to add your image to `assets/icons/` and declare it in `pubspec.yaml`:
```yaml
flutter:
assets:
- assets/icons/custom_pin.png
```
## Color-Coded Markers
Use different hue values of the default marker for categories:
```dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class ColorCodedMarkersScreen extends StatefulWidget {
@override
_ColorCodedMarkersScreenState createState() =>
_ColorCodedMarkersScreenState();
}
class _ColorCodedMarkersScreenState extends State