# Brasília Bike Lanes — Interactive Map Overlay Plan

Research brief for a single self-contained `index.html` map of Brasília's
cycling infrastructure. No build step, CDN-only dependencies, double-click
to run. Data is supplied as GeoJSON in `data/` (already being collected
by parallel agents).

---

## 1. Library choice — MapLibre GL JS

**Pick: MapLibre GL JS v4.x via CDN (`unpkg.com/maplibre-gl@4`).**

Justification:
- WebGL renders smoothly at high zoom without tile pixelation; cycle
  lanes need crisp lines when users zoom into the Eixo Monumental or
  the Plano Piloto's S1/S2 axis.
- Native data-driven styling (`paint` expressions on
  `cycleway`/`network` properties) means we keep one GeoJSON source
  and color it by attribute — far cleaner than Leaflet's per-feature
  `style` callbacks.
- Built-in `FullscreenControl`, `GeolocateControl`,
  `ScaleControl`, `NavigationControl` — fewer plugins.
- ~290 KB gzipped vs Leaflet's ~42 KB is a non-issue for a personal
  desktop-first project; we trade size for fidelity, which is the
  stated requirement.
- Free, MIT-licensed, no token required. The only Leaflet advantage
  (raster tile simplicity) is outweighed by vector basemap quality.

Reject Leaflet: SVG/Canvas rendering becomes a bottleneck if the
network grows past ~5–10k segments, and re-styling all features on
filter change is sluggish. Keep Leaflet as a Plan B only if a vector
basemap source disappears.

---

## 2. Basemap / tile provider

### Primary: **OpenFreeMap — `positron` style** (vector, no key)

- URL: `https://tiles.openfreemap.org/styles/positron`
- Free, unlimited, no signup, no API key, no usage cap. Hosted by
  Zsolt Ero on dedicated hardware. Funded by donations.
- Three styles available out of the box: `positron` (clean light
  grayscale — recommended), `bright` (everyday colorful), `liberty`
  (rich detail). All are MapLibre-style JSON, drop-in.
- Attribution: OpenStreetMap contributors + OpenFreeMap (auto-added
  by MapLibre when the style is loaded).
- This is the right call for our use case: cycling lines need a
  desaturated background so the colored overlay reads. Positron is
  literally designed for that.

### Fallback / dark mode: **CARTO Voyager + Dark Matter** (raster)

- URLs:
  - `https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png`
  - `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`
- No API key for low-traffic, non-commercial use. Personal portfolio
  fits inside CARTO's fair-use envelope.
- Use as the dark-mode toggle and as a hard fallback if OpenFreeMap
  is ever down. Raster, but rendered through MapLibre's `raster`
  source so we don't need a second library.
- Attribution: `© OpenStreetMap contributors © CARTO`.

### Rejected

- **Stadia Maps / Stamen Toner**: now requires API key signup. Free
  for non-commercial but adds friction; key would be visible in the
  HTML. Skip.
- **MapTiler**: 100k loads/month free but requires key and account.
  Skip for the same reason.
- **OSM standard `tile.openstreetmap.org`**: tile usage policy
  forbids "heavy use" and embedding in apps. Fine for dev but not as
  the primary for a published file.
- **Esri World Light Gray Canvas**: usable without key but Esri's
  current terms restrict commercial/heavy use, and the style is a
  little dated. Hold as third-tier fallback only.
- **Mapbox**: paid + token. Off the table per constraints.

---

## 3. Cycling-specific overlay reference

### CyclOSM as toggleable basemap — **YES, include**

- URL pattern (raster, three subdomains for load balancing):
  `https://{a,b,c}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png`
- Hosted by OpenStreetMap-France under the OSM tile usage policy
  (fair use, no key). Attribution: `© CyclOSM, © OpenStreetMap-France,
  © OpenStreetMap contributors`.
- Purpose-built cycling cartography (renders cycleways, lanes, bike
  parking, repair stations) — gives the user a "show me the OSM
  reference rendering" view to sanity-check our overlay against.
- Treat as a *basemap* option, not the default — its strong styling
  competes with our custom overlay. Toggle exposes it for verification.

### Rejected

- **OpenCycleMap / Thunderforest**: requires API key, free tier
  capped at 150k tiles/month, key would be visible in HTML.
- **Strava Heatmap**: requires authenticated session cookies. Not
  embeddable. Skip entirely.

---

## 4. Styling plan for cycling features

Colors chosen for: (a) Okabe–Ito colorblind-safe palette adjacency,
(b) high contrast against Positron's grays and Dark Matter's blacks,
(c) hierarchy that reads at a glance — segregated paths jump out.

| Feature | Hex | Width (light basemap) | Dash | Casing |
|---|---|---|---|---|
| Ciclovia (segregated cycleway) | `#0E7C3A` (deep green) | 4.5 px @ z14 → 7 px @ z17 | solid | white casing 1.5 px |
| Ciclofaixa (painted lane on road) | `#1F77B4` (strong blue) | 3 px @ z14 → 5 px @ z17 | `[6, 4]` long dash | none |
| Ciclorrota (signed shared route) | `#E07B00` (warm orange) | 2.5 px @ z14 → 4 px @ z17 | `[2, 4]` short dotted | none |
| Bike route relation halo | `#7B2CBF` (purple) | 9 px translucent (`opacity 0.18`) | solid | renders *under* segments as a glow |

Dark mode adjustments (when Dark Matter is active):

| Feature | Hex |
|---|---|
| Ciclovia | `#3DDB7E` (brighter green) |
| Ciclofaixa | `#5BA8E5` (lighter blue) |
| Ciclorrota | `#FFB347` (warmer amber) |
| Halo | `#C77DFF` |

Notes:
- Colors are distinguishable under deuteranopia and protanopia
  (verified against Okabe–Ito-derived greens/blues/oranges with
  intentional luminance separation).
- The white casing on ciclovia gives a subtle "outline" effect that
  reads as "primary infrastructure" without screaming.
- Width interpolation: use MapLibre `interpolate ['linear', zoom]`
  expressions so lines thicken with zoom — keeps the network
  visible at z11 and detailed at z18.
- Hover/selected state: brighten by ~20% lightness and bump width
  +1.5 px; do not change hue.

---

## 5. Interactive controls

Listed in priority order with the simplest implementation path.

| Control | Implementation |
|---|---|
| Layer toggle (4 cycling layers + context layers like parks/lake) | Custom HTML checkbox panel in `<aside>`, fires `map.setLayoutProperty(layerId, 'visibility', ...)`. No plugin. |
| Basemap switcher: Positron / Dark Matter / Voyager / CyclOSM | Custom radio control in same sidebar, swaps style or toggles the raster source's `visibility`. Hand-roll, ~20 LoC. |
| Search / geocode (Brasília-biased) | `@maplibre/maplibre-gl-geocoder` via CDN, configured with Nominatim adapter. Bias `viewbox` to DF bounds and `countrycodes=br`. Honor 1-req/sec Nominatim policy via input debounce. Required attribution: `© OpenStreetMap contributors` (already shown). |
| Hover highlight + click popup | MapLibre `mousemove` + `feature-state` for hover; click opens popup with name, length (m, computed via Turf.js `lineString` length), surface, lit, type, RA. |
| Stats panel: total km by type, amenity counts, RA filter | Vanilla JS — iterate features once on load, compute totals with Turf.js (`@turf/length` via CDN). Re-compute on filter change. |
| RA dropdown filter | `<select>` populated from unique `ra` property; sets `map.setFilter(layerId, ['==', ['get','ra'], value])`. |
| Length filter (hide segments under N m) | Range slider on the panel; updates same MapLibre filter expression. |
| Scale bar | Built-in `maplibregl.ScaleControl({ unit: 'metric' })`. |
| Geolocate | Built-in `maplibregl.GeolocateControl`. |
| Fullscreen | Built-in `maplibregl.FullscreenControl`. |
| Mobile responsive | CSS Grid: sidebar collapses to a bottom-sheet under 768 px; controls stack. No JS framework needed. |

Single CDN dependency budget: MapLibre GL JS, MapLibre Geocoder,
Turf (`@turf/length`, `@turf/bbox`). Everything else is vanilla.

---

## 6. High-fidelity polish

- **Hover halo**: increase line width via `feature-state` and add a
  semi-transparent wider line beneath. Smooth transition via
  MapLibre's built-in interpolation.
- **Animated dash flow on ciclovias**: every ~50 ms, advance
  `line-dasharray-offset` on a sibling layer styled as `[0.5, 6]`
  with the brand green at 60% opacity. Reads as "active corridor."
  Disabled if `prefers-reduced-motion: reduce`.
- **Lake Paranoá + parks**: load a small auxiliary GeoJSON (Parque
  da Cidade, Jardim Botânico, ESAF, Parque Nacional, lake polygon)
  rendered as soft tinted fills (`#A8DADC` lake at 0.35 opacity,
  `#B7E4C7` parks at 0.30) under the cycling layers. Source these
  from OSM via Overpass; budget ~1 hour to extract.
- **Typography**: Inter via Google Fonts (`<link>` in head). Falls
  back to system stack `-apple-system, "Segoe UI", Roboto, sans-serif`
  if blocked. Numeric weights 500 / 600 / 700 only — keeps payload
  small.
- **Branded sidebar**: header with title "Brasília — Rede
  Cicloviária", subtitle line with last-updated date pulled from a
  `data/meta.json`, dataset counts table, source attribution
  block (OSM, CyclOSM, OpenFreeMap, CARTO). Soft drop-shadow,
  16 px radius, white surface.
- **Print layout**: `@media print` hides controls, expands map to
  full page, shows a corner legend block. Useful for portfolio PDF.
- **Loading state**: skeleton over the sidebar while GeoJSON fetches;
  fade in once the source `data` event fires.
- **Empty/error**: if a GeoJSON fetch fails, show a banner; degrade
  to whichever layers loaded.

---

## 7. File structure

```
brasilia-bike-lanes/
  index.html                  # single self-contained file (HTML + CSS + JS inline)
  data/
    cycleways.geojson         # ciclovias (segregated)
    cyclelanes.geojson        # ciclofaixas (painted)
    cycleroutes.geojson       # ciclorrotas (signed shared)
    bike-relations.geojson    # OSM bike route relations
    amenities.geojson         # bike parking, repair, drinking water
    context-parks.geojson     # parks/protected polygons
    context-lake.geojson      # Lake Paranoá polygon
    ra-boundaries.geojson     # Região Administrativa polygons (for filter + clip)
    meta.json                 # { generatedAt, counts, sources }
  research/
    OVERLAY-PLAN.md           # this file
  README.md                   # project overview + how to run (open index.html)
```

**Loading strategy:** async `fetch()` in parallel for all GeoJSON on
init. If any single file exceeds ~5 MB, switch *that file only* to
streaming via `fetch().body.getReader()` with a JSON streamer, or
pre-tile it to PMTiles (single static `.pmtiles` file served via
`pmtiles` JS library — also works double-click). Keep that as a
fallback decision the build phase can take if file sizes warrant.

No build step, no Node, no server. `file://` works because all data
sits next to the HTML and is fetched by relative path.

---

## 8. Risks / open questions

1. **GeoJSON file size.** Brasília's ~700 km network plus
   amenities + RA polygons could push a single file past
   5 MB unminified. Mitigation: minify (no whitespace), drop
   unused OSM tags, split by feature type as proposed. Hard fallback:
   PMTiles.
2. **`file://` CORS quirks.** Some browsers (Firefox especially)
   block `fetch()` from `file://` to relative paths. Document the
   one-liner workaround in README:
   `npx http-server .` or `python3 -m http.server`. Also note that
   Chrome with `--allow-file-access-from-files` is *not* recommended.
3. **Schema agreement with the data agents.** The styling and
   filter logic assumes specific properties on each feature:
   `type` (ciclovia | ciclofaixa | ciclorrota | bike_route),
   `ra` (string, normalized — "Plano Piloto", not "RA I" vs "Brasília"),
   `name`, `length_m` (precomputed), `surface`, `lit`,
   `network` (for relations). The data agents should be told to
   normalize to this exact schema, or this map's loader needs an
   adapter step.
4. **CyclOSM availability.** The OSM-FR server has occasional
   outages. The basemap switcher must gracefully show a "tiles
   unavailable" message and fall back, rather than leaving a blank
   map.
5. **Nominatim rate limit.** 1 request/second for unauthenticated
   use. Geocoder must debounce input (suggest 600 ms) and not fire
   on every keystroke.
6. **OpenFreeMap longevity.** Donation-funded; if it disappears, the
   primary basemap dies. Mitigation: the basemap switcher already
   makes Voyager a one-click swap. Document this in code comments
   so future-me understands the dependency.
7. **RA boundary source.** Codeplan / GeoPortal DF publishes RA
   shapefiles. License is open but attribution wording should be
   verified before publication.
8. **Bike route relation rendering.** OSM relations need to be
   resolved to ways before they're a renderable line. The data
   agents must flatten relations into LineString features with a
   `network` / `route_name` property; MapLibre cannot consume
   relations directly.
9. **Performance ceiling.** If the four cycling layers combined
   exceed ~50k vertices, hover hit-testing slows down. Solution:
   compute a simplified version (`@turf/simplify`, tolerance ~3 m)
   for the hover layer only, keep full geometry for display.
10. **Mobile interaction tradeoffs.** Hover-popup pattern doesn't
    exist on touch — needs a tap-to-select alternate state and a
    slide-up bottom sheet for segment details. Plan for it; don't
    bolt it on.

---

## Sources

- [MapLibre GL JS docs](https://maplibre.org/maplibre-gl-js/docs/)
- [MapLibre vs Leaflet (Jawg)](https://blog.jawg.io/maplibre-gl-vs-leaflet-choosing-the-right-tool-for-your-interactive-map/)
- [Mapbox vs Leaflet vs MapLibre 2026 (PkgPulse)](https://www.pkgpulse.com/guides/mapbox-vs-leaflet-vs-maplibre-interactive-maps-2026)
- [OpenFreeMap](https://openfreemap.org/)
- [OpenFreeMap Quick Start](https://openfreemap.org/quick_start/)
- [CARTO basemap styles GitHub](https://github.com/CartoDB/basemap-styles)
- [Stadia Maps migration / authentication](https://docs.stadiamaps.com/authentication/)
- [CyclOSM tile server](https://www.cyclosm.org/)
- [CyclOSM OSM Wiki](https://wiki.openstreetmap.org/wiki/CyclOSM)
- [OSM raster tile providers](https://wiki.openstreetmap.org/wiki/Raster_tile_providers)
- [maplibre-gl-geocoder](https://github.com/maplibre/maplibre-gl-geocoder)
- [leaflet-control-geocoder Nominatim notes](https://www.liedman.net/leaflet-control-geocoder/docs/classes/geocoders.Nominatim.html)
- [DER-DF: 636.89 km de ciclovias em 28 RAs](https://www.der.df.gov.br/vai-de-bike-distrito-federal-oferece-63689-km-de-ciclovias-em-28-ras/)
- [Agência Brasília: DF encerra 2023 com quase 700 km](https://www.agenciabrasilia.df.gov.br/2023/12/17/df-encerra-2023-com-quase-700-km-de-malha-cicloviaria/)
- [Agência Brasília: 1.000 km até 2026](https://www.agenciabrasilia.df.gov.br/2024/09/20/df-tera-mil-quilometros-de-ciclovias-ate-2026-e-300-novas-bikes-eletricas-compartilhadas/)
- [Esri designing maps for colorblind readability](https://www.esri.com/arcgis-blog/products/arcgis-pro/mapping/designing-maps-for-colorblind-readability)
- [ColorBrewer 2.0](https://colorbrewer2.org/)
- [Coloring for Colorblindness — David Nichols](https://davidmathlogic.com/colorblind/)
