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:
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:
- Compose the chosen arcs into one ordered centerline.
- 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.
- 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.
- Resample along arc length every 5 m, which comes out to 242 samples for this lap. With even spacing, distance lookups become plain arithmetic.
- 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:
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 and a lateral limit , the cap is
so a tighter corner means bigger , 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:
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.