Compatible with onX Offroad, Gaia GPS, Garmin Zumo & Rever
Recent conditions logged by dual-sport & ADV riders
Upload a .GPX track file or plot waypoints directly on the topographic map
Node.js + Express + TypeScript + MySQL 8.0 Native Spatial Geometry (SRID 4326)
LINESTRING SRID 4326 with SPATIAL INDEX for fast bounding-box map queries.
-- MySQL 8.0+ Spatial Schema for Backcountry Motorcycle Routes
CREATE DATABASE IF NOT EXISTS mototrack CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mototrack;
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
primary_bike VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE routes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(180) NOT NULL,
region VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
difficulty ENUM('Easy', 'Moderate', 'Hard', 'Expert') NOT NULL DEFAULT 'Moderate',
primary_surface VARCHAR(80) NOT NULL,
recommended_bike VARCHAR(100),
distance_miles DECIMAL(6, 2) NOT NULL,
elevation_gain_ft INT UNSIGNED NOT NULL,
max_elevation_ft INT UNSIGNED NOT NULL,
-- Native MySQL 8.0 Spatial Columns (SRID 4326 = WGS84 GPS coordinates)
start_point POINT NOT NULL SRID 4326,
track_geom LINESTRING NOT NULL SRID 4326,
elevation_profile_json JSON NOT NULL,
gpx_s3_key VARCHAR(255) NOT NULL,
avg_rating DECIMAL(3, 2) DEFAULT 0.00,
review_count INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
SPATIAL INDEX idx_routes_track_geom (track_geom),
SPATIAL INDEX idx_routes_start_point (start_point),
INDEX idx_routes_difficulty (difficulty)
) ENGINE=InnoDB;
CREATE TABLE route_reviews (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
route_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
rating TINYINT UNSIGNED NOT NULL CHECK (rating BETWEEN 1 AND 5),
trail_condition ENUM('Trail Clear', 'Downed Trees', 'Muddy / Rutted', 'Snowpack / Blocked') NOT NULL,
bike_ridden VARCHAR(100) NOT NULL,
comment TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (route_id) REFERENCES routes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_reviews_route (route_id, created_at DESC)
) ENGINE=InnoDB;
@tmcw/togeojson and mysql2.
// src/routes/routeController.ts (Node.js + Express + TypeScript + MySQL2)
import { Request, Response } from 'express';
import mysql from 'mysql2/promise';
import { DOMParser } from '@xmldom/xmldom';
import * as toGeoJSON from '@tmcw/togeojson';
const pool = mysql.createPool({
host: process.env.MYSQL_HOST || '127.0.0.1',
user: process.env.MYSQL_USER || 'mototrack',
password: process.env.MYSQL_PASSWORD,
database: 'mototrack',
waitForConnections: true,
connectionLimit: 15,
});
// 1. POST /api/routes - Parse GPX XML & Insert into MySQL Spatial LINESTRING
export async function uploadRoute(req: Request, res: Response) {
const gpxXml = req.file?.buffer.toString('utf-8');
if (!gpxXml) return res.status(400).json({ error: 'GPX file required' });
const doc = new DOMParser().parseFromString(gpxXml, 'text/xml');
const geojson = toGeoJSON.gpx(doc);
const track = geojson.features.find((f) => f.geometry.type === 'LineString');
if (!track || track.geometry.type !== 'LineString') {
return res.status(422).json({ error: 'No valid LineString track found in GPX' });
}
// IMPORTANT: In MySQL 8.0 SRID 4326, WKT order is POINT(latitude longitude)
const coords = track.geometry.coordinates; // [lon, lat, ele]
const wktPoints = coords.map(([lon, lat]) => `${lat} ${lon}`).join(', ');
const lineStringWKT = `LINESTRING(${wktPoints})`;
const startPointWKT = `POINT(${coords[0][1]} ${coords[0][0]})`;
const [result] = await pool.execute(
`INSERT INTO routes
(author_id, title, region, description, difficulty, primary_surface,
distance_miles, elevation_gain_ft, max_elevation_ft, start_point, track_geom, elevation_profile_json, gpx_s3_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ST_GeomFromText(?, 4326), ST_GeomFromText(?, 4326), ?, ?)`,
[
req.body.authorId,
req.body.title,
req.body.region,
req.body.description,
req.body.difficulty,
req.body.primarySurface,
req.body.distanceMiles,
req.body.elevationGainFt,
req.body.maxElevationFt,
startPointWKT,
lineStringWKT,
JSON.stringify(coords.map((c, idx) => ({ idx, eleMeters: c[2] || 0 }))),
`routes/${Date.now()}-${req.file?.originalname}`,
]
);
res.status(201).json({ id: (result as mysql.ResultSetHeader).insertId });
}
// 2. GET /api/routes/bbox?minLat=43.5&maxLat=45.8&minLon=-124.0&maxLon=-121.0
export async function getRoutesInViewport(req: Request, res: Response) {
const { minLat, maxLat, minLon, maxLon } = req.query;
// MySQL 8.0 SRID 4326 Polygon uses (Lat Lon) axis order
const bboxWKT = `POLYGON((${minLat} ${minLon}, ${maxLat} ${minLon}, ${maxLat} ${maxLon}, ${minLat} ${maxLon}, ${minLat} ${minLon}))`;
const [rows] = await pool.query(
`SELECT id, title, region, difficulty, primary_surface, distance_miles, elevation_gain_ft,
avg_rating, review_count, ST_AsGeoJSON(track_geom) AS geojson
FROM routes
WHERE MBRIntersects(track_geom, ST_GeomFromText(?, 4326))
ORDER BY avg_rating DESC LIMIT 100`,
[bboxWKT]
);
res.json(rows);
}
Unlike PostGIS or GeoJSON (which use [longitude, latitude]), MySQL 8.0 strictly enforces the EPSG:4326 standard axis order in WKT strings: POINT(latitude longitude). However, when calling ST_AsGeoJSON(track_geom), MySQL automatically outputs standard GeoJSON [lon, lat] ready for Leaflet or Mapbox!
In MySQL 8.0, a SPATIAL INDEX will be ignored by the query optimizer unless the geometry column explicitly defines SRID 4326 in the CREATE TABLE statement and the query uses MBRIntersects() or ST_Contains() with matching SRID 4326 geometry.
Raw Garmin or phone GPX recordings often log 1 point per second (15,000+ points for a 100-mile ride). Store the raw untouched GPX file in Cloudflare R2 / AWS S3 for user download, and run Ramer-Douglas-Peucker simplification (@turf/simplify) before inserting into MySQL track_geom so map rendering stays instant.