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 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 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

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 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, 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
  • Already using Mapbox? The Mapbox GL JS integration guide covers the same feature set with a Mapbox base style