PageSourceSearch

https://bikeatthebeach.com/js/geo/track-math.js

js bikeatthebeach.com collected 2026-09-24 13:59:51 UTC 8,548 bytes, 225 lines download raw bytes

1// js/geo/track-math.js
2//
3// Pure measurement helpers for GPS tracks. No map, no DOM, no state.
4//
5// Written for the trip-import flow rather than reached for from Turf on
6// purpose. That flow is used on a phone in the field, and Turf arrives
7// through the page's import map from a CDN; every dependency in the
8// upload path is one more thing that can fail on a weak connection at a
9// put-in. Nothing here reaches for Turf, and every function is small
10// enough to read in one sitting.
11//
12// The trip-import flow uses the first five. The rest were added on
13// August 22, 2026 for the route corridor and day-stage work, which needs
14// the same measurements against a route rather than a recorded track.
15//
16// Coordinates are GeoJSON order throughout: [lng, lat] or [lng, lat, ele].
17
18const EARTH_RADIUS_M = 6371008.8;   // IUGG mean radius
19const M_PER_DEG_LAT  = 111320;      // close enough anywhere on the globe
20const toRad = deg => (deg * Math.PI) / 180;
21
22/**
23 * Great-circle distance in meters between two [lng, lat] positions.
24 * Haversine is accurate to well under a meter at trip scale, which is
25 * far finer than consumer GPS.
26 */
27export function haversineMeters(a, b) {
28  const [lng1, lat1] = a;
29  const [lng2, lat2] = b;
30  const dLat = toRad(lat2 - lat1);
31  const dLng = toRad(lng2 - lng1);
32  const s =
33    Math.sin(dLat / 2) ** 2 +
34    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
35  return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(s)));
36}
37
38/** Total length of a coordinate ring in meters. */
39export function lineLengthMeters(coords) {
40  if (!Array.isArray(coords) || coords.length < 2) return 0;
41  let total = 0;
42  for (let i = 1; i < coords.length; i++) {
43    total += haversineMeters(coords[i - 1], coords[i]);
44  }
45  return total;
46}
47
48/**
49 * Remove `meters` of track from the start and the same from the end.
50 *
51 * This is the privacy trim. Someone who paddles from their own dock or
52 * rides from their driveway has their home address sitting in the first
53 * few hundred meters of the file, and blurring the whole track would
54 * ruin the observation it exists to support. Cutting the ends keeps the
55 * middle exact.
56 *
57 * The cut lands on an interpolated position rather than the nearest
58 * recorded point, so "500 m" means 500 m and not "somewhere between 480
59 * and 690 depending on how often the watch sampled".
60 *
61 * Returns null when the track is too short to survive the trim — the
62 * caller must treat that as "do not share this track" rather than
63 * falling back to the untrimmed original.
64 */
65export function trimLineEnds(coords, meters) {
66  if (!Array.isArray(coords) || coords.length < 2) return null;
67  if (!meters) return coords.slice();
68
69  const total = lineLengthMeters(coords);
70  // Need something meaningful left in the middle, not two points a
71  // meter apart that still pinpoint where the trim happened.
72  if (total <= meters * 2 + 50) return null;
73
74  const start = positionAtDistance(coords, meters);
75  const end = positionAtDistance(coords, total - meters);
76  if (!start || !end) return null;
77
78  const kept = coords.filter(
79    (_, i) => i > start.index && i <= end.index
80  );
81
82  return [start.position, ...kept, end.position];
83}
84
85/**
86 * Walk `meters` along the line and return { position, index } where
87 * `index` is the last vertex at or before the returned position.
88 *
89 * Also used for "drop a pin anywhere along my track" — the participant
90 * scrubs a slider and this resolves it to a real coordinate.
91 */
92export function positionAtDistance(coords, meters) {
93  if (!Array.isArray(coords) || coords.length < 2) return null;
94  if (meters <= 0) return { position: coords[0].slice(0, 2), index: 0 };
95
96  let travelled = 0;
97  for (let i = 1; i < coords.length; i++) {
98    const seg = haversineMeters(coords[i - 1], coords[i]);
99    if (travelled + seg >= meters) {
100      // Linear interpolation across the segment. Over a GPS sample
101      // interval the great-circle and the straight line differ by far
102      // less than the fix accuracy, so this is not worth doing properly.
103      const t = seg === 0 ? 0 : (meters - travelled) / seg;
104      const [x1, y1] = coords[i - 1];
105      const [x2, y2] = coords[i];
106      return {
107        position: [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t],
108        index: i - 1
109      };
110    }
111    travelled += seg;
112  }
113
114  const last = coords[coords.length - 1];
115  return { position: last.slice(0, 2), index: coords.length - 1 };
116}
117
118/**
119 * Thin a dense track by dropping points closer than `minMeters` to the
120 * last point kept. First and last are always kept.
121 *
122 * A watch recording at one-second intervals produces thousands of points
123 * for a short ride, most of them within a meter of each other. This is
124 * deliberately not Ramer-Douglas-Peucker: RDP is shape-aware and better,
125 * but it needs the whole line in memory at once and is markedly slower on
126 * a phone. Distance thinning is one pass and good enough for a track that
127 * is about to be drawn as a route.
128 */
129export function thinByDistance(coords, minMeters = 10) {
130  if (!Array.isArray(coords) || coords.length < 3) return coords?.slice() || [];
131
132  const out = [coords[0]];
133  for (let i = 1; i < coords.length - 1; i++) {
134    if (haversineMeters(out[out.length - 1], coords[i]) >= minMeters) {
135      out.push(coords[i]);
136    }
137  }
138  out.push(coords[coords.length - 1]);
139  return out;
140}
141
142/**
143 * Flatten a LineString or MultiLineString geometry, or a Feature
144 * wrapping one, to a single coordinate array.
145 *
146 * MultiLineString parts are joined end to end. That is a real
147 * simplification: it invents a segment across each gap. Callers that
148 * measure a route can live with it, because an imported GPX splits into
149 * parts where the recording paused rather than where the rider teleported.
150 */
151export function lineCoords(input) {
152  const geom = input?.type === 'Feature' ? input.geometry : input;
153  if (!geom) return [];
154  if (geom.type === 'LineString') return geom.coordinates || [];
155  if (geom.type === 'MultiLineString') return (geom.coordinates || []).flat();
156  return [];
157}
158
159/**
160 * Project a point onto a segment. Returns the distance to it in meters
161 * and `t`, the fraction along the segment where the foot of the
162 * perpendicular lands, clamped to the segment ends.
163 *
164 * Projected flat around the point before the math. Over the distances
165 * this is asked about, a corridor width or the offset of a campground
166 * from a road, the error from ignoring curvature is centimeters, and
167 * doing it this way keeps the inner loop to arithmetic.
168 */
169export function projectPointToSegment(p, a, b) {
170  const kx = M_PER_DEG_LAT * Math.cos((p[1] * Math.PI) / 180);
171  const ky = M_PER_DEG_LAT;
172
173  const ax = (a[0] - p[0]) * kx, ay = (a[1] - p[1]) * ky;
174  const bx = (b[0] - p[0]) * kx, by = (b[1] - p[1]) * ky;
175
176  const dx = bx - ax, dy = by - ay;
177  const lenSq = dx * dx + dy * dy;
178
179  // A zero-length segment has no direction to project onto, so the foot
180  // is the segment itself.
181  let t = lenSq === 0 ? 0 : ((0 - ax) * dx + (0 - ay) * dy) / lenSq;
182  t = Math.max(0, Math.min(1, t));
183
184  return { meters: Math.hypot(ax + t * dx, ay + t * dy), t };
185}
186
187/**
188 * The piece of a line between two distances along it, in meters.
189 *
190 * Both ends are interpolated rather than snapped to the nearest vertex.
191 * That is the whole point where this is used: cutting a tour into days
192 * at the overnight stops. An imported route can carry a kilometer
193 * between vertices, and snapping would move a day's end by that much.
194 *
195 * Returns null rather than a degenerate line if the two distances do not
196 * enclose anything.
197 */
198export function sliceByDistance(coords, startMeters, endMeters) {
199  if (!Array.isArray(coords) || coords.length < 2) return null;
200
201  const total = lineLengthMeters(coords);
202  const from = Math.max(0, Math.min(startMeters, total));
203  const to   = Math.max(0, Math.min(endMeters, total));
204  if (to - from <= 0) return null;
205
206  const start = positionAtDistance(coords, from);
207  const end   = positionAtDistance(coords, to);
208  if (!start || !end) return null;
209
210  const out = [start.position];
211  for (let i = start.index + 1; i <= end.index; i++) {
212    // A cut that lands exactly on a vertex would otherwise repeat it.
213    if (haversineMeters(out[out.length - 1], coords[i]) > 0.01) {
214      out.push(coords[i].slice(0, 2));
215    }
216  }
217  if (haversineMeters(out[out.length - 1], end.position) > 0.01) {
218    out.push(end.position);
219  }
220
221  return out.length >= 2 ? out : null;
222}
223
224export const metersToMiles = m => m / 1609.344;
225export const metersToFeet = m => m * 3.28084;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.