Skip to content

Lesson 4 — Extracting a map component

The Lab page’s inline map block has grown. We have one source, two layers, and hover state — about a dozen lines of DSL inside the Vera::Map block. It’s still readable, but it’s reaching the boundary of what’s pleasant to keep on a view template. Lesson 5 will add depot points, which would push the inline block well past comfortable.

This is the right moment to extract a component. We’ll move the map’s logic out of app/views/lab/index.html.erb and into a proper Phlex component at app/components/service_area_map.rb. The Lab page becomes a single render call again, and the component file holds everything map-shaped.

This is the pattern Module 2 hinted at — pages render Phlex components, components encapsulate their internal complexity. The inline approach was right earlier (one map, simple configuration); the extraction is right now (the map’s configuration warrants its own home).

What we’re moving where

The Lab page currently looks something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<% content_for   :title, "Lab — Vera Dispatch Manager" %>

<%= render Components::PageHeader.new(
      title:    "Lab",
      subtitle: "A development-only space for experimenting with map components.",
      breadcrumb: ["Development", "Lab"]
    ) %>

<div class="p-8">
  <div class="rounded-lg overflow-hidden border border-border bg-surface">
    <%= render Vera::Map.new(id: "lab-map", height: "600px") do |m|
          m.source :service_areas, url: "/service_areas.json"

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

          m.layer :service_areas_outline, source: :service_areas, type: :line,
                  paint: { line_color: "#1e3a5f", line_width: 1, line_opacity: 0.6 },
                  on_hover: { line_width: 2, line_opacity: 1.0 }
        end %>
  </div>
</div>

We’ll move everything inside the Vera::Map.new(...) do |m| ... end block plus its initialisation arguments into a Phlex component. The page header, the wrapping divs, the Tailwind classes — all of that stays on the Lab page, since they’re page-level chrome, not map concerns.

After the extraction, the Lab page will look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<% content_for  :title, "Lab — Vera Dispatch Manager" %>

<%= render Components::PageHeader.new(
      title:    "Lab",
      subtitle: "A development-only space for experimenting with map components.",
      breadcrumb: ["Development", "Lab"]
    ) %>

<div class="p-8">
  <div class="rounded-lg overflow-hidden border border-border bg-surface">
    <%= render Components::ServiceAreaMap.new %>
  </div>
</div>

Single render call, name describes what the map shows. Anyone reading this template knows immediately that there’s a map showing service areas; they can dive into the component file when they want to know how it’s configured.

Creating the component

Create app/components/service_area_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
# frozen_string_literal: true

# A map showing all service area boundaries — the SA3 polygons
# from the Australian Bureau of Statistics, fetched as GeoJSON
# from the ServiceAreasController and rendered with hover state.
#
# This is the dispatch deck's default "all of Australia"
# overview map. Future variants — a state-scoped version for
# dispatchers, a single-area version for field officers —
# will be separate components, each composing the same
# Vera::Map primitive with their own data and styling.
class Components::ServiceAreaMap < Components::Base
  prop :id,     String, default: -> { "service-area-map" }
  prop :height, String, default: -> { "600px" }

  def view_template
    render Vera::Map.new(id: @id, height: @height) do |m|
      m.source :service_areas, url: "/service_areas.json"

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

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

A few things worth noting.

prop :id and prop :height with defaults. The component takes two arguments, both optional. The defaults match what was previously hardcoded on the Lab page — same DOM ID, same height. If a different page later wants the same map at a different size or under a different ID, it can pass those in.

prop :id, String, default: -> { … } — typed properties from the literal gem, which Phlex 2 uses to declare component arguments. The argument becomes available as @id inside view_template.

The view_template method. Phlex 2’s convention. It’s where the component renders. We render Vera::Map.new(...) with the same DSL block we had inline before — just moved here.

No new logic. The component doesn’t add anything; it relocates. Same source, same layers, same paint, same hover overrides. The point of the extraction isn’t to introduce new behaviour — it’s to put the existing behaviour somewhere appropriate. Pure refactoring.

Updating the Lab page

Replace the inline map block on app/views/lab/index.html.erb with a single component render:

1
<%= render Components::ServiceAreaMap.new %>

The <div class="rounded-lg overflow-hidden border border-border bg-surface"> wrapping div stays — it’s page-level chrome (the rounded border, the background colour) that’s about how the map sits on the Lab page, not about the map itself.

A different page later might render the same component without that wrapping treatment, or with a different one. The component itself takes no view on chrome; the page applies whatever frame is appropriate for the context.

Activity 1 — Verify nothing has changed visually

Refresh the Lab page. You should see exactly the same map you saw at the end of Lesson 3 — translucent fills, blue outlines, hover state working. Functionally identical.

If anything has broken:

  • Component-not-found errors usually mean the file path or class name doesn’t match. The file at app/components/service_area_map.rb should define Components::ServiceAreaMap. Zeitwerk reads filename and expects matching class structure.
  • Style or hover differences usually mean a paint key got miscopied. Check the component’s paint hash against what Lesson 3 had inline.

Activity 2 — Try changing the component

Make a small visible change in the component to confirm it’s genuinely the source of truth now. Open app/components/service_area_map.rb and change the line color in the outline layer:

1
2
3
4
5
paint: {
  line_color:   "#dc2626",   # red instead of blue
  line_width:   1,
  line_opacity: 0.6
}

Refresh the Lab page. The polygon outlines should now be red.

This confirms the extraction worked: the Lab page is no longer involved in the map’s styling decisions. Anything map-related goes through Components::ServiceAreaMap. Change the component, the Lab page reflects it.

Revert the colour back to #1e3a5f when you’re done — we’ll keep the dispatch deck’s primary blue for the rest of the tutorial.

What this opens up

A few things become natural now that the map is a component:

Reuse. Other pages can render Components::ServiceAreaMap without duplicating any logic. Module 6’s dispatcher page will use a closely-related variant; Module 9’s monitoring page another. Each gets its own component, but they all live in app/components/ next to this one and follow the same shape.

Per-page customisation through props. The component can grow new optional props as needs arise — for_state: to scope to a single state, with_legend: to add a legend overlay, zoom: to change the initial zoom. The Lab page can keep calling Components::ServiceAreaMap.new with no arguments; other pages pass what they need.

Testability. A Phlex component can be rendered to a string in a test, asserted against, exercised in isolation. The inline form was much harder to test because it lived inside an ERB template.

Encapsulation of complexity. When Lesson 5 adds depot pins, the new logic goes inside the component. The Lab page doesn’t change. The component grows; everything else stays the same.

Where this leaves us

We’ve finally landed the component-extraction pattern. The Lab page is back to a clean single-line render. The component holds everything map-shaped — sources, layers, paint, hover state — in a single named place.

Lesson 5 takes this component and adds depots: a second source, a circle layer to render the depot points, popup behaviour when one is clicked. All of those changes happen inside app/components/service_area_map.rb. The Lab page stays identical.