# MPH / Speed Calculator, complete source bundle

This single file contains the entire MPH / Speed Calculator feature: the math, its
tests, the live React component, a dependency free standalone HTML version, and a
README written for handing this tool to an LLM. Paste this whole file into your AI
assistant of choice and ask it questions, or ask it to extend or port the tool.

## `mph.ts`

```ts
export type Split = { label: string; distanceYards: number; timeSeconds: number };

export type SplitResult = Split & {
  velocityMs: number;
  speedMph: number;
  paceMinPerMile: number;
  accelerationMsPerS: number;
  cumulativeDistanceYards: number;
  cumulativeTimeSeconds: number;
  percentOfPeakVelocity: number;
};

export type MphResult = {
  splits: SplitResult[];
  totalDistanceYards: number;
  totalTimeSeconds: number;
  averageVelocityMs: number;
  averageSpeedMph: number;
  peakVelocityMs: number;
  peakSpeedMph: number;
  peakSplitLabel: string;
};

export type SpeedSignature = {
  acceleration: number;
  topEndSpeed: number;
  consistency: number;
  power: number;
};

// Four axes for the "Speed Signature" radar, each 0-100 and each scored
// against the athlete's OWN run or a fixed reference ceiling, never against
// other athletes: this free tool has no norms database, and pretending
// otherwise with a fabricated percentile would be a real credibility risk.
// Each ceiling below is a deliberate, disclosed simplification, not a
// validated norm; the UI must say so.
const TOP_END_SPEED_REFERENCE_MPH = 25; // a fast but realistic ceiling for a timed sprint
const PEAK_ACCELERATION_REFERENCE_MS2 = 10; // near the top of human peak acceleration off the line

function clampPercent(value: number): number {
  if (!Number.isFinite(value)) return 0;
  return Math.max(0, Math.min(100, value));
}

export function computeSpeedSignature(result: MphResult): SpeedSignature {
  if (result.splits.length === 0) {
    return { acceleration: 0, topEndSpeed: 0, consistency: 0, power: 0 };
  }

  // Acceleration: how much of peak velocity the athlete already holds by
  // the split that crosses the halfway point of the run. Front-loaded
  // (high) vs. still building (low).
  const halfDistance = result.totalDistanceYards / 2;
  const midSplit =
    result.splits.find((split) => split.cumulativeDistanceYards >= halfDistance) ??
    result.splits[result.splits.length - 1]!;
  const acceleration = clampPercent(midSplit.percentOfPeakVelocity);

  // Top-end speed: peak speed against a fixed reference ceiling, not other
  // athletes. Explicitly labeled as "vs a 25 mph reference" in the UI.
  const topEndSpeed = clampPercent((result.peakSpeedMph / TOP_END_SPEED_REFERENCE_MPH) * 100);

  // Consistency: how much speed the athlete still holds at the very last
  // split. 100 means no fall-off from peak by the end of the run.
  const lastSplit = result.splits[result.splits.length - 1]!;
  const consistency = clampPercent(lastSplit.percentOfPeakVelocity);

  // Power/drive: the single highest instantaneous acceleration reached
  // anywhere in the run, against a fixed reference ceiling.
  const maxAcceleration = Math.max(...result.splits.map((split) => split.accelerationMsPerS));
  const power = clampPercent((maxAcceleration / PEAK_ACCELERATION_REFERENCE_MS2) * 100);

  return { acceleration, topEndSpeed, consistency, power };
}

// A representative high school 40 yard dash (about 4.90s total), split into
// even 10 yard segments. Acceleration is steepest in the first segment and
// the athlete approaches, but does not exceed, a realistic top speed: no
// human has ever run 30 mph, and even elite sprinters top out around 27-28.
export const DEFAULT_SPLITS: Split[] = [
  { label: '0-10', distanceYards: 10, timeSeconds: 1.8 },
  { label: '10-20', distanceYards: 10, timeSeconds: 1.1 },
  { label: '20-30', distanceYards: 10, timeSeconds: 1.02 },
  { label: '30-40', distanceYards: 10, timeSeconds: 0.98 },
];

export type SplitIssue = { index: number; message: string };

// Surfaces problems a coach can fix before trusting the numbers: a zero or
// negative time makes velocity undefined or nonsensical, and a distance of
// zero collapses the whole split to nothing. We still compute a result so
// the UI can show it, but callers should treat any issue as "do not trust
// this number yet."
export function validateSplits(splits: Split[]): SplitIssue[] {
  const issues: SplitIssue[] = [];
  splits.forEach((split, index) => {
    if (!Number.isFinite(split.timeSeconds) || split.timeSeconds <= 0) {
      issues.push({ index, message: `${split.label || `Split ${index + 1}`}: time must be greater than zero.` });
    }
    if (!Number.isFinite(split.distanceYards) || split.distanceYards <= 0) {
      issues.push({ index, message: `${split.label || `Split ${index + 1}`}: distance must be greater than zero.` });
    }
  });
  return issues;
}

export function computeMph(splits: Split[]): MphResult {
  const rawSplits: (Split & {
    velocityMs: number;
    speedMph: number;
    paceMinPerMile: number;
    accelerationMsPerS: number;
    cumulativeDistanceYards: number;
    cumulativeTimeSeconds: number;
  })[] = [];
  let totalDistanceYards = 0;
  let totalTimeSeconds = 0;
  let previousVelocityMs = 0;

  for (const [index, split] of splits.entries()) {
    const velocityMs = (split.distanceYards * 0.9144) / split.timeSeconds;
    const speedMph = velocityMs * 2.23694;
    const paceMinPerMile = 60 / speedMph;
    const accelerationMsPerS =
      index === 0 ? velocityMs / split.timeSeconds : (velocityMs - previousVelocityMs) / split.timeSeconds;

    totalDistanceYards += split.distanceYards;
    totalTimeSeconds += split.timeSeconds;
    rawSplits.push({
      ...split,
      velocityMs,
      speedMph,
      paceMinPerMile,
      accelerationMsPerS,
      cumulativeDistanceYards: totalDistanceYards,
      cumulativeTimeSeconds: totalTimeSeconds,
    });
    previousVelocityMs = velocityMs;
  }

  const averageVelocityMs = (totalDistanceYards * 0.9144) / totalTimeSeconds;
  const averageSpeedMph = averageVelocityMs * 2.23694;

  const peak = rawSplits.reduce<(typeof rawSplits)[number] | undefined>(
    (best, current) => (!best || current.velocityMs > best.velocityMs ? current : best),
    undefined,
  );
  const peakVelocityMs = peak?.velocityMs ?? 0;
  const peakSpeedMph = peak?.speedMph ?? 0;
  const peakSplitLabel = peak?.label ?? '';

  const splitResults: SplitResult[] = rawSplits.map((split) => ({
    ...split,
    percentOfPeakVelocity: peakVelocityMs > 0 ? (split.velocityMs / peakVelocityMs) * 100 : 0,
  }));

  return {
    splits: splitResults,
    totalDistanceYards,
    totalTimeSeconds,
    averageVelocityMs,
    averageSpeedMph,
    peakVelocityMs,
    peakSpeedMph,
    peakSplitLabel,
  };
}
```

## `mph.test.ts`

```ts
import { describe, expect, it } from 'vitest';
import { DEFAULT_SPLITS, computeMph, computeSpeedSignature, validateSplits } from './mph';

describe('computeMph', () => {
  it('matches the 5-yard worked example', () => {
    const result = computeMph([{ label: 'test', distanceYards: 5, timeSeconds: 0.86 }]);
    const split = result.splits[0];
    expect(split).toBeDefined();
    expect(split?.velocityMs).toBeCloseTo(5.32, 2);
    expect(split?.speedMph).toBeCloseTo(11.89, 2);
    expect(split?.percentOfPeakVelocity).toBeCloseTo(100, 2);
  });

  it('flags the fastest split as peak and scales the rest relative to it', () => {
    const result = computeMph([
      { label: 'slow', distanceYards: 5, timeSeconds: 1 },
      { label: 'fast', distanceYards: 10, timeSeconds: 1 },
    ]);
    expect(result.peakSplitLabel).toBe('fast');
    expect(result.peakSpeedMph).toBeCloseTo(result.splits[1]!.speedMph, 5);
    expect(result.splits[0]!.percentOfPeakVelocity).toBeCloseTo(50, 2);
    expect(result.splits[1]!.percentOfPeakVelocity).toBeCloseTo(100, 2);
  });

  it('keeps the default scenario peak in a plausible human speed range', () => {
    const result = computeMph(DEFAULT_SPLITS);
    // No human runs 30 mph; elite sprinters top out around 27-28. A
    // realistic high school 40 should peak in the high teens to low 20s.
    expect(result.peakSpeedMph).toBeGreaterThanOrEqual(18);
    expect(result.peakSpeedMph).toBeLessThanOrEqual(22);
  });
});

describe('computeSpeedSignature', () => {
  it('scores all four axes within 0-100 for the default scenario', () => {
    const signature = computeSpeedSignature(computeMph(DEFAULT_SPLITS));
    for (const value of Object.values(signature)) {
      expect(value).toBeGreaterThanOrEqual(0);
      expect(value).toBeLessThanOrEqual(100);
    }
  });

  it('scores consistency at 100 for an athlete still at peak on the last split', () => {
    const result = computeMph([
      { label: 'a', distanceYards: 10, timeSeconds: 1.5 },
      { label: 'b', distanceYards: 10, timeSeconds: 1 },
    ]);
    const signature = computeSpeedSignature(result);
    expect(signature.consistency).toBeCloseTo(100, 1);
  });

  it('scores consistency below 100 for an athlete slowing down late', () => {
    const result = computeMph([
      { label: 'a', distanceYards: 10, timeSeconds: 1 },
      { label: 'b', distanceYards: 10, timeSeconds: 1.5 },
    ]);
    const signature = computeSpeedSignature(result);
    expect(signature.consistency).toBeLessThan(100);
  });
});

describe('validateSplits', () => {
  it('passes clean splits with no issues', () => {
    expect(validateSplits([{ label: 'test', distanceYards: 5, timeSeconds: 0.86 }])).toEqual([]);
  });

  it('flags a zero or negative time as invalid', () => {
    const issues = validateSplits([
      { label: 'zero', distanceYards: 5, timeSeconds: 0 },
      { label: 'negative', distanceYards: 5, timeSeconds: -1 },
    ]);
    expect(issues).toHaveLength(2);
    expect(issues[0]?.message).toContain('zero');
    expect(issues[1]?.message).toContain('negative');
  });

  it('flags a zero or negative distance as invalid', () => {
    const issues = validateSplits([{ label: 'flat', distanceYards: 0, timeSeconds: 1 }]);
    expect(issues).toHaveLength(1);
    expect(issues[0]?.message).toContain('flat');
  });
});
```

## `sprint-power.ts`

```ts
// Horizontal sprint force-velocity-power profiling (Samozino et al. 2016,
// "A Simple Method for Measuring Power, Force, Velocity Properties, and
// Mechanical Effectiveness in Sprint Running"). This estimates the same
// construct as Triple F's "Speed Profile" Samozino markers: horizontal
// propulsive power during acceleration, not vertical/jump power (Sayers).
// The two are not comparable and must not be labeled interchangeably.
//
// Method, validated against the paper's own field spreadsheet (jbmorin.net)
// and sanity-checked against a worked example (see sprint-power.test.ts):
// 1. Model center-of-mass velocity as mono-exponential: v(t) = Vmax(1 - e^-t/tau)
// 2. Integrate to position: d(t) = Vmax * (t + tau*e^-t/tau - tau)
// 3. Fit Vmax and tau to the athlete's own cumulative (time, distance) splits
//    by least squares (no closed form; small grid search converges fine for
//    the 4-6 points a split-time tool provides).
// 4. F0 (theoretical max horizontal force) = Vmax / tau (relative, N/kg).
//    V0 (theoretical max velocity) = Vmax (drag makes true V0 slightly less,
//    ignored here per the simplification below).
// 5. Pmax = F0 * V0 / 4 (exact identity for the linear force-velocity
//    relationship this model implies).
//
// Simplification shipped here: no aerodynamic drag term. The full method
// also takes height, air temperature, and barometric pressure to correct
// for drag; that correction is roughly 2-4% at these speeds, within the
// noise of hand- or gate-timed splits, so it is dropped to keep the input
// to "splits + body weight" as asked. This is a deliberate simplification,
// not an oversight, and the UI must disclose it.

export type SprintPowerInput = {
  cumulativeTimeSeconds: number;
  cumulativeDistanceMeters: number;
};

export type SprintPowerResult = {
  vMaxMs: number;
  tauSeconds: number;
  f0NPerKg: number;
  v0Ms: number;
  peakPowerWattsPerKg: number;
  peakPowerWatts: number;
};

function predictedDistance(vMax: number, tau: number, t: number): number {
  return vMax * (t + tau * Math.exp(-t / tau) - tau);
}

function sumSquaredError(points: SprintPowerInput[], vMax: number, tau: number): number {
  let error = 0;
  for (const point of points) {
    const predicted = predictedDistance(vMax, tau, point.cumulativeTimeSeconds);
    const residual = predicted - point.cumulativeDistanceMeters;
    error += residual * residual;
  }
  return error;
}

// Coarse-to-fine grid search over (Vmax, tau). Two free parameters and a
// handful of data points do not need or benefit from a general nonlinear
// solver; this converges to the same result deterministically without a
// new dependency.
function fitVelocityModel(points: SprintPowerInput[]): { vMax: number; tau: number } {
  let vMaxRange: [number, number] = [3, 15];
  let tauRange: [number, number] = [0.2, 2.5];
  let bestVMax = (vMaxRange[0] + vMaxRange[1]) / 2;
  let bestTau = (tauRange[0] + tauRange[1]) / 2;

  const STEPS = 60;
  for (let round = 0; round < 6; round++) {
    let bestError = Infinity;
    for (let i = 0; i <= STEPS; i++) {
      const vMax = vMaxRange[0] + ((vMaxRange[1] - vMaxRange[0]) * i) / STEPS;
      for (let j = 0; j <= STEPS; j++) {
        const tau = tauRange[0] + ((tauRange[1] - tauRange[0]) * j) / STEPS;
        const error = sumSquaredError(points, vMax, tau);
        if (error < bestError) {
          bestError = error;
          bestVMax = vMax;
          bestTau = tau;
        }
      }
    }
    const vMaxSpan = (vMaxRange[1] - vMaxRange[0]) / 4;
    const tauSpan = (tauRange[1] - tauRange[0]) / 4;
    vMaxRange = [Math.max(0.5, bestVMax - vMaxSpan), bestVMax + vMaxSpan];
    tauRange = [Math.max(0.05, bestTau - tauSpan), bestTau + tauSpan];
  }

  return { vMax: bestVMax, tau: bestTau };
}

export function computeSprintPower(
  splits: SprintPowerInput[],
  bodyMassKg: number,
): SprintPowerResult {
  const points: SprintPowerInput[] = [{ cumulativeTimeSeconds: 0, cumulativeDistanceMeters: 0 }, ...splits];
  const { vMax, tau } = fitVelocityModel(points);

  const f0NPerKg = vMax / tau;
  const v0Ms = vMax;
  const peakPowerWattsPerKg = (f0NPerKg * v0Ms) / 4;
  const peakPowerWatts = peakPowerWattsPerKg * bodyMassKg;

  return {
    vMaxMs: vMax,
    tauSeconds: tau,
    f0NPerKg,
    v0Ms,
    peakPowerWattsPerKg,
    peakPowerWatts,
  };
}
```

## `sprint-power.test.ts`

```ts
import { describe, expect, it } from 'vitest';
import { computeSprintPower } from './sprint-power';

describe('computeSprintPower', () => {
  it('lands in the expert-validated band for the worked example (~75kg, splits to 4.80s/40yd)', () => {
    // 0-10/10-20/20-30/30-40 yard splits at 1.70/1.15/1.00/0.95s, converted
    // to cumulative meters/seconds (10yd = 9.144m). This is the exact
    // worked example an S&C-science review validated to Vmax ~9.8-10.0 m/s,
    // tau ~1.0s, F0 ~9.8 N/kg, Pmax ~24 W/kg (~1800-1950 W absolute).
    const result = computeSprintPower(
      [
        { cumulativeTimeSeconds: 1.7, cumulativeDistanceMeters: 9.144 },
        { cumulativeTimeSeconds: 2.85, cumulativeDistanceMeters: 18.288 },
        { cumulativeTimeSeconds: 3.85, cumulativeDistanceMeters: 27.432 },
        { cumulativeTimeSeconds: 4.8, cumulativeDistanceMeters: 36.576 },
      ],
      75,
    );

    expect(result.vMaxMs).toBeGreaterThan(9);
    expect(result.vMaxMs).toBeLessThan(11);
    expect(result.tauSeconds).toBeGreaterThan(0.7);
    expect(result.tauSeconds).toBeLessThan(1.3);
    expect(result.peakPowerWattsPerKg).toBeGreaterThan(15);
    expect(result.peakPowerWattsPerKg).toBeLessThan(30);
    expect(result.peakPowerWatts).toBeGreaterThan(1500);
    expect(result.peakPowerWatts).toBeLessThan(2200);
  });

  it('scales absolute power with body mass at a fixed profile', () => {
    const splits = [
      { cumulativeTimeSeconds: 1.7, cumulativeDistanceMeters: 9.144 },
      { cumulativeTimeSeconds: 2.85, cumulativeDistanceMeters: 18.288 },
      { cumulativeTimeSeconds: 3.85, cumulativeDistanceMeters: 27.432 },
      { cumulativeTimeSeconds: 4.8, cumulativeDistanceMeters: 36.576 },
    ];
    const lighter = computeSprintPower(splits, 60);
    const heavier = computeSprintPower(splits, 90);

    expect(heavier.peakPowerWatts).toBeGreaterThan(lighter.peakPowerWatts);
    expect(heavier.peakPowerWattsPerKg).toBeCloseTo(lighter.peakPowerWattsPerKg, 2);
  });
});
```

## `speed-radar.tsx`

```tsx
// Domain-agnostic radar/spider chart, hand-rolled SVG (no chart dependency
// for a handful of axes). Built for the MPH calculator's 4-axis Speed
// Signature, but the props take arbitrary axes and optional reference
// overlays so future paid report tools (real cohort archetypes, 8-quality
// profiles) can reuse this component as-is rather than forking it — the
// primary polygon (the athlete's own data) is always filled and glowing;
// overlays are always dashed, stroke-only, and never the primary color, so
// the athlete's own shape stays visually dominant no matter how many
// reference lines are added later.
export type RadarAxis = {
  label: string;
  value: number; // 0-100
};

export type RadarOverlay = {
  label: string;
  values: number[]; // one per axis, same order and length as axes
  color: string;
};

export function SpeedRadar({
  axes,
  overlays = [],
  size = 280,
  ariaLabel,
}: {
  axes: RadarAxis[];
  overlays?: RadarOverlay[];
  size?: number;
  ariaLabel: string;
}) {
  const center = size / 2;
  const radius = size / 2 - 36;
  const rings = 4;

  function pointAt(axisIndex: number, value: number): { x: number; y: number } {
    const angle = (Math.PI * 2 * axisIndex) / axes.length - Math.PI / 2;
    const distance = (Math.max(0, Math.min(100, value)) / 100) * radius;
    return { x: center + distance * Math.cos(angle), y: center + distance * Math.sin(angle) };
  }

  function polygonPoints(values: number[]): string {
    return values.map((value, index) => {
      const { x, y } = pointAt(index, value);
      return `${x},${y}`;
    }).join(' ');
  }

  const ringLevels = Array.from({ length: rings }, (_, i) => ((i + 1) / rings) * 100);

  return (
    <svg viewBox={`0 0 ${size} ${size}`} role="img" aria-label={ariaLabel}>
      <title>{ariaLabel}</title>

      {ringLevels.map((level) => (
        <polygon
          key={level}
          points={polygonPoints(axes.map(() => level))}
          fill="none"
          stroke="var(--line)"
          strokeWidth={1}
        />
      ))}

      {axes.map((axis, index) => {
        const outer = pointAt(index, 100);
        return (
          <line
            key={axis.label}
            x1={center}
            y1={center}
            x2={outer.x}
            y2={outer.y}
            stroke="var(--line)"
            strokeWidth={1}
          />
        );
      })}

      {overlays.map((overlay) => (
        <polygon
          key={overlay.label}
          points={polygonPoints(overlay.values)}
          fill="none"
          stroke={overlay.color}
          strokeWidth={2}
          strokeDasharray="5 4"
        />
      ))}

      <polygon
        points={polygonPoints(axes.map((axis) => axis.value))}
        fill="var(--profile-fill)"
        stroke="var(--profile-stroke)"
        strokeWidth={2.5}
        style={{ filter: 'var(--profile-glow-filter, none)' }}
      />
      {axes.map((axis, index) => {
        const { x, y } = pointAt(index, axis.value);
        return <circle key={axis.label} cx={x} cy={y} r={4} fill="var(--profile-stroke)" />;
      })}

      {axes.map((axis, index) => {
        const labelPoint = pointAt(index, 122);
        return (
          <text
            key={axis.label}
            x={labelPoint.x}
            y={labelPoint.y}
            textAnchor="middle"
            dominantBaseline="middle"
            fontSize="11"
            fill="var(--ink-3)"
          >
            {axis.label}
          </text>
        );
      })}
    </svg>
  );
}
```

## `mph-calculator.tsx`

```tsx
'use client';

import { useEffect, useMemo, useState } from 'react';
import {
  DEFAULT_SPLITS,
  computeMph,
  computeSpeedSignature,
  validateSplits,
  type MphResult,
  type Split,
  type SpeedSignature,
} from '../../../../lib/tools/mph';
import { computeSprintPower, type SprintPowerResult } from '../../../../lib/tools/sprint-power';
import { SpeedRadar } from '../speed-radar';
import styles from '../tools.module.css';
import reportStyles from './mph-report.module.css';

const LB_PER_KG = 0.453592;

function formatNumber(value: number, digits = 2): string {
  return Number.isFinite(value) ? value.toFixed(digits) : 'n/a';
}

function radarAxes(signature: SpeedSignature) {
  return [
    { label: 'Acceleration', value: signature.acceleration },
    { label: 'Top-End Speed', value: signature.topEndSpeed },
    { label: 'Consistency', value: signature.consistency },
    { label: 'Power', value: signature.power },
  ];
}

function speedSignatureNote(signature: SpeedSignature): { before: string; flag: string; after: string } {
  const entries = Object.entries(signature) as [keyof SpeedSignature, number][];
  const lowest = entries.reduce((min, entry) => (entry[1] < min[1] ? entry : min));
  const labels: Record<keyof SpeedSignature, string> = {
    acceleration: 'acceleration',
    topEndSpeed: 'top-end speed',
    consistency: 'late-run consistency',
    power: 'power output',
  };
  return {
    before: "This profile's biggest gap relative to the rest of the run is ",
    flag: labels[lowest[0]],
    after: '. That is the one to target first.',
  };
}

function VelocityCurveChart({ result, tone = 'screen' }: { result: MphResult; tone?: 'screen' | 'print' }) {
  const width = 640;
  const height = 220;
  const padding = 28;
  const maxDistance = result.totalDistanceYards || 1;
  const maxVelocity = result.peakVelocityMs || 1;
  const stroke = tone === 'screen' ? 'var(--green)' : '#1f9d43';
  const axisStroke = tone === 'screen' ? 'var(--line-strong)' : '#3a3550';
  const labelFill = tone === 'screen' ? 'var(--ink-3)' : '#8b87a0';

  const points = [
    { x: 0, y: 0 },
    ...result.splits.map((split) => ({ x: split.cumulativeDistanceYards, y: split.velocityMs })),
  ].map((point) => ({
    x: padding + (point.x / maxDistance) * (width - padding * 2),
    y: height - padding - (point.y / maxVelocity) * (height - padding * 2),
  }));

  const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ');

  return (
    <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Velocity curve across the run, in meters per second">
      <line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke={axisStroke} />
      <path d={path} fill="none" stroke={stroke} strokeWidth={2.5} style={{ filter: tone === 'screen' ? 'var(--profile-glow-filter, none)' : 'none' }} />
      {points.slice(1).map((point, index) => (
        <circle key={result.splits[index]!.label} cx={point.x} cy={point.y} r={4} fill={stroke} />
      ))}
      {result.splits.map((split) => (
        <text
          key={split.label}
          x={padding + (split.cumulativeDistanceYards / maxDistance) * (width - padding * 2)}
          y={height - padding + 16}
          textAnchor="middle"
          fontSize="11"
          fill={labelFill}
        >
          {split.label}
        </text>
      ))}
    </svg>
  );
}

function ReportView({
  sessionLabel,
  result,
  signature,
  power,
  onClose,
}: {
  sessionLabel: string;
  result: MphResult;
  signature: SpeedSignature;
  power: SprintPowerResult | null;
  onClose: () => void;
}) {
  const today = useMemo(
    () => new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
    [],
  );
  const note = speedSignatureNote(signature);

  return (
    <div className={reportStyles.overlay} data-print-report-root>
      <div className={reportStyles.toolbar}>
        <button className={reportStyles.toolbarButton} type="button" onClick={onClose}>
          Close
        </button>
        <button
          className={`${reportStyles.toolbarButton} ${reportStyles.toolbarButtonPrimary}`}
          type="button"
          onClick={() => window.print()}
        >
          Download PDF report
        </button>
      </div>
      <div className={reportStyles.page}>
        <div className={reportStyles.kickerRule} />
        <p className={reportStyles.label}>Speed Profile Report &middot; {today}</p>
        <h1 className={reportStyles.title}>{sessionLabel || 'Speed Profile'}</h1>
        <p className={reportStyles.meta}>Built with the MPH Speed Calculator, richburnett.net</p>

        <div className={reportStyles.heroRow}>
          <div className={reportStyles.heroStat}>
            <span className={reportStyles.heroStatLabel}>Peak speed</span>
            <span className={reportStyles.heroStatValue}>{formatNumber(result.peakSpeedMph, 1)}</span>
            <span className={reportStyles.heroStatUnit}>mph, {result.peakSplitLabel || 'n/a'} split</span>
          </div>
          <div className={reportStyles.secondaryStat}>
            <span className={reportStyles.heroStatLabel}>Total time</span>
            <span className={reportStyles.secondaryStatValue}>{formatNumber(result.totalTimeSeconds, 2)}s</span>
          </div>
          <div className={reportStyles.secondaryStat}>
            <span className={reportStyles.heroStatLabel}>Average speed</span>
            <span className={reportStyles.secondaryStatValue}>{formatNumber(result.averageSpeedMph, 1)} mph</span>
          </div>
          {power ? (
            <div className={reportStyles.secondaryStat}>
              <span className={reportStyles.heroStatLabel}>Peak power (est.)</span>
              <span className={reportStyles.secondaryStatValue}>{formatNumber(power.peakPowerWatts, 0)} W</span>
            </div>
          ) : null}
        </div>

        <h2 className={reportStyles.sectionTitle}>Speed Signature</h2>
        <div className={reportStyles.radarRow}>
          <div className={reportStyles.radarChart}>
            <SpeedRadar axes={radarAxes(signature)} ariaLabel="Speed signature: acceleration, top-end speed, consistency, and power" />
          </div>
          <p className={reportStyles.radarNote}>
            {note.before}
            <strong>{note.flag}</strong>
            {note.after} Each axis is scored against this athlete&apos;s own run (acceleration, consistency, power)
            or a fixed reference ceiling (top-end speed, vs. 25 mph) &mdash; not against other athletes. This tool
            has no norms database.
          </p>
        </div>

        <h2 className={reportStyles.sectionTitle}>Speed curve</h2>
        <div className={reportStyles.chart}>
          <VelocityCurveChart result={result} tone="print" />
        </div>

        <h2 className={reportStyles.sectionTitle}>Splits</h2>
        <table className={reportStyles.table}>
          <thead>
            <tr>
              <th>Split</th>
              <th>Yards</th>
              <th>Seconds</th>
              <th>Velocity m/s</th>
              <th>Speed mph</th>
              <th>% of peak</th>
            </tr>
          </thead>
          <tbody>
            {result.splits.map((split) => (
              <tr key={split.label}>
                <td>{split.label}</td>
                <td>{formatNumber(split.distanceYards, 1)}</td>
                <td>{formatNumber(split.timeSeconds, 2)}</td>
                <td>{formatNumber(split.velocityMs)}</td>
                <td>{formatNumber(split.speedMph)}</td>
                <td>{formatNumber(split.percentOfPeakVelocity, 1)}%</td>
              </tr>
            ))}
          </tbody>
          <tfoot>
            <tr>
              <td>Total</td>
              <td>{formatNumber(result.totalDistanceYards, 1)}</td>
              <td>{formatNumber(result.totalTimeSeconds, 2)}</td>
              <td>{formatNumber(result.averageVelocityMs)}</td>
              <td>{formatNumber(result.averageSpeedMph)}</td>
              <td />
            </tr>
          </tfoot>
        </table>

        <p className={reportStyles.disclaimer}>
          This is a single session split analysis, not a validated sports science model. Peak power is a
          field-method estimate (Samozino sprint force-velocity-power profiling) derived from these splits and
          body weight, not a force-plate or radar measurement, and is most sensitive to the accuracy of your
          first split. Treat this whole profile as a discussion starter for the coach, not a prescription or a
          diagnosis.
        </p>

        <p className={reportStyles.footer}>Built with the MPH Speed Calculator, richburnett.net</p>
      </div>
    </div>
  );
}

function buildReport(
  sessionLabel: string,
  result: MphResult,
  signature: SpeedSignature,
  power: SprintPowerResult | null,
): string {
  const rows = result.splits
    .map(
      (split) =>
        `| ${split.label} | ${formatNumber(split.distanceYards, 1)} | ${formatNumber(split.timeSeconds, 2)} | ${formatNumber(split.velocityMs)} | ${formatNumber(split.speedMph)} | ${formatNumber(split.percentOfPeakVelocity, 1)}% | ${formatNumber(split.accelerationMsPerS)} |`,
    )
    .join('\n');

  const powerLine = power
    ? `- Peak power (estimated, Samozino sprint force-velocity-power method): ${formatNumber(power.peakPowerWatts, 0)} W (${formatNumber(power.peakPowerWattsPerKg, 1)} W/kg)\n`
    : '';

  return `# Speed Profile${sessionLabel ? ` — ${sessionLabel}` : ''}

Generated with the MPH / Speed Calculator (richburnett.net/tools).

## Splits

| Split | Yards | Seconds | Velocity (m/s) | Speed (mph) | % of Peak Velocity | Acceleration (m/s/s) |
| --- | --- | --- | --- | --- | --- | --- |
${rows}

## Summary

- Peak speed: ${formatNumber(result.peakSpeedMph, 1)} mph, reached in the ${result.peakSplitLabel} split
- Total distance: ${formatNumber(result.totalDistanceYards, 1)} yards in ${formatNumber(result.totalTimeSeconds, 2)} seconds
- Average speed across the run: ${formatNumber(result.averageSpeedMph, 1)} mph
${powerLine}
## Speed Signature (0-100, self-referential; this tool has no norms database)

- Acceleration: ${formatNumber(signature.acceleration, 0)}
- Top-end speed (vs. a 25 mph reference): ${formatNumber(signature.topEndSpeed, 0)}
- Consistency (late-run fall-off): ${formatNumber(signature.consistency, 0)}
- Power: ${formatNumber(signature.power, 0)}

## Instructions for your AI assistant

Paste this file into ChatGPT, Claude, or the assistant of your choice and ask it to:

1. Say whether this athlete's limiter looks like acceleration (early splits) or top-end speed (late splits), based on how quickly they climb toward peak velocity and whether they are still climbing at the last split.
2. Suggest two or three drill categories that address the biggest gap in this specific profile (use the Speed Signature scores above to identify the lowest axis).
3. Flag any split that looks like an outlier or a likely timing error rather than a real mechanical limiter.

This is a single-session split analysis, not a validated sports-science model. Peak power is a field-method estimate, most sensitive to first-split timing accuracy, not a lab measurement. Treat the AI's read as a discussion starter for the coach, not a prescription.
`;
}

function downloadReport(content: string, filename: string) {
  const blob = new Blob([content], { type: 'text/markdown' });
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement('a');
  anchor.href = url;
  anchor.download = filename;
  anchor.click();
  URL.revokeObjectURL(url);
}

export function MphCalculator() {
  const [sessionLabel, setSessionLabel] = useState('');
  const [bodyWeightLbs, setBodyWeightLbs] = useState('');
  const [splits, setSplits] = useState<Split[]>(() => DEFAULT_SPLITS.map((split) => ({ ...split })));
  const [showReport, setShowReport] = useState(false);
  const result = useMemo(() => computeMph(splits), [splits]);
  const signature = useMemo(() => computeSpeedSignature(result), [result]);
  const issues = useMemo(() => validateSplits(splits), [splits]);
  const hasIssues = issues.length > 0;

  const bodyWeightNumber = Number(bodyWeightLbs);
  const hasValidBodyWeight = Number.isFinite(bodyWeightNumber) && bodyWeightNumber > 0;
  const power = useMemo(() => {
    if (hasIssues || !hasValidBodyWeight) return null;
    const bodyMassKg = bodyWeightNumber * LB_PER_KG;
    const sprintPoints = result.splits.map((split) => ({
      cumulativeTimeSeconds: split.cumulativeTimeSeconds,
      cumulativeDistanceMeters: split.cumulativeDistanceYards * 0.9144,
    }));
    return computeSprintPower(sprintPoints, bodyMassKg);
  }, [hasIssues, hasValidBodyWeight, bodyWeightNumber, result]);

  useEffect(() => {
    document.body.dataset.printReport = showReport ? 'true' : 'false';
    return () => {
      delete document.body.dataset.printReport;
    };
  }, [showReport]);

  function updateSplit(index: number, field: keyof Split, value: string) {
    setSplits((current) =>
      current.map((split, i) => {
        if (i !== index) return split;
        if (field === 'label') return { ...split, label: value };
        const numericValue = Number(value);
        return { ...split, [field]: Number.isFinite(numericValue) ? numericValue : 0 };
      }),
    );
  }

  function addSplit() {
    setSplits((current) => [
      ...current,
      { label: `Split ${current.length + 1}`, distanceYards: 10, timeSeconds: 1 },
    ]);
  }

  function removeSplit(index: number) {
    setSplits((current) => current.filter((_, i) => i !== index));
  }

  function handleDownload() {
    const report = buildReport(sessionLabel, result, signature, power);
    const slug = sessionLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'speed-profile';
    downloadReport(report, `${slug}.md`);
  }

  const note = speedSignatureNote(signature);

  return (
    <div className={styles.panel}>
      <h2 className={styles.h2}>Split Inputs</h2>
      <p className={styles.body}>
        Enter each split as a distance in yards and the time it took to cover that distance,
        for example the 0 to 10 yard segment of a 40 yard dash. The calculator converts each
        split to velocity and speed, so you can see where an athlete is still accelerating and
        where they have reached top speed.
      </p>
      <div className={styles.formRow}>
        <label className={styles.field} style={{ gridColumn: '1 / -1' }}>
          Athlete or session label (optional, used in the download)
          <input
            className={styles.input}
            type="text"
            value={sessionLabel}
            onChange={(event) => setSessionLabel(event.target.value)}
            placeholder="e.g. Jordan Lee, 2026-07-04"
          />
        </label>
      </div>
      <div className={styles.formRow}>
        <label className={styles.field} style={{ gridColumn: '1 / -1' }}>
          Body weight in pounds (optional, adds an estimated peak power to the profile)
          <input
            className={styles.input}
            type="number"
            min="0"
            step="1"
            value={bodyWeightLbs}
            onChange={(event) => setBodyWeightLbs(event.target.value)}
            placeholder="e.g. 165"
          />
        </label>
      </div>

      {splits.map((split, index) => (
        <div className={styles.splitCard} key={`${split.label}-${index}`}>
          <div className={styles.splitCardHeader}>
            <input
              className={styles.splitCardLabel}
              type="text"
              value={split.label}
              onChange={(event) => updateSplit(index, 'label', event.target.value)}
              aria-label="Split label"
            />
            <span className={styles.speedChip}>
              {formatNumber(result.splits[index]?.speedMph ?? 0, 1)}
              <span>mph</span>
            </span>
            <button
              className={styles.splitCardRemove}
              type="button"
              onClick={() => removeSplit(index)}
              disabled={splits.length === 1}
            >
              Remove
            </button>
          </div>
          <div className={styles.splitCardInputs}>
            <label className={styles.field}>
              Distance (yd)
              <input
                className={styles.input}
                type="number"
                min="0"
                step="0.1"
                value={split.distanceYards}
                onChange={(event) => updateSplit(index, 'distanceYards', event.target.value)}
              />
            </label>
            <label className={styles.field}>
              Time (s)
              <input
                className={styles.input}
                type="number"
                min="0.01"
                step="0.01"
                value={split.timeSeconds}
                onChange={(event) => updateSplit(index, 'timeSeconds', event.target.value)}
              />
            </label>
          </div>
        </div>
      ))}
      <p className={styles.buttonRow}>
        <button className={styles.ctaSecondary} type="button" onClick={addSplit}>
          Add split
        </button>
      </p>

      {hasIssues ? (
        <div className={styles.disclaimer} role="alert">
          <strong>Check these splits before you trust the numbers:</strong>
          <ul>
            {issues.map((issue) => (
              <li key={`${issue.index}-${issue.message}`}>{issue.message}</li>
            ))}
          </ul>
        </div>
      ) : (
        <div className={styles.heroBand}>
          <p className={styles.heroBandLabel}>{sessionLabel || 'This run’s'} peak speed</p>
          <p className={styles.heroBandNumber}>
            {formatNumber(result.peakSpeedMph, 1)}
            <span>mph, {result.peakSplitLabel || 'n/a'} split</span>
          </p>
        </div>
      )}

      {!hasIssues ? (
        <>
          <h2 className={styles.h2}>Speed Signature</h2>
          <div className={styles.radarRow}>
            <div className={styles.radarChart}>
              <SpeedRadar
                axes={radarAxes(signature)}
                ariaLabel="Speed signature: acceleration, top-end speed, consistency, and power"
              />
            </div>
            <p className={styles.radarNote}>
              {note.before}
              <strong>{note.flag}</strong>
              {note.after} Each axis is scored against this athlete&apos;s own run, or a fixed reference ceiling
              for top-end speed (25 mph) &mdash; this tool has no norms database to compare against other
              athletes.
            </p>
          </div>
        </>
      ) : null}

      <div className={styles.resultGrid}>
        <div className={styles.metric}>
          <span className={styles.metricLabel}>Peak speed</span>
          <span className={styles.metricValue}>{formatNumber(result.peakSpeedMph, 1)}</span>
          <span className={styles.metricText}>mph, {result.peakSplitLabel || 'n/a'} split</span>
        </div>
        <div className={styles.metric}>
          <span className={styles.metricLabel}>Total time</span>
          <span className={styles.metricValue}>{formatNumber(result.totalTimeSeconds, 2)}</span>
          <span className={styles.metricText}>seconds over {formatNumber(result.totalDistanceYards, 1)} yards</span>
        </div>
        <div className={styles.metric}>
          <span className={styles.metricLabel}>Average speed</span>
          <span className={styles.metricValue}>{formatNumber(result.averageSpeedMph, 1)}</span>
          <span className={styles.metricText}>mph across the run</span>
        </div>
        <div className={styles.metric}>
          <span className={styles.metricLabel}>Peak power (est.)</span>
          {power ? (
            <>
              <span className={styles.metricValue}>{formatNumber(power.peakPowerWatts, 0)}</span>
              <span className={styles.metricText}>watts ({formatNumber(power.peakPowerWattsPerKg, 1)} W/kg)</span>
            </>
          ) : (
            <span className={styles.metricText}>Add body weight above to estimate</span>
          )}
        </div>
      </div>

      <h2 className={styles.h2}>Velocity curve</h2>
      {hasIssues ? (
        <p className={styles.body}>Fix the splits above to see the chart.</p>
      ) : (
        <VelocityCurveChart result={result} />
      )}

      <details className={styles.panel}>
        <summary className={styles.h2} style={{ cursor: 'pointer', display: 'inline-block' }}>
          Full split data
        </summary>
        <table className={styles.table}>
          <thead>
            <tr>
              <th>Split</th>
              <th>Yards</th>
              <th>Seconds</th>
              <th>Velocity m/s</th>
              <th>Speed mph</th>
              <th>% of peak</th>
              <th>Pace min/mi</th>
              <th>Accel m/s/s</th>
            </tr>
          </thead>
          <tbody>
            {result.splits.map((split) => (
              <tr key={split.label}>
                <td>{split.label}</td>
                <td>{formatNumber(split.distanceYards, 1)}</td>
                <td>{formatNumber(split.timeSeconds, 2)}</td>
                <td>{formatNumber(split.velocityMs)}</td>
                <td>{formatNumber(split.speedMph)}</td>
                <td>{formatNumber(split.percentOfPeakVelocity, 1)}%</td>
                <td>{formatNumber(split.paceMinPerMile)}</td>
                <td>{formatNumber(split.accelerationMsPerS)}</td>
              </tr>
            ))}
          </tbody>
          <tfoot>
            <tr>
              <td>Total</td>
              <td>{formatNumber(result.totalDistanceYards, 1)}</td>
              <td>{formatNumber(result.totalTimeSeconds, 2)}</td>
              <td>{formatNumber(result.averageVelocityMs)}</td>
              <td>{formatNumber(result.averageSpeedMph)}</td>
              <td colSpan={3} />
            </tr>
          </tfoot>
        </table>
      </details>

      <p className={styles.buttonRow}>
        <button className={styles.cta} type="button" onClick={handleDownload} disabled={hasIssues}>
          Download report (.md)
        </button>
        <button
          className={styles.ctaSecondary}
          type="button"
          onClick={() => setShowReport(true)}
          disabled={hasIssues}
        >
          Download PDF report
        </button>
      </p>
      <p className={styles.disclaimer}>
        The markdown download includes the raw data, the Speed Signature scores, and a ready-made prompt for
        your own AI assistant to interpret the profile. The PDF report opens a print-ready page with the same
        profile, ready to save as a PDF from your browser&apos;s print dialog. Peak power is a field-method
        estimate most sensitive to first-split timing accuracy, not a lab measurement. All of this is a
        discussion starter, not a prescription.
      </p>

      {showReport ? (
        <ReportView
          sessionLabel={sessionLabel}
          result={result}
          signature={signature}
          power={power}
          onClose={() => setShowReport(false)}
        />
      ) : null}

      <h2 className={styles.h2}>Download the build</h2>
      <p className={styles.body}>
        Want the whole tool, not just a report? These downloads contain the complete source
        for this calculator: the math, its tests, the component used on this page, and a
        dependency free standalone version you can open straight in a browser. Feed either
        one to an LLM to ask questions, extend the tool, or port it to another language.
      </p>
      <p className={styles.buttonRow}>
        <a className={styles.ctaSecondary} href="/tools/mph-calculator-build.zip" download>
          Download the full build (.zip)
        </a>
        <a className={styles.ctaSecondary} href="/tools/mph-calculator-llm.md" download>
          Single file for your LLM (.md)
        </a>
      </p>
    </div>
  );
}
```

## `mph-report.module.css`

```css
/* Print-quality report view for the MPH / Speed Calculator. Dark by design
   (D-199): matches the on-screen tool and the rest of the marketing site's
   world, and reads as "premium sports-science report" rather than "office
   document" when screenshotted or shared as a PDF, which is this artifact's
   real life more often than a laser printer. @media print at the bottom
   flips to a light, ink-friendly palette for the physical-print path only.
   Reuses the .shell token scope (this component always renders nested
   inside the (marketing) layout's .shell wrapper) rather than redeclaring
   colors, so it stays in sync with the rest of the site's palette. */

.overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: var(--bg);
  color: var(--ink);
  overflow-y: auto;
  padding: 2.5rem 1.5rem 5rem;
}

.toolbar {
  max-width: 760px;
  margin: 0 auto 1.5rem;
  display: flex;
  justify-content: flex-end;
  gap: 0.75rem;
}

.toolbarButton {
  font-family: var(--sans);
  font-weight: 600;
  font-size: 0.95rem;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  padding: 0.7rem 1.2rem;
  cursor: pointer;
}

.toolbarButton:hover {
  border-color: var(--violet);
}

.toolbarButtonPrimary {
  background: var(--green);
  border-color: var(--green);
  color: #06230f;
}

.page {
  max-width: 760px;
  margin: 0 auto;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  padding: 3rem;
}

.kickerRule {
  width: 46px;
  height: 4px;
  background: var(--green);
  border-radius: 2px;
  margin-bottom: 1.1rem;
}

.label {
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--green-soft);
  margin: 0 0 0.4rem;
}

.title {
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(2rem, 5vw, 3.2rem);
  line-height: 0.95;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 0 0 0.35rem;
}

.meta {
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--ink-3);
  margin: 0 0 1.75rem;
}

.sectionTitle {
  font-family: var(--display);
  font-weight: 700;
  font-size: 1.15rem;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 2rem 0 0.9rem;
  border-top: 1px solid var(--line);
  padding-top: 1.4rem;
}

.sectionTitle:first-of-type {
  border-top: none;
  padding-top: 0;
  margin-top: 0;
}

/* Hero identity + three stat callouts, the "read it in five seconds" layer.
   Peak speed is the dominant hero number; the rest are secondary but still
   large, not boxed metric cards. */
.heroRow {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 1.75rem;
  margin-bottom: 0.5rem;
}

.heroStat {
  display: flex;
  flex-direction: column;
}

.heroStatLabel {
  font-family: var(--mono);
  font-size: 0.66rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 0.35rem;
}

.heroStatValue {
  font-family: var(--display);
  font-weight: 800;
  font-size: clamp(2.6rem, 7vw, 3.6rem);
  line-height: 0.9;
  color: var(--violet-text);
  filter: var(--profile-glow-filter);
}

.heroStatUnit {
  font-size: 0.85rem;
  color: var(--ink-2);
  margin-top: 0.2rem;
}

.secondaryStat {
  display: flex;
  flex-direction: column;
}

.secondaryStatValue {
  font-family: var(--display);
  font-weight: 700;
  font-size: 1.7rem;
  color: var(--ink);
}

.radarRow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1.75rem;
}

.radarChart {
  flex: 0 0 auto;
  width: min(240px, 100%);
}

.radarNote {
  flex: 1;
  min-width: 220px;
  color: var(--ink-2);
  font-size: 0.9rem;
  line-height: 1.6;
}

.radarNote strong {
  color: var(--report-flag);
}

.table {
  width: 100%;
  border-collapse: collapse;
}

.table th,
.table td {
  border-bottom: 1px solid var(--line);
  color: var(--ink-2);
  font-size: 0.85rem;
  padding: 0.55rem 0.5rem;
  text-align: left;
}

.table th {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.table tfoot td {
  color: var(--ink);
  font-weight: 700;
}

.chart {
  margin-top: 0.5rem;
}

.footer {
  margin-top: 2.25rem;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  color: var(--ink-3);
}

.disclaimer {
  font-size: 0.82rem;
  color: var(--ink-2);
  margin-top: 1rem;
  line-height: 1.55;
}

@media print {
  @page {
    margin: 0.6in;
  }

  /* Ink-friendly fallback for the physical-print path: the accents
     (violet/green/amber) all print fine on white, only the grounds and
     ink flip. Position is handled globally for [data-print-report-root]
     (see globals.css); this just resets the visual chrome for print. */
  .overlay {
    background: #fff;
    padding: 0;
    overflow: visible;
  }

  .toolbar {
    display: none;
  }

  .page {
    background: #fff;
    color: #17171c;
    border: none;
    border-radius: 0;
    padding: 0;
    max-width: none;
  }

  .label {
    color: #46464e;
  }

  .title,
  .sectionTitle,
  .secondaryStatValue {
    color: #17171c;
  }

  .meta,
  .heroStatLabel,
  .heroStatUnit,
  .radarNote,
  .footer,
  .disclaimer {
    color: #46464e;
  }

  .heroStatValue {
    color: #4a3fa5;
    filter: none;
  }

  .sectionTitle,
  .footer {
    border-top-color: #e2e2de;
  }

  .table th,
  .table td {
    border-bottom-color: #e2e2de;
    color: #2c2c33;
  }

  .table th {
    color: #66666e;
  }

  .table tfoot td {
    color: #17171c;
  }

  /* A page break landing mid-stat or mid-row looks broken; keep these
     atomic and let the break fall between them instead. */
  .heroStat,
  .secondaryStat,
  .table tr,
  .chart,
  .radarRow {
    break-inside: avoid;
  }

  .sectionTitle {
    break-after: avoid;
  }
}
```

## `standalone/index.html`

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MPH / Speed Calculator (standalone)</title>
<style>
  :root {
    color-scheme: dark;
    --bg: #0b0b12;
    --surface: #14141f;
    --ink: #f2f1f7;
    --ink-2: #b9b6c9;
    --ink-3: #8b87a0;
    --line: #262336;
    --line-strong: #34304a;
    --green: #4ed66f;
    --green-soft: #7ee89a;
    --violet: #7f77dd;
    --violet-strong: #aba0ea;
    --violet-text: #b7b1f7;
    --report-flag: #ff9d5c;
    --profile-fill: rgba(127, 119, 221, 0.26);
    --profile-stroke: #aba0ea;
    --profile-glow: drop-shadow(0 0 10px rgba(127, 119, 221, 0.45));
  }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    padding: 2rem 1.25rem 4rem;
  }
  main { max-width: 900px; margin: 0 auto; }
  h1 {
    font-size: clamp(1.8rem, 5vw, 2.6rem);
    letter-spacing: 0.01em;
    margin: 0 0 0.5rem;
  }
  p.lead { color: var(--ink-2); max-width: 60ch; line-height: 1.6; }
  h2 { font-size: 1.3rem; margin: 2rem 0 1rem; }
  .field {
    display: grid;
    gap: 0.35rem;
    color: var(--ink-3);
    font-size: 0.72rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
  }
  input {
    background: var(--surface);
    border: 1px solid var(--line-strong);
    border-radius: 8px;
    color: var(--ink);
    font-size: 1rem;
    padding: 0.7rem 0.85rem;
    width: 100%;
  }
  button {
    font-weight: 600;
    font-size: 0.95rem;
    border-radius: 8px;
    padding: 0.7rem 1.1rem;
    cursor: pointer;
    border: 1px solid var(--line-strong);
    background: transparent;
    color: var(--ink);
  }
  button.primary {
    background: var(--green);
    border: none;
    color: #06230f;
    font-weight: 700;
  }
  table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
  th, td {
    text-align: left;
    padding: 0.6rem 0.5rem;
    border-bottom: 1px solid var(--line);
    font-size: 0.9rem;
    color: var(--ink-2);
    white-space: nowrap;
  }
  th { color: var(--ink-3); font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; }
  .result-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.9rem; margin-top: 1rem; }
  @media (max-width: 720px) { .result-grid { grid-template-columns: 1fr; } }
  .metric { border: 1px solid var(--line-strong); border-radius: 10px; padding: 1rem; background: var(--surface); }
  .metric-label { display: block; font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 0.5rem; }
  .metric-value { display: block; font-size: 1.8rem; font-weight: 700; color: var(--violet-text); }
  .issues {
    border: 1px solid var(--line-strong);
    border-left: 3px solid var(--green);
    border-radius: 10px;
    padding: 0.9rem 1.1rem;
    margin-top: 1rem;
    color: var(--ink-2);
    font-size: 0.9rem;
  }
  .footnote { color: var(--ink-3); font-size: 0.85rem; margin-top: 2rem; line-height: 1.6; }

  /* Each split is a self-contained card, not a row in a grid that collapses
     to two columns on narrow screens and balloons in height. */
  .split-card {
    background: var(--surface);
    border: 1px solid var(--line-strong);
    border-radius: 12px;
    padding: 0.9rem 1rem;
    margin-bottom: 0.75rem;
  }
  .split-card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    margin-bottom: 0.7rem;
  }
  .split-card-header input {
    background: transparent;
    border: none;
    border-bottom: 1px solid var(--line-strong);
    border-radius: 0;
    padding: 0.2rem 0;
    font-size: 0.85rem;
  }
  .speed-chip { font-size: 0.95rem; color: var(--violet-text); white-space: nowrap; }
  .speed-chip span { color: var(--ink-3); font-size: 0.72rem; margin-left: 0.25rem; }
  .split-card-remove {
    background: transparent; border: none; color: var(--ink-3);
    font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer;
  }
  .split-card-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }

  /* Hero band: peak speed as one giant number. */
  .hero-band {
    display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
    gap: 1.5rem; background: var(--surface); border: 1px solid var(--violet);
    border-radius: 14px; padding: 1.5rem 1.75rem; margin: 1.5rem 0 2rem;
  }
  .hero-band-label { font-size: 1.3rem; font-weight: 700; margin: 0; }
  .hero-band-number { font-size: clamp(2.4rem, 7vw, 3.6rem); font-weight: 800; line-height: 0.9; color: var(--violet-text); filter: var(--profile-glow); margin: 0; text-align: right; }
  .hero-band-number span { display: block; font-size: 0.8rem; color: var(--ink-3); font-weight: 400; filter: none; }

  .radar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 1.75rem; margin: 1rem 0 2rem; }
  .radar-chart { flex: 0 0 auto; width: min(260px, 100%); }
  .radar-note { flex: 1; min-width: 240px; color: var(--ink-2); line-height: 1.6; }
  .radar-note strong { color: var(--report-flag); }

  /* Print-quality report overlay: dark by default, matching the on-screen
     tool (screenshots/shares better as a lead-gen artifact); a @media print
     block flips it to a light, ink-friendly palette for physical printing. */
  #report-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 200;
    background: var(--bg);
    color: var(--ink);
    overflow-y: auto;
    padding: 2.5rem 1.25rem 5rem;
  }
  #report-overlay.open { display: block; }
  .report-toolbar {
    max-width: 720px;
    margin: 0 auto 1.5rem;
    display: flex;
    justify-content: flex-end;
    gap: 0.75rem;
  }
  .report-toolbar button {
    font-weight: 600;
    color: var(--ink);
    background: var(--surface);
    border: 1px solid var(--line-strong);
  }
  .report-toolbar button.primary {
    background: var(--green);
    border-color: var(--green);
    color: #06230f;
  }
  .report-page {
    max-width: 720px;
    margin: 0 auto;
    background: var(--surface);
    border: 1px solid var(--line-strong);
    border-radius: 8px;
    padding: 2.5rem;
  }
  .report-kicker-rule { width: 40px; height: 4px; background: var(--green); border-radius: 2px; margin-bottom: 1rem; }
  .report-page h1 { font-size: 1.9rem; margin: 0 0 0.35rem; color: var(--ink); text-transform: uppercase; }
  .report-label {
    font-size: 0.7rem;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--green-soft);
    margin: 0 0 0.35rem;
  }
  .report-meta { font-size: 0.9rem; color: var(--ink-3); margin: 0 0 1.5rem; }
  .report-page h2 {
    font-size: 1.1rem;
    color: var(--ink);
    text-transform: uppercase;
    margin: 1.75rem 0 0.85rem;
    border-top: 1px solid var(--line);
    padding-top: 1.25rem;
  }
  .report-page h2:first-of-type { border-top: none; padding-top: 0; margin-top: 0; }
  .report-page table { width: 100%; border-collapse: collapse; }
  .report-page th, .report-page td {
    border-bottom: 1px solid var(--line);
    color: var(--ink-2);
    font-size: 0.85rem;
    padding: 0.5rem 0.45rem;
    text-align: left;
    white-space: normal;
  }
  .report-page th { color: var(--ink-3); font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; }
  .report-hero-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 1.5rem; margin-bottom: 0.5rem; }
  .report-hero-stat, .report-secondary-stat { display: flex; flex-direction: column; }
  .report-stat-label { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 0.3rem; }
  .report-hero-value { font-size: clamp(2.2rem, 6vw, 3rem); font-weight: 800; line-height: 0.9; color: var(--violet-text); filter: var(--profile-glow); }
  .report-hero-unit { font-size: 0.82rem; color: var(--ink-2); margin-top: 0.2rem; }
  .report-secondary-value { font-size: 1.5rem; font-weight: 700; color: var(--ink); }
  .report-footer {
    margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--line);
    font-size: 0.7rem; letter-spacing: 0.03em; color: var(--ink-3);
  }
  .report-disclaimer { font-size: 0.82rem; color: var(--ink-2); margin-top: 1rem; line-height: 1.55; }

  @media print {
    @page { margin: 0.6in; }
    body {
      background: #fff;
      padding: 0;
    }
    body > *:not(#report-overlay) { display: none !important; }
    #report-overlay {
      display: block;
      position: static;
      background: #fff;
      color: #17171c;
      padding: 0;
      overflow: visible;
    }
    .report-toolbar { display: none; }
    .report-page { background: #fff; color: #17171c; border: none; border-radius: 0; padding: 0; max-width: none; }
    .report-page h1, .report-page h2, .report-secondary-value { color: #17171c; }
    .report-label { color: #46464e; }
    .report-meta, .report-stat-label, .report-hero-unit, .radar-note, .report-footer, .report-disclaimer { color: #46464e; }
    .report-hero-value { color: #4a3fa5; filter: none; }
    .report-page h2, .report-footer { border-top-color: #e2e2de; }
    .report-page th, .report-page td { border-bottom-color: #e2e2de; color: #2c2c33; }
    .report-page th { color: #66666e; }
    .report-hero-stat, .report-secondary-stat, .report-page tr, .radar-row { break-inside: avoid; }
    .report-page h2 { break-after: avoid; }
  }
</style>
</head>
<body>
<main>
  <h1>MPH / Speed Calculator</h1>
  <p class="lead">
    Standalone build. No server, no dependencies, no analytics. Enter each split as a
    distance in yards and the time it took to cover it, for example the 0 to 10 yard segment
    of a 40 yard dash.
  </p>

  <h2>Split inputs</h2>
  <label class="field" style="margin-bottom: 0.85rem;">Athlete or session label (optional, used in the report)
    <input id="session-label" type="text" placeholder="e.g. Jordan Lee, 2026-07-04" />
  </label>
  <label class="field" style="margin-bottom: 0.85rem;">Body weight in pounds (optional, adds an estimated peak power)
    <input id="body-weight" type="number" min="0" step="1" placeholder="e.g. 165" />
  </label>
  <div id="splits"></div>
  <p><button id="add-split" type="button">Add split</button></p>
  <div id="issues"></div>

  <div id="hero-band"></div>

  <h2>Speed Signature</h2>
  <div class="radar-row" id="radar-row"></div>

  <h2>Profile</h2>
  <div class="result-grid" id="result-grid"></div>

  <details style="margin-top: 2rem;">
    <summary style="cursor: pointer; font-size: 1.3rem; font-weight: 700;">Full split data</summary>
    <table id="results-table">
      <thead>
        <tr>
          <th>Split</th><th>Yards</th><th>Seconds</th><th>Velocity m/s</th>
          <th>Speed mph</th><th>% of peak</th><th>Pace min/mi</th><th>Accel m/s/s</th>
        </tr>
      </thead>
      <tbody id="results-body"></tbody>
    </table>
  </details>

  <p style="margin-top: 1.5rem;"><button id="open-report" class="primary" type="button">Download PDF report</button></p>

  <p class="footnote">
    This is a single session split analysis, not a validated sports science model. Peak power is
    a field-method estimate (Samozino sprint force-velocity-power profiling), most sensitive to
    first-split timing accuracy, not a lab measurement. The Speed Signature axes are scored
    against this athlete's own run, or a fixed reference ceiling for top-end speed (25 mph); this
    tool has no norms database to compare against other athletes. Treat any read on this profile
    as a discussion starter for the coach, not a prescription. The math matches the live tool at
    richburnett.net/tools/mph-speed-calculator.
  </p>
</main>

<div id="report-overlay">
  <div class="report-toolbar">
    <button id="close-report" type="button">Close</button>
    <button id="print-report" class="primary" type="button">Download PDF report</button>
  </div>
  <div class="report-page">
    <div class="report-kicker-rule"></div>
    <p class="report-label" id="report-label"></p>
    <h1 id="report-title">Speed Profile</h1>
    <p class="report-meta">Built with the MPH Speed Calculator, richburnett.net</p>

    <div class="report-hero-row" id="report-hero-row"></div>

    <h2>Speed Signature</h2>
    <div class="radar-row" id="report-radar-row"></div>

    <h2>Speed curve</h2>
    <div id="report-chart"></div>

    <h2>Splits</h2>
    <table>
      <thead>
        <tr><th>Split</th><th>Yards</th><th>Seconds</th><th>Velocity m/s</th><th>Speed mph</th><th>% of peak</th></tr>
      </thead>
      <tbody id="report-results-body"></tbody>
    </table>

    <p class="report-disclaimer">
      This is a single session split analysis, not a validated sports science model. Peak power
      is a field-method estimate, most sensitive to first-split timing accuracy, not a lab
      measurement. Treat this profile as a discussion starter for the coach, not a prescription.
    </p>
    <p class="report-footer">Built with the MPH Speed Calculator, richburnett.net</p>
  </div>
</div>

<script>
// ---- math, ported from mph.ts and sprint-power.ts (see README.md) ----
function computeMph(splits) {
  var rawSplits = [];
  var totalDistanceYards = 0;
  var totalTimeSeconds = 0;
  var previousVelocityMs = 0;

  splits.forEach(function (split, index) {
    var velocityMs = (split.distanceYards * 0.9144) / split.timeSeconds;
    var speedMph = velocityMs * 2.23694;
    var paceMinPerMile = 60 / speedMph;
    var accelerationMsPerS = index === 0
      ? velocityMs / split.timeSeconds
      : (velocityMs - previousVelocityMs) / split.timeSeconds;

    totalDistanceYards += split.distanceYards;
    totalTimeSeconds += split.timeSeconds;
    rawSplits.push(Object.assign({}, split, {
      velocityMs: velocityMs,
      speedMph: speedMph,
      paceMinPerMile: paceMinPerMile,
      accelerationMsPerS: accelerationMsPerS,
      cumulativeDistanceYards: totalDistanceYards,
      cumulativeTimeSeconds: totalTimeSeconds,
    }));
    previousVelocityMs = velocityMs;
  });

  var averageVelocityMs = (totalDistanceYards * 0.9144) / totalTimeSeconds;
  var averageSpeedMph = averageVelocityMs * 2.23694;

  var peak = rawSplits.reduce(function (best, current) {
    return !best || current.velocityMs > best.velocityMs ? current : best;
  }, undefined);
  var peakVelocityMs = peak ? peak.velocityMs : 0;
  var peakSpeedMph = peak ? peak.speedMph : 0;
  var peakSplitLabel = peak ? peak.label : '';

  var splitResults = rawSplits.map(function (split) {
    return Object.assign({}, split, {
      percentOfPeakVelocity: peakVelocityMs > 0 ? (split.velocityMs / peakVelocityMs) * 100 : 0,
    });
  });

  return {
    splits: splitResults,
    totalDistanceYards: totalDistanceYards,
    totalTimeSeconds: totalTimeSeconds,
    averageVelocityMs: averageVelocityMs,
    averageSpeedMph: averageSpeedMph,
    peakVelocityMs: peakVelocityMs,
    peakSpeedMph: peakSpeedMph,
    peakSplitLabel: peakSplitLabel,
  };
}

function validateSplits(splits) {
  var issues = [];
  splits.forEach(function (split, index) {
    var label = split.label || ('Split ' + (index + 1));
    if (!isFinite(split.timeSeconds) || split.timeSeconds <= 0) {
      issues.push(label + ': time must be greater than zero.');
    }
    if (!isFinite(split.distanceYards) || split.distanceYards <= 0) {
      issues.push(label + ': distance must be greater than zero.');
    }
  });
  return issues;
}

var TOP_END_SPEED_REFERENCE_MPH = 25;
var PEAK_ACCELERATION_REFERENCE_MS2 = 10;

function clampPercent(value) {
  if (!isFinite(value)) return 0;
  return Math.max(0, Math.min(100, value));
}

function computeSpeedSignature(result) {
  if (result.splits.length === 0) {
    return { acceleration: 0, topEndSpeed: 0, consistency: 0, power: 0 };
  }
  var halfDistance = result.totalDistanceYards / 2;
  var midSplit = result.splits.find(function (s) { return s.cumulativeDistanceYards >= halfDistance; }) || result.splits[result.splits.length - 1];
  var acceleration = clampPercent(midSplit.percentOfPeakVelocity);
  var topEndSpeed = clampPercent((result.peakSpeedMph / TOP_END_SPEED_REFERENCE_MPH) * 100);
  var lastSplit = result.splits[result.splits.length - 1];
  var consistency = clampPercent(lastSplit.percentOfPeakVelocity);
  var maxAcceleration = Math.max.apply(null, result.splits.map(function (s) { return s.accelerationMsPerS; }));
  var power = clampPercent((maxAcceleration / PEAK_ACCELERATION_REFERENCE_MS2) * 100);
  return { acceleration: acceleration, topEndSpeed: topEndSpeed, consistency: consistency, power: power };
}

// Samozino sprint force-velocity-power profiling: fit a mono-exponential
// velocity model to the cumulative (time, distance) splits by a small
// coarse-to-fine grid search (two free parameters, a handful of points; no
// solver dependency needed), then derive F0/V0/Pmax from the fit.
function predictedDistance(vMax, tau, t) {
  return vMax * (t + tau * Math.exp(-t / tau) - tau);
}

function sumSquaredError(points, vMax, tau) {
  var error = 0;
  for (var i = 0; i < points.length; i++) {
    var predicted = predictedDistance(vMax, tau, points[i].t);
    var residual = predicted - points[i].d;
    error += residual * residual;
  }
  return error;
}

function fitVelocityModel(points) {
  var vMaxRange = [3, 15];
  var tauRange = [0.2, 2.5];
  var bestVMax = 9, bestTau = 1;
  var STEPS = 60;
  for (var round = 0; round < 6; round++) {
    var bestError = Infinity;
    for (var i = 0; i <= STEPS; i++) {
      var vMax = vMaxRange[0] + (vMaxRange[1] - vMaxRange[0]) * i / STEPS;
      for (var j = 0; j <= STEPS; j++) {
        var tau = tauRange[0] + (tauRange[1] - tauRange[0]) * j / STEPS;
        var error = sumSquaredError(points, vMax, tau);
        if (error < bestError) { bestError = error; bestVMax = vMax; bestTau = tau; }
      }
    }
    var vMaxSpan = (vMaxRange[1] - vMaxRange[0]) / 4;
    var tauSpan = (tauRange[1] - tauRange[0]) / 4;
    vMaxRange = [Math.max(0.5, bestVMax - vMaxSpan), bestVMax + vMaxSpan];
    tauRange = [Math.max(0.05, bestTau - tauSpan), bestTau + tauSpan];
  }
  return { vMax: bestVMax, tau: bestTau };
}

function computeSprintPower(splits, bodyMassKg) {
  var points = [{ t: 0, d: 0 }].concat(splits.map(function (s) {
    return { t: s.cumulativeTimeSeconds, d: s.cumulativeDistanceYards * 0.9144 };
  }));
  var fit = fitVelocityModel(points);
  var f0NPerKg = fit.vMax / fit.tau;
  var v0Ms = fit.vMax;
  var peakPowerWattsPerKg = (f0NPerKg * v0Ms) / 4;
  return {
    peakPowerWattsPerKg: peakPowerWattsPerKg,
    peakPowerWatts: peakPowerWattsPerKg * bodyMassKg,
  };
}

// ---- Speed Signature radar (SVG), matches the live tool's SpeedRadar ----
function buildRadarSvg(axes) {
  var size = 240, center = size / 2, radius = size / 2 - 32, rings = 4;
  function pointAt(index, value) {
    var angle = (Math.PI * 2 * index) / axes.length - Math.PI / 2;
    var distance = (Math.max(0, Math.min(100, value)) / 100) * radius;
    return { x: center + distance * Math.cos(angle), y: center + distance * Math.sin(angle) };
  }
  function polygonPoints(values) {
    return values.map(function (v, i) { var p = pointAt(i, v); return p.x + ',' + p.y; }).join(' ');
  }
  var rings_svg = '';
  for (var r = 1; r <= rings; r++) {
    var level = (r / rings) * 100;
    rings_svg += '<polygon points="' + polygonPoints(axes.map(function () { return level; })) + '" fill="none" stroke="var(--line-strong)" stroke-width="1" />';
  }
  var spokes = axes.map(function (axis, i) {
    var outer = pointAt(i, 100);
    return '<line x1="' + center + '" y1="' + center + '" x2="' + outer.x + '" y2="' + outer.y + '" stroke="var(--line-strong)" stroke-width="1" />';
  }).join('');
  var polygon = '<polygon points="' + polygonPoints(axes.map(function (a) { return a.value; })) + '" fill="var(--profile-fill)" stroke="var(--profile-stroke)" stroke-width="2.5" style="filter: var(--profile-glow)" />';
  var dots = axes.map(function (axis, i) {
    var p = pointAt(i, axis.value);
    return '<circle cx="' + p.x + '" cy="' + p.y + '" r="4" fill="var(--profile-stroke)" />';
  }).join('');
  var labels = axes.map(function (axis, i) {
    var p = pointAt(i, 122);
    return '<text x="' + p.x + '" y="' + p.y + '" text-anchor="middle" dominant-baseline="middle" font-size="11" fill="var(--ink-3)">' + axis.label + '</text>';
  }).join('');
  return '<svg viewBox="0 0 ' + size + ' ' + size + '" role="img" aria-label="Speed signature: acceleration, top-end speed, consistency, and power">' +
    rings_svg + spokes + polygon + dots + labels + '</svg>';
}

function radarAxes(signature) {
  return [
    { label: 'Acceleration', value: signature.acceleration },
    { label: 'Top-End Speed', value: signature.topEndSpeed },
    { label: 'Consistency', value: signature.consistency },
    { label: 'Power', value: signature.power },
  ];
}

function speedSignatureNote(signature) {
  var labels = { acceleration: 'acceleration', topEndSpeed: 'top-end speed', consistency: 'late-run consistency', power: 'power output' };
  var lowestKey = 'acceleration', lowestValue = signature.acceleration;
  Object.keys(labels).forEach(function (key) {
    if (signature[key] < lowestValue) { lowestKey = key; lowestValue = signature[key]; }
  });
  return { before: "This profile's biggest gap relative to the rest of the run is ", flag: labels[lowestKey], after: '. That is the one to target first.' };
}

// ---- state and rendering ----
var DEFAULT_SPLITS = [
  { label: '0-10', distanceYards: 10, timeSeconds: 1.8 },
  { label: '10-20', distanceYards: 10, timeSeconds: 1.1 },
  { label: '20-30', distanceYards: 10, timeSeconds: 1.02 },
  { label: '30-40', distanceYards: 10, timeSeconds: 0.98 },
];

var splits = DEFAULT_SPLITS.map(function (s) { return Object.assign({}, s); });
var LB_PER_KG = 0.453592;

function fmt(value, digits) {
  digits = digits === undefined ? 2 : digits;
  return isFinite(value) ? value.toFixed(digits) : 'n/a';
}

function render() {
  var splitsEl = document.getElementById('splits');
  splitsEl.innerHTML = '';
  splits.forEach(function (split, index) {
    var card = document.createElement('div');
    card.className = 'split-card';
    card.innerHTML =
      '<div class="split-card-header">' +
        '<input data-field="label" data-index="' + index + '" value="' + split.label + '" aria-label="Split label" />' +
        '<span class="speed-chip" id="speed-chip-' + index + '"></span>' +
        '<button type="button" class="split-card-remove" data-remove="' + index + '"' + (splits.length === 1 ? ' disabled' : '') + '>Remove</button>' +
      '</div>' +
      '<div class="split-card-inputs">' +
        '<label class="field">Distance (yd)<input type="number" min="0" step="0.1" data-field="distanceYards" data-index="' + index + '" value="' + split.distanceYards + '" /></label>' +
        '<label class="field">Time (s)<input type="number" min="0.01" step="0.01" data-field="timeSeconds" data-index="' + index + '" value="' + split.timeSeconds + '" /></label>' +
      '</div>';
    splitsEl.appendChild(card);
  });

  splitsEl.querySelectorAll('input[data-field]').forEach(function (input) {
    input.addEventListener('input', function () {
      var index = Number(input.getAttribute('data-index'));
      var field = input.getAttribute('data-field');
      var value = field === 'label' ? input.value : Number(input.value);
      splits[index][field] = field === 'label' ? value : (isFinite(value) ? value : 0);
      compute();
    });
  });
  splitsEl.querySelectorAll('[data-remove]').forEach(function (button) {
    button.addEventListener('click', function () {
      var index = Number(button.getAttribute('data-remove'));
      splits = splits.filter(function (_, i) { return i !== index; });
      render();
      compute();
    });
  });

  compute();
}

var lastResult = null;
var lastSignature = null;
var lastPower = null;

function compute() {
  var issues = validateSplits(splits);
  var issuesEl = document.getElementById('issues');
  var openReportButton = document.getElementById('open-report');
  var heroBandEl = document.getElementById('hero-band');
  var radarRowEl = document.getElementById('radar-row');
  if (issues.length > 0) {
    issuesEl.innerHTML = '<div class="issues"><strong>Check these splits:</strong><ul>' +
      issues.map(function (m) { return '<li>' + m + '</li>'; }).join('') + '</ul></div>';
    document.getElementById('result-grid').innerHTML = '';
    document.getElementById('results-body').innerHTML = '';
    heroBandEl.innerHTML = '';
    radarRowEl.innerHTML = '';
    lastResult = null;
    openReportButton.disabled = true;
    return;
  }
  issuesEl.innerHTML = '';
  openReportButton.disabled = false;

  var result = computeMph(splits);
  var signature = computeSpeedSignature(result);
  lastResult = result;
  lastSignature = signature;

  var bodyWeightLbs = Number(document.getElementById('body-weight').value);
  var hasValidBodyWeight = isFinite(bodyWeightLbs) && bodyWeightLbs > 0;
  lastPower = hasValidBodyWeight ? computeSprintPower(result.splits, bodyWeightLbs * LB_PER_KG) : null;

  result.splits.forEach(function (split, index) {
    var chip = document.getElementById('speed-chip-' + index);
    if (chip) chip.innerHTML = fmt(split.speedMph, 1) + '<span>mph</span>';
  });

  heroBandEl.innerHTML =
    '<p class="hero-band-label">Peak speed</p>' +
    '<p class="hero-band-number">' + fmt(result.peakSpeedMph, 1) + '<span>mph, ' + result.peakSplitLabel + ' split</span></p>';

  var note = speedSignatureNote(signature);
  radarRowEl.innerHTML =
    '<div class="radar-chart">' + buildRadarSvg(radarAxes(signature)) + '</div>' +
    '<p class="radar-note">' + note.before + '<strong>' + note.flag + '</strong>' + note.after +
    ' Each axis is scored against this athlete&rsquo;s own run, or a fixed reference ceiling for top-end speed (25 mph) &mdash; this tool has no norms database to compare against other athletes.</p>';

  document.getElementById('result-grid').innerHTML =
    '<div class="metric"><span class="metric-label">Peak speed</span><span class="metric-value">' + fmt(result.peakSpeedMph, 1) + '</span> mph, ' + result.peakSplitLabel + ' split</div>' +
    '<div class="metric"><span class="metric-label">Total time</span><span class="metric-value">' + fmt(result.totalTimeSeconds, 2) + '</span> s over ' + fmt(result.totalDistanceYards, 1) + ' yd</div>' +
    '<div class="metric"><span class="metric-label">Average speed</span><span class="metric-value">' + fmt(result.averageSpeedMph, 1) + '</span> mph</div>' +
    '<div class="metric"><span class="metric-label">Peak power (est.)</span>' +
      (lastPower
        ? '<span class="metric-value">' + fmt(lastPower.peakPowerWatts, 0) + '</span> watts (' + fmt(lastPower.peakPowerWattsPerKg, 1) + ' W/kg)'
        : 'Add body weight above to estimate') +
    '</div>';

  document.getElementById('results-body').innerHTML = result.splits.map(function (split) {
    return '<tr><td>' + split.label + '</td><td>' + fmt(split.distanceYards, 1) + '</td><td>' + fmt(split.timeSeconds, 2) +
      '</td><td>' + fmt(split.velocityMs) + '</td><td>' + fmt(split.speedMph) + '</td><td>' + fmt(split.percentOfPeakVelocity, 1) +
      '%</td><td>' + fmt(split.paceMinPerMile) + '</td><td>' + fmt(split.accelerationMsPerS) + '</td></tr>';
  }).join('');
}

document.getElementById('add-split').addEventListener('click', function () {
  splits.push({ label: 'Split ' + (splits.length + 1), distanceYards: 10, timeSeconds: 1 });
  render();
});
document.getElementById('body-weight').addEventListener('input', compute);

// ---- report overlay: builds a print-quality view of the current result ----
function buildReportChart(result) {
  var width = 640, height = 200, padding = 28;
  var maxDistance = result.totalDistanceYards || 1;
  var maxVelocity = result.peakVelocityMs || 1;
  var points = [{ x: 0, y: 0 }].concat(result.splits.map(function (split) {
    return { x: split.cumulativeDistanceYards, y: split.velocityMs };
  })).map(function (point) {
    return {
      x: padding + (point.x / maxDistance) * (width - padding * 2),
      y: height - padding - (point.y / maxVelocity) * (height - padding * 2),
    };
  });
  var path = points.map(function (point, index) {
    return (index === 0 ? 'M ' : 'L ') + point.x + ' ' + point.y;
  }).join(' ');
  var dots = points.slice(1).map(function (point) {
    return '<circle cx="' + point.x + '" cy="' + point.y + '" r="4" fill="var(--green)" />';
  }).join('');
  var labels = result.splits.map(function (split) {
    var x = padding + (split.cumulativeDistanceYards / maxDistance) * (width - padding * 2);
    return '<text x="' + x + '" y="' + (height - padding + 16) + '" text-anchor="middle" font-size="11" fill="var(--ink-3)">' + split.label + '</text>';
  }).join('');

  return '<svg viewBox="0 0 ' + width + ' ' + height + '" role="img" aria-label="Speed curve across the run, in miles per hour">' +
    '<line x1="' + padding + '" y1="' + (height - padding) + '" x2="' + (width - padding) + '" y2="' + (height - padding) + '" stroke="var(--line-strong)" />' +
    '<path d="' + path + '" fill="none" stroke="var(--green)" stroke-width="2.5" />' +
    dots + labels +
    '</svg>';
}

function renderReport() {
  if (!lastResult) return;
  var result = lastResult;
  var signature = lastSignature;
  var power = lastPower;
  var label = document.getElementById('session-label').value.trim();
  var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });

  document.getElementById('report-label').textContent = 'Speed Profile Report · ' + today;
  document.getElementById('report-title').textContent = label || 'Speed Profile';

  var heroRow =
    '<div class="report-hero-stat"><span class="report-stat-label">Peak speed</span><span class="report-hero-value">' + fmt(result.peakSpeedMph, 1) + '</span><span class="report-hero-unit">mph, ' + result.peakSplitLabel + ' split</span></div>' +
    '<div class="report-secondary-stat"><span class="report-stat-label">Total time</span><span class="report-secondary-value">' + fmt(result.totalTimeSeconds, 2) + 's</span></div>' +
    '<div class="report-secondary-stat"><span class="report-stat-label">Average speed</span><span class="report-secondary-value">' + fmt(result.averageSpeedMph, 1) + ' mph</span></div>';
  if (power) {
    heroRow += '<div class="report-secondary-stat"><span class="report-stat-label">Peak power (est.)</span><span class="report-secondary-value">' + fmt(power.peakPowerWatts, 0) + ' W</span></div>';
  }
  document.getElementById('report-hero-row').innerHTML = heroRow;

  var note = speedSignatureNote(signature);
  document.getElementById('report-radar-row').innerHTML =
    '<div class="radar-chart">' + buildRadarSvg(radarAxes(signature)) + '</div>' +
    '<p class="radar-note">' + note.before + '<strong>' + note.flag + '</strong>' + note.after +
    ' Each axis is scored against this athlete&rsquo;s own run (acceleration, consistency, power) or a fixed reference ceiling (top-end speed, vs. 25 mph) &mdash; not against other athletes. This tool has no norms database.</p>';

  document.getElementById('report-results-body').innerHTML = result.splits.map(function (split) {
    return '<tr><td>' + split.label + '</td><td>' + fmt(split.distanceYards, 1) + '</td><td>' + fmt(split.timeSeconds, 2) +
      '</td><td>' + fmt(split.velocityMs) + '</td><td>' + fmt(split.speedMph) + '</td><td>' + fmt(split.percentOfPeakVelocity, 1) + '%</td></tr>';
  }).join('') +
    '<tr><td><strong>Total</strong></td><td>' + fmt(result.totalDistanceYards, 1) + '</td><td>' + fmt(result.totalTimeSeconds, 2) +
    '</td><td>' + fmt(result.averageVelocityMs) + '</td><td>' + fmt(result.averageSpeedMph) + '</td><td></td></tr>';

  document.getElementById('report-chart').innerHTML = buildReportChart(result);
}

document.getElementById('open-report').addEventListener('click', function () {
  renderReport();
  document.getElementById('report-overlay').classList.add('open');
});
document.getElementById('close-report').addEventListener('click', function () {
  document.getElementById('report-overlay').classList.remove('open');
});
document.getElementById('print-report').addEventListener('click', function () {
  window.print();
});

render();
</script>
</body>
</html>
```

## `README.md`

```md
# MPH / Speed Calculator, complete build

This is the full source of the MPH / Speed Calculator tool from richburnett.net/tools,
packaged so you can hand it to an LLM (Claude, ChatGPT, or anything else) and ask it to
explain, extend, port, or rebuild the tool.

## What this tool does

A coach enters a set of splits from a timed sprint (most commonly a 40 yard dash broken
into 0-10, 10-20, 20-30, and 30-40 yard segments). For each split, the coach records the
distance in yards and the time in seconds it took to cover that distance. The tool converts
each split into:

- velocity in meters per second
- speed in miles per hour
- pace in minutes per mile
- acceleration in meters per second per second, relative to the previous split
- percent of the athlete's peak velocity for that session

It also reports totals: overall distance, overall time, average speed across the whole run,
and which split held the peak speed. The point is to let a coach see, at a glance, whether
an athlete is still accelerating late in the run (a top end speed limiter) or reaches peak
velocity early and holds it (an acceleration limiter, or a different training need).

Adding body weight (in pounds) unlocks an estimated peak power, using the Samozino sprint
force-velocity-power field method: it fits a model of the athlete's velocity over time to
the splits, then derives the theoretical maximum force and velocity that model implies, and
from those, peak power. This is a model estimate, not a force-plate or radar measurement, and
it is most sensitive to the accuracy of the first split (a mistimed start distorts the whole
fit). Report both watts and watts per kilogram; the latter is what actually compares across
athletes of different sizes.

The tool also derives a four-axis "Speed Signature" (acceleration, top-end speed, consistency,
power) and renders it as a radar chart. Every axis is scored against the athlete's own run, or
against a fixed reference ceiling for top-end speed (25 mph) — never against other athletes.
This tool has no norms database, and the report says so explicitly rather than implying a
percentile it cannot back up.

Both the live tool and this standalone build can also produce a print quality PDF report:
an athlete or session label, the date, headline stats (including peak power if you entered
body weight), the Speed Signature radar, the speed curve, and the splits table. The report
renders dark to match the on-screen tool (it is meant to be shared as a screenshot or PDF as
often as it is printed on paper), with a separate light, ink-friendly stylesheet that only
applies when you actually print to paper. Click "Download PDF report" to open the report
view, then use your browser's print dialog (or "Save as PDF") to export it. No server round
trip and no dependencies: the report is just a print stylesheet and `window.print()`.

## The math and its assumptions

All of the math lives in `mph.ts`. The core conversion:

```
velocityMs   = (distanceYards * 0.9144) / timeSeconds
speedMph     = velocityMs * 2.23694
paceMinPerMi = 60 / speedMph
```

0.9144 converts yards to meters. 2.23694 converts meters per second to miles per hour.

Acceleration for a split is the change in velocity from the previous split divided by that
split's time. For the first split, there is no previous split, so acceleration is simply
that split's velocity divided by its time (acceleration from a standing start).

Peak velocity is the fastest split in the set. Every split's `percentOfPeakVelocity` is
its velocity as a percentage of that peak, so a coach can see relative fall off, not just
raw numbers.

Average speed across the whole run uses total distance and total time, not the average of
the per-split speeds. This matters when splits have different distances.

### Validation

`validateSplits` flags any split whose time or distance is zero, negative, or not a finite
number. A zero time makes velocity undefined. A zero distance makes the split meaningless.
The calculator still computes a result for display purposes, but the caller should treat any
flagged split as untrustworthy until fixed.

### What this is not

This is a single session split analysis, not a validated sports science model. It has no
concept of wind, surface, timing method (laser gate vs hand time), or measurement error. It
assumes the distances and times you enter are correct. Peak power specifically is a field
estimate that assumes a maximal, well-paced effort on a flat surface, and it is highly
sensitive to first-split timing precision; a mistimed or rolling start will distort it more
than any other input error. Treat its output as a coach's discussion starter, not a
diagnostic or a prescription.

## File map

- `mph.ts`: the core math. `computeMph(splits)` returns the full profile. `validateSplits(splits)`
  returns a list of issues. `computeSpeedSignature(result)` derives the four Speed Signature
  axes. `DEFAULT_SPLITS` is the starter data shown on first load (a representative 40 yard dash).
- `mph.test.ts`: unit tests for the math, the validation, and the Speed Signature, using
  Vitest. Run with `npx vitest run mph.test.ts` if you have Vitest installed, or port the
  assertions to whatever test runner you prefer.
- `sprint-power.ts`: the Samozino sprint force-velocity-power estimate. `computeSprintPower(splits, bodyMassKg)`
  fits the velocity model and returns peak power in watts and watts per kilogram. Self-contained,
  no dependency on `mph.ts`'s types beyond a plain `{cumulativeTimeSeconds, cumulativeDistanceMeters}` shape.
- `sprint-power.test.ts`: unit tests, including a worked example checked against an expert-validated
  power range for a real 75kg sprinter's splits.
- `speed-radar.tsx`: a small, domain-agnostic radar/spider chart React component (hand-rolled
  SVG, no chart library). Takes arbitrary axes and optional reference overlays, so it is not
  specific to this tool.
- `mph-calculator.tsx`: the React component used on the live site. It is a client component
  built for Next.js with CSS Modules (`styles` import). It renders the split input cards, a
  hero band with the peak speed, the Speed Signature radar, a velocity curve chart, a
  collapsible full data table, a markdown report download, and the print quality PDF report
  overlay (`mph-report.module.css` holds its styles, including the `@media print` rules).
- `standalone/index.html`: a single file HTML and vanilla JavaScript version of the same
  tool, including peak power and the Speed Signature radar. No build step, no framework, no
  dependencies. Open it directly in a browser. The math inside is a direct, dependency free
  port of `mph.ts` and `sprint-power.ts`, kept in sync by hand, so if you change one you
  should change the other. It includes the same print quality PDF report overlay as the live
  tool, built with a print stylesheet inline in the file.

## Suggested prompts for extending this tool

- "Add a fifth default split for the 40 to 60 yard range and update the default data."
- "Change the units to metric only (meters, meters per second, kilometers per hour) and
  remove the yards and miles conversions."
- "Add a second athlete's splits so the tool can compare two profiles side by side on the
  same chart."
- "Port `mph.ts` and `mph.test.ts` to Python, keeping the same function names and the same
  test cases."
- "Explain, in plain language a parent could understand, what this athlete's numbers mean
  for their next few months of training." (Paste the athlete's actual splits after this
  prompt.)
- "Rebuild `standalone/index.html` as a Vue or Svelte single file component with the same
  behavior."

## A note on the live tool

The live version at richburnett.net/tools/mph-speed-calculator is free to use and does not
require an account. This bundle exists so a coach can also run the tool offline, audit the
math, or hand it to their own developer or AI assistant to adapt.
```

