Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ Content backend (Payload + OSM — see below):
```bash
pnpm db:up # Start local Postgres via docker compose
pnpm db:migrate # Apply migrations
pnpm db:seed:bend # Import Bend's trails (Chattanooga has its own script)
pnpm db:seed:bend # Import Bend's trails and Casual routes
pnpm db:seed:bend-routes # Sync only Bend's 8 Casual routes (also runs on deploy)
pnpm db:seed:chattanooga-routes # Sync 5 legacy Studio-backed route rows
pnpm generate:types # Regenerate src/payload-types.ts after a collection change
pnpm generate:importmap # Regenerate the admin import map after adding a component
```
Expand Down Expand Up @@ -107,10 +109,10 @@ src/

### Core Data Flow

1. **Page Entry** (`src/app/(frontend)/page.tsx`): Reads CMS trails on the server, then renders `HomeClient.tsx`, which dynamically imports Map with SSR disabled
1. **Page Entry** (`src/app/(frontend)/page.tsx`): Reads CMS trails and routes on the server, then renders `HomeClient.tsx`, which dynamically imports Map with SSR disabled
2. **Map Component** (`src/components/Map.tsx`): Main orchestrator that initializes Mapbox, manages markers, and handles custom events
3. **Data Sources** (`src/data/`):
- `geo_data.ts`: barrel re-exporting the **active city's** data (`bikeRoutes`, `mapFeatures`, `bikeResources`, `mountainBikeTrails`, `elevationBasePath`, ...) — components import from here and stay city-agnostic
- `geo_data.ts`: barrel re-exporting the **active city's** static config (`mapFeatures`, `bikeResources`, route/network URLs, and trail-layer config). Published lists come from `route-source.ts` and `trail-source.ts`.
- `gbfs.ts`: live bike share data (station-based for Chattanooga, free-bike/Veo for Bend — a discriminated `GBFSConfig` union)

### Event-Driven Communication
Expand Down Expand Up @@ -143,7 +145,7 @@ The app uses custom DOM events (`window.dispatchEvent`) for component communicat

### Map Styling

Routes are styled via Mapbox Studio (referenced by layer IDs like `riverwalk-loop-v3-public`). Route bounds are calculated from layer features at runtime to enable zoom-to-fit.
Route display metadata comes from Payload and is keyed by stable layer IDs such as `riverwalk-loop-v3-public`. Each Route explicitly chooses imported geometry, a linked Trail, or a legacy Mapbox Studio layer. Chattanooga's imported Riverwalk geometry comes only from Payload; its same-named Studio layer must stay disabled even when the database is unavailable or unseeded.

### Mountain Bike Trails

Expand Down Expand Up @@ -459,6 +461,15 @@ measurements are still derived, via the same `measureParts` the OSM path uses.
- `src/payload/osm/build.ts` — orchestrates the OSM path
- `src/payload/components/TrailMapEditor.tsx` — the one admin map (pick/move/draw)
- `src/payload/read/trails.ts` — reads trails back out for the public map
- `src/payload/collections/Routes.ts` + `src/payload/read/routes.ts` — Casual
route records and the public map read path. A route either owns imported
geometry, selects a same-city Trail and reuses its current measurements, or
explicitly names a legacy Mapbox Studio layer that has not been migrated.
Casual reads Routes only; linking a Trail is how a curator exposes it there
without duplicating its line. Use
`pnpm db:import:chattanooga-routes` rather than committing generated route
GeoJSON. Bend's seed derives its eight imported routes directly from the
committed bike-network source.
- `src/payload/globals/Theme.ts` + `read/theme.ts` — admin appearance, editable
at `/admin/globals/theme` and injected by the admin layout
- `src/payload/collections/{Organizations,TrailAreas}.ts` — the options behind
Expand All @@ -474,10 +485,11 @@ measurements are still derived, via the same `measureParts` the OSM path uses.
differ (Bend has OSM-referenced geometry; Chattanooga imports an archived GIS
snapshot without osmIds), and **only Bend is seeded by default**

**How the public map gets its trails.** `src/app/(frontend)/page.tsx` is a
**How the public map gets its trails and routes.** `src/app/(frontend)/page.tsx` is a
server component: it calls `getCityTrails()` (Payload's Local API — a typed
function call, no HTTP hop) and passes trails into `HomeClient` as props, which
publishes them to `src/data/trail-source.ts` during render. The page resolves
function call, no HTTP hop) plus `getCityRoutes()`, and passes both into
`HomeClient` as props. The client publishes them to `src/data/trail-source.ts`
and `src/data/route-source.ts` during render. The page resolves
its city from the request hostname, so it reads `headers()` and renders per
request; `/api/map/trails` sends `Cache-Control: max-age=60`, so an admin edit
is live within a minute without a rebuild.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ pnpm dev # http://localhost:3000

A free [Mapbox](https://account.mapbox.com/access-tokens/) public token is required for the map to render.

The `city` query parameter overrides hostname and environment selection for a
request, which makes it easy to exercise either configured city locally:

```text
http://localhost:3000/?city=chattanooga
http://localhost:3000/?city=bend
```

### Scripts

| Command | What it does |
Expand Down
23 changes: 17 additions & 6 deletions docs/DATA.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Data files

All content shown on the map lives in `src/data/` as plain, typed TypeScript
arrays — no database, no CMS, no admin panel. To change what a deployment
shows, edit an array and ship a PR.
Seed metadata and checked-in trail fallbacks live in `src/data/` as typed
TypeScript arrays. Published trail and route content comes from Payload,
while other content can remain explicitly configured to use static files or
Mapbox Studio layers. Payload-owned route geometry has no implicit Studio
fallback.

Each file exports a typed array; the `interface` at the top of the file is the
contract. `icon` fields are Font Awesome `IconDefinition` values imported from
Expand All @@ -25,19 +27,25 @@ The MTB trail array lives in its own `mountain-bike-trails.data.ts` so the

## BikeRoute (`bike-routes.ts`)

Routes are line layers styled in Mapbox Studio; the entry here wires a layer to
its sidebar card.
The TypeScript arrays are import metadata, not the public Casual list. Published
Route records supply both display metadata and geometry through
`/api/map/routes?city=<city>`. Imported and Trail-backed routes have no Studio
or checked-in route-GeoJSON fallback. A Route may select a same-city Trail, in
which case the read path uses that Trail's current geometry, distance, and
bounds. Chattanooga's five not-yet-migrated routes explicitly select their
existing Studio layers; their cards still come from Payload.

| Field | Type | Notes |
|---|---|---|
| `id` | `string` | **Must equal the Mapbox Studio layer ID** for this route |
| `id` | `string` | Must equal the GeoJSON feature `id` or Mapbox Studio layer ID |
| `name` | `string` | Display name |
| `color` | `string` | Hex; should match the layer's color in Studio |
| `description` | `string` | Sidebar copy |
| `icon` | `IconDefinition` | Font Awesome icon |
| `defaultWidth` | `number` | Line width in px |
| `opacity` | `number` | 0–1 |
| `distance` | `number` | Miles |
| `kind?` | `ride \| greenway \| path \| trail` | Editorial route classification |
| `defaultBounds?` | `[swLng, swLat, neLng, neLat]` | Zoom-to-fit fallback when runtime bounds aren't available |
| `bounds?` | `mapboxgl.LngLatBounds` | Computed at runtime — do not hand-author |

Expand Down Expand Up @@ -81,6 +89,9 @@ pointless, they get overwritten. See [DEPLOYING.md](DEPLOYING.md) for setup.

| Script | Writes |
|---|---|
| `import-chattanooga-routes.ts` | Normalizes the verified Riverwalk shapefile and upserts it into Payload without writing generated GIS data to Git |
| `prepare_chattanooga_routes.py` | Streams normalized route GeoJSON to the importer, or writes an explicitly requested temporary output |
| `fix_route_directions.py` | Detects and repairs inconsistent multipart loop direction |
| `add_trail_elevation.py` | `MountainBikeTrail` elevation stats (`elevationGain/Loss/Min/Max`, `distance`) + per-trail `public/data/elevation/chattanooga/{slug}.json` |
| `add_trail_bounds.py` | `MountainBikeTrail.defaultBounds` and `distance` |
| `validate_trails.py` | Read-only — flags geometry/elevation anomalies |
Expand Down
23 changes: 20 additions & 3 deletions docs/DEPLOYING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ pnpm dev # http://localhost:3000
The map will be blank until you add a Mapbox token (next step) — the browser
console says so explicitly.

For a multi-city checkout, `?city=<id>` overrides hostname and
`NEXT_PUBLIC_CITY_ID` selection on public pages. For example, use
`http://localhost:3000/?city=bend` to test Bend without restarting the dev
server. Invalid city ids are ignored and fall back to the normal hostname/env
resolution.

## 2. Mapbox setup

1. In [Mapbox Studio](https://studio.mapbox.com/), create (or duplicate) a map
Expand Down Expand Up @@ -77,8 +83,12 @@ contract of `BikeRoute`, `MountainBikeTrail`, `BikeResource`, `MapFeature`, and

## 6. Routes and curated trails

- **Routes** — draw/upload each route as a line layer in your style, then set
each `BikeRoute.id` in `bike-routes.ts` to that layer's ID.
- **Routes** — publish Route records in Payload; Casual mode reads them from
`/api/map/routes?city=<city>`. Imported routes store normalized geometry plus
provenance. To reuse an existing curated trail, choose “Existing trail” and
select a same-city Trail; its current line, distance, and bounds then drive
the route automatically. “Mapbox Studio layer” is an explicit transitional
source for a known layer, not a fallback when database geometry fails.
- **Trails** — prepare a WGS84 GeoJSON file with one `MultiLineString` feature
per curated trail, seed it into Payload, and configure the city layer with
`/api/map/trails?city=<city>` plus the static file as
Expand Down Expand Up @@ -214,14 +224,21 @@ A database-free fork skips migrations and still builds the checked-in fallback
map.

Seed every city into the same fresh database after the initial migration. Both
commands are idempotent and match existing rows on `(trailName, city)`:
commands are idempotent; Bend's command also seeds its eight Casual routes:

```bash
pnpm db:migrate
pnpm db:seed:chattanooga
pnpm db:seed:bend
```

Production builds also run `pnpm db:seed:bend-routes` and
`pnpm db:seed:chattanooga-routes` after migrations. The former derives Bend's
eight rows from the committed bike network. The latter ensures Chattanooga's
five not-yet-migrated Studio routes have Payload records. Both preserve a row
whose geometry source was deliberately migrated. Chattanooga's Riverwalk
import remains manual because its verified GIS archive is not committed.

Any Node host also works: set the same variables, run `pnpm run ci`, then
`pnpm start`.

Expand Down
137 changes: 137 additions & 0 deletions docs/guides/chattanooga-gis-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Chattanooga GIS import

The regional mountain-bike geometry archived from
[PR #67](https://github.com/kwiens/bikemap/pull/67) is permitted for use in this
project. The raw trail shapefile stays outside the application repository; its
normalized WGS84 trail GeoJSON remains the checked-in public fallback. Curated
route geometry takes a stricter path: normalize it directly into Payload and
do not commit the generated coordinates.

## Canonical input

Use the `Chattanooga_Regional_Trails_4` shapefile set dated 2026-04-09. The
required components and SHA-256 checksums are:

| File | SHA-256 |
|---|---|
| `Chattanooga_Regional_Trails_4.shp` | `83e844b759dc34d35fca207396479957491d0d3f0f2ef72930cba38f927ad2d2` |
| `Chattanooga_Regional_Trails_4.shx` | `8c9a3a1a835d379651bd592cc05f45d0513d5d0e2fb321ecea5420218676cd3a` |
| `Chattanooga_Regional_Trails_4.dbf` | `d1f292db756972ca50112cbc7deceb5c2d0a42a0d2fdebfade55276d649d874c` |
| `Chattanooga_Regional_Trails_4.prj` | `2b085fa42af77cb88c9a175d5b20c99ac21e388de6dc87b33a165be38e9264a8` |
| `Chattanooga_Regional_Trails_4.cpg` | `3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824` |

The ArcGIS metadata is preserved in the source bundle but is not required by
the converter.

## Prepare and import

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r scripts/requirements.txt
python scripts/prepare_chattanooga_trails.py \
/path/to/Chattanooga_Regional_Trails_4.shp

docker compose up -d
pnpm db:migrate
pnpm db:seed:chattanooga
```

The converter reads the projection from `.prj`, transforms NAD83 / UTM zone
16N to WGS84, groups split features by their raw `Trail` value, removes exact
duplicate line parts, and writes
`public/data/chattanooga/trails.geojson` deterministically.

The source contains 320 records. The generated file contains 224 named
MultiLineStrings and 397 line parts. Of the app's 224 curated trails, 218 match
by exact `Trail` name and import with geometry. The six without geometry are:

- Godsey Ridge Blue 1
- Godsey Ridge Blue 2
- Godsey Ridge Expert 1
- Godsey Ridge Expert 2
- Godsey Ridge Expert Spur
- Godsey Ridge Green

Those six are expected: they render from the separate `Godsey Ridge Trails`
layer baked into the Chattanooga Mapbox style. The regional source also has six
named geometries not represented in the curated sidebar (`River Walk`, `South
Chick Greenway`, `South Chickamauga Creek Greenway`, `Tennessee Riverwalk`,
`Valley`, and `unmaintained`). The map's curated-name filter prevents them from
appearing as orphan lines; the seed ignores them because no matching trail row
exists.

Imported Chattanooga rows use `geometrySource: 'imported'`. They keep the
archived line until a curator draws a replacement or the trail is matched to
OSM way ids. Bulk seeding passes `context.skipOsmRebuild`, so it never sends an
Overpass request per row.

## Curated route geometry

Riverwalk Loop is normalized from its route shapefile in the same PR #67
archive. It is the only route migrated here because its archived geometry was
verified against the current Studio tileset; several other Studio routes have
newer geometry than the archive.

| Route source (`GIS/Uncompressed files/RiverWalk_Loop_v3.1/…`) | SHA-256 |
|---|---|
| `OSM_RiverWalk_Loop_V3_1.cpg` | `3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824` |
| `OSM_RiverWalk_Loop_V3_1.dbf` | `3bcc134651d0bf0159f2e63fcc6ebddf0332a13e9f2abf4c0c2ccb6d757cd1b7` |
| `OSM_RiverWalk_Loop_V3_1.prj` | `f2e7fb14d55bdd8d6a3bc2c272a48729d8f9d0ad72936e20eae6a9a81c2fccd0` |
| `OSM_RiverWalk_Loop_V3_1.shp` | `ef797a83b5a630747faa0f32ca16956b989fa5877d929313b1847657e1a98460` |
| `OSM_RiverWalk_Loop_V3_1.shx` | `60349a0c0cacd4817330aa390de2d0d794c9aa31bc026f68fd41bbf1b8d07b33` |

```bash
pnpm db:import:chattanooga-routes \
"/path/to/GIS/Uncompressed files"
```

The importer invokes `prepare_chattanooga_routes.py`, verifies every component
of the archived shapefile, normalizes it in memory, and upserts the result into
Payload's `routes` collection. No generated route coordinates are written
under `public/` or committed to Git. The database row keeps the existing
`BikeRoute.id`, so route selection and styling continue to use the same public
identifier.

The public map reads its route list from Payload. Riverwalk geometry comes from
`/api/map/routes?city=chattanooga`: the same-named Studio layer stays disabled
whether the endpoint returns geometry, is empty, or is unavailable. The five
routes awaiting a verified current import also have Payload records, but each
explicitly selects its known Studio layer as the geometry source. Run the
migration and Riverwalk import before relying on Riverwalk in an environment.

Multipart route direction is normalized by `scripts/fix_route_directions.py`.
When two parts form alternate paths between the same junctions, the paths must
run in opposite directions. The utility solves those relationships together
and reverses the least total distance, avoiding route-specific coordinate
exceptions. To inspect or independently check the normalized output, write it
to a temporary file explicitly:

```bash
python scripts/prepare_chattanooga_routes.py \
"/path/to/GIS/Uncompressed files" --output /tmp/chattanooga-routes.geojson
python scripts/fix_route_directions.py \
/tmp/chattanooga-routes.geojson --check
```

The current source normalization reverses the four-point 5th/Lookout branch of
Riverwalk Loop while leaving its 556-point main path unchanged.

## Other PR #67 datasets

The app has dedicated database collections for curated mountain-bike trails
and routes, not for every GIS layer in the archive. Do not coerce unrelated
data into either collection:

| Source group | Intended treatment |
|---|---|
| TPL full and filtered trail inventories | Preserve as reference/network data; mixed-use and hiking records need a separate network-segment model. |
| Riverwalk Loop route file | Normalize and import into Payload's `routes` collection; display metadata remains in `BikeRoute`. |
| Zoo, Riverwalk Greenway, Cherokee, Moccasin Bend, and South Chickamauga route files | Keep their newer Studio geometry until each current source is archived and verified. |
| Bike Chattanooga station snapshot | Do not seed as current availability; the app already reads live GBFS. Keep only as a dated reference snapshot. |
| Traffic garden points | Candidate geometry for a future map-features collection. |
| Godsey–Waldens connection | Preserve as an unmapped route candidate until its product/route identity is decided. |

This division is intentional: “ready to import” means the data matches a real
collection contract, not merely that every source file can be inserted into a
JSON column.
22 changes: 18 additions & 4 deletions docs/guides/osm-trail-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ What it buys:
```bash
docker compose up -d # Postgres on :5432
pnpm db:migrate # create the schema
pnpm db:seed:bend # import Bend's 182 trails
pnpm db:seed:bend # import Bend's 182 trails and 8 Casual routes
pnpm db:seed:bend-routes # sync only the 8 routes
pnpm dev # /admin — first visit creates the admin user
```

Expand Down Expand Up @@ -349,11 +350,11 @@ The map at `/` is a **server component**. It reads Payload through the Local API
as props:

```
app/(frontend)/page.tsx getCityTrails(cityId) ← Local API, per request
app/(frontend)/page.tsx getCityTrails + getCityRoutes ← Local API, per request
↓ props
HomeClient.tsx setMountainBikeTrails(trails) ← during render
HomeClient.tsx set trails + routes ← during render
data/trail-source.ts getMountainBikeTrails() ← what every consumer reads
data/{trail,route}-source.ts ← what consumers read
```

Consumers call `getMountainBikeTrails()` rather than importing an array, because
Expand All @@ -365,13 +366,26 @@ Geometry follows the same path: both cities point their regional curated layer
at `/api/map/trails?city=<city>` and keep the GeoJSON used by the seed as a
static fallback.

Casual mode always reads Route records. A Route can either own imported
geometry or select an existing same-city Trail; the latter resolves the trail's
current geometry, distance, and bounds at read time. This keeps the two sidebar
concepts distinct while making “show this trail in Casual” a single Route admin
record instead of a second copy of the line.

Migration-capable builds run `db:seed:bend-routes` immediately after schema
migrations. The sync skips unchanged rows and preserves any row a curator has
switched to a Trail, so routine deploys neither add route versions nor undo an
editorial link. Chattanooga's route import remains manual because its source
GIS is external to the repository.

### Seeding

One script per city, because their pipelines genuinely differ:

| | `pnpm db:seed:bend` | `pnpm db:seed:chattanooga` |
|---|---|---|
| Trails | 182 | 224 |
| Casual routes | 8, derived from `bike-network.geojson` | imported separately with `pnpm db:import:chattanooga-routes` |
| Geometry | `public/data/bend/trails.geojson`, by slug | `public/data/chattanooga/trails.geojson`, by raw `Trail` name |
| Prepared profile | none — run the backfill | imported from `public/data/elevation/chattanooga`, measured from the same GIS line |
| `osmIds` | yes | none |
Expand Down
Loading
Loading