Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions lib/track/laneDraw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Lane-draw effects for the sprints run entirely in lanes on a curve.
//
// A slope is the change in finishing time per lane moved outward, in seconds,
// so a negative slope means outer lanes are faster. Each source estimate comes
// from a fixed-effects regression of time on lane number with athlete and race
// fixed effects, which compares an athlete to themselves across lanes instead
// of comparing the (better) athletes seeded into middle lanes with everyone
// else.

export type LaneEvent = "200m" | "400m";
export type Gender = "men" | "women";

export interface LaneSlope {
/** seconds per lane moved outward; negative means outer lanes are faster */
slope: number;
/** 95% confidence interval on the slope, [low, high] */
ci95: [number, number];
}

export interface LaneSlopeSource extends LaneSlope {
event: LaneEvent;
gender: Gender;
dataset: "elite" | "college";
}

// The two datasets behind the pooled constants: Diamond League results
// (2015-2025) and NCAA results scraped from FlashResults (2016-2026).
export const SOURCE_ESTIMATES: LaneSlopeSource[] = [
{
dataset: "elite",
event: "200m",
gender: "men",
slope: -0.0256,
ci95: [-0.041, -0.01],
},
{
dataset: "elite",
event: "200m",
gender: "women",
slope: -0.0081,
ci95: [-0.024, 0.008],
},
{
dataset: "elite",
event: "400m",
gender: "men",
slope: -0.0183,
ci95: [-0.043, 0.006],
},
{
dataset: "elite",
event: "400m",
gender: "women",
slope: -0.0236,
ci95: [-0.06, 0.013],
},
{
dataset: "college",
event: "200m",
gender: "men",
slope: -0.0227,
ci95: [-0.03, -0.0154],
},
{
dataset: "college",
event: "200m",
gender: "women",
slope: -0.0137,
ci95: [-0.0221, -0.0053],
},
{
dataset: "college",
event: "400m",
gender: "men",
slope: -0.0265,
ci95: [-0.0429, -0.0101],
},
{
dataset: "college",
event: "400m",
gender: "women",
slope: -0.0396,
ci95: [-0.0605, -0.0187],
},
];

// One pooled slope per event and gender: the inverse-variance-weighted mean of
// the two source estimates above, where each standard error is the width of
// that estimate's 95% interval divided by 3.92, the pooled standard error is
// 1/sqrt(sum of weights), and the pooled interval is the mean +/- 1.96 pooled
// standard errors. The college estimates carry most of the weight because
// their intervals are the tighter of the two, especially for the women.
export const LANE_SLOPES: Record<LaneEvent, Record<Gender, LaneSlope>> = {
"200m": {
men: { slope: -0.0232, ci95: [-0.0298, -0.0166] },
women: { slope: -0.0125, ci95: [-0.0199, -0.0051] },
},
"400m": {
men: { slope: -0.024, ci95: [-0.0376, -0.0103] },
women: { slope: -0.0356, ci95: [-0.0538, -0.0175] },
},
};

export function laneSlope({
event,
gender,
}: {
event: LaneEvent;
gender: Gender;
}): LaneSlope {
return LANE_SLOPES[event][gender];
}
17 changes: 13 additions & 4 deletions lib/track/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,31 @@ const dir = join(process.cwd(), "pages/projects/track");

export type TrackPage = Metas & { page: string };

// A page that exports `hidden` stays reachable but unlisted, e.g. the redirect
// left behind by a renamed slug.
interface PageExports {
metas?: Metas;
hidden?: boolean;
}

export function getAllPages(): TrackPage[] {
return fs
.readdirSync(dir)
.filter(fn => fn !== "index.tsx")
.map(fn => {
// remove extension
const page = fn.split(".")[0];
const { metas } = require(`pages/projects/track/${page}`) as {
metas?: Metas;
};
const { metas, hidden } = require(
`pages/projects/track/${page}`,
) as PageExports;
if (!metas) {
throw new Error(
`File ${page} does not export required export \`metas\`!`,
);
}
return { ...metas, page };
return { metas, hidden, page };
})
.filter(({ hidden }) => !hidden)
.map(({ metas, page }) => ({ ...metas, page }))
.sort((a, b) => a.page.localeCompare(b.page));
}
196 changes: 19 additions & 177 deletions pages/projects/track/200m-lane-draw.tsx
Original file line number Diff line number Diff line change
@@ -1,206 +1,48 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect } from "react";

import { useRouter } from "next/router";

import { useInitialQueryParams } from "lib/hooks";
import { getAllPages } from "lib/track/pages";
import type { TrackPage } from "lib/track/pages";

import Meta from "components/Meta";
import RelatedPosts from "components/RelatedPosts";
import Title from "components/Title";
import UnitInput from "components/UnitInput";

import blogStyles from "styles/components/Blog.module.scss";
import styles from "styles/pages/track-calculators.module.scss";

import type { GetStaticProps } from "next";
import type { Metas } from "lib/types";

const LANE_EFFECT = 0.018;
const LANES = [1, 2, 3, 4, 5, 6, 7, 8, 9];

function convertLaneTime(
time: string | number,
currentLane: string | number,
targetLane: string | number,
) {
const timeNum = parseFloat(time as string);
const currentNum = parseInt(currentLane as string, 10);
const targetNum = parseInt(targetLane as string, 10);

if (isNaN(timeNum) || isNaN(currentNum) || isNaN(targetNum)) {
return null;
}

return timeNum + (currentNum - targetNum) * LANE_EFFECT;
}
// The 200m-only converter this slug used to serve now lives at /lane-draw/,
// which covers the 400m and both sexes as well.
const TARGET = "/projects/track/lane-draw/";

export const metas: Metas = {
title: "200m Lane Draw Converter",
description:
"Converts 200m times between lanes based on lane draw advantage.",
description: "Moved to the Lane Draw Converter.",
};

interface LaneDrawConverterProps {
pages: TrackPage[];
}
// keeps the slug out of the utilities index and the "Other Utilities" lists
export const hidden = true;

export default function LaneDrawConverter({ pages }: LaneDrawConverterProps) {
export default function Moved200mLaneDraw() {
const router = useRouter();

const [time, setTime] = useState<string | number>(19.79);
const [currentLane, setCurrentLane] = useState(5);
const [targetLane, setTargetLane] = useState(5);
const [hasShared, setHasShared] = useState(false);

const loadedFromUrl = useInitialQueryParams(params => {
const urlTime = parseFloat(params.get("time") ?? "");
if (!isNaN(urlTime)) {
setTime(urlTime);
}

const urlCurrentLane = parseInt(params.get("currentLane") ?? "", 10);
if (LANES.includes(urlCurrentLane)) {
setCurrentLane(urlCurrentLane);
}

const urlTargetLane = parseInt(params.get("targetLane") ?? "", 10);
if (LANES.includes(urlTargetLane)) {
setTargetLane(urlTargetLane);
}
});

useEffect(() => {
if (!loadedFromUrl) {
return;
const existing = new URLSearchParams(window.location.search);
const params = new URLSearchParams({ event: "200m", gender: "men" });
for (const key of ["time", "currentLane", "targetLane"]) {
const value = existing.get(key);
if (value) {
params.set(key, value);
}
}

const params = new URLSearchParams();
if (time) params.set("time", time.toString());
if (currentLane) params.set("currentLane", currentLane.toString());
if (targetLane) params.set("targetLane", targetLane.toString());

const newUrl = `${window.location.pathname}?${params.toString()}`;
if (window.location.pathname + window.location.search !== newUrl) {
router.replace(newUrl, undefined, { shallow: true });
setHasShared(false);
}
}, [loadedFromUrl, time, currentLane, targetLane, router]);

const handleShare = useCallback(() => {
navigator.clipboard.writeText(window.location.href);
setHasShared(true);
setTimeout(() => setHasShared(false), 2000);
}, []);

const convertedTime = convertLaneTime(time, currentLane, targetLane);
router.replace(`${TARGET}?${params.toString()}`);
}, [router]);

return (
<article className={blogStyles.article}>
<Meta {...metas} />
<Title title={metas.title} />
<p>
Converts outdoor 200m times between lanes. Outside lanes have an
advantage of approximately 0.018 seconds per lane, based on{" "}
<a
href="/posts/Effect-of-Lane-Draw-In-200m-Sprinters/"
target="_blank"
rel="noreferrer"
>
analysis of Diamond League results from 2015-2021
</a>
.
This calculator moved to the <a href={TARGET}>Lane Draw Converter</a>,
which also handles the 400m and women&apos;s races.
</p>
<label className={styles.formContainer}>
<strong>200m Time</strong>
<UnitInput
className={styles.input}
type="number"
step="0.01"
value={time}
onChange={v => setTime(v)}
unit="s"
/>
</label>
<label className={styles.formContainer}>
<strong>Current Lane</strong>
<div className={styles.selectWrapper}>
<select
className={styles.select}
value={currentLane}
onChange={e =>
setCurrentLane(
parseInt((e.target as HTMLSelectElement).value, 10),
)
}
>
{LANES.map(lane => (
<option value={lane} key={lane}>
Lane {lane}
</option>
))}
</select>
</div>
<small>The lane the time was run in.</small>
</label>
<label className={styles.formContainer}>
<strong>Target Lane</strong>
<div className={styles.selectWrapper}>
<select
className={styles.select}
value={targetLane}
onChange={e =>
setTargetLane(parseInt((e.target as HTMLSelectElement).value, 10))
}
>
{LANES.map(lane => (
<option value={lane} key={lane}>
Lane {lane}
</option>
))}
</select>
</div>
<small>The lane to convert the time to.</small>
</label>
<label className={styles.formContainer}>
<strong>Converted Time</strong>
<UnitInput
className={styles.input}
disabled={true}
type="number"
value={convertedTime?.toFixed(2)}
unit="s"
/>
</label>
<button
className={styles.shareButton}
onClick={handleShare}
aria-label="Copy link to clipboard"
disabled={hasShared}
>
{hasShared ? "Copied link!" : "Share"}
</button>
<RelatedPosts
title="Other Utilities"
posts={pages
.filter(({ title }) => title !== metas.title)
.map(({ title, page }) => ({
fullSlug: `/projects/track/${page}`,
title,
}))}
/>
</article>
);
}

export const getStaticProps: GetStaticProps<
LaneDrawConverterProps
> = async () => {
const pages = getAllPages();
return {
props: {
pages,
},
};
};
Loading