ADAM LIN

SESSION · LOG

LOG

← LOG

· LOG ENTRY

Building the lap data pipeline

How I turn an OSM track outline into this site's scrolling lap: spline resampling, curvature, and a disclosed point-mass model for the demo channels.

The track map on the home page, the one you drive by scrolling, runs on one generated dataset. This is the path from raw track geometry to the screen. It's a small pipeline, but the rule behind it matters: if something looks like telemetry, I should be able to tell you exactly where it came from.

Starting with the real track

The circuit is the Sonoma Raceway Karting Center, my home track. The outline comes from OpenStreetMap (ways 230287123 / 127 / 129 / 131 / 133 / 139, ODbL, which is why the footer credits OSM contributors wherever the map shows).

OSM doesn't store the kart track as one neat loop because the facility runs in different configurations. It stores a network: ten junction nodes connected by fifteen arcs. I had to compose "the track" from those arcs. The path I chose matches the National Circuit layout, and I confirmed it against the real thing: 1,203.8 m once splined, run clockwise, T1 at the northernmost corner, start/finish on the straight leading into it. All eleven corner labels are verified. Without that check, they wouldn't render as callouts. The figure shows the full OSM network with the loop I actually run pulled out of it:

S/FCLOCKWISET1T2T3T4T5T6T7T8T9T10T11OSM NETWORK · 15 ARCSNATIONAL CIRCUIT · 5 M SPLINE10 JUNCTIONS100 MN
FIG 01OSM NETWORK, 10 JUNCTIONS AND 15 ARCS · NATIONAL CIRCUIT COMPOSED, 1203.8 M SPLINED · T1–T11 OWNER-VERIFIEDMAP DATA © OPENSTREETMAP CONTRIBUTORS · ODbL · SNAPSHOT 2026-08-04

From outline to geometry

scripts/build-lap-data.mjs turns that outline into the dataset. It's deterministic (same input, byte-identical output) and dependency-free. Five steps:

  1. Compose the chosen arcs into one ordered centerline.
  2. Project the GPS coordinates into local meters. Plain equirectangular projection is fine at track scale. The y-axis flips because SVG's y points down.
  3. Fit a centripetal Catmull-Rom spline through the loop. The centripetal variant matters here: it smooths the raw polyline into a track-shaped curve without the kinks and overshoot a naive fit gives you.
  4. Resample along arc length every 5 m, which comes out to 242 samples for this lap. With even spacing, distance lookups become plain arithmetic.
  5. Estimate curvature at each sample: fit a circle through it and its two neighbors, then smooth lightly.

Curvature ties the track shape to the speed model. It says how tight the track is at each point, so it sets the corner-speed limit. Local curvature maxima also locate the corners. I assigned T1 through T11 by hand and checked the numbering against the confirmed layout. In the plot, each peak is a corner and the sign tells you which way it turns:

-0.15-0.10-0.0500.050.100.150200 M400 M600 M800 M1000 M1200 MT1T2T3T4T5T6T7T8T9T10T11CURVATURE · 1/M (0.10 = R 10 M)RIGHT-HANDERSLEFT-HANDERS
FIG 02SIGNED CURVATURE VS LAP DISTANCE · 242 SAMPLES AT 5 M · PEAKS ARE THE CORNERS, T1–T11THREE-POINT CIRCLE FIT, LIGHT SMOOTHING · DERIVED FROM THE OSM OUTLINE

Making up speed data, and saying so

The speed and lateral-g traces on this site are generated, not measured. These aren't logged laps. I could have drawn a plausible squiggle, but then it wouldn't mean anything. Instead, the channels come from the track geometry and a point-mass quasi-steady-state model with its assumptions disclosed.

The model starts with the cornering limit. With curvature κ\kappa and a lateral limit alata_\mathrm{lat}, the cap is

v=min⁡ ⁣(vmax, alat/∣κ∣)v = \min!\left(v_\mathrm{max},\ \sqrt{a_\mathrm{lat}/\lvert\kappa\rvert}\right)

so a tighter corner means bigger κ\kappa, which means a lower cap. Then a forward pass limits how fast speed can build coming out of corners (the acceleration limit), and a backward pass limits how fast it can shed going in (the braking limit). Running that second pass backward is the trick that puts braking zones before corners, where they belong. Iterate until nothing changes, and you have a lap. The plot puts the geometry's grip cap beside the speed that survives both passes:

0204060801000200 M400 M600 M800 M1000 M1200 MT1T2T3T4T5T6T7T8T9T10T11SPEED · KM/HGRIP CAP FROM CURVATUREFINAL SPEED · AFTER FORWARD + BACKWARD PASSESV MAX 105 KM/H · LATERAL 1.9 G · BRAKING 1.6 G · ACCELERATION 0.55 G
FIG 03GRIP CAP VS FINAL SPEED, ONE LAP · POINT-MASS QUASI-STEADY-STATE PASSDEMO CHANNELS · GENERATED FROM TRACK GEOMETRY · NOT MEASURED

The parameters sit right at the top of the script: 105 km/h top speed, 1.9 g lateral, 1.6 g braking, 0.55 g acceleration. Roughly kart-shaped numbers. Every place the data renders, it's labeled demo. The test suite enforces that label because a comment in the source isn't enough.

The swap contract

Generated and measured laps use the same data shape. scripts/import-aim-csv.mjs takes an AiM RaceStudio CSV export (distance, GPS position, GPS speed, lateral g) and runs it through the same 5 m resample. It regenerates the same file with source: "aim-racestudio" and a real session label. A synthetic fixture proves it in the test flow: the site builds and renders a swapped dataset with zero component changes. Putting a real logged lap on the map is a one-file change.

The dataset stops at the lap data. I hand-author the content stations, and the timing sectors derived from them, in lap-course.ts. That keeps the sectors tied to where the content sits on the page rather than to the track geometry.

NEXT IN THREAD → Instrumenting a Macan S: the plan

← Back to the log