Skip to content

Lesson 3 — Styling polygons

Lesson 2 ended with translucent blue polygons covering Australia. The data was flowing correctly, but the styling was deliberately minimal — flat fills, no outlines, no hover behaviour.

This lesson turns that bare presentation into something that looks like part of a real product. By the end, the polygons will have proper visual edges, respond to mouse hover, and feel like dispatch infrastructure rather than a debugging view.

The data flow is unchanged. What changes is the visual layer stack — we’ll add a second layer (an outline) backed by the same source, and introduce hover state through Vera’s on_hover: hook.

Why fill_outline_color isn’t the answer

A natural first instinct is to just add fill_outline_color to the fill layer’s paint hash. MapLibre does support that key, and it produces a 1-pixel border around each polygon.

Don’t reach for it. fill_outline_color has limitations:

  • The width is fixed at 1 pixel — no thicker borders
  • The anti-aliasing is rough — boundaries look slightly jagged at most zoom levels
  • Hover and styling logic don’t work on it the way they do on a proper line layer

The standard MapLibre pattern is a separate line layer rendered above the fill. The line layer references the same GeoJSON source, draws the polygon’s edges as proper lines, and supports the full range of styling and interactivity. We’ll use this pattern throughout the dispatch deck.

Adding the outline layer

Open the Lab page. The map block currently has one source and one fill layer. Add a line layer underneath:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<%= 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
              }

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

Two things worth noting.

Same source, different layer. Both layers reference source: :service_areas. MapLibre fetches the GeoJSON once; both layers consume from that one fetch. This is the multi-layer-from-one-source pattern in action.

Layer order matters. MapLibre renders layers in the order they’re declared — later layers paint on top of earlier ones. The fill goes first, the outline goes second. Reverse the order and the fill would obscure the outline. Vera’s DSL preserves declaration order, so the obvious code shape is the correct one.

The line layer’s paint settings:

line_color: "#1e3a5f" — same primary blue as the fill, keeping the visual identity consistent. A darker shade or black would also work; the primary blue gives the polygons a quietly self-consistent look.

line_width: 1 — 1-pixel borders. Thin enough not to dominate the map, thick enough to clearly separate adjacent regions.

line_opacity: 0.6 — slightly less than fully opaque. Pure 1.0 lines can look harsh against a basemap; backing off to 0.6 lets the basemap show through subtly and softens the visual.

Refresh the Lab page. The polygons now have proper edges. Each SA3 is a clearly bounded region, distinguishable from its neighbours, with the basemap still visible underneath.

Activity 1 — Visit the Lab page and inspect outlines

Visit http://localhost:3000/lab. You should see:

  • The same translucent fills as before
  • Plus thin blue outlines tracing the boundary of each SA3
  • Adjacent SA3s now visually separated rather than blending into each other

Zoom into Sydney. Individual SA3s become clearly distinct — “Sydney - City and Inner South” is recognisably different from “Sydney - Inner West.” Pan around the country; every region’s boundary is visible.

If the outlines aren’t appearing:

  • Check the Network tab — only one request to /service_areas.json should fire (both layers share the source)
  • Check the Console for MapLibre errors about unknown source or layer IDs

Now that we have proper polygon shapes, we can think about interactivity.

Hover state with on_hover

A polygon with no hover response feels static — the user can’t tell whether they’re meant to interact with it. Adding hover state — slightly bolder fill, slightly thicker outline — communicates “this is interactive” before the user even clicks.

Vera provides an on_hover: hook on layer declarations. Any paint keys included in on_hover: override the corresponding paint keys when the user’s pointer is over a feature. The rest of paint stays unchanged.

Update both layers to add hover overrides:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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
        }

What’s happening here:

Fill layer hover. When the pointer is over a feature, the fill opacity bumps from 0.15 to 0.35 — the polygon becomes clearly visible without losing the basemap underneath. Colour stays the same; only opacity changes. The visual identity stays consistent; the change is purely “more present.”

Outline layer hover. Width goes from 1 to 2 pixels; opacity from 0.6 to 1.0. The outline becomes prominent enough to clearly trace the polygon under the cursor.

Together they produce a coherent hover treatment — the polygon under the cursor is brighter, with a stronger border. The user sees immediately which SA3 they’d be selecting if they clicked.

The mechanism Vera uses behind the scenes: the on_hover: hash is folded into MapLibre’s paint expressions as feature-state case expressions. When the cursor enters a feature, MapLibre sets that feature’s hover state to true, and the paint expression evaluates to the override value. When the cursor leaves, the hover state goes back to false and the paint expression evaluates to the default. The result is fast, GPU-accelerated hover that doesn’t require Stimulus or any custom JavaScript.

Activity 2 — Test hover behaviour

Visit http://localhost:3000/lab and move your mouse around the map. You should see:

  • Each SA3 highlights as you hover over it
  • The fill becomes more prominent (35% opacity vs 15%)
  • The outline becomes thicker and fully opaque
  • Adjacent regions don’t highlight — only the one directly under the cursor
  • Moving off a polygon returns it to its default state smoothly

Try hovering at the boundary between two SA3s. You should see exactly one highlight at a time — MapLibre’s hover state is per-feature, so adjacent polygons don’t both trigger.

Try hovering over a coastal SA3 with islands (a multipolygon with multiple disconnected pieces). All the pieces highlight together because they’re all part of the same feature. The hover state attaches to the feature, not to the individual geometric pieces.

A note on visual identity

The fills and outlines we’ve used are deliberately quiet. Bright fills or thick outlines would dominate the map and fight with the basemap visually. The dispatch deck’s philosophy is the data should communicate, not shout — polygons are present, distinguishable, and responsive, but they don’t overwhelm the viewer.

Later modules will add more visual weight in specific places. Module 6 introduces a choropleth — fills coloured by population — where the colours genuinely matter and need to stand out. Module 9’s real-time view highlights the active service areas of the moment. Both build on the quiet baseline established here, adding emphasis where the data demands it rather than blaring throughout.

Where this leaves us

The map now looks like part of a product. The polygons have proper edges, respond to user hover, and present the dispatch deck’s visual identity in a way that’s calm but informative.

The Lab page’s Vera::Map block is also starting to grow. We have one source and two layers, and the inline declaration is reaching the boundary of what’s pleasant to keep on a view template. Lesson 4 takes this growing block and extracts it into a proper Phlex component — the pattern that’s been promised since Module 2 lands here, exactly when the complexity warrants it.