diff --git a/lib/track/laneDraw.ts b/lib/track/laneDraw.ts new file mode 100644 index 0000000..aa1ac6b --- /dev/null +++ b/lib/track/laneDraw.ts @@ -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> = { + "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]; +} diff --git a/lib/track/pages.ts b/lib/track/pages.ts index e8f1042..98fb4e4 100644 --- a/lib/track/pages.ts +++ b/lib/track/pages.ts @@ -7,6 +7,13 @@ 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) @@ -14,15 +21,17 @@ export function getAllPages(): TrackPage[] { .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)); } diff --git a/pages/projects/track/200m-lane-draw.tsx b/pages/projects/track/200m-lane-draw.tsx index ef68244..8d6c58b 100644 --- a/pages/projects/track/200m-lane-draw.tsx +++ b/pages/projects/track/200m-lane-draw.tsx @@ -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(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 (
- <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'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, - }, - }; -}; diff --git a/pages/projects/track/lane-draw.tsx b/pages/projects/track/lane-draw.tsx new file mode 100644 index 0000000..7272bd1 --- /dev/null +++ b/pages/projects/track/lane-draw.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useEffect, useState } 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 { laneSlope } from "lib/track/laneDraw"; +import type { Gender, LaneEvent } from "lib/track/laneDraw"; + +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 LANES = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + +// World-class marks, used as the starting point for each cohort +const defaultTimes: Record<LaneEvent, Record<Gender, string>> = { + "200m": { men: "19.79", women: "21.87" }, + "400m": { men: "44.15", women: "49.50" }, +}; + +function isLaneEvent(value: string | null): value is LaneEvent { + return value === "200m" || value === "400m"; +} + +function isGender(value: string | null): value is Gender { + return value === "men" || value === "women"; +} + +interface Conversion { + time: number; + /** half-width of the converted time's 95% interval, seconds */ + uncertainty: number; +} + +function convertLaneTime( + event: LaneEvent, + gender: Gender, + time: string | number, + currentLane: number, + targetLane: number, +): Conversion | null { + const timeNum = parseFloat(time as string); + if (isNaN(timeNum)) { + return null; + } + + const { slope, ci95 } = laneSlope({ event, gender }); + const lanes = targetLane - currentLane; + + return { + time: timeNum + slope * lanes, + uncertainty: (Math.abs(lanes) * (ci95[1] - ci95[0])) / 2, + }; +} + +export const metas: Metas = { + title: "Lane Draw Converter", + description: + "Converts 200m and 400m times between lanes based on lane draw advantage.", +}; + +interface LaneDrawConverterProps { + pages: TrackPage[]; +} + +export default function LaneDrawConverter({ pages }: LaneDrawConverterProps) { + const router = useRouter(); + + const [event, setEvent] = useState<LaneEvent>("200m"); + const [gender, setGender] = useState<Gender>("men"); + const [time, setTime] = useState<string | number>(defaultTimes["200m"].men); + // a time that came from the user or a link is never replaced by a cohort + // default + const [timeIsUserSet, setTimeIsUserSet] = useState(false); + const [currentLane, setCurrentLane] = useState(5); + const [targetLane, setTargetLane] = useState(5); + const [hasShared, setHasShared] = useState(false); + + const loadedFromUrl = useInitialQueryParams(params => { + const urlEvent = params.get("event"); + const urlGender = params.get("gender"); + const cohortEvent = isLaneEvent(urlEvent) ? urlEvent : "200m"; + const cohortGender = isGender(urlGender) ? urlGender : "men"; + setEvent(cohortEvent); + setGender(cohortGender); + + const urlTime = parseFloat(params.get("time") ?? ""); + if (isNaN(urlTime)) { + setTime(defaultTimes[cohortEvent][cohortGender]); + } else { + setTime(urlTime); + setTimeIsUserSet(true); + } + + 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 params = new URLSearchParams(); + params.set("event", event); + params.set("gender", gender); + 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, event, gender, time, currentLane, targetLane, router]); + + const handleShare = useCallback(() => { + navigator.clipboard.writeText(window.location.href); + setHasShared(true); + setTimeout(() => setHasShared(false), 2000); + }, []); + + const handleEvent = useCallback( + (value: LaneEvent) => { + setEvent(value); + if (!timeIsUserSet) { + setTime(defaultTimes[value][gender]); + } + }, + [gender, timeIsUserSet], + ); + const handleGender = useCallback( + (value: Gender) => { + setGender(value); + if (!timeIsUserSet) { + setTime(defaultTimes[event][value]); + } + }, + [event, timeIsUserSet], + ); + const handleTime = useCallback((value: string) => { + setTime(value); + setTimeIsUserSet(true); + }, []); + + const conversion = convertLaneTime( + event, + gender, + time, + currentLane, + targetLane, + ); + + return ( + <article className={blogStyles.article}> + <Meta {...metas} /> + <Title title={metas.title} /> + <p> + Converts outdoor 200m and 400m times between lanes. Outer lanes are + faster, by an amount per lane that depends on the event and the sex of + the athlete. The constants come from within-athlete comparisons — the + same athlete running the same event out of different lanes — across{" "} + <a + href="/posts/Effect-of-Lane-Draw-In-200m-Sprinters/" + target="_blank" + rel="noreferrer" + > + Diamond League results from 2015-2025 + </a>{" "} + and 26,000+ NCAA results from 2016-2026. Naive lane comparisons that do + not control for who is in each lane overstate the effect several-fold, + and the women's elite estimates are the least precise of the four. + </p> + <label className={styles.formContainer}> + <strong>Event</strong> + <div className={styles.selectWrapper}> + <select + className={styles.select} + value={event} + onChange={e => + handleEvent((e.target as HTMLSelectElement).value as LaneEvent) + } + > + <option value="200m">200m</option> + <option value="400m">400m</option> + </select> + </div> + </label> + <label className={styles.formContainer}> + <strong>Sex</strong> + <div className={styles.selectWrapper}> + <select + className={styles.select} + value={gender} + onChange={e => + handleGender((e.target as HTMLSelectElement).value as Gender) + } + > + <option value="men">Men</option> + <option value="women">Women</option> + </select> + </div> + </label> + <label className={styles.formContainer}> + <strong>{event} Time</strong> + <UnitInput + className={styles.input} + type="number" + step="0.01" + value={time} + onChange={handleTime} + 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={conversion?.time.toFixed(2)} + unit="s" + /> + {conversion && conversion.uncertainty > 0 && ( + <small> + range {(conversion.time - conversion.uncertainty).toFixed(2)}– + {(conversion.time + conversion.uncertainty).toFixed(2)} s + </small> + )} + </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, + }, + }; +}; diff --git a/scripts/generate-search-index.ts b/scripts/generate-search-index.ts index ea69527..22ba2c9 100644 --- a/scripts/generate-search-index.ts +++ b/scripts/generate-search-index.ts @@ -25,8 +25,8 @@ const ALL_PAGES: SearchIndexItem[] = [ group: "Pages", }, { - title: "200m Lane Draw Converter", - route: "/projects/track/200m-lane-draw/", + title: "Lane Draw Converter", + route: "/projects/track/lane-draw/", group: "Pages", }, {