Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,39 @@ The script outputs:
3. Run `scripts/add_trail_elevation.py --trail "Trail Name"` to generate elevation data and populate `distance`, elevation stats, and `defaultBounds`
4. If the trail is in a new `recArea`, add it to `REGION_MAP` in `mountain-bike-trails.ts`

### Nationwide OSM Bike Trails

A toggleable nationwide bike-trails layer sourced from the OpenStreetMap US
[tile service](https://openstreetmap.us/our-work/tileservice/). It is separate
from the curated Chattanooga MTB/route layers and off by default.

- Constants live in `src/data/osm-trails.ts`; the tileset is attached at runtime
via `ensureOsmTrailsSource(map)` in `utils/map.ts` (same pattern as the MTB
tileset — it is **not** in the Mapbox Studio style). We pass the TileJSON URL
(`https://tiles.openstreetmap.us/vector/trails.json`) so Mapbox picks up zoom
bounds (z0–14) and the "© OpenStreetMap contributors" attribution for free.
- The `trail` source-layer carries OSM tags. `OSM_BIKE_TRAIL_FILTER` selects
bike-relevant ways: `bicycle` in {yes, designated, permissive}, OR a present
`mtb:scale` tag (MTB singletrack often lacks an explicit bicycle tag), OR
`highway=cycleway`. Lines are colored by `mtb:scale` difficulty, with a white
casing for legibility, and inserted beneath the curated MTB layer.
- It toggles independently of the marker layers (it's a vector line layer, not a
marker group): the "Nationwide trails" switch in the MTB **Trails** tab
(`MapLegend`) dispatches `layer-toggle` with `layer: 'osmTrails'`; `Map.tsx`
flips visibility via `setOsmTrailsVisible`.
- Trail POIs come from the `trail_poi` source-layer as a single symbol layer
(`OSM_POI_LAYER_ID`, `minzoom 12`). `OSM_POI_FILTER` keeps trailhead parking
(`amenity=parking`) and information points (`tourism=information`); the icon is
picked per-category from the Mapbox style's built-in **Maki** sprite (`parking`
/ `information`) — no custom sprite/spreet step. It shares the trails toggle
via `setOsmTrailsVisible`.
- Clickable: a transparent extra-wide hit layer (`OSM_TRAILS_HIT_LAYER_ID`) is
the tap target; `registerOsmTrailPopup` opens a Mapbox popup built by
`buildOsmTrailPopupHTML` (in `osm-trails.ts`) from the feature's OSM tags —
name, a difficulty badge (from `mtb:scale`, raw scale not shown), type,
surface, bike access, plus a "View on OpenStreetMap" link. OSM values are
HTML-escaped. Popup styling lives in `osm-trail-*` classes in `app/map.css`.

### Mapbox UI Overlays

- The Mapbox canvas (`.map-container`) uses `position: absolute` with `z-index: 500` and covers the full viewport. It will obscure any sibling or child elements with a lower z-index.
Expand Down
20 changes: 17 additions & 3 deletions scripts/add_trail_elevation.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,21 @@ def _read_mapbox_token():
)
sys.exit(1)

MAPBOX_TOKEN = _read_mapbox_token()
_MAPBOX_TOKEN = None


def _get_mapbox_token():
"""Resolve the Mapbox token lazily (cached), only when a tile fetch needs it.

Resolving at import time would kill any module that imports this one purely
for its decoder helpers (e.g. validate_trails.py) when no token is set.
"""
global _MAPBOX_TOKEN
if _MAPBOX_TOKEN is None:
_MAPBOX_TOKEN = _read_mapbox_token()
return _MAPBOX_TOKEN


MVT_TILESET = 'swuller.ccfw1cmr'
TRAIL_BBOX = (-85.48, 34.76, -84.85, 35.21)
MVT_ZOOM = 12
Expand Down Expand Up @@ -290,7 +304,7 @@ def fetch_mvt(z, x, y):

# Fetch from API
url = (f'https://api.mapbox.com/v4/{MVT_TILESET}'
f'/{z}/{x}/{y}.mvt?access_token={MAPBOX_TOKEN}')
f'/{z}/{x}/{y}.mvt?access_token={_get_mapbox_token()}')
resp = _session.get(url)

os.makedirs(os.path.dirname(cache_path), exist_ok=True)
Expand Down Expand Up @@ -324,7 +338,7 @@ def fetch_terrain_tile(tile_x, tile_y):
# Fetch from API
url = (f'https://api.mapbox.com/v4/mapbox.terrain-rgb'
f'/{TERRAIN_ZOOM}/{tile_x}/{tile_y}.pngraw'
f'?access_token={MAPBOX_TOKEN}')
f'?access_token={_get_mapbox_token()}')
resp = _session.get(url)
resp.raise_for_status()

Expand Down
12 changes: 7 additions & 5 deletions src/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Image from 'next/image';
import Link from 'next/link';
import type { Metadata } from 'next';
import { mapConfig } from '@/config/map.config';
import { siteConfig } from '@/config/site.config';
import {
ArrowLeft,
Expand Down Expand Up @@ -28,6 +29,8 @@ export const metadata: Metadata = {
description: `About the ${siteConfig.name} project — a free interactive map of bike routes, trails, and resources.`,
};

const region = mapConfig.region.displayName;

const LOGO_DOWNLOADS = [
{
label: 'Primary (color)',
Expand Down Expand Up @@ -92,9 +95,8 @@ export default function AboutPage() {
priority
/>
<p className="text-gray-600 text-lg leading-relaxed max-w-md">
A free, open-source interactive map of Chattanooga bike routes,
mountain bike trails, and cycling resources, built by the local
riding community.
A free, open-source interactive map of {region} bike routes,
mountain bike trails, and cycling resources.
</p>
</div>

Expand Down Expand Up @@ -146,7 +148,7 @@ export default function AboutPage() {
</h2>
<p className="text-sm text-gray-500 mb-5">
Use these for stickers, flyers, or anything that helps people find
safe rides in Chattanooga.
safe rides in {region}.
</p>

<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-3">
Expand Down Expand Up @@ -187,7 +189,7 @@ export default function AboutPage() {
{/* Footer */}
<footer className="border-t border-gray-200 pt-8 pb-12 text-center text-sm text-gray-400">
<p>
Made in Chattanooga.{' '}
Made for {region}.{' '}
<a
href="https://github.com/kwiens/bikemap/blob/main/LICENSE"
className="underline hover:text-gray-600"
Expand Down
6 changes: 5 additions & 1 deletion src/app/export/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ export default function ExportPage() {
});
const gpx = buildGpx(tracks);
if (gpx)
downloadFile(gpx, 'chattanooga-bike-routes.gpx', 'application/gpx+xml');
downloadFile(
gpx,
`${slugify(mapConfig.region.displayName)}-bike-routes.gpx`,
'application/gpx+xml',
);
}

function handleDownloadAllSvg() {
Expand Down
92 changes: 92 additions & 0 deletions src/app/map.css
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,95 @@
.map-popup a:hover {
text-decoration: underline;
}

.osm-trail-popup {
min-width: 200px;
max-width: 280px;
}

.osm-trail-popup .osm-trail-name {
margin: 0 18px 8px 0;
font-size: 15px;
font-weight: 600;
line-height: 1.25;
color: #111827;
}

.osm-trail-subhead {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}

.osm-trail-badge {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #fff;
padding: 2px 8px;
border-radius: 999px;
}

.osm-trail-badge--easy {
background: #16a34a;
}
.osm-trail-badge--intermediate {
background: #2563eb;
}
.osm-trail-badge--advanced {
background: #374151;
}
.osm-trail-badge--expert {
background: #000;
}

.osm-trail-type {
font-size: 13px;
color: #6b7280;
}

.osm-trail-facts {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}

.osm-trail-fact {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
line-height: 1.4;
}

.osm-trail-fact dt {
color: #6b7280;
}

.osm-trail-fact dd {
margin: 0;
font-weight: 500;
color: #111827;
text-align: right;
}

.osm-trail-source {
margin-top: 12px;
border-top: 1px solid #eee;
padding-top: 10px;
font-size: 13px;
}

.osm-trail-source a {
color: #2563eb;
font-weight: 500;
text-decoration: none;
}

.osm-trail-source a:hover {
text-decoration: underline;
}
36 changes: 34 additions & 2 deletions src/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
mapFeatures,
bikeResources,
mountainBikeTrails,
hiddenStyleLayerIds,
trailMetadata,
} from '@/data/geo_data';
import {
createLocationMarker,
Expand Down Expand Up @@ -39,6 +41,10 @@ import {
initMtnBikeColors,
initMtnBikeLayers,
ensureMtnBikeSource,
ensureOsmTrailsSource,
setOsmTrailsVisible,
registerOsmTrailPopup,
hideStyleLayers,
hideStrayStyleLayers,
TRAIL_LAYERS,
addRideLayer,
Expand All @@ -50,7 +56,6 @@ import {
import { loadRide } from '@/utils/ride-storage';
import { mapConfig } from '@/config/map.config';
import { MAP_EVENTS } from '@/events';
import { TRAIL_METADATA } from '@/data/trail-metadata';
import { HeadingSmoother } from '@/utils/compass';

// Recenter pause durations: how long to suppress auto-centering after
Expand Down Expand Up @@ -94,6 +99,9 @@ const MapboxMap = memo(function MapboxMap() {
const [showAttractions, setShowAttractions] = useState(false);
const [showBikeResources, setShowBikeResources] = useState(false);
const [showBikeRentals, setShowBikeRentals] = useState(false);
// Desired OSM-trails visibility, tracked in a ref so a toggle flipped before
// the style finishes loading can be replayed once the layers are attached.
const osmTrailsVisibleRef = useRef(false);

// Trail auto-detection during ride recording
const autoDetectEnabledRef = useRef(false);
Expand Down Expand Up @@ -532,6 +540,15 @@ const MapboxMap = memo(function MapboxMap() {
}
}

// Nationwide OSM bike trails are an independent vector layer (not a marker
// group), so just flip their visibility. Remember the desired state so it
// can be replayed if the style hadn't finished loading yet.
if (layer === 'osmTrails') {
osmTrailsVisibleRef.current = visible;
setOsmTrailsVisible(map.current, visible);
return;
}

if (visible) {
updateRouteOpacity(map.current, bikeRoutes, null, {
selected: 0.1,
Expand Down Expand Up @@ -828,6 +845,8 @@ const MapboxMap = memo(function MapboxMap() {
}
}

hideStyleLayers(newMap, hiddenStyleLayerIds);

// Set initial line width for specific layers
if (style?.layers) {
style.layers.forEach((layer) => {
Expand Down Expand Up @@ -959,9 +978,17 @@ const MapboxMap = memo(function MapboxMap() {
ensureMtnBikeSource(newMap);
initMtnBikeColors(newMap);
initMtnBikeLayers(newMap);

// Suppress orphan trail layers baked into the Studio style (e.g. the
// leftover TPL trails layer) so they don't render over our routes.
hideStrayStyleLayers(newMap);

// Attach the nationwide OSM bike-trails layer (hidden until toggled).
// Replay any toggle the user flipped before the style finished loading.
ensureOsmTrailsSource(newMap);
if (osmTrailsVisibleRef.current) {
setOsmTrailsVisible(newMap, true);
}
initTrailBoundsFromDefaults(mountainBikeTrails);

// Apply unselected defaults (opacity/width) through the shared
Expand All @@ -975,10 +1002,11 @@ const MapboxMap = memo(function MapboxMap() {
const hId = `${cfg.layerId} Hit`;
if (newMap.getLayer(hId)) {
newMap.on('click', hId, (e) => {
if (e.defaultPrevented) return;
e.preventDefault();
const rawName = e.features?.[0]?.properties?.[cfg.trailProp];
if (!rawName) return;
const meta = TRAIL_METADATA[rawName];
const meta = trailMetadata[rawName];
const trailName = meta?.displayName ?? rawName;
window.dispatchEvent(
new CustomEvent(MAP_EVENTS.TRAIL_SELECT, {
Expand All @@ -997,6 +1025,10 @@ const MapboxMap = memo(function MapboxMap() {
}
}

// Register OSM popups after curated trail handlers so curated
// route/trail selections win when layers overlap.
registerOsmTrailPopup(newMap);

// Click on empty map area deselects routes and trails.
// Check originalEvent.target to ignore ghost clicks that land on
// the canvas after an overlay (e.g. elevation panel) is removed
Expand Down
39 changes: 39 additions & 0 deletions src/components/MapLegend.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ vi.mock('./sidebar', () => ({
</div>
),
MapLayers: () => <div data-testid="map-layers" />,
MapLayersSection: ({ children }: { children: React.ReactNode }) => (
<div data-testid="map-layers-section">{children}</div>
),
ToggleRow: ({
label,
isActive,
onToggle,
}: {
label: string;
isActive: boolean;
onToggle: () => void;
}) => (
<button type="button" onClick={onToggle} data-active={isActive}>
{label}
</button>
),
AttractionsList: () => <div data-testid="attractions" />,
BikeResourcesList: () => <div data-testid="bike-resources" />,
BikeRentalList: () => <div data-testid="bike-rentals" />,
Expand Down Expand Up @@ -264,4 +280,27 @@ describe('MapLegendProvider', () => {

expect(screen.queryByTestId('map-layers')).not.toBeInTheDocument();
});

it('Nationwide trails toggle lives in the trails tab and dispatches osmTrails', () => {
render(
<MapLegendProvider>
<div />
</MapLegendProvider>,
);

// Not present in the casual (routes) tab
expect(screen.queryByText('Nationwide trails')).not.toBeInTheDocument();

dispatch(MAP_EVENTS.RIDE_STYLE_CHOSEN, { style: 'mountain' });

fireEvent.click(screen.getByText('Nationwide trails'));

const toggle = events.find(
(e) =>
e.type === MAP_EVENTS.LAYER_TOGGLE &&
(e.detail as { layer?: string }).layer === 'osmTrails',
);
expect(toggle).toBeDefined();
expect((toggle?.detail as { visible: boolean }).visible).toBe(true);
});
});
Loading
Loading