Add calculator route
This commit is contained in:
@@ -0,0 +1,256 @@
|
|||||||
|
import { activities, Activity, Attribute, attributes, BenchmarkPerformance, findNearestPoints, Gender, getActivityAttribute, kgToLb, Levels, msToTime, StandardsItem } from "./util";
|
||||||
|
import rawStandards from "./standards.json" assert { type: "json" }
|
||||||
|
import { which } from "bun";
|
||||||
|
|
||||||
|
const standards = rawStandards as StandardsItem[];
|
||||||
|
|
||||||
|
// SOURCES
|
||||||
|
// Squat, Bench, Dead Lift:
|
||||||
|
// http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf
|
||||||
|
// 1 mile run:
|
||||||
|
// https://runninglevel.com/running-times/1-mile-times
|
||||||
|
// Dash:
|
||||||
|
// https://marathonhandbook.com/average-100-meter-time/
|
||||||
|
// Broad Jump:
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Level calculations
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
export const calcBenchmarkLevel = (performance: number, levels: Record<number, number>): number => {
|
||||||
|
let playerLevel = 1;
|
||||||
|
let playerLevelDiff = Infinity;
|
||||||
|
|
||||||
|
for (const level in levels) {
|
||||||
|
const diff = Math.abs(performance - levels[level as unknown as number]);
|
||||||
|
if (diff < playerLevelDiff) {
|
||||||
|
playerLevel = +level;
|
||||||
|
playerLevelDiff = diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calcAttributeLevel = (
|
||||||
|
attribute: Attribute,
|
||||||
|
benchmarkPerformances: BenchmarkPerformance[],
|
||||||
|
): number => {
|
||||||
|
if (benchmarkPerformances.some((bp) => getActivityAttribute(bp.activity) !== attribute)) {
|
||||||
|
throw new Error("Wrong benchmark performance attribute");
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarkLevels = benchmarkPerformances.map((abp) =>
|
||||||
|
calcBenchmarkLevel(abp.performance, abp.levels),
|
||||||
|
);
|
||||||
|
|
||||||
|
const benchmarkLevelsAvg = Math.round(
|
||||||
|
benchmarkLevels.reduce((sum, curr) => sum + curr, 0) / benchmarkLevels.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
return benchmarkLevelsAvg;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calcAttributeLevels = (
|
||||||
|
benchmarkPerformances: BenchmarkPerformance[],
|
||||||
|
): Record<Attribute, number> => {
|
||||||
|
const attrLevels: Record<Attribute, number> = {
|
||||||
|
[attributes.STRENGTH]: 1,
|
||||||
|
[attributes.POWER]: 1,
|
||||||
|
[attributes.ENDURANCE]: 1,
|
||||||
|
[attributes.SPEED]: 1,
|
||||||
|
[attributes.AGILITY]: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
(Object.values(attributes) as Attribute[]).forEach((attribute) => {
|
||||||
|
const attrBenchmarkPerformances = benchmarkPerformances.filter(
|
||||||
|
(bp) => getActivityAttribute(bp.activity) === attribute,
|
||||||
|
);
|
||||||
|
attrLevels[attribute] = calcAttributeLevel(attribute, attrBenchmarkPerformances);
|
||||||
|
});
|
||||||
|
|
||||||
|
return attrLevels;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calcPlayerLevel = (benchmarkPerformances: BenchmarkPerformance[]): number => {
|
||||||
|
const attrLevels = calcAttributeLevels(benchmarkPerformances);
|
||||||
|
const attrLevelsSum = Object.values(attrLevels).reduce((sum, lvl) => sum + lvl, 0);
|
||||||
|
return Math.round(attrLevelsSum / Object.keys(attributes).length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Presentation helpers
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
export const formatLevelValues = (
|
||||||
|
levels: Record<number, number>,
|
||||||
|
activity: Activity,
|
||||||
|
): Record<number, string> => {
|
||||||
|
const timedActivities: Activity[] = [activities.RUN, activities.DASH, activities.CONE_DRILL];
|
||||||
|
|
||||||
|
const formattedLevels: Record<number, string> = {};
|
||||||
|
for (const level in levels) {
|
||||||
|
const lvl = +level as number;
|
||||||
|
let displayValue = "";
|
||||||
|
if (timedActivities.includes(activity)) {
|
||||||
|
displayValue = msToTime(levels[lvl]);
|
||||||
|
} else if (activity === activities.TREADMILL_DASH) {
|
||||||
|
displayValue = `${(levels[lvl] * 1000).toFixed(2)} m/s`;
|
||||||
|
} else if (activity === activities.BROAD_JUMP) {
|
||||||
|
displayValue = `${(levels[lvl] * 0.0328084).toFixed(2)} ft.`;
|
||||||
|
} else {
|
||||||
|
displayValue = kgToLb(levels[lvl]).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedLevels[lvl] = displayValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedLevels;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Level utilities
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
const compressLevels = (
|
||||||
|
levels: Record<number, number>,
|
||||||
|
targetLevelsAmount: number,
|
||||||
|
): Record<number, number> => {
|
||||||
|
const levelsAmount = Object.keys(levels).length;
|
||||||
|
if (levelsAmount < targetLevelsAmount) {
|
||||||
|
throw new Error(
|
||||||
|
"Target levels amount must be greater than or equal to the current levels amount",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = levelsAmount / targetLevelsAmount;
|
||||||
|
const compressedLevels: Record<number, number> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < targetLevelsAmount; ++i) {
|
||||||
|
const ratioIndex = i * ratio;
|
||||||
|
const lowerIndex = Math.floor(ratioIndex);
|
||||||
|
const upperIndex = Math.ceil(ratioIndex);
|
||||||
|
|
||||||
|
const lowerValue = levels[lowerIndex + 1];
|
||||||
|
const upperValue = levels[upperIndex + 1];
|
||||||
|
|
||||||
|
const weight = ratioIndex - lowerIndex;
|
||||||
|
compressedLevels[i + 1] = lowerValue + (upperValue - lowerValue) * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressedLevels;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandLevels = (
|
||||||
|
levels: Levels | Record<number, number>,
|
||||||
|
i: number
|
||||||
|
): Record<number, number> => {
|
||||||
|
if (i == 0) {
|
||||||
|
// ensure we always use number keys
|
||||||
|
const newLevels = {};
|
||||||
|
for (let k = 0; k < Object.keys(levels).length; ++k) {
|
||||||
|
newLevels[k + 1] = levels[Object.keys(levels)[k]];
|
||||||
|
}
|
||||||
|
return newLevels;
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLevels = {};
|
||||||
|
let j = 1;
|
||||||
|
for (let k = 0; k < Object.keys(levels).length; ++k) {
|
||||||
|
const currLevel = Object.keys(levels)[k];
|
||||||
|
const nextLevel = Object.keys(levels)[k + 1];
|
||||||
|
|
||||||
|
newLevels[j++] = levels[currLevel];
|
||||||
|
|
||||||
|
if (nextLevel) {
|
||||||
|
newLevels[j++] = (levels[currLevel] + levels[nextLevel]) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandLevels(newLevels, i - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Main level function
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
export const computeLevels = (
|
||||||
|
age: number,
|
||||||
|
weightKG: number,
|
||||||
|
gender: Gender,
|
||||||
|
activity: Activity,
|
||||||
|
) => {
|
||||||
|
const levels = calculateLevels(age, weightKG, gender, activity, standards);
|
||||||
|
if (levels) {
|
||||||
|
const expandedLevels = expandLevels(levels, 5);
|
||||||
|
const compressedLevels = compressLevels(expandedLevels, 100);
|
||||||
|
return compressedLevels;
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Standards interpolation (heavy math bits)
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
const calculateLevels = (
|
||||||
|
age: number,
|
||||||
|
weightKG: number,
|
||||||
|
gender: Gender,
|
||||||
|
activity: Activity,
|
||||||
|
standards: StandardsItem[],
|
||||||
|
): Levels | null => {
|
||||||
|
// Filter dataset
|
||||||
|
const filtered = standards.filter(
|
||||||
|
(item) => item.gender === gender && item.activityType === activity,
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
// Age interpolation
|
||||||
|
const ageArray = [...new Set(filtered.map((s) => s.age))].sort((a, b) => a - b);
|
||||||
|
const { lower: ageLower, upper: ageUpper } = findNearestPoints(age, ageArray);
|
||||||
|
|
||||||
|
const lowerValues = interpolateByBodyWeight(ageLower, weightKG, filtered);
|
||||||
|
const upperValues = interpolateByBodyWeight(ageUpper, weightKG, filtered);
|
||||||
|
if (!lowerValues || !upperValues) return null;
|
||||||
|
|
||||||
|
let ageRatio = ageUpper === ageLower ? 1 : (age - ageLower) / (ageUpper - ageLower);
|
||||||
|
ageRatio = Math.max(0, Math.min(1, ageRatio));
|
||||||
|
|
||||||
|
return interpolateStandardsValues(lowerValues, upperValues, ageRatio);
|
||||||
|
};
|
||||||
|
|
||||||
|
const interpolateByBodyWeight = (
|
||||||
|
agePoint: number,
|
||||||
|
bodyWeightKG: number,
|
||||||
|
filtered: StandardsItem[],
|
||||||
|
): Levels | null => {
|
||||||
|
const ageFiltered = filtered.filter((item) => item.age === agePoint);
|
||||||
|
if (ageFiltered[0].bodyWeight === -1) return ageFiltered[0];
|
||||||
|
|
||||||
|
const weightArray = [...new Set(ageFiltered.map((obj) => obj.bodyWeight))].sort((a, b) => a - b);
|
||||||
|
const { lower, upper } = findNearestPoints(bodyWeightKG, weightArray);
|
||||||
|
|
||||||
|
const weightLower = ageFiltered.find((i) => i.bodyWeight === lower);
|
||||||
|
const weightUpper = ageFiltered.find((i) => i.bodyWeight === upper);
|
||||||
|
if (!weightLower || !weightUpper) return null;
|
||||||
|
|
||||||
|
let weightRatio = upper === lower ? 1 : (bodyWeightKG - lower) / (upper - lower);
|
||||||
|
weightRatio = Math.max(0, Math.min(1, weightRatio));
|
||||||
|
|
||||||
|
return interpolateStandardsValues(weightLower, weightUpper, weightRatio);
|
||||||
|
};
|
||||||
|
|
||||||
|
const interpolateStandardsValues = (
|
||||||
|
lower: Levels,
|
||||||
|
upper: Levels,
|
||||||
|
ratio: number,
|
||||||
|
): Levels => {
|
||||||
|
const lerp = (k: keyof Levels): number =>
|
||||||
|
(lower[k] as number) + ((upper[k] as number) - (lower[k] as number)) * ratio;
|
||||||
|
|
||||||
|
return {
|
||||||
|
physicallyActive: lerp("physicallyActive"),
|
||||||
|
beginner: lerp("beginner"),
|
||||||
|
intermediate: lerp("intermediate"),
|
||||||
|
advanced: lerp("advanced"),
|
||||||
|
elite: lerp("elite"),
|
||||||
|
};
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
|||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Enumerations & basic value objects
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
export const activities = Object.freeze({
|
||||||
|
BACK_SQUAT: "Back Squat",
|
||||||
|
DEADLIFT: "Deadlift",
|
||||||
|
BENCH_PRESS: "Bench Press",
|
||||||
|
RUN: "Run",
|
||||||
|
DASH: "Dash",
|
||||||
|
TREADMILL_DASH: "Treadmill Dash",
|
||||||
|
BROAD_JUMP: "Broad Jump",
|
||||||
|
CONE_DRILL: "Cone Drill",
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export type Activity = typeof activities[keyof typeof activities];
|
||||||
|
|
||||||
|
export const genders = Object.freeze({
|
||||||
|
MALE: "male",
|
||||||
|
FEMALE: "female",
|
||||||
|
} as const);
|
||||||
|
export type Gender = typeof genders[keyof typeof genders];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Attributes
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const attributes = Object.freeze({
|
||||||
|
STRENGTH: "Strength",
|
||||||
|
POWER: "Power",
|
||||||
|
ENDURANCE: "Endurance",
|
||||||
|
SPEED: "Speed",
|
||||||
|
AGILITY: "Agility",
|
||||||
|
} as const);
|
||||||
|
export type Attribute = typeof attributes[keyof typeof attributes];
|
||||||
|
|
||||||
|
export const getActivityAttribute = (activity: Activity): Attribute => {
|
||||||
|
const activityAttrMap: Record<Activity, Attribute> = {
|
||||||
|
[activities.RUN]: attributes.ENDURANCE,
|
||||||
|
[activities.TREADMILL_DASH]: attributes.SPEED,
|
||||||
|
[activities.DASH]: attributes.SPEED,
|
||||||
|
[activities.DEADLIFT]: attributes.STRENGTH,
|
||||||
|
[activities.BACK_SQUAT]: attributes.STRENGTH,
|
||||||
|
[activities.BENCH_PRESS]: attributes.STRENGTH,
|
||||||
|
[activities.BROAD_JUMP]: attributes.POWER,
|
||||||
|
[activities.CONE_DRILL]: attributes.AGILITY,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const attribute = activityAttrMap[activity];
|
||||||
|
if (!attribute) throw new Error(`${activity} does not have an associated attribute`);
|
||||||
|
return attribute;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Data model interfaces
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
export interface BenchmarkPerformance {
|
||||||
|
activity: Activity;
|
||||||
|
performance: number;
|
||||||
|
/**
|
||||||
|
* Key: level number, Value: performance value (kg, ms, m/s, etc.)
|
||||||
|
*/
|
||||||
|
levels: Record<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Levels {
|
||||||
|
/** KG or -1 for body‑weight‑agnostic entries */
|
||||||
|
physicallyActive: number;
|
||||||
|
beginner: number;
|
||||||
|
intermediate: number;
|
||||||
|
advanced: number;
|
||||||
|
elite: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandardsItem extends Levels {
|
||||||
|
bodyWeight: number;
|
||||||
|
gender: Gender;
|
||||||
|
activityType: Activity;
|
||||||
|
age: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Conversion utilities
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
export const lbToKg = (lb: number | string): number => +lb * 0.453592;
|
||||||
|
export const kgToLb = (kg: number | string): number => +kg * 2.20462;
|
||||||
|
|
||||||
|
export const feetToCm = (feet: number): number => feet * 30.48;
|
||||||
|
|
||||||
|
export const mphToMtrsPerMs = (mph: number): number => (mph * 0.44704) / 1000;
|
||||||
|
|
||||||
|
export const timeToMs = (time: string): number => {
|
||||||
|
const [main, decimal = "0"] = time.split(".");
|
||||||
|
const milliseconds = Number(decimal.padEnd(3, "0").slice(0, 3));
|
||||||
|
|
||||||
|
const parts = main.split(":").map(Number);
|
||||||
|
let minutes = 0,
|
||||||
|
seconds = 0;
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
seconds = parts[0];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
[minutes, seconds] = parts as [number, number];
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid time format");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (minutes * 60 + seconds) * 1000 + milliseconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const msToTime = (ms: number, includeMs = false): string => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
const milliseconds = Math.floor(ms % 1000);
|
||||||
|
|
||||||
|
let formattedTime = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
if (includeMs) {
|
||||||
|
formattedTime += `.${milliseconds.toString().padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
return formattedTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Misc. utilities
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const findNearestPoints = (value: number, arr: number[]): { lower: number; upper: number } => {
|
||||||
|
if (arr.length === 1) return { lower: arr[0], upper: arr[0] };
|
||||||
|
|
||||||
|
if (value <= arr[0]) return { lower: arr[0], upper: arr[1] };
|
||||||
|
if (value > arr[arr.length - 1])
|
||||||
|
return { lower: arr[arr.length - 2], upper: arr[arr.length - 1] };
|
||||||
|
|
||||||
|
for (let i = 1; i < arr.length; i++) {
|
||||||
|
if (arr[i] >= value) return { lower: arr[i - 1], upper: arr[i] };
|
||||||
|
}
|
||||||
|
// Fallback, though logic should always return above
|
||||||
|
return { lower: arr[0], upper: arr[0] };
|
||||||
|
};
|
||||||
|
|
||||||
Binary file not shown.
@@ -1,12 +1,50 @@
|
|||||||
|
import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts";
|
||||||
|
import { activities, Activity, BenchmarkPerformance, Gender, genders } from "./calculator/util.ts";
|
||||||
import { productRecords } from "./data/product-records.ts";
|
import { productRecords } from "./data/product-records.ts";
|
||||||
import { Printful } from "./printful.ts"
|
import { Printful } from "./printful.ts"
|
||||||
import { Webflow } from "./webflow.ts";
|
import { Webflow } from "./webflow.ts";
|
||||||
|
|
||||||
|
interface CalcRequest {
|
||||||
|
player: {
|
||||||
|
age: number,
|
||||||
|
weightKG: number,
|
||||||
|
gender: Gender
|
||||||
|
}
|
||||||
|
performances: {
|
||||||
|
activity: Activity,
|
||||||
|
performance: number,
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
routes: {
|
routes: {
|
||||||
"/test": {
|
"/calc": {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
return Response.json("Hello world");
|
const calcReq: CalcRequest = await req.json();
|
||||||
|
const { player, performances } = calcReq;
|
||||||
|
|
||||||
|
console.log(calcReq);
|
||||||
|
|
||||||
|
const computedPerformances: BenchmarkPerformance[] = [];
|
||||||
|
for (const p of performances) {
|
||||||
|
computedPerformances.push({
|
||||||
|
activity: p.activity,
|
||||||
|
performance: p.performance,
|
||||||
|
levels: computeLevels(
|
||||||
|
player.age,
|
||||||
|
player.weightKG,
|
||||||
|
player.gender,
|
||||||
|
p.activity
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
levels: {
|
||||||
|
attributes: calcAttributeLevels(computedPerformances),
|
||||||
|
player: calcPlayerLevel(computedPerformances)
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/webhook/printful": {
|
"/webhook/printful": {
|
||||||
@@ -41,7 +79,7 @@ const server = Bun.serve({
|
|||||||
Response.error();
|
Response.error();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json("Hello world");
|
return Response.json("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user