Skip to content

Lesson 4 — Coverage and territory

The previous three lessons all queried geometries — measure distance, filter by proximity, compute travel between consecutive locations. This lesson does something different: it constructs new geometries from existing data.

That’s a real shift. Until now, the spatial primitives in the database (customer points, SA boundaries, depot points) were fixed — we asked questions about them but didn’t change their shapes. Construction operations create new shapes derived from existing ones:

  • ST_Buffer(geom, distance) — a polygon expanded outward from the source by the given distance
  • ST_Union(geoms) — the merger of multiple geometries into one
  • ST_Difference(a, b) — the part of a that’s not in b
  • ST_ConvexHull(geom) — the smallest convex polygon containing a set of points
  • ST_ConcaveHull(geom, target_percent) — a non-convex polygon that follows the density of a set of points

These functions answer questions that pure querying can’t. “What’s the area covered by my customer base around each depot?” needs a constructed polygon. “Where does our combined service area actually cover?” needs a union. “How does our official territory compare to where customers really are?” needs a difference.

The lesson covers two real operational questions:

  • What’s each depot’s customer coverage? Buffer each customer by 3km; union the buffers per depot. The result is a polygon showing every area within 3km of a real customer — a natural shape for service-zone planning.
  • Where does the official territory match the customer reality? Compare the customer coverage polygon to the union of the depot’s SAs. Show where they align and where they don’t.

The output is a new map report: Depot Territories, showing each depot’s customer footprint overlaid on the country.

Constructing geometries

A small mental shift to start with. Most of what we’ve done so far treats geometries as data — points and polygons that exist in the database, we query relationships between them. Construction operations produce new geometries by computing on existing ones.

PostgreSQL handles these the same way it handles any other function: the result is a value, the value can be stored, returned, or used as input to another function. So you can:

1
2
3
4
5
6
7
-- Customer coverage area for a depot, as GeoJSON
SELECT ST_AsGeoJSON(
         ST_Union(ST_Buffer(c.location::geography, 3000)::geometry)
       )
FROM customers c
JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)
WHERE sa.depot_id = 1;

For each customer location, ST_Buffer produces a polygon expanding 3km outward (the geography cast makes the distance metres). ST_Union merges all those overlapping circles into a single coverage polygon. ST_AsGeoJSON serialises it. Three function calls, one output: a polygon ready to ship to a map.

This composability is one of PostGIS’s quiet strengths. Function output flows into function input naturally; complex constructions emerge from simple primitives.

Spatial joins, briefly

The query above uses ST_Contains to associate customers with service areas. Worth pausing on this — it’s a different pattern from earlier modules.

In Module 6 (Lesson 5 in particular) we used FK joins where possible: JOIN jobs j ON j.service_area_id = sa.id. The FK already encodes the spatial relationship; the join is fast because it uses an indexed column.

Customers don’t have an SA foreign key in our schema. They have a location (geometry point), and the relationship to service areas is purely spatial — a customer is “in” an SA when their location falls inside the SA’s boundary polygon. To associate them, we have to actually compute the spatial relationship at query time:

1
JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)

This is the right tool for the job. The relationship hasn’t been pre-computed into a column, so we use the spatial operation.

The good news: PostGIS’s GIST index on customers.location makes this fast. Run EXPLAIN ANALYZE on the query above and you’ll see something like:

Index Scan using index_customers_on_location
  Index Cond: (location @ sa.boundary)
  Filter: st_contains(sa.boundary, location)

The @ operator (contained-by) is the bounding-box pre-filter — the GIST index quickly narrows candidates to customers whose bounding boxes are inside the SA’s boundary box, then ST_Contains refines to actual containment. Fast, accurate, indexed.

For our scale (~3000 customers, ~340 SAs), the join takes ~20ms. At larger scales the index keeps it under control. This is the indexed-spatial-join pattern at work.

What ST_Buffer and ST_Union do together

ST_Buffer(geom, distance) produces a polygon expanded outward from the input geometry by the given distance. For a point, this is a circle (well, a many-sided polygon approximating a circle); for a line, it’s a thick band; for a polygon, it’s the polygon expanded outward.

When we say c.location::geography, we cast the location to geography so the distance is interpreted in metres — same trick as Lesson 1’s distance work. ST_Buffer returns a geography result, which we cast back to geometry so it can be unioned with other geometry values.

ST_Union (used as an aggregate) combines geometries. Where they overlap or touch, the boundaries are smoothed away. The result is a single polygon (or multi-polygon if there are disconnected parts).

Together, buffer-then-union is a natural way to express “the area within X of any of these things”:

  • All the area within 3km of any customer
  • All the area within 5km of any depot
  • All the area within 100m of any road

The shape of the result reflects the underlying density. Where points are clustered, the buffers overlap heavily and merge into a solid mass. Where points are isolated, they appear as small circular bumps. Where points are scattered along a line (a highway corridor, a coastal road), the result is a thick band.

Why we don’t use a convex or concave hull

PostGIS provides two hull functions — ST_ConvexHull(geom) and ST_ConcaveHull(geom, target_percent) — that wrap a polygon around a set of points. They sound like they should fit our “customer footprint” question. They do, until you actually try them.

The convex hull is the smallest convex polygon containing the points. For 518 customers spread across metro Sydney, it might have just 5 vertices — a coarse bounding shape that says “the customers are somewhere in this rectangle.” For depots with fewer customers, the hull collapses into triangles. Visually useless.

The concave hull tries harder, producing a non-convex polygon that follows point density. It works better for dense compact clusters. But for spread-out distributions — customers along a highway corridor, customers in a coastal town — the concave hull produces thin, awkward shapes that are visually unhelpful too.

The buffered union avoids both problems. It doesn’t try to be a single coherent polygon enclosing everything; it’s an honest representation of “areas within 3km of a customer.” The shape reflects reality without trying to summarise it.

The cost: buffered unions are computationally heavier than hulls. For 3000 customers across 9 depots, generating ~3000 buffers and unioning them takes ~120ms — fast enough for an on-demand report. At larger scale (millions of customers), this approach becomes expensive and you’d switch to a different strategy (sampling, caching, periodic batch-precomputation). For our scale, it’s fine.

Building the coverage query

Step by step. First, get all customers for a single depot:

1
2
3
4
5
SELECT c.id, c.name, ST_AsText(c.location)
FROM customers c
JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)
WHERE sa.depot_id = 1
LIMIT 10;

Substitute a depot id you have. The result is a sample of customer points belonging to one depot’s catchment.

Now buffer each customer by 3km and merge the buffers:

1
2
3
4
5
6
SELECT ST_AsText(
         ST_Union(ST_Buffer(c.location::geography, 3000)::geometry)
       )
FROM customers c
JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)
WHERE sa.depot_id = 1;

The output is a long MULTIPOLYGON(...) string — the unioned shape can have many vertices and may consist of multiple disconnected polygons (if there are isolated customer pockets beyond the 3km reach of the main cluster).

To confirm the data flow, check the size:

1
2
3
4
5
6
SELECT LENGTH(ST_AsText(
              ST_Union(ST_Buffer(c.location::geography, 3000)::geometry)
            )) AS shape_size
FROM customers c
JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)
WHERE sa.depot_id = 1;

A non-zero size confirms the operation worked and produced a real shape.

Per-depot coverage

Replicate the calculation across all depots:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
SELECT d.id,
       d.code,
       d.name,
       COUNT(c.id) AS customer_count,
       ST_AsGeoJSON(
         ST_Union(ST_Buffer(c.location::geography, 3000)::geometry)
       )::jsonb AS coverage
FROM depots d
JOIN service_areas sa ON sa.depot_id = d.id
JOIN customers c ON ST_Contains(sa.boundary, c.location)
GROUP BY d.id, d.code, d.name
ORDER BY d.code;

Output:

 id |  code  |     name      | customer_count |        footprint
----+--------+---------------+----------------+--------------------------
  1 | SYDCBD | Sydney CBD    |         412    | {"type":"Polygon",...}
  2 | MELCBD | Melbourne CBD |         387    | {"type":"Polygon",...}
  ...

One row per depot, with the customer count and a GeoJSON polygon ready for the map.

A few things worth noting about this query.

Two joins, two different relationships. depots → service_areas uses the FK (fast, indexed). service_areas → customers uses ST_Contains (the spatial relationship that hasn’t been pre-computed). Each join uses the right tool for what’s available.

The aggregation works on geometry. ST_Collect is a PostGIS-provided aggregate function — it operates the same way as SUM or COUNT but produces a geometry. Inside GROUP BY, it gathers all the customer locations for each depot.

The output is GeoJSON, ready for the map. No Ruby post-processing needed; the SQL produces what the browser consumes.

What ST_Union does

ST_ConvexHull answers “where are the customers?” The other question — “what’s the depot’s official territory?” — needs a different operation.

A depot’s official territory is the union of all its service areas. Each SA is a polygon (the boundary column); the depot’s territory is the merged shape covering all of them.

ST_Union does this:

1
2
3
SELECT ST_AsGeoJSON(ST_Union(sa.boundary))::jsonb AS territory
FROM service_areas sa
WHERE sa.depot_id = 1;

Output is a polygon (or multi-polygon, if the SAs aren’t all contiguous) representing the combined territory.

ST_Union is also an aggregate — it takes a column of geometries and merges them. Where the input geometries overlap or share boundaries, the result smooths over the seams.

A note on validity

ST_Union (and several other PostGIS operations) requires its inputs to be valid geometries. A geometry is valid when its structure is internally consistent — no self-intersecting boundaries, no holes outside the outer ring, no degenerate components.

Most polygons are valid by construction. But polygons imported from external sources — administrative boundaries, GIS exports, shapefile imports — can carry subtle structural problems. The ABS service area boundaries we use in this tutorial include a small number with self-intersections at very fine scales. You’d never see them visually (they’re rendering-imperceptible), but ST_Union raises an error when it tries to merge them:

ERROR: lwgeom_unaryunion_prec: GEOS Error: TopologyException: 
  side location conflict at 149.89757... -21.01685...

Two ways to handle this:

Repair the data once. A one-time fix that means downstream queries stay clean:

1
2
3
UPDATE service_areas
SET boundary = ST_MakeValid(boundary)
WHERE NOT ST_IsValid(boundary);

ST_MakeValid repairs invalid geometries — usually by minor adjustments at the problematic vertices. Valid geometries pass through unchanged.

Defensive repair in queries. Wrap inputs in ST_MakeValid wherever they feed into operations that need validity:

1
2
3
SELECT ST_AsGeoJSON(ST_Union(ST_MakeValid(sa.boundary)))::jsonb
FROM service_areas sa
WHERE sa.depot_id = 1;

For our chassis, the seed task applies the one-time repair after importing boundaries (see the seed file). The lesson’s queries assume valid inputs from this point forward.

To diagnose invalid geometries in your own data:

1
2
3
SELECT id, code, ST_IsValidReason(boundary)
FROM service_areas
WHERE NOT ST_IsValid(boundary);

ST_IsValidReason returns a description of what’s wrong (and where), which is invaluable when investigating. See Appendix B — Validity and repair for fuller treatment.

The comparison query

Now the substantive question: how does the customer footprint compare to the official territory?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
SELECT d.id,
       d.code,
       d.name,
       (SELECT COUNT(c.id) 
        FROM customers c 
        JOIN service_areas sa ON ST_Contains(sa.boundary, c.location) 
        WHERE sa.depot_id = d.id) AS customer_count,
       (SELECT ST_AsGeoJSON(
                 ST_Union(ST_Buffer(c.location::geography, 3000)::geometry)
               )::jsonb 
        FROM customers c 
        JOIN service_areas sa ON ST_Contains(sa.boundary, c.location) 
        WHERE sa.depot_id = d.id) AS customer_coverage,
       (SELECT ST_AsGeoJSON(ST_Union(sa.boundary))::jsonb 
        FROM service_areas sa 
        WHERE sa.depot_id = d.id) AS official_territory
FROM depots d
ORDER BY d.code;

Each row now has both polygons. Showing them on a map together reveals the relationship visually — overlapping shapes, areas where one extends beyond the other.

The mismatch is interesting:

  • Customer coverage is much smaller than official territory — the typical case, especially for regional depots. The official territory covers vast administrative regions; the customer coverage shows only the populated pockets where customers actually exist.
  • Customer coverage extends to the edges of official territory — the depot’s customers are spread to use most of its territory.
  • Customer coverage extends beyond official territory — would indicate customers near boundary edges. With our seed data (customers placed by spatial containment in SAs), this shouldn’t happen.

The visual contrast between the dense customer coverage zones and the sparse-but-vast official territories is operationally revealing — it shows where the depot’s effort is actually deployed versus where it could in principle be deployed.

A note on buffer distance

The 3km radius is a parameter choice with operational meaning. It represents “what counts as being served by this depot.” Different businesses would pick different values:

  • 5-10km for typical urban service operations (drive time of 10-20 minutes)
  • 1-2km for walk-shed analysis (urban planning, retail catchments)
  • 20-50km for rural service operations
  • 100m for highly localised analysis (delivery routes, foot traffic)

In a production system, the buffer distance would likely come from a configuration value or a per-depot field rather than being hardcoded. Worth keeping the constant visible at the top of the service object so it’s easy to find and adjust.

A service object for the data

Wrap the comparison query in a service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
module DepotTerritories
  extend self

  # Buffer distance for the customer coverage zone. Each customer
  # contributes a circle of this radius (metres); circles are
  # unioned per depot to produce the coverage polygon. 3km is a
  # reasonable starting point for urban service operations —
  # roughly 10-15 minutes drive time.
  COVERAGE_RADIUS_M = 3000

  # Returns each depot with its customer coverage (buffered union
  # of customer locations) and official territory (union of
  # assigned SAs). Both are GeoJSON-ready.
  #
  # The query uses correlated subqueries — three per depot — to
  # avoid the cross-aggregation issues that come with mixing
  # buffer/union on customers with ST_Union on SAs in a single
  # GROUP BY. Postgres handles the small number of subqueries
  # efficiently because each one engages spatial indexes.
  def call
    rows = ActiveRecord::Base.connection.select_all(<<~SQL).rows
      SELECT d.id,
             d.code,
             d.name,
             ST_AsGeoJSON(d.location)::jsonb AS depot_location,
             (SELECT COUNT(c.id)
              FROM customers c
              JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)
              WHERE sa.depot_id = d.id) AS customer_count,
             (SELECT ST_AsGeoJSON(
                       ST_Union(ST_Buffer(c.location::geography, #{COVERAGE_RADIUS_M})::geometry)
                     )::jsonb
              FROM customers c
              JOIN service_areas sa ON ST_Contains(sa.boundary, c.location)
              WHERE sa.depot_id = d.id) AS customer_coverage,
             (SELECT ST_AsGeoJSON(ST_Union(sa.boundary))::jsonb
              FROM service_areas sa WHERE sa.depot_id = d.id) AS official_territory
      FROM depots d
      ORDER BY d.code
    SQL

    rows.map { |id, code, name, depot_location, customer_count, coverage, territory|
      {
        id:                  id,
        code:                code,
        name:                name,
        customer_count:      customer_count.to_i,
        depot_location:      JSON.parse(depot_location),
        customer_coverage:   coverage && JSON.parse(coverage),
        official_territory:  territory && JSON.parse(territory)
      }
    }
  end
end

A few things worth noting.

Correlated subqueries instead of GROUP BY. When a query needs multiple aggregations on different relationships (customers counted spatially per depot, SAs unioned per depot), correlated subqueries are often cleaner than trying to make one GROUP BY handle both. Each subquery is small and uses indexes.

Three geometries per row. Depot location (point), customer footprint (polygon from convex hull), official territory (polygon from union of SAs). The map can show all three layered.

Defensive JSON.parse. footprint && JSON.parse(footprint) protects against NULL — a depot with zero customers has no footprint geometry, so ST_ConvexHull(ST_Collect(NULL)) returns NULL. The guard avoids a JSON parse error.

A new report page

Adding the report to the manager’s reports section:

1
2
3
4
REPORTS = [
  # ... existing reports ...
  { label: "Depot Territories", path: "/reports/depot_territories", icon: :map, roles: %w[manager] }
].freeze

In ReportsController:

1
2
3
def depot_territories
  @territories = DepotTerritories.call
end

Route:

1
get "/reports/depot_territories", to: "reports#depot_territories", as: :depot_territories_report

The view at app/views/reports/depot_territories.html.erb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="h-full flex flex-col">
  <header class="px-6 py-4 border-b border-slate-200">
    <h1 class="text-2xl font-bold text-slate-900">Depot Territories</h1>
    <p class="text-sm text-slate-500 mt-1">
      Customer footprint (where your customers actually are) compared to
      official territory (the union of assigned service areas) for each depot.
    </p>
  </header>

  <div class="flex-1 min-h-0 p-6">
    <div class="h-full rounded-lg border border-slate-200 bg-white overflow-hidden flex">
      <div class="flex-1 min-w-0">
      <%= render Components::Reports::DepotTerritoriesMap.new(territories: @territories) %>
    </div>
      <aside id="sa-detail" class="w-96 border-l border-slate-200 overflow-y-auto">
        <div class="p-6 text-sm text-slate-500">
          <%= render Components::Reports::DepotTerritoriesList.new(territories: @territories) %>
        </div>
      </aside>
    </div>
  </div>
</div>

Clean composition: a header, a map, a sidebar. The view is just layout; the two components do the substantive work.

The map component

The map’s GeoJSON construction lives in a Phlex component, not in the view. This keeps the view readable and makes the map’s logic testable and reusable.

Create app/components/reports/depot_territories_map.rb:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
# frozen_string_literal: true

# Renders the depot territories map — three layers showing each
# depot's:
#
#   - depot location (dark dot)
#   - customer coverage zone (indigo polygon, buffered union of
#     customer locations within 3km)
#   - official territory (slate dashed polygon, union of assigned SAs)
#
# The component owns the GeoJSON construction. The view passes in
# the data structure from DepotTerritories.call; the component
# converts it to the three FeatureCollections the map needs.
class Components::Reports::DepotTerritoriesMap < Components::Base
  prop :territories, Array
  prop :height, String, default: -> { "100%" }

  def view_template
    render Vera::Map.new(
      id:                "depot-territories-map",
      height: @height,
      centre: [-34, 151],
      zoom: 9,
      zoom_indicator: :bottom_left,
      loading_indicator: true
    ) do |m|
      m.control :navigation
      m.control :fullscreen
      m.control :scale, unit: :metric

      m.source :territories, type: :geojson, data: territories_geojson
      m.source :coverage,    type: :geojson, data: coverage_geojson
      m.source :depots,      type: :geojson, data: depots_geojson

      # Official territory in light slate (background layer)
      m.layer :territory_fill, type: :fill, source: :territories,
              paint: { fill_color: "#ede8b2", fill_opacity: 0.15 }
      m.layer :territory_line, type: :line, source: :territories,
              paint: { line_color: "#ff0000", line_width: 1, line_dasharray: [2, 2] }

      # Customer coverage in indigo (foreground layer)
      m.layer :coverage_fill, type: :fill, source: :coverage,
              paint: { fill_color: "#6366f1", fill_opacity: 0.35 }
      m.layer :coverage_line, type: :line, source: :coverage,
              paint: { line_color: "#4338ca", line_width: 1.5 }

      # Depot points
      m.layer :depot_points, type: :circle, source: :depots,
              paint: {
                circle_radius:       8,
                circle_color:        "#1e293b",
                circle_stroke_color: "#ffffff",
                circle_stroke_width: 2
              }
    end
  end

  private

  def territories_geojson
    {
      type:     "FeatureCollection",
      features: @territories.filter_map { |t|
        next unless t[:official_territory]
        {
          type:       "Feature",
          geometry:   t[:official_territory],
          properties: { code: t[:code], name: t[:name] }
        }
      }
    }
  end

  def coverage_geojson
    {
      type:     "FeatureCollection",
      features: @territories.filter_map { |t|
        next unless t[:customer_coverage]
        {
          type:       "Feature",
          geometry:   t[:customer_coverage],
          properties: {
            code:           t[:code],
            name:           t[:name],
            customer_count: t[:customer_count]
          }
        }
      }
    }
  end

  def depots_geojson
    {
      type:     "FeatureCollection",
      features: @territories.map { |t|
        {
          type:       "Feature",
          geometry:   t[:depot_location],
          properties: { code: t[:code], name: t[:name] }
        }
      }
    }
  end
end

A few things worth noting.

Three GeoJSON FeatureCollections, each from a different source. Territories from t[:official_territory], footprints from t[:customer_footprint], depots from t[:depot_location]. Each gets the same wrapping shape (FeatureCollection with features: [...]); each has slightly different properties relevant to the data (territories get just code+name; footprints add the customer count; depots get code+name).

filter_map for nullable geometries. A depot with zero customers has no customer_footprint (the convex hull of an empty set is NULL). filter_map skips those entries cleanly — next unless returns nil, filter_map discards nils. Without this, those features would have null geometry and the map would probably error.

Three layers per source for polygons. Each polygon source (territories, footprints) gets a fill layer and a line layer. This is standard MapLibre — the fill is the body colour, the line is the boundary. Drawing both gives the polygon a clear outline. Different colours, different styles (the territory uses dashed lines, the footprint uses solid).

Z-ordering by layer declaration. MapLibre draws layers in the order they’re added. Territory layers are declared first, so they appear behind the footprints. Depot points are declared last and sit on top. This is the visual hierarchy we want — territory in the background, footprint overlaid, depot point on top.

The sidebar list

A simple Phlex component listing depots with their customer counts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Components::Reports::DepotTerritoriesList < Components::Base
  prop :territories, Array

  def view_template
    div(class: "p-4 space-y-3") do
      h2(class: "text-sm font-semibold text-slate-900 uppercase tracking-wider mb-3") { "Depots" }

      @territories.each do |territory|
        div(class: "p-3 rounded-lg bg-slate-50 border border-slate-200") do
          div(class: "flex items-center justify-between") do
            div do
              p(class: "font-semibold text-slate-900") { territory[:name] }
              p(class: "text-xs text-slate-500") { territory[:code] }
            end
            span(class: "text-sm font-medium text-slate-700 tabular-nums") do
              territory[:customer_count].to_s
            end
          end
        end
      end
    end
  end
end

Look what just happened

Sign in as the manager, click Depot Territories. A national map loads with overlapping polygons — the official territories in light beige (dashed), the customer coverage zones in indigo (solid). Depot locations show as dark dots.

The visual story is immediate. Each depot’s customer coverage appears as a connected mass of overlapping circles around populated areas, contrasting sharply with the much larger slate official territory. The size difference is operationally striking — a depot’s official territory might cover thousands of square kilometres while the actual customer coverage is concentrated in a small area.

Pan around. Sydney metro shows densely packed coverage zones — overlapping, complex shapes that follow the urban distribution. Regional depots like Cairns or Darwin show smaller coverage zones isolated within vast official territories — visible reminders that “we’re responsible for this whole area” and “we have customers here” are very different things.

A note on the country-scale view

At full country zoom, the coverage zones for less-populated regions appear as tiny dots — a Cairns or Darwin coverage zone is barely visible against the entire continent. This isn’t a flaw in the visualisation; it’s an honest reflection of population distribution. A depot serving Cairns really does have its customer base concentrated in a small area surrounded by vast empty territory. Zoom in to a region (right-click drag in MapLibre, or use the zoom controls) and the coverage zones become visible at the appropriate scale.

For per-region operational analysis, a per-depot map (similar to the SA Detail report from Module 6) would be a natural next extension — fly to one depot, see its coverage zone at appropriate zoom, examine the relationship between coverage and territory at a useful scale. We don’t build that here; it’s a small extension you could add if it’d be operationally useful.

This is the kind of report a regional manager looks at when considering territory adjustments. Where are we deployed inefficiently? Where are big sections of official territory producing no customer activity? Where might we need new depots because customer coverage is straining at the edges? The visual reveals patterns that aren’t obvious from tables of numbers alone.

What this introduced

Several patterns worth carrying forward:

Construction operations vs query operations. The previous lessons asked questions about existing geometries. This lesson creates geometries — buffers, unions. The distinction matters because construction operations often produce shapes that are themselves stored or shipped to clients; they’re data outputs, not just intermediate filter steps.

Spatial joins for un-FKed relationships. When a relationship hasn’t been pre-computed into a foreign key column, ST_Contains (and its siblings) are how you express it in SQL. The GIST index keeps these performant — a spatial join with an indexed location column scans candidates by bounding box first, then refines with the precise predicate.

ST_Buffer + ST_Union for coverage zones. The natural expression of “the area within X of any of these points.” Each point gets a buffer; the buffers union into a single coverage polygon. The shape reflects the underlying density honestly — clusters merge into solid masses, isolated points appear as disconnected islands.

ST_Union for combining geometries. Beyond the buffered-union pattern, ST_Union is the aggregate that merges overlapping or adjacent geometries into a single shape. The depot’s official territory is a union of its assigned SAs; the country’s total covered area would be a union across all depots. Anywhere geometric “addition” makes sense, ST_Union does the work.

Hulls as the wrong abstraction (sometimes). PostGIS provides ST_ConvexHull and ST_ConcaveHull for wrapping polygons around point clouds. They sound like they should fit visualisation problems but often produce poor results — too coarse (convex), too thin (concave on linear distributions). Worth knowing they exist, but worth being skeptical of them as the default tool for “show me where the points are.” The buffered union is often the better choice.

The official-vs-empirical comparison pattern. Two representations of the same operational concept (territory) often differ. Comparing them reveals where the model diverges from reality. This is a recurring shape in operational analytics — not just for territories, but for capacity vs utilisation, plan vs actual, official vs informal.

Validity matters for construction operations. ST_Union, ST_Buffer, ST_Difference, and other construction functions require their inputs to be valid geometries. Imported polygons — especially from administrative datasets like ABS — can carry subtle invalidity (self-intersections at fine scales) that don’t render visually but break these operations. The fix is ST_MakeValid — applied once during seeding to clean the data, or defensively in queries when the data shape is unknown.

Where this leaves us

Module 7 is complete. The toolkit now includes distance and projection (Lesson 1), indexed proximity with the cast-blocks-index gotcha (Lesson 2), window functions plus spatial distance for sequence analysis (Lesson 3), and construction operations — buffers, unions, validity repair — for analytical synthesis (Lesson 4). Together they cover the bulk of what real GIS-using applications need.