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 layersource-layertext-fieldSource zoomLayer zoom
twp{prov}_twp0–146–12
twp-label{prov}_twp_label{descriptor}0–1410–12
sec{prov}_sec9–1412–14
sec-label{prov}_sec_label{section}9–1412–14
qtr{prov}_qtr9–1412–14
qtr-label{prov}_qtr_label{descriptor}9–1412–14
lsd{prov}_lsd9–1414–20
lsd-label{prov}_lsd_label{lsd}9–1414–20

MB River Lots (province: mb):

URL layersource-layertext-fieldSource zoomLayer zoom
river-lotsmb_river_lots0–1412–20
river-lots-labelmb_river_lots_label{descriptor}0–1412–20

NTS grid (province: bc):

URL layersource-layertext-fieldSource zoomLayer zoom
seriesbc_series0–140–10
series-labelbc_series_label{descriptor}0–147–10
blockbc_block9–1410–13
block-labelbc_block_label{descriptor}9–1410–13
unitbc_unit9–1413–14
unit-labelbc_unit_label{descriptor}9–1413–14
qtr-unitbc_qtr_unit9–1414–20
qtr-unit-labelbc_qtr_unit_label{descriptor}9–1414–20

Ontario (province: on):

URL layersource-layertext-fieldSource zoomLayer zoom
twpon_twp0–146–12
twp-labelon_twp_label{descriptor}0–146–12
conon_con0–1412–14
con-labelon_con_label{descriptor}0–1412–14
loton_lot0–1414–20
lot-labelon_lot_label{descriptor}0–1414–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.

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

TilesetLabel tilesetDescription
oil_gas_fieldsoil_gas_fields_labelPetroleum field boundaries
municipal_boundariesmunicipal_boundaries_labelMunicipal and county borders
parks_and_protected_areasparks_and_protected_areas_labelNational and provincial parks
water_bodieswater_bodies_labelLakes 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, and bc/qtr-unit layers
  • 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-v12 for satellite imagery under the grid