Skip to content

Lesson 5 — Composing the map

Lesson 4 ended with a working regional map: three sources, four layers, two popup templates, one image registration, all crammed into a single view_template block. It works, but view_template has stopped reading at human scale. A new contributor opening the file has to scroll past everything to find what’s where, and adding a fifth layer will make it worse.

This lesson refactors that. No new GIS, no new endpoints, no behaviour change. Same map, cleaner organisation.

The pattern is the same one Phlex teaches for HTML: when a view’s view_template grows, break it into private methods named for what they produce. We’ll do exactly that for the map’s layers.

What’s wrong with the current shape

Look at the end of Lesson 4’s RegionalMap#view_template:

 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
def view_template
  render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
    m.source :service_areas,  url: "/service_areas/regional.json", fit_bounds: { padding: 20 }
    m.source :jobs,           url: "/api/jobs/regional.json"
    m.source :field_officers, url: "/api/field_officers/regional.json"

    m.image :truck, svg: PhlexIcons::Hero::Truck.new(class: "size-8"), tintable: true

    m.layer :service_areas_fill, source: :service_areas, type: :fill,
            paint:    { fill_color: "#1e3a5f", fill_opacity: 0.05 },
            on_hover: { fill_opacity: 0.15 }

    m.layer :service_areas_outline, source: :service_areas, type: :line,
            paint: {
              line_color:   "#dc2626",
              line_width:   1,
              line_opacity: 0.3
            },
            on_hover: { line_width: 2, line_opacity: 1.0 }

    m.layer :jobs_circle, source: :jobs, type: :circle,
            paint: { ... 8 lines ... },
            on_hover: { circle_radius: 7 },
            popup: { template: JOB_POPUP_TEMPLATE }

    m.layer :field_officers, source: :field_officers, type: :symbol,
            layout: { ... 5 lines ... },
            paint:  { ... 7 lines ... },
            popup:  { template: FO_POPUP_TEMPLATE }
  end
end

Maybe 50 lines of declarations. The structure is real but hidden — there’s a jobs feature (source + layer + popup) in there, and an FO feature (source + image + layer + popup), and the SAs feature (source + two layers), but all three have been spread across the block, separated by declarations that belong to other features.

A few things suffer:

  • Adding a feature means inserting code in three places. A new “depots” layer means a new m.source near the top, maybe a new m.image, and a new m.layer further down. Easy to miss one; easy to put them in the wrong spot.
  • Removing a feature means reverse archaeology. Tracking down all the lines that belong to “jobs” requires reading the whole block.
  • The high-level structure of the map is invisible. A reader has to mentally group declarations to understand “this map has three feature types”. The structure is in the file, but only implicitly.

The fix is cohesion: lines that change together live together.

The pattern

Each feature on the map gets its own private method. The method takes the map builder m as a parameter and emits that feature’s source, image (if any), layer(s), and popup. view_template becomes a high-level table of contents that calls each method in order:

1
2
3
4
5
6
7
def view_template
  render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
    add_service_areas(m)
    add_jobs(m)
    add_field_officers(m)
  end
end

Read view_template and you can see at a glance: this map has service areas, jobs, and field officers. The detail of how each is configured is one method jump away. For a map with eight layers across five sources, that’s a real readability win.

Applying it

Replace the whole view_template and the layers it calls with the refactored version. Constants and popup templates stay where they were — they’re configuration data, named once, referenced from the methods that use them.

 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
class Components::RegionalMap < Components::Base
  prop :id,     String, default: -> { "regional-map" }
  prop :height, String, default: -> { "100%" }

  JOB_STATUS_COLOURS = {
    "pending"     => "#f59e0b",
    "scheduled"   => "#3b82f6",
    "in_progress" => "#10b981"
  }.freeze

  FO_STATE_COLOURS = {
    "available" => "#f59e0b",
    "en_route"  => "#3b82f6",
    "at_job"    => "#10b981"
  }.freeze

  JOB_POPUP_TEMPLATE = <<~HTML.freeze
    # ... unchanged ...
  HTML

  FO_POPUP_TEMPLATE = <<~HTML.freeze
    # ... unchanged ...
  HTML

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
      add_service_areas(m)
      add_jobs(m)
      add_field_officers(m)
    end
  end

  private

  def add_service_areas(m)
    m.source :service_areas, url: "/service_areas/regional.json",
                             fit_bounds: { padding: 20 }

    m.layer :service_areas_fill, source: :service_areas, type: :fill,
            paint:    { fill_color: "#1e3a5f", fill_opacity: 0.05 },
            on_hover: { fill_opacity: 0.15 }

    m.layer :service_areas_outline, source: :service_areas, type: :line,
            paint: {
              line_color:   "#dc2626",
              line_width:   1,
              line_opacity: 0.3
            },
            on_hover: { line_width: 2, line_opacity: 1.0 }
  end

  def add_jobs(m)
    m.source :jobs, url: "/api/jobs/regional.json"

    m.layer :jobs_circle, source: :jobs, type: :circle,
            paint: {
              circle_color: ["match", ["get", "status"],
                             "pending",     JOB_STATUS_COLOURS["pending"],
                             "scheduled",   JOB_STATUS_COLOURS["scheduled"],
                             "in_progress", JOB_STATUS_COLOURS["in_progress"],
                             "#64748b"],
              circle_radius:       5,
              circle_stroke_color: "#ffffff",
              circle_stroke_width: 1
            },
            on_hover: { circle_radius: 7 },
            popup:    { template: JOB_POPUP_TEMPLATE }
  end

  def add_field_officers(m)
    m.source :field_officers, url: "/api/field_officers/regional.json"

    m.image :truck, svg: PhlexIcons::Hero::Truck.new(class: "size-8"),
                    tintable: true

    m.layer :field_officers, source: :field_officers, type: :symbol,
            layout: {
              icon_image:         "truck",
              icon_size:          1.0,
              icon_anchor:        "center",
              icon_allow_overlap: true
            },
            paint: {
              icon_color: ["match", ["get", "state"],
                           "available", FO_STATE_COLOURS["available"],
                           "en_route",  FO_STATE_COLOURS["en_route"],
                           "at_job",    FO_STATE_COLOURS["at_job"],
                           "#64748b"]
            },
            popup: { template: FO_POPUP_TEMPLATE }
  end
end

That’s it. Refresh the dashboard — the map renders identically. The behaviour is unchanged; only the organisation has shifted.

What the pattern earns

A few things, all of them about cognitive load.

view_template becomes the index. Three lines, three features. A reader who needs to know “what’s on this map?” gets the answer at a glance. Compare with the original 50-line view template, where the answer required reading and grouping declarations mentally.

Each method owns its feature’s full configuration. The jobs source and the jobs layer live in the same place. The FO source, the FO image registration, and the FO layer live in the same place. Adding a new layer to an existing feature means editing one method; removing a feature means deleting one method. Cohesion follows feature.

Layer order is explicit at the top. Vera adds layers to the map in declaration order; later layers render on top. With the refactor, that order is set by the order of calls in view_template — three lines, easy to read, trivial to reorder. In the original block, layer order was implicit in the declaration sequence inside a 50-line method.

Helpers compose further. If add_field_officers grew to include a hover-grow effect or a clustering layer, it could split into add_field_officer_pins and add_field_officer_clusters. The pattern scales naturally as features grow.

Why pass m rather than store it

The m we yield from Vera::Map.new’s block is the live Vera::Map instance — calls to m.source, m.layer, and the rest mutate its accumulators. We could store it as @m for the duration of view_template and have add_jobs reach for @m, but that introduces hidden state the methods depend on.

Passing m explicitly makes the dependency obvious: this method needs m to do its work; if you call it outside the map’s block, it errors immediately. The cost is a tiny bit of typing; the win is methods that only operate on what they’re given.

Naming convention

add_* reads imperatively, matches what the methods do (mutate the map). It’s the convention I’d land on for the tutorial.

A few alternatives that didn’t quite fit:

  • compose_* — works but is less common in Rails. Ruby reaches for add_* when something accumulates.
  • *_layer — doesn’t fit because some methods do source + image + layer together. Naming them after one of those pieces understates what they do.
  • feature_* — too generic; “feature” already has a GeoJSON meaning we don’t want to overload.

add_* per feature on the map is what the rest of this tutorial uses.

When this pattern shines

For a map with three or four feature types like the dispatcher’s regional view, the organisation is nice but not essential. The original 50-line block was readable, just busy.

For maps with eight or twelve feature types — a future Module 6 dispatcher view with choropleth + clustering + drawing zones + customer pins + FO pins + traffic overlay

  • heat tiles — this organisation becomes load-bearing. Without it, the view template grows past readable size and contributors start making mistakes.

For maps with one or two feature types, the pattern is overkill — leave them as a flat block.

The right time to refactor is when adding the next layer makes you think twice about where to put it. That’s the signal view_template has hit its ceiling.

Where this leaves us

RegionalMap is now organised at human scale. Three feature methods, each responsible for one map source’s worth of declarations. Adding a fourth feature is a four-step, fully-localised change: write a new service object, add the controller action, write add_<feature>(m), call it in view_template. No editing in the middle of an existing block; no risk of a declaration ending up in the wrong neighbourhood.

The map is solid. The next lesson wraps it in the rest of the dispatcher’s dashboard — the stats panel above and the sidebars to the right.