Lesson 11 of 12 · The micro view

One route, one day, one vehicle. The map that tells the day’s story.

Pattern detection (Lesson 10) was the macro view — a year of pings condensed to per-parcel signatures. RouteView is the macro’s opposite number: zoom all the way in. Pick a single (route, day, vehicle). Render a self-contained HTML with an interactive MapLibre map — the truck’s trail as colored GPS dots, every parcel colored served / missed / unknown, the depot, the landfills, a side panel with the load-by-load tonnage breakdown. Drop the file on a USB stick, email it, host it static. The day’s story, one HTML per (route, vehicle).

Time: ~120 min You'll touch: routeview/{rank,trail,parcel_eval,render,runner} Result: a self-contained interactive HTML per (route, day, vehicle)

· Objective

Five modules, one bundled HTML template, three runner modes. All built on top of the engine and segments — zero new spatial work.

  • routeview/rank.py — for one (route, day), which vehicles touched it, sorted by ping count.
  • routeview/trail.py — GPS pings as colored dots (not a polyline); phase color comes from the L9 segments timeline.
  • routeview/parcel_eval.py — per parcel: served today? Expected by the L10 patterns analysis? Surfaces both in the popup.
  • routeview/render.py — wraps the bundled king.html template from the v1 RouteView notebook (preserved byte-for-byte) and substitutes the data blobs.
  • routeview/runner.py — file-in / HTML-out driver with three modes: one (route, day, vehicle); all routes on a day; top-N vehicles.
Trail as dots, not polylines. A polyline interpolates between pings — it hides the corners cut, the stops, the irregularities. Dots preserve every recorded ping and let the viewer see the truck’s actual stop-and-go behavior. Each dot colored by phase (depot = green, windshield = gray, collection = blue, dump = red) gives an instant read of how the workday was spent. Granularity preserved, no interpretation imposed.

· Build it, step by step

1 Rank vehicles per route-day — routeview/rank.py

For a (route, day) tuple, return the vehicles that meaningfully touched it, sorted by ping count. The runner uses this to pick which vehicle to render (top-1 by default, top-N for the multi-HTML mode). Filters out drive-bys via a min_pings floor — a vehicle with 2 pings on a route polygon was almost certainly just passing through.

ranked = rank_vehicles_for_route_day(
    enriched_glob="enriched/2026-01-18/*.parquet",
    route_id="R1", day="2026-01-18", min_pings=5,
)
# vehicle_id  n_pings  first_dt_local       last_dt_local
# 815001      843      2026-01-18 06:42:00  2026-01-18 16:18:00
# 815014       18      2026-01-18 09:30:00  2026-01-18 09:35:00

2 Trail as colored dots — routeview/trail.py

Filter enriched pings to (route, day, vehicle). Optionally downsample to keep HTML size sane (default: one ping per 30 seconds → about 1200 dots per shift). Time-range-join against the L9 segments timeline so each ping carries the segment_type it falls into. Map segment_type to a hex color. Emit GeoJSON.

PHASE_COLORS = {
    "depot_departure":  "#22c55e",  # green
    "windshield":        "#9ca3af",  # gray
    "collection":        "#2563eb",  # blue
    "dump":              "#dc2626",  # red
    "depot_arrival":     "#16a34a",
}

One subtle bug worth knowing about: pyarrow often hands you datetime64[us] not [ns], so the bucket-math for downsampling must coerce to ns first. Caught by the tests on the first run.

3 Parcel evaluation + patterns integration — routeview/parcel_eval.py

For each parcel on the route, three pieces of information per popup:

  • Served today? Did any enriched ping for this (day, vehicle) carry this APN? Boolean.
  • Expected from patterns (optional, from L10): the weekly1 / weekly2 / biweekly vehicles that the long-period analysis surfaced. If they exist and the parcel wasn’t served, the parcel is colored red (missed); if it was served, green; if no expectation either way, gray.
  • Actual vehicle: whichever vehicle this view is showing, with served/not-served context.
parcels_json = build_parcels_geojson(
    parcels_wkb_path="parcels_wkb.parquet",
    enriched_glob="enriched/2026-01-18/*.parquet",
    route_id="R1", day="2026-01-18", vehicle_id="815001",
    patterns_chunk_path="patterns/2025-03-01_to_2026-02-28/by_route/R1.parquet",
)

This is the validation loop: the macro prediction (patterns) overlaid on the micro reality (today’s pings). Generously matching = ops are running to plan. Diverging = a story to investigate.

4 Render the HTML — routeview/render.py

The KING template from the v1 RouteView notebook is bundled as package data at opentrash/routeview/templates/king.html. It’s preserved byte-for-byte except for one transformation: Python f-string expressions were swapped for plain str.format() placeholders so we can render without eval. Literal JS braces (MapLibre tile-URL {x}/{y}/{z}, regex escapes) are doubled in the template so they survive the format pass.

html = build_routeview_html(
    route_id="R1", vehicle="815001",
    day=date(2026, 1, 18),
    day_time_label="06:42 → 16:18",
    route_json=route_geojson_string,
    pts_json=trail_geojson_string,
    parcels_json=parcels_geojson_string,
    lf_json=landfills_geojson, sites_json=sites_geojson,
    summary_json=summary_blob, stats_json_enriched=stats_blob,
    center_lon=-117.20, center_lat=32.70,
    rad_note="RAD: ...", arts_note="ARTS: ...",
)
out_path.write_text(html, encoding="utf-8")

No Jinja2 dependency. The template is f-string-shaped already, and str.format() handles the substitution. One less package to install.

5 Three runner modes — routeview/runner.py

The unit of work is one (route, day, vehicle) HTML. Three drivers iterate that unit differently:

  • render_routeview(route_id, day, vehicle_id, ...) — the singular case. One HTML out.
  • render_all_routes_for_day(day, ...) — pick the top-1 vehicle for each route touched that day. One HTML per route.
  • render_top_n_vehicles(day, top_n=5, ...) — the day’s busiest 5 vehicles, each with HTMLs for every route they meaningfully touched.

The runner also wires in the tonnage join: for each dump segment in the day’s timeline, look up L5 tonnage by vehicle + time-window matching (45-min pad, matching the notebook’s approach). The side panel surfaces per-load tons.

routeview/
  2026-01-18/
    R1__815001.html       one (route, day, vehicle) HTML
    R1__815014.html
    R2__815002.html
    ...

6 Tests + commit

11 tests covering rank (sorting + min_pings filter), trail (phase coloring + downsample correctness across datetime precisions), parcel_eval (served / missed / unknown statuses, patterns integration), render (template substitution + no unfilled placeholders + tile-URL literals preserved), and an end-to-end runner test that produces a real HTML on disk.

pip install -e ".[dev,geotab,postgres]"
ruff check .
pytest -q
git add . && git commit -m "Lesson 11: RouteView — interactive map product"
git push origin main

· Package anatomy after this lesson

Where everything lives now. new marks files added this lesson.

opentrash/ ├── pyproject.toml · mkdocs.yml · .github/workflows/{ci,docs}.yml ├── docs/{index,architecture}.md + CNAME ├── opentrash/ │ ├── adapters/gps/ # base, geotab, postgres │ ├── core/ # crs, duckdb_session, vehicle_ids │ ├── prep/ # sites, parcels, static_layers, parcels_wkb │ ├── cache/ # gps_cache, gps_indexes, master_index │ ├── tonnage/ # registry, cleaners, keys, upsert, pipeline │ ├── engine/ # config, enrichment, segments │ ├── patterns/ # config, window, detector, runner, validator │ └── routeview/ │ ├── rank.py # [new] │ ├── trail.py # [new] │ ├── parcel_eval.py # [new] │ ├── render.py # [new] │ ├── runner.py # [new] │ └── templates/ │ └── king.html # [new] bundled template └── tests/ ├── (existing 17 test files) └── test_routeview.py # [new] 11 tests

· What you built

  • The micro counterpart to pattern detection — one (route, day, vehicle) rendered as a self-contained interactive HTML.
  • GPS pings as colored dots — granularity preserved, phase legible at a glance, irregularities visible (which a polyline would smooth over).
  • Parcel popups with patterns integration — the macro view’s expectations overlaid on the micro view’s reality. The validation loop made visual.
  • Tonnage on the load-by-load panel — L5 tonnage joined in at render time via vehicle + time-window matching (no spatial work).
  • Three runner modes — one HTML, all routes on a day, top-N vehicles. The atomic unit (one HTML per route×vehicle) is shared; the drivers differ only in iteration.
  • Zero spatial joins. Every input was already produced by the engine, segments, parcels_wkb, or static layers. RouteView is pure rendering.
The architecture has held across 11 lessons. Spatial joins are infrastructure; products are calculations. Patterns is route-agnostic regularity analysis; RouteView is route-aware visual rendering. Both products read from the same enriched-ping stream produced by the engine. One package, two views, one validation loop.

· Companion resources

Optional, for going deeper.

  • MapLibre GL JS: official docs — the open-source fork of Mapbox GL JS, fully community-maintained. Sources, layers, sprites, expressions: everything that makes the KING template tick.
  • Tile servers: the template uses OpenStreetMap raster tiles by default. For production map quality, swap in a vector-tile provider (Stadia, MapTiler, Protomaps) by editing the sources block in the template.
  • Self-contained HTML as a product: single-file deliverables travel well — USB sticks, email attachments, static hosting. The same pattern as Bokeh embeds or Plotly's interactive export. Reliable, low-friction, no servers to maintain.

· Next lesson

Lesson 12 — Publishing: tag a release, push to PyPI, finalize the documentation site, and write the README. The last lesson takes the working package and turns it into a real open-source artifact that other agencies can pip-install and run.