Lesson 6 — Map-driven sidebars
The choropleths from Lessons 4 and 5 are displays. The reader looks at colours and reads the gist. A popup gives a small detail spike on click. But beyond that, there’s no way to drill in.
Real reporting work isn’t just looking. It’s interrogating. “That SA is amber on SLA — what’s actually happening there? Is it trending up or down? Is it an outlier within its depot, or is the whole depot struggling?” The choropleth surfaces the question; another tool has to answer it.
This lesson builds that other tool: a new manager report called Service Area Detail. The map looks similar to Lesson 5’s SLA performance map — same divergent ramp, same SLA semantics — but the click behaviour is different. Clicking an SA dispatches a Turbo Stream that replaces the contents of a fixed sidebar with rich detail — trend sparklines, priority breakdown, depot comparison. The map becomes a navigator into the detail, not a destination in itself.
The lesson teaches three new things:
- Vera’s
on_click: { turbo_stream: ... }— declaring that layer clicks should dispatch a Turbo Stream request rather than open a popup - Turbo Streams as the page-update primitive for click- driven UI — server returns stream actions, page applies them
- Aggregation queries that return chart-shaped data — weekly time-bucketed counts, ready for sparkline rendering
By the end, the manager has a third report — and a different interaction pattern in the dispatch deck’s vocabulary.
Why a new report instead of upgrading Lesson 5
Lesson 5’s SLA choropleth uses click-to-popup. The popup gives a quick spike of detail — “On-time: 87.3% (487 of 517)” — which is exactly the right detail for a quick glance.
Sidebars and popups are different tools for different questions. A popup answers what’s this? — small, fast, glance-and-dismiss. A sidebar answers tell me about this — panel-shaped, contextual, click-and-explore.
This lesson treats them as alternatives, not replacements. Lesson 5 stays as it is — a complete, working report. This lesson builds a different report that uses the sidebar pattern. The reader ends up with both interaction idioms in their toolkit, and gains an intuition for when each fits.
A rough rubric for choosing:
Reach for a popup when:
- The answer fits in 200×150 pixels comfortably
- The user expects to glance and move on
- The map’s geographic context matters more than the detail
- The user might click many features in quick succession
Reach for a sidebar when:
- The answer wants a full panel — multiple sections, charts, lists
- The user expects to dwell on one selection
- The detail is the primary interaction; the map is the navigator
- Multiple data dimensions can usefully share the same selection context
Service Area Detail fits the sidebar pattern because the useful detail is multi-dimensional — trends, breakdowns, comparisons. None of those individually fit a popup, and together they earn a full panel.
What we’re building
A new page in the manager’s reports section:
+----------------------------------+--------------+
| Service Area Detail | SA panel |
| | |
| ┌──────────────────────────┐ | Bondi - |
| │ │ | Bondi Jct. |
| │ SLA map │ | |
| │ │ | 1,247 jobs |
| │ (click an SA) │ | ──╱╲╱╲── |
| │ │ | |
| └──────────────────────────┘ | 87.3% OT |
| | ──╲──╱── |
| | |
| | ▓▓▓░░ ... |
+----------------------------------+--------------+When the page first loads, the sidebar shows a placeholder (“Click a service area to see details”). Clicking an SA dispatches a request; the server responds with a Turbo Stream that replaces the sidebar’s contents with the detail panel. Clicking a different SA replaces the contents again. No page reload, no manual JavaScript.
How on_click with turbo_stream works
Vera’s on_click: is a layer-level option (alongside
paint:, popup:, etc.) that wires click behaviour. The
turbo_stream: variant dispatches a Turbo Stream request:
|
|
When a click happens:
- Vera resolves the URL —
:idgets substituted with the clicked feature’sidproperty at click time. (Rails- flavour placeholders.:property_namematches any feature property by name.) - Vera fires a
fetchrequest to that URL with theAccept: text/vnd.turbo-stream.htmlheader. - The server responds with a Turbo Stream document — a
sequence of stream actions like
replace,append,update. - Turbo applies the stream actions to the page. Elements targeted by id get updated in place.
The mechanism is elegant: the map declares “clicks dispatch a stream”; the server returns stream actions; the page auto-updates. No JavaScript on your part, no Turbo Frame wrapping, no DOM relationships to manage.
Note that popup: and on_click: would compete for the
same click event. A layer can have one or the other; we pick
the interaction model that fits the report.
Streams vs frames — when each fits
The Vera gem also supports popup: { turbo_frame: ... },
which uses Turbo Frames inside MapLibre popups. So why
streams here, not frames?
Turbo Frames are scoped DOM regions. A <turbo-frame id="x"> element can be replaced by a server response that
also contains a <turbo-frame id="x">. Frames are great for
self-contained UI regions that update independently — a
search results pane, a comment thread, a popup body.
Turbo Streams are general-purpose page updates. The server returns instructions like “replace the element with id ‘sa-detail’” — and Turbo finds and updates that element wherever it is on the page. Streams are right for updates that aren’t naturally bounded to one region — broadcast notifications, multi-element updates, sidebar replacements driven by interactions elsewhere.
For our case the sidebar is a fixed page region updated by a click on a different page region (the map). Stream is the right tool. A Frame would also work but would require the sidebar to be wrapped as a Frame, and the server response to include a matching Frame element. Stream is more direct.
The data the sidebar needs
Designing the panel before the queries. For each SA, useful detail at a glance:
- Identity — name and SA3 code
- Volume in the period — total completed in the last 90 days, plus a weekly sparkline showing the trend
- SLA performance — overall on-time percentage, plus a weekly sparkline showing whether it’s stable or sliding
- Priority breakdown — distribution of urgent / high / normal / low across completed jobs
- Depot comparison — this SA’s volume and SLA, alongside the depot’s average, so the reader can tell whether the SA is an outlier or typical
That’s five sections. Each is a small query against the same data the choropleths already aggregate; the new work is shaping the results to feed sparklines and comparison rows.
The summary service
Create app/services/sa_detail.rb:
|
|
A few things worth understanding.
completed_window(sa) is a private helper relation. Many of
the queries start from “completed jobs in this SA over the
window.” Rather than rewriting the same where clauses, the
helper returns the relation; each caller chains its own
specifics. ActiveRecord lazy evaluation means no SQL runs
until .count or similar is called — five callers, five
distinct queries with shared setup.
DATE_TRUNC('week', ...) is Postgres’s date-bucketing
function. It rounds a timestamp down to the start of its
containing week. Combined with GROUP BY and COUNT, it
gives the weekly aggregations the sparklines need. Postgres
weeks default to Monday-starting (ISO 8601 conventions).
The Arel.sql(...) wrapper is required because Rails 7+
rejects raw SQL fragments in group() and order() by
default — a guard against accidental SQL injection from
user input. Arel.sql is the explicit “I assert this is
safe” escape hatch. The string is hardcoded inside the
service module, so there’s no actual injection risk; the
wrapper just satisfies Rails’ guard.
The SLA trend can have nil entries. Weeks with zero
completed jobs don’t have a denominator; we emit nil rather
than fudge a zero. The sparkline component will render gaps.
Depot comparison divides total by SA count, not job-weighted average. “Average SA volume” is a more useful framing than “average job within depot.” It tells the manager whether the clicked SA is a high-volume outlier or a typical performer within its depot.
Run it from console
|
|
The sparkline component
A small reusable piece. Create app/components/sparkline.rb:
|
|
A couple of decisions worth understanding.
stroke: "currentColor" lets the sparkline inherit text
colour from its parent. The component using the sparkline can
control its colour with Tailwind classes — text-emerald-600
for SLA, text-slate-600 for volume, etc.
Auto-scaling vertical range. Each sparkline scales to its own min/max, so even a line that ranges from 2 to 9 fills the sparkline’s height. The trade-off: sparklines aren’t directly comparable between SAs (one SA’s “high” might be 200, another’s might be 8). For our purpose — showing trend shape, not absolute magnitude — auto-scaling is the right call. The headline number conveys magnitude.
Nil values produce gaps. filter_map drops the nils; the
polyline doesn’t draw segments through them. A reader sees a
broken line — accurately representing “no data this week”
rather than a falsely-zero dip.
Stroke styling chosen for clarity. stroke_linecap: round
and stroke_linejoin: round keep the line visually clean at
small sizes. stroke_width: 1.5 is heavier than 1 (which
disappears at high density on retina displays) but lighter
than 2 (which feels chunky for sparkline scale).
The panel component
Create app/components/reports/sa_detail_panel.rb:
|
|
A few things worth a closer look.
The component renders just the panel contents. No
turbo_frame_tag wrapper, no surrounding aside. The panel is
the inner content; the page’s <aside> element wraps it
externally. The Turbo Stream response targets that aside by
id and replaces its contents with this rendered component.
The volume and SLA sections have parallel structure. A big number on the left, a sparkline on the right. The reader’s eye gets a quick read at the top (the number) and supporting trend context to the side. Same shape, different metric.
The priority bars are pure HTML. No SVG, no chart library. A flex row per priority with a small horizontal bar. The bar’s width is the percentage of total; the colour is priority-coded.
The comparison row shows — for missing data. When the SA
has zero completed jobs in the window, both volume and on-time
percentage might be missing. The em-dash is a small honest
marker rather than collapsing or hiding.
The controller and routes
Add to ReportsController:
|
|
A few things to know about the sa_detail_panel action.
render turbo_stream: is Rails’ helper for returning a
Turbo Stream response. The HTTP response is
Content-Type: text/vnd.turbo-stream.html and contains
<turbo-stream> elements that Turbo applies to the page.
turbo_stream.update("sa-detail", panel_html) is the
stream-builder for the update action — replace the contents
of the element with id sa-detail. Other actions exist
(replace, append, prepend, before, after, remove)
for different update patterns; update fits this case
exactly.
Components::Reports::SaDetailPanel.new(...).call renders
the Phlex component to a string. This works because Phlex
components render to strings when called; the string is the
HTML that becomes the new contents of #sa-detail.
Routes in config/routes.rb:
|
|
The panel route uses :id as a path segment — it’s the URL
shape Vera substitutes feature properties into. The map’s
on_click config will reference this URL with :id in place.
The sidebar entry
Add to the REPORTS constant in Components::Sidebar:
|
|
magnifying_glass for the drill-in idiom. If your icon
component doesn’t have it, alternatives are
:cursor_arrow_rays, :rectangle_group, or :bars_3.
The map component
Create app/components/reports/sa_detail_map.rb:
|
|
Two things worth noting about this map vs Lesson 5’s:
It reuses /api/service_areas/sla_performance.json. Same
endpoint, same data, same paint expression. The new lesson
doesn’t need a different report — the question on the map
(“where is performance worth investigating?”) is exactly the
question Lesson 5’s data already answers.
It has no popup, no legend. Click drives the sidebar.
There’s no need for a competing popup, and the legend’s
absence is deliberate — Lesson 5’s report includes a legend;
this report’s emphasis is the sidebar. (You could add a
legend via m.overlay if you wanted; the cleaner default
here is “the sidebar tells the story.”)
The on_click: { turbo_stream: ... } is the new piece. The
URL string /reports/service_area_detail/panel/:id
substitutes the clicked feature’s id property at click
time — /reports/service_area_detail/panel/123 for an SA
with id 123.
The page view
Create app/views/reports/service_area_detail.html.erb:
|
|
The <aside id="sa-detail"> is the target the Turbo Stream
will update. Its initial contents are the placeholder; on
click, the stream replaces the inner content with the
rendered detail panel.
The structure: a header at top, a flex-1 container below
splitting into map (left, fills remaining space) and sidebar
(right, fixed 80-character width). overflow-hidden on the
parent ensures the sidebar’s scroll doesn’t leak past the
container.
Look what just happened
Sign in as the manager. The Reports section now has three items. Click Service Area Detail.
The map fills with the familiar SLA colour scheme — green for high on-time areas, amber for the threshold, red for problem SAs, grey where there’s no data. The sidebar on the right shows a placeholder.
Click any green SA. The sidebar’s contents replace themselves instantly. You see:
- The SA’s name and SA3 code
- A big number for completed-job volume, with a small sparkline beside it showing the weekly trend
- The on-time percentage in colour (green for high, amber for threshold, red for poor), with its own sparkline showing whether performance is stable, climbing, or sliding
- A four-row breakdown bar showing the priority mix
- A comparison row: this SA’s volume and SLA next to the depot averages
Click a red SA. The sidebar updates. The numbers tell a different story — fewer jobs, lower on-time percentage, maybe a sparkline showing the slide. The priority breakdown might be heavier on urgent.
Click an SA in a different depot. The depot comparison shifts to that depot’s averages. The reader is browsing the country through the lens of operational performance.
What this introduced
Several patterns worth carrying forward.
on_click: { turbo_stream: ... } for click-driven UI updates.
Vera’s declarative bridge from map clicks to in-page state
changes via Turbo Streams. The map declares “clicks on this
layer dispatch a stream request to this URL”; the server
returns stream actions; Turbo applies them.
Turbo Streams as the page-update primitive for click-driven UI. Streams update arbitrary elements on the page by id, unbounded by Frame relationships. The right tool for sidebar updates driven by interactions elsewhere.
Aggregation queries that return chart-shaped data.
DATE_TRUNC('week', ...) plus GROUP BY ... ORDER BY
produces weekly buckets ready for sparklines. The same shape
works for daily, monthly, hourly buckets — just change the
truncation.
Sparklines as inline dense data. A small SVG component
renders a polyline; data feeds in as an array; colour
inherits via currentColor. Reusable for any future inline
trend indicator.
Same data, different question. The lesson’s map uses the
same /api/service_areas/sla_performance.json endpoint as
Lesson 5, but the page asks a different question of it.
Reports aren’t only differentiated by what data they show
but also by how the user interacts with it. The
choropleth-with-popup says “here’s the picture, here’s a
glance at one SA’s metric.” The choropleth-with-sidebar says
“here’s the picture, click to dive in.”
Where this leaves us
Three reports in the manager’s toolbox. Two interaction patterns established (popups in Lessons 4 and 5; sidebars here). One reusable sparkline component. One new mental model — the map as navigator, not destination.
The next lesson moves to a different visualisation entirely. Heatmaps render dense point data as gradient clouds rather than discrete features — a different mental model for “where is concentration?” Good for cases where individual points aren’t the unit of interest; the gradient is.