Using Township Canada API with MapLibre GL JS
Display Canadian survey grids and search legal land descriptions using MapLibre GL JS and the Township Canada API. Open-source, no Mapbox token required.
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 MapLibre GL JS and the Township Canada API.
MapLibre GL JS is a community-maintained, open-source fork of Mapbox GL JS. It has full support for vector tiles, uses the same addSource/addLayer API you already know, and requires no Mapbox account or token. The only credential you need is a Township Canada API key.
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 MapLibre map
- Searches legal land descriptions and flies to the result
- Shows a popup with legal land description details when you click a grid cell
- Toggles data layers (oil & gas fields, municipal boundaries) on and off
No Mapbox account, no billing setup — just a Township Canada API key and a free base map style.
Prerequisites
- 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 MapLibre GL JS loaded from CDN. This example uses v4.x and a free OpenFreeMap style — no tokens required for either:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Township Canada + MapLibre GL JS</title>
<meta
name="viewport"
content="initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<link
href="https://unpkg.com/maplibre-gl@4/dist/maplibre-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 TC_API_KEY = "YOUR_TOWNSHIP_CANADA_API_KEY";
const map = new maplibregl.Map({
container: "map",
style: "https://tiles.openfreemap.org/styles/liberty",
center: [-114, 51], // Calgary, AB
zoom: 9
});
map.addControl(new maplibregl.NavigationControl());
</script>
</body>
</html>
Open this file in a browser. You should see a map centred on Calgary with no API keys beyond your Township Canada key.
If you prefer a minimal style, substitute https://demotiles.maplibre.org/style.json as the style value — it has no external dependencies and works offline.
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
The tile URL format is identical to the Mapbox GL JS integration — you can reuse the same layer configuration.
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 a map.on('load') callback. Each grid level uses zoom-dependent visibility so townships appear first, then sections, then LSDs as you zoom in:
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 on the map. Townships appear first, then sections, then LSDs — matching the natural hierarchy of the DLS survey system.
To add grids for other provinces, add new sources using the province prefix (e.g. sk/twp, mb/sec) and the 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
let currentMarker = null;
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;
}
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 maplibregl.Marker({ color: "#2d5a47" })
.setLngLat([lng, lat])
.setPopup(
new maplibregl.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 }
});
}
}
// 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:
map.on("click", "ab_twp", (e) => {
if (e.features.length === 0) return;
const props = e.features[0].properties;
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`<strong>${props.legal_location || props.descriptor || "Township"}</strong>`)
.addTo(map);
});
map.on("mouseenter", "ab_twp", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "ab_twp", () => {
map.getCanvas().style.cursor = "";
});
Attach the same click handler to ab_sec and ab_lsd so the popup works at every zoom level.
Step 5: 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 with toggle controls
Add the layer controls HTML above the map <div>:
<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>
</div>
Then add the data layers inside your map.on('load') callback, hidden by default:
// Oil & gas fields
map.addSource("oil_gas_fields_source", {
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",
"source-layer": "oil_gas_fields",
paint: { "line-color": "#b45309", "line-width": 1 },
layout: { visibility: "none" }
});
map.addSource("oil_gas_fields_label_source", {
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",
"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_source", {
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",
"source-layer": "municipal_boundaries",
paint: { "line-color": "#6366f1", "line-width": 1.5, "line-dasharray": [4, 2] },
layout: { visibility: "none" }
});
map.addSource("municipal_boundaries_label_source", {
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",
"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 }
});
Wire up the toggle checkboxes:
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);
});
});
});
Full working example
Here's the complete HTML file combining all the steps above. The only key you need is your Township Canada API key — no Mapbox token, no third-party billing.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Township Canada + MapLibre GL JS</title>
<meta
name="viewport"
content="initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<link
href="https://unpkg.com/maplibre-gl@4/dist/maplibre-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 TC_API_KEY = "YOUR_TOWNSHIP_CANADA_API_KEY";
const TC_TILES = "https://maps.townshipcanada.com";
const TC_API = "https://developer.townshipcanada.com";
const map = new maplibregl.Map({
container: "map",
style: "https://tiles.openfreemap.org/styles/liberty",
center: [-114, 51],
zoom: 9
});
map.addControl(new maplibregl.NavigationControl());
// --- Helpers ---
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) {
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: "none" }
});
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: "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");
addDataLayer("municipal_boundaries", "#6366f1");
// Click-to-identify on township layer
map.on("click", "ab_twp", (e) => {
if (e.features.length === 0) return;
new maplibregl.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 maplibregl.Marker({ color: "#2d5a47" })
.setLngLat([lng, lat])
.setPopup(
new maplibregl.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, drop in your Township Canada API key, and open it in a browser. You'll see the DLS survey grid across Alberta, Saskatchewan, and Manitoba with search and layer toggles — built entirely on free, open-source software.
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 - Already using Mapbox? The Mapbox GL JS integration guide covers the same feature set with a Mapbox base style
Related guides
- API Integration Guide — API endpoints, pricing, and key management
- Mapbox GL JS Integration — Same features using Mapbox GL JS
- Maps API Vector Tiles — Full tileset reference and NTS grids
- 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.