Using Township Canada API with Mapbox GL JS
Add Canadian legal land description search, survey grid overlays, and data layers to your Mapbox GL JS application using the Township Canada API. Includes working code examples.
Build a web map that displays Canadian survey grids (DLS, NTS), searches legal land descriptions, and shows data layers like oil & gas fields — all using Mapbox GL JS and the Township Canada API.
Mapbox GL JS has native support for vector tiles, which makes it the best fit for Township Canada's Maps API. Survey grid layers load directly as vector tile sources with no plugins or workarounds required.
What you'll build
By the end of this guide, you'll have a web page that:
- Displays DLS township, section, and LSD grid boundaries on a Mapbox map
- Searches legal land descriptions and flies to the result
- Shows a popup with legal land description details when you click a grid cell
- Includes autocomplete suggestions as you type
- Toggles data layers (oil & gas fields, municipal boundaries, parks) on and off
Prerequisites
- A Mapbox access token — sign up at mapbox.com and copy your default public token
- A Township Canada API key — subscribe to the Maps API and Search API from the Developer Portal, then create an API key from your account settings
- Basic knowledge of HTML and JavaScript
Step 1: Set up the project
Create an index.html file with the Mapbox GL JS library loaded from CDN:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Township Canada + Mapbox GL JS</title>
<meta
name="viewport"
content="initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.9.3/mapbox-gl.js"></script>
<link
href="https://api.mapbox.com/mapbox-gl-js/v3.9.3/mapbox-gl.css"
rel="stylesheet"
/>
<style>
body {
margin: 0;
padding: 0;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
const MAPBOX_TOKEN = "YOUR_MAPBOX_TOKEN";
const TC_API_KEY = "YOUR_TOWNSHIP_CANADA_API_KEY";
mapboxgl.accessToken = MAPBOX_TOKEN;
const map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/light-v11",
center: [-114, 51], // Calgary, AB
zoom: 9
});
map.addControl(new mapboxgl.NavigationControl());
</script>
</body>
</html>
Open this file in a browser. You should see a Mapbox map centred on Calgary.
Step 2: Add Township survey grid layers
Township Canada serves survey grid boundaries as vector tiles. Each grid level (township, section, LSD) is a separate tileset with province-specific source layers inside it.
Tile URL pattern
https://maps.townshipcanada.com/{province}/{layer}/{z}/{x}/{y}.mvt?api_key=YOUR_API_KEY
Available grid layers
DLS grid (provinces: ab, sk, mb, bc):
| URL layer | source-layer | text-field | Source zoom | Layer zoom |
|---|---|---|---|---|
twp | {prov}_twp | — | 0–14 | 6–12 |
twp-label | {prov}_twp_label | {descriptor} | 0–14 | 10–12 |
sec | {prov}_sec | — | 9–14 | 12–14 |
sec-label | {prov}_sec_label | {section} | 9–14 | 12–14 |
qtr | {prov}_qtr | — | 9–14 | 12–14 |
qtr-label | {prov}_qtr_label | {descriptor} | 9–14 | 12–14 |
lsd | {prov}_lsd | — | 9–14 | 14–20 |
lsd-label | {prov}_lsd_label | {lsd} | 9–14 | 14–20 |
MB River Lots (province: mb):
| URL layer | source-layer | text-field | Source zoom | Layer zoom |
|---|---|---|---|---|
river-lots | mb_river_lots | — | 0–14 | 12–20 |
river-lots-label | mb_river_lots_label | {descriptor} | 0–14 | 12–20 |
NTS grid (province: bc):
| URL layer | source-layer | text-field | Source zoom | Layer zoom |
|---|---|---|---|---|
series | bc_series | — | 0–14 | 0–10 |
series-label | bc_series_label | {descriptor} | 0–14 | 7–10 |
block | bc_block | — | 9–14 | 10–13 |
block-label | bc_block_label | {descriptor} | 9–14 | 10–13 |
unit | bc_unit | — | 9–14 | 13–14 |
unit-label | bc_unit_label | {descriptor} | 9–14 | 13–14 |
qtr-unit | bc_qtr_unit | — | 9–14 | 14–20 |
qtr-unit-label | bc_qtr_unit_label | {descriptor} | 9–14 | 14–20 |
Ontario (province: on):
| URL layer | source-layer | text-field | Source zoom | Layer zoom |
|---|---|---|---|---|
twp | on_twp | — | 0–14 | 6–12 |
twp-label | on_twp_label | {descriptor} | 0–14 | 6–12 |
con | on_con | — | 0–14 | 12–14 |
con-label | on_con_label | {descriptor} | 0–14 | 12–14 |
lot | on_lot | — | 0–14 | 14–20 |
lot-label | on_lot_label | {descriptor} | 0–14 | 14–20 |
Replace {prov} with the province code (ab, sk, mb, bc) in the source-layer column. The URL uses hyphens (e.g. twp-label) while the source-layer inside the tile data uses underscores (e.g. ab_twp_label).
Adding DLS grid layers
Add the following inside your map.on('load') callback. This example adds Alberta township, section, and LSD grids with zoom-dependent visibility so each grid level appears at an appropriate zoom:
map.on("load", () => {
// --- Township layer (visible at zoom 6-12) ---
map.addSource("ab_twp", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/ab/twp/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: "ab_twp",
type: "line",
source: "ab_twp",
"source-layer": "ab_twp",
minzoom: 6,
maxzoom: 12,
paint: {
"line-color": "#2d5a47",
"line-width": 1.5
}
});
// Township labels
map.addSource("ab_twp_label", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/ab/twp-label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: "ab_twp_label",
type: "symbol",
source: "ab_twp_label",
"source-layer": "ab_twp_label",
minzoom: 10,
maxzoom: 12,
layout: {
"text-field": "{descriptor}",
"text-size": 14
},
paint: {
"text-color": "#333",
"text-halo-color": "#fff",
"text-halo-width": 2
}
});
// --- Section layer (visible at zoom 12-14) ---
map.addSource("ab_sec", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/ab/sec/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 9,
maxzoom: 14
});
map.addLayer({
id: "ab_sec",
type: "line",
source: "ab_sec",
"source-layer": "ab_sec",
minzoom: 12,
maxzoom: 14,
paint: {
"line-color": "#4a7c59",
"line-width": 1
}
});
// Section labels
map.addSource("ab_sec_label", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/ab/sec-label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 9,
maxzoom: 14
});
map.addLayer({
id: "ab_sec_label",
type: "symbol",
source: "ab_sec_label",
"source-layer": "ab_sec_label",
minzoom: 12,
maxzoom: 14,
layout: {
"text-field": "{section}",
"text-size": 14
},
paint: {
"text-color": "#333",
"text-halo-color": "#fff",
"text-halo-width": 2
}
});
// --- LSD layer (visible at zoom 14+) ---
map.addSource("ab_lsd", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/ab/lsd/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 9,
maxzoom: 14
});
map.addLayer({
id: "ab_lsd",
type: "line",
source: "ab_lsd",
"source-layer": "ab_lsd",
minzoom: 14,
maxzoom: 20,
paint: {
"line-color": "#6b9e7a",
"line-width": 0.5
}
});
// LSD labels
map.addSource("ab_lsd_label", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/ab/lsd-label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 9,
maxzoom: 14
});
map.addLayer({
id: "ab_lsd_label",
type: "symbol",
source: "ab_lsd_label",
"source-layer": "ab_lsd_label",
minzoom: 14,
maxzoom: 20,
layout: {
"text-field": "{lsd}",
"text-size": 12
},
paint: {
"text-color": "#333",
"text-halo-color": "#fff",
"text-halo-width": 2
}
});
});
Zoom in and out on the map. Townships appear first, then sections, then LSDs — matching how the DLS survey hierarchy works in practice.
To add grids for other provinces, add new sources using the province prefix in the URL (e.g. sk/twp, mb/sec) and their corresponding source-layer names (sk_twp, mb_sec, etc.).
Step 3: Search and fly to a location
Use the Search API to convert a legal land description to coordinates and fly the map to the result.
Search API endpoint
GET https://developer.townshipcanada.com/search/legal-location?location={query}
Header: X-API-Key: YOUR_API_KEY
Response format
The Search API returns a GeoJSON FeatureCollection with two features: a polygon (the parcel boundary) and a point (the centroid):
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [[[-114.0625, 51.5625], ...]]
},
"properties": {
"descriptor": "NW-25-24-1-W5",
"quarter_section": "NW",
"section": 25,
"township": 24,
"range": 1,
"meridian": "W5",
"survey_system": "DLS",
"province": "AB"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-114.03125, 51.53125]
},
"properties": {
"descriptor": "NW-25-24-1-W5",
"shape": "centroid"
}
}
]
}
Search and fly to implementation
async function searchAndFlyTo(query) {
const response = await fetch(
`https://developer.townshipcanada.com/search/legal-location?location=${encodeURIComponent(query)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
if (!data.features || data.features.length === 0) {
console.error("No results found");
return;
}
// Find the centroid feature
const centroid = data.features.find((f) => f.properties.shape === "centroid");
const polygon = data.features.find((f) => f.geometry.type === "MultiPolygon");
if (!centroid) return;
const [lng, lat] = centroid.geometry.coordinates;
// Fly to the location
map.flyTo({ center: [lng, lat], zoom: 14, duration: 2000 });
// Add a marker at the centroid
new mapboxgl.Marker({ color: "#2d5a47" })
.setLngLat([lng, lat])
.setPopup(
new mapboxgl.Popup().setHTML(
`<strong>${centroid.properties.legal_location}</strong><br>` +
`${lat.toFixed(6)}, ${lng.toFixed(6)}`
)
)
.addTo(map);
// Draw the parcel boundary
if (polygon) {
// Remove previous search result if it exists
if (map.getSource("search-result")) {
map.removeLayer("search-result-fill");
map.removeLayer("search-result-outline");
map.removeSource("search-result");
}
map.addSource("search-result", {
type: "geojson",
data: polygon
});
map.addLayer({
id: "search-result-fill",
type: "fill",
source: "search-result",
paint: {
"fill-color": "#2d5a47",
"fill-opacity": 0.15
}
});
map.addLayer({
id: "search-result-outline",
type: "line",
source: "search-result",
paint: {
"line-color": "#2d5a47",
"line-width": 2
}
});
}
}
// Example: search for a quarter section in Alberta
searchAndFlyTo("NW-25-24-1-W5");
Step 4: Click-to-identify grid cells
Add a click handler that shows a popup with the legal land description when a user clicks on a grid cell:
// Make grid layers interactive
map.on("click", "ab_twp", (e) => {
if (e.features.length === 0) return;
const feature = e.features[0];
const props = feature.properties;
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(`<strong>${props.legal_location || props.descriptor || "Township"}</strong>`)
.addTo(map);
});
// Change cursor on hover
map.on("mouseenter", "ab_twp", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "ab_twp", () => {
map.getCanvas().style.cursor = "";
});
You can attach the same click handler to section and LSD layers (ab_sec, ab_lsd) so the popup works at every zoom level.
Step 5: Add autocomplete search
Build a search box with autocomplete suggestions using the Autocomplete API.
Autocomplete API endpoint
GET https://developer.townshipcanada.com/autocomplete/legal-location?location={query}&limit=3
Header: X-API-Key: YOUR_API_KEY
The response is a GeoJSON FeatureCollection with up to 10 matching locations:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-114.03125, 51.53125] },
"properties": {
"descriptor": "NW-2-24-1-W5",
"survey_system": "DLS",
"province": "AB"
}
}
]
}
Autocomplete implementation
Add this HTML above the map <div>:
<div
id="search-container"
style="position:absolute; top:10px; left:10px; z-index:1; width:320px;"
>
<input
id="search-input"
type="text"
placeholder="Search legal land description..."
style="width:100%; padding:10px 14px; font-size:14px; border:1px solid #ccc;
border-radius:6px; box-shadow:0 2px 6px rgba(0,0,0,0.1);"
/>
<ul
id="suggestions"
style="list-style:none; margin:4px 0 0; padding:0; background:#fff;
border-radius:6px; box-shadow:0 2px 6px rgba(0,0,0,0.15); display:none;"
></ul>
</div>
And this JavaScript:
const searchInput = document.getElementById("search-input");
const suggestionsEl = document.getElementById("suggestions");
let debounceTimer;
searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
if (query.length < 2) {
suggestionsEl.style.display = "none";
return;
}
// Debounce: wait 300ms after the user stops typing
debounceTimer = setTimeout(() => fetchSuggestions(query), 300);
});
async function fetchSuggestions(query) {
// Pass the current map center for proximity-biased results
const center = map.getCenter();
const response = await fetch(
`https://developer.townshipcanada.com/autocomplete/legal-location` +
`?location=${encodeURIComponent(query)}&limit=3` +
`&proximity=${center.lng.toFixed(4)},${center.lat.toFixed(4)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
suggestionsEl.innerHTML = "";
if (!data.features || data.features.length === 0) {
suggestionsEl.style.display = "none";
return;
}
data.features.forEach((feature) => {
const li = document.createElement("li");
li.textContent = `${feature.properties.legal_location} (${feature.properties.province})`;
li.style.cssText =
"padding:10px 14px; cursor:pointer; border-bottom:1px solid #eee; font-size:14px;";
li.addEventListener("mouseenter", () => (li.style.backgroundColor = "#f5f5f5"));
li.addEventListener("mouseleave", () => (li.style.backgroundColor = "#fff"));
li.addEventListener("click", () => {
searchInput.value = feature.properties.legal_location;
suggestionsEl.style.display = "none";
searchAndFlyTo(feature.properties.legal_location);
});
suggestionsEl.appendChild(li);
});
suggestionsEl.style.display = "block";
}
// Close suggestions when clicking elsewhere
document.addEventListener("click", (e) => {
if (!document.getElementById("search-container").contains(e.target)) {
suggestionsEl.style.display = "none";
}
});
The proximity parameter biases results toward the current map centre, so a user zoomed into Saskatchewan sees Saskatchewan results first.
Step 6: Add data layers
Township Canada serves additional data layers as vector tiles. These work the same way as grid layers — add a source and layer for each.
Available data tilesets
| Tileset | Label tileset | Description |
|---|---|---|
oil_gas_fields | oil_gas_fields_label | Petroleum field boundaries |
municipal_boundaries | municipal_boundaries_label | Municipal and county borders |
parks_and_protected_areas | parks_and_protected_areas_label | National and provincial parks |
water_bodies | water_bodies_label | Lakes and reservoirs |
watercourses | (same tileset) | Rivers and streams |
railways | (same tileset) | Railway lines |
roads | (same tileset) | Road network |
elevation | (same tileset) | Contour lines |
Adding data layers
// Oil & gas fields
map.addSource("oil_gas_fields", {
type: "vector",
tiles: [`https://maps.townshipcanada.com/oil_gas_fields/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 12
});
map.addLayer({
id: "oil_gas_fields",
type: "line",
source: "oil_gas_fields",
"source-layer": "oil_gas_fields",
paint: {
"line-color": "#b45309",
"line-width": 1
},
layout: { visibility: "none" } // Hidden by default
});
// Oil & gas field labels
map.addSource("oil_gas_fields_label", {
type: "vector",
tiles: [
`https://maps.townshipcanada.com/oil_gas_fields_label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`
],
minzoom: 0,
maxzoom: 12
});
map.addLayer({
id: "oil_gas_fields_label",
type: "symbol",
source: "oil_gas_fields_label",
"source-layer": "oil_gas_fields_label",
layout: {
"text-field": "{name}",
"text-size": 12,
visibility: "none"
},
paint: {
"text-color": "#b45309",
"text-halo-color": "#fff",
"text-halo-width": 1.5
}
});
// Municipal boundaries
map.addSource("municipal_boundaries", {
type: "vector",
tiles: [
`https://maps.townshipcanada.com/municipal_boundaries/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`
],
minzoom: 0,
maxzoom: 12
});
map.addLayer({
id: "municipal_boundaries",
type: "line",
source: "municipal_boundaries",
"source-layer": "municipal_boundaries",
paint: {
"line-color": "#6366f1",
"line-width": 1.5,
"line-dasharray": [4, 2]
},
layout: { visibility: "none" }
});
map.addSource("municipal_boundaries_label", {
type: "vector",
tiles: [
`https://maps.townshipcanada.com/municipal_boundaries_label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`
],
minzoom: 0,
maxzoom: 12
});
map.addLayer({
id: "municipal_boundaries_label",
type: "symbol",
source: "municipal_boundaries_label",
"source-layer": "municipal_boundaries_label",
layout: {
"text-field": "{name}",
"text-size": 12,
visibility: "none"
},
paint: {
"text-color": "#6366f1",
"text-halo-color": "#fff",
"text-halo-width": 1.5
}
});
Use the same pattern for parks_and_protected_areas, water_bodies, and other data layers listed in the table above.
Step 7: Toggle layers on and off
Add a control panel that lets users show and hide layers:
<div
id="layer-controls"
style="position:absolute; top:10px; right:10px; z-index:1; background:#fff;
padding:12px 16px; border-radius:8px; box-shadow:0 2px 6px rgba(0,0,0,0.15);
font-family:sans-serif; font-size:13px;"
>
<strong style="display:block; margin-bottom:8px;">Data Layers</strong>
<label style="display:block; margin:4px 0; cursor:pointer;">
<input
type="checkbox"
data-layers="oil_gas_fields,oil_gas_fields_label"
/>
Oil & Gas Fields
</label>
<label style="display:block; margin:4px 0; cursor:pointer;">
<input
type="checkbox"
data-layers="municipal_boundaries,municipal_boundaries_label"
/>
Municipal Boundaries
</label>
<label style="display:block; margin:4px 0; cursor:pointer;">
<input
type="checkbox"
data-layers="parks_and_protected_areas,parks_and_protected_areas_label"
/>
Parks
</label>
</div>
document.querySelectorAll('#layer-controls input[type="checkbox"]').forEach((checkbox) => {
checkbox.addEventListener("change", (e) => {
const layerIds = e.target.dataset.layers.split(",");
const visibility = e.target.checked ? "visible" : "none";
layerIds.forEach((id) => {
if (map.getLayer(id)) {
map.setLayoutProperty(id, "visibility", visibility);
}
});
});
});
Full working example
Here's the complete HTML file combining all the steps above. Replace YOUR_MAPBOX_TOKEN and YOUR_TOWNSHIP_CANADA_API_KEY with your actual keys.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Township Canada + Mapbox GL JS</title>
<meta
name="viewport"
content="initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.9.3/mapbox-gl.js"></script>
<link
href="https://api.mapbox.com/mapbox-gl-js/v3.9.3/mapbox-gl.css"
rel="stylesheet"
/>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
#search-container {
position: absolute;
top: 10px;
left: 10px;
z-index: 1;
width: 320px;
}
#search-input {
width: 100%;
padding: 10px 14px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
}
#suggestions {
list-style: none;
margin: 4px 0 0;
padding: 0;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
display: none;
}
#suggestions li {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid #eee;
font-size: 14px;
}
#suggestions li:hover {
background: #f5f5f5;
}
#suggestions li:last-child {
border-bottom: none;
}
#layer-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
background: #fff;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
font-size: 13px;
}
#layer-controls label {
display: block;
margin: 4px 0;
cursor: pointer;
}
</style>
</head>
<body>
<div id="search-container">
<input
id="search-input"
type="text"
placeholder="Search legal land description..."
/>
<ul id="suggestions"></ul>
</div>
<div id="layer-controls">
<strong style="display:block; margin-bottom:8px;">Data Layers</strong>
<label
><input
type="checkbox"
data-layers="oil_gas_fields,oil_gas_fields_label"
/>
Oil & Gas Fields</label
>
<label
><input
type="checkbox"
data-layers="municipal_boundaries,municipal_boundaries_label"
/>
Municipal Boundaries</label
>
</div>
<div id="map"></div>
<script>
// --- Configuration ---
const MAPBOX_TOKEN = "YOUR_MAPBOX_TOKEN";
const TC_API_KEY = "YOUR_TOWNSHIP_CANADA_API_KEY";
const TC_TILES = "https://maps.townshipcanada.com";
const TC_API = "https://developer.townshipcanada.com";
mapboxgl.accessToken = MAPBOX_TOKEN;
const map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/light-v11",
center: [-114, 51],
zoom: 9
});
map.addControl(new mapboxgl.NavigationControl());
// --- Helper: add grid boundary + label layers ---
function addGridLayer(province, layer, layers) {
const sourceId = `${province}_${layer}`;
map.addSource(sourceId, {
type: "vector",
tiles: [`${TC_TILES}/${province}/${layer}/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 14
});
layers.forEach((l) => {
map.addLayer({
id: l.id,
type: "line",
source: sourceId,
"source-layer": l.id,
minzoom: l.minZoom,
maxzoom: l.maxZoom,
paint: { "line-color": "#2d5a47", "line-width": l.width || 1 }
});
});
}
function addGridLabels(province, layer, layers) {
const sourceId = `${province}_${layer}_label`;
map.addSource(sourceId, {
type: "vector",
tiles: [`${TC_TILES}/${province}/${layer}-label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 14
});
layers.forEach((l) => {
map.addLayer({
id: l.id,
type: "symbol",
source: sourceId,
"source-layer": l.id,
minzoom: l.minZoom,
maxzoom: l.maxZoom,
layout: { "text-field": l.text, "text-size": 13 },
paint: { "text-color": "#333", "text-halo-color": "#fff", "text-halo-width": 2 }
});
});
}
function addDataLayer(tileset, color, visible) {
map.addSource(`${tileset}_source`, {
type: "vector",
tiles: [`${TC_TILES}/${tileset}/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 12
});
map.addLayer({
id: tileset,
type: "line",
source: `${tileset}_source`,
"source-layer": tileset,
paint: { "line-color": color, "line-width": 1 },
layout: { visibility: visible ? "visible" : "none" }
});
// Labels
map.addSource(`${tileset}_label_source`, {
type: "vector",
tiles: [`${TC_TILES}/${tileset}_label/{z}/{x}/{y}.mvt?api_key=${TC_API_KEY}`],
minzoom: 0,
maxzoom: 12
});
map.addLayer({
id: `${tileset}_label`,
type: "symbol",
source: `${tileset}_label_source`,
"source-layer": `${tileset}_label`,
layout: {
"text-field": "{name}",
"text-size": 12,
visibility: visible ? "visible" : "none"
},
paint: { "text-color": color, "text-halo-color": "#fff", "text-halo-width": 1.5 }
});
}
// --- Map load ---
map.on("load", () => {
// DLS Grid: Townships
addGridLayer("ab", "twp", [{ id: "ab_twp", minZoom: 6, maxZoom: 12, width: 1.5 }]);
addGridLayer("sk", "twp", [{ id: "sk_twp", minZoom: 6, maxZoom: 12, width: 1.5 }]);
addGridLayer("mb", "twp", [{ id: "mb_twp", minZoom: 6, maxZoom: 12, width: 1.5 }]);
addGridLabels("ab", "twp", [
{ id: "ab_twp_label", minZoom: 10, maxZoom: 12, text: "{descriptor}" }
]);
addGridLabels("sk", "twp", [
{ id: "sk_twp_label", minZoom: 10, maxZoom: 12, text: "{descriptor}" }
]);
addGridLabels("mb", "twp", [
{ id: "mb_twp_label", minZoom: 10, maxZoom: 12, text: "{descriptor}" }
]);
// DLS Grid: Sections
addGridLayer("ab", "sec", [{ id: "ab_sec", minZoom: 12, maxZoom: 14 }]);
addGridLayer("sk", "sec", [{ id: "sk_sec", minZoom: 12, maxZoom: 14 }]);
addGridLayer("mb", "sec", [{ id: "mb_sec", minZoom: 12, maxZoom: 14 }]);
addGridLabels("ab", "sec", [
{ id: "ab_sec_label", minZoom: 12, maxZoom: 14, text: "{section}" }
]);
addGridLabels("sk", "sec", [
{ id: "sk_sec_label", minZoom: 12, maxZoom: 14, text: "{section}" }
]);
addGridLabels("mb", "sec", [
{ id: "mb_sec_label", minZoom: 12, maxZoom: 14, text: "{section}" }
]);
// DLS Grid: LSDs
addGridLayer("ab", "lsd", [{ id: "ab_lsd", minZoom: 14, maxZoom: 20, width: 0.5 }]);
addGridLayer("sk", "lsd", [{ id: "sk_lsd", minZoom: 14, maxZoom: 20, width: 0.5 }]);
addGridLayer("mb", "lsd", [{ id: "mb_lsd", minZoom: 14, maxZoom: 20, width: 0.5 }]);
addGridLabels("ab", "lsd", [
{ id: "ab_lsd_label", minZoom: 14, maxZoom: 20, text: "{lsd}" }
]);
addGridLabels("sk", "lsd", [
{ id: "sk_lsd_label", minZoom: 14, maxZoom: 20, text: "{lsd}" }
]);
addGridLabels("mb", "lsd", [
{ id: "mb_lsd_label", minZoom: 14, maxZoom: 20, text: "{lsd}" }
]);
// Data layers (hidden by default)
addDataLayer("oil_gas_fields", "#b45309", false);
addDataLayer("municipal_boundaries", "#6366f1", false);
// Click-to-identify on township layer
map.on("click", "ab_twp", (e) => {
if (e.features.length === 0) return;
new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setHTML(
`<strong>${e.features[0].properties.legal_location || e.features[0].properties.descriptor || "Township"}</strong>`
)
.addTo(map);
});
map.on("mouseenter", "ab_twp", () => (map.getCanvas().style.cursor = "pointer"));
map.on("mouseleave", "ab_twp", () => (map.getCanvas().style.cursor = ""));
});
// --- Search and fly to ---
let currentMarker = null;
async function searchAndFlyTo(query) {
const response = await fetch(
`${TC_API}/search/legal-location?location=${encodeURIComponent(query)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
if (!data.features || data.features.length === 0) return;
const centroid = data.features.find((f) => f.properties.shape === "centroid");
const polygon = data.features.find((f) => f.geometry.type === "MultiPolygon");
if (!centroid) return;
const [lng, lat] = centroid.geometry.coordinates;
map.flyTo({ center: [lng, lat], zoom: 14, duration: 2000 });
if (currentMarker) currentMarker.remove();
currentMarker = new mapboxgl.Marker({ color: "#2d5a47" })
.setLngLat([lng, lat])
.setPopup(
new mapboxgl.Popup().setHTML(
`<strong>${centroid.properties.legal_location}</strong><br>${lat.toFixed(6)}, ${lng.toFixed(6)}`
)
)
.addTo(map);
if (polygon) {
if (map.getSource("search-result")) {
map.removeLayer("search-result-fill");
map.removeLayer("search-result-outline");
map.removeSource("search-result");
}
map.addSource("search-result", { type: "geojson", data: polygon });
map.addLayer({
id: "search-result-fill",
type: "fill",
source: "search-result",
paint: { "fill-color": "#2d5a47", "fill-opacity": 0.15 }
});
map.addLayer({
id: "search-result-outline",
type: "line",
source: "search-result",
paint: { "line-color": "#2d5a47", "line-width": 2 }
});
}
}
// --- Autocomplete ---
const searchInput = document.getElementById("search-input");
const suggestionsEl = document.getElementById("suggestions");
let debounceTimer;
searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
if (query.length < 2) {
suggestionsEl.style.display = "none";
return;
}
debounceTimer = setTimeout(() => fetchSuggestions(query), 300);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
suggestionsEl.style.display = "none";
searchAndFlyTo(searchInput.value.trim());
}
});
async function fetchSuggestions(query) {
const center = map.getCenter();
const response = await fetch(
`${TC_API}/autocomplete/legal-location?location=${encodeURIComponent(query)}&limit=3` +
`&proximity=${center.lng.toFixed(4)},${center.lat.toFixed(4)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
suggestionsEl.innerHTML = "";
if (!data.features || data.features.length === 0) {
suggestionsEl.style.display = "none";
return;
}
data.features.forEach((feature) => {
const li = document.createElement("li");
li.textContent = `${feature.properties.legal_location} (${feature.properties.province})`;
li.addEventListener("click", () => {
searchInput.value = feature.properties.legal_location;
suggestionsEl.style.display = "none";
searchAndFlyTo(feature.properties.legal_location);
});
suggestionsEl.appendChild(li);
});
suggestionsEl.style.display = "block";
}
document.addEventListener("click", (e) => {
if (!document.getElementById("search-container").contains(e.target)) {
suggestionsEl.style.display = "none";
}
});
// --- Layer toggles ---
document.querySelectorAll('#layer-controls input[type="checkbox"]').forEach((cb) => {
cb.addEventListener("change", (e) => {
const ids = e.target.dataset.layers.split(",");
const vis = e.target.checked ? "visible" : "none";
ids.forEach((id) => {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", vis);
});
});
});
</script>
</body>
</html>
Save this file, replace the two API keys at the top, and open it in a browser. You'll see the DLS survey grid across Alberta, Saskatchewan, and Manitoba, with a search box and layer toggles.
Next steps
- Add NTS grid layers for British Columbia using the
bc/series,bc/block,bc/unit, andbc/qtr-unitlayers - Use the Batch API to plot hundreds of locations at once
- Add more data layers —
parks_and_protected_areas,water_bodies,railways,elevation - Switch to
mapbox://styles/mapbox/satellite-streets-v12for satellite imagery under the grid
Related guides
- API Integration Guide — API endpoints, pricing, and key management
- Interactive Maps — Map features in the Township Canada web app
- Batch Conversion — Process thousands of locations at once
- What is a Legal Land Description? — DLS, NTS, and other survey systems explained
Related Guides
Legal Land Description API Integration Guide
Integrate legal land description APIs into your applications. Convert LLDs to coordinates, add autocomplete search, process batch records, and display DLS/NTS grid maps. REST API with JSON responses.
Managing API Keys for Development, Staging, and Production
Create and manage multiple Township Canada API keys for different environments. Naming conventions, key rotation, environment variables, and CI/CD setup.
Building Autocomplete Search with the Township Canada API
Build a search-as-you-type component for legal land descriptions using the Township Canada Autocomplete API. Includes debouncing, proximity biasing, and examples in vanilla JS and React.