Add docs/inspirations.md as the durable record of the 2026-07-05/06 prior-art research — per-project learnings with source paths, canonical credit lines, and the changes each spawned — and extend the acknowledgment lists in philosophy.md/architecture.md (Organic Maps, Endurain, wanderer). New OpenSpec changes (proposal/design/specs/tasks each): - Organic Maps: elevation-profile-hardening, gpx-parser-robustness, hiking-time-estimate, poi-index, hiking-foot-profile - Endurain: account-export, activity-duplicate-review, fit-parsing-hardening, activity-locations, self-hosting-guide, activity-privacy-controls - wanderer: federation-hardening, link-share-tokens - credits-page (user-visible acknowledgments) Updated in-flight changes with wanderer prior-art sections: route-federation, route-discovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
206 lines
8.5 KiB
Markdown
206 lines
8.5 KiB
Markdown
## Context
|
|
|
|
Routes in the Journal store geometry as PostGIS `LineString(4326)` in the
|
|
`journal.routes.geom` column (see `packages/db/src/schema/journal.ts`). The
|
|
`@trails-cool/map` package provides `MapView` (Leaflet map with layer controls)
|
|
and `RouteLayer` (GeoJSON polyline rendering). The route-features change adds a
|
|
`visibility` column to routes, enabling public/private distinction. This change
|
|
builds on all of that to let users discover public routes by browsing a map.
|
|
|
|
The existing route-features tasks.md (section 4) sketches spatial search in five
|
|
bullet points. This change breaks it out into a standalone, fully specified
|
|
implementation plan.
|
|
|
|
## Goals / Non-Goals
|
|
|
|
**Goals:**
|
|
- Full-page map at `/routes/explore` for browsing public routes
|
|
- Efficient PostGIS bounding box queries with spatial indexing
|
|
- Clickable route polylines with preview popups
|
|
- Debounced viewport-based fetching with result caching
|
|
|
|
**Non-Goals:**
|
|
- Text search, filtering, sorting, recommendations
|
|
- Sidebar with route list (map-only for v1)
|
|
- Federated route discovery across instances
|
|
- Route clustering for dense areas
|
|
|
|
## Decisions
|
|
|
|
### D1: Explore page -- full-page Leaflet map
|
|
|
|
The explore page at `/routes/explore` renders a full-page `MapView` from
|
|
`@trails-cool/map` with no sidebar or list panel. The map fills the viewport
|
|
below the navigation bar. This is the simplest useful interface and avoids
|
|
premature layout decisions.
|
|
|
|
The page is accessible to all users (including unauthenticated visitors) since
|
|
it only shows public routes. The initial map center and zoom come from the
|
|
user's last position (stored in localStorage) or fall back to the default
|
|
center (Europe overview, `[50.1, 10.0]` zoom 6).
|
|
|
|
Route: `route("routes/explore", "routes/routes.explore.tsx")` added to
|
|
`apps/journal/app/routes.ts`.
|
|
|
|
### D2: Bounding box query API
|
|
|
|
A new API route at `GET /api/routes/explore` accepts the map viewport as query
|
|
parameters and returns public routes within the bounds:
|
|
|
|
```
|
|
GET /api/routes/explore?south=47.2&west=5.8&north=55.1&east=15.0
|
|
```
|
|
|
|
The server query:
|
|
|
|
```sql
|
|
SELECT id, name, distance, elevation_gain, owner_id,
|
|
ST_AsGeoJSON(ST_Simplify(geom, 0.001)) AS geom_json
|
|
FROM journal.routes
|
|
WHERE visibility = 'public'
|
|
AND geom IS NOT NULL
|
|
AND ST_Intersects(
|
|
geom,
|
|
ST_MakeEnvelope(:west, :south, :east, :north, 4326)
|
|
)
|
|
ORDER BY distance DESC NULLS LAST
|
|
LIMIT 50;
|
|
```
|
|
|
|
Key decisions:
|
|
- **`ST_Intersects`** over `ST_Within`: routes that cross the viewport boundary
|
|
should still appear, not just routes fully contained.
|
|
- **`ST_Simplify(geom, 0.001)`**: Simplify geometries for transfer (~100m
|
|
tolerance at European latitudes). The explore map doesn't need full-resolution
|
|
tracks -- users click through to the detail page for that.
|
|
- **Limit 50**: Prevents overwhelming the map and keeps response times fast.
|
|
Ordered by distance descending so longer (likely more interesting) routes
|
|
appear first when the limit is hit.
|
|
- **Owner join**: Include owner username and display name for the popup author
|
|
attribution.
|
|
|
|
Response format:
|
|
|
|
```json
|
|
{
|
|
"routes": [
|
|
{
|
|
"id": "abc123",
|
|
"name": "Berlin to Prague",
|
|
"distance": 343000,
|
|
"elevationGain": 1240,
|
|
"author": { "username": "ullrich", "displayName": "Ullrich" },
|
|
"geometry": { "type": "LineString", "coordinates": [...] }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### D3: Route rendering with interactive popups
|
|
|
|
Public routes are rendered as polylines on the explore map. Each route is a
|
|
clickable Leaflet polyline. Clicking opens a Leaflet popup showing:
|
|
|
|
- Route name (linked to `/routes/:id`)
|
|
- Distance (formatted: "343 km")
|
|
- Elevation gain (formatted: "+1,240 m")
|
|
- Author name (linked to `/users/:username`)
|
|
|
|
This requires a new component in `@trails-cool/map` -- an `ExploreRouteLayer`
|
|
that takes an array of route objects and renders them as interactive polylines.
|
|
Unlike the existing `RouteLayer` (which renders a single GeoJSON object), this
|
|
component manages multiple routes with distinct click handlers.
|
|
|
|
Styling:
|
|
- Default: blue polyline (`#2563eb`, weight 3, opacity 0.6)
|
|
- Hover: increase opacity to 0.9 and weight to 5
|
|
- Active (popup open): keep highlighted styling
|
|
|
|
### D4: Spatial index verification
|
|
|
|
The `journal.routes.geom` column uses `geometry(LineString, 4326)`. PostGIS
|
|
does not automatically create a spatial index. A GiST index is required for
|
|
`ST_Intersects` to perform well:
|
|
|
|
```sql
|
|
CREATE INDEX IF NOT EXISTS idx_routes_geom ON journal.routes USING GIST (geom);
|
|
```
|
|
|
|
This should be added as a Drizzle migration or verified to already exist. If
|
|
using Drizzle's `db:push`, the index needs to be added via a custom SQL
|
|
migration since Drizzle ORM does not natively support GiST index declarations
|
|
on custom types.
|
|
|
|
### D5: Debounced viewport fetching
|
|
|
|
The explore page fetches routes when the map viewport changes (Leaflet
|
|
`moveend` event). To avoid excessive API calls during panning and zooming:
|
|
|
|
- **Debounce 300ms**: Wait 300ms after the last `moveend` before fetching.
|
|
- **Abort previous**: Cancel in-flight requests when a new fetch starts
|
|
(AbortController).
|
|
- **Cache by bounds**: Store the last response keyed by rounded bounds. If the
|
|
user pans back to a previously viewed area, serve from cache. Simple
|
|
Map-based cache with a max of 20 entries (LRU eviction).
|
|
- **Loading state**: Show a subtle loading indicator (spinner in map corner)
|
|
during fetches. Don't clear existing routes while loading -- overlay new
|
|
results when they arrive.
|
|
|
|
This logic lives in a custom hook: `useExploreRoutes(map)` that returns
|
|
`{ routes, isLoading }`.
|
|
|
|
### D6: Dependency on route-sharing
|
|
|
|
This change cannot function without the `visibility` column on
|
|
`journal.routes`. The bounding box query filters on `visibility = 'public'`.
|
|
If the column doesn't exist, the query fails.
|
|
|
|
Implementation order: route-sharing schema changes (route-features section 1)
|
|
must be completed first. The explore page can be built in parallel but only
|
|
tested after visibility exists and at least one route is set to public.
|
|
|
|
## Prior art: wanderer (reviewed 2026-07-06)
|
|
|
|
Full analysis in `docs/inspirations.md`. wanderer runs trail discovery at
|
|
real scale (Meilisearch over PocketBase); their patterns don't change this
|
|
change's v1 scope but pre-answer its scaling questions:
|
|
|
|
- **Access control lives in the query layer, always.** They mint
|
|
per-session Meilisearch tenant tokens so the index itself enforces
|
|
`public OR author OR shared-with` — a client can't bypass it. Our
|
|
PostGIS equivalent: the `visibility = 'public'` predicate (D6) must be
|
|
part of the shared bbox-query helper, never a caller responsibility.
|
|
Worth an explicit regression test.
|
|
- **Clustering (currently a non-goal):** when dense areas force it,
|
|
their shape is proven — a server endpoint running `supercluster`
|
|
returning only `{id, point, bbox_diagonal}`, with a dynamic threshold
|
|
where the N largest-bounding-box routes render as actual polylines and
|
|
the rest cluster as points. A direct answer to the 50-result-limit
|
|
risk below when v1 outgrows it.
|
|
- **Recommendations without ML:** random-offset sampling over the
|
|
filtered result count ("surprise me") — cheap and effective.
|
|
- **Cross-instance discovery** (also a non-goal here): they index
|
|
federated-in remote trails as local stub records, searchable with
|
|
`is_federated`/`domain` facets — the likely shape for trails once
|
|
`route-federation` mirrors exist, rather than live fan-out search.
|
|
- Robustness details worth copying at implementation time:
|
|
antimeridian-aware bbox handling, a request-race guard on viewport
|
|
queries (stale responses discarded by request id), and a
|
|
payload-too-large fallback that shrinks page size.
|
|
|
|
## Risks / Trade-offs
|
|
|
|
- **50-result limit may frustrate users**: In dense areas (Alps, popular hiking
|
|
regions) many routes could exist. The limit means some routes are invisible.
|
|
Mitigate by ordering by distance (longer routes first) and noting this is v1.
|
|
Clustering or pagination can come later.
|
|
- **Geometry simplification may look rough**: `ST_Simplify(geom, 0.001)` is
|
|
aggressive. At the explore zoom level this is fine, but if a user zooms in
|
|
close, simplified routes look jagged. Acceptable because clicking opens the
|
|
detail page with full geometry.
|
|
- **No spatial index in Drizzle**: Drizzle doesn't support GiST indexes on
|
|
custom types declaratively. The index must be managed via raw SQL migration.
|
|
This is a minor operational concern, not a technical risk.
|
|
- **Unauthenticated access**: The explore endpoint is public. This is
|
|
intentional (discovery should not require login) but means rate limiting
|
|
should be considered to prevent abuse.
|