← Back to Splits

[ Parable build guide ]

How Splits was built

A city marathon site with two working signatures: a course elevation profile that draws itself as you scroll — runner dot riding the line, km markers popping in as it passes them — and a pace calculator that turns a goal time into every split on the course. Vite + vanilla TypeScript, no framework, no chart library.

vite + typescriptinline SVG, zero imagesAnton / Inter / Martian Monolight + darkreduced-motion honest

The idea

Every race site shows an elevation chart as a static JPEG that runners squint at and screenshot. But a marathon is experienced in order — the false flat, the climb, the summit, the sting at 34 — so the chart should be too. Scrolling the page replays the course: the line draws from gun to tape, and each named point surfaces as you "reach" it. The pace calculator closes the loop: once you set a goal, the course readout tells you the clock time you'll crest Reservoir Hill. The chart and the math talk to each other, which is the moment the site stops being a brochure.

The stack

Vite with vanilla TypeScript — no framework, because the whole thing is two interactive islands and a pile of honest HTML. The elevation SVG is inline in the markup, fully drawn: kill JavaScript and you still get the complete chart, the schedule, and every word of copy. The modules (course.ts, pace.ts, countdown.ts, reveal.ts) only add behavior. Type is Anton — a bib-number typeface if there ever was one — with Inter for body and Martian Mono for anything a race official would print.

Signature technique — the self-drawing course

The profile path ships in the HTML with real coordinates (x = 40 + km / 42.195 × 940, y = 280 − ele × 2). At runtime we measure the path, dash it to exactly its own length, and feed the dash offset from scroll progress. The runner dot is the same trick inverted: getPointAtLength converts progress back into a coordinate on the line — and its x-position tells us which km markers the "runner" has passed.

// course.ts — the draw, verbatim
const lineLen = line.getTotalLength();
line.style.strokeDasharray = `${lineLen}`;
line.style.strokeDashoffset = `${lineLen}`;

const update = (): void => {
  const r = fig.getBoundingClientRect();
  const vh = window.innerHeight;
  const p = clamp((vh * 0.9 - r.top) / (vh * 0.75), 0, 1);
  line.style.strokeDashoffset = `${lineLen * (1 - p)}`;
  const pt = line.getPointAtLength(lineLen * p);
  runner.setAttribute('transform', `translate(${pt.x.toFixed(1)} ${pt.y.toFixed(1)})`);
  groups.forEach((g, m) => {
    if (pt.x >= m.x - 1) g.classList.add('is-past'); // marker pops in
  });
};
addEventListener('scroll', onScroll, { passive: true }); // rAF-throttled

The markers are real SVG <g role="button" tabindex="0"> elements — hover, click, or Tab to one and the readout panel updates with the elevation, the grade, and what the locals call that stretch. The pace calculator broadcasts a splits:plan event when it recalculates; the readout listens and appends your projected arrival time at that marker.

Details that matter

The registration form is a demo — it validates, confirms in place with a fictional bib number, and sends nothing. Wire it to a real entry system before you take anyone's money.

Ship it on GitHub Pages

Vite builds into docs/ with base: '/splits/', which is exactly the shape GitHub Pages wants for a project site served from a subpath.

npm run build          # tsc --noEmit && vite build  →  docs/
git add docs && git commit -m "build"
gh repo create bswxyz/splits --public --source . --push
# Pages → deploy from branch → main /docs

A .nojekyll in public/ rides along into docs/ so Pages serves the build untouched, and this guide is a plain static page in public/guide/ — copied as-is, no bundling required.

← Back to Splits