Get ui logic into working state

This commit is contained in:
Dominic Ferrando
2025-09-19 14:29:03 -04:00
parent da21e4e988
commit 9bc7035d62
4 changed files with 240 additions and 155 deletions
+45 -39
View File
@@ -4751,7 +4751,7 @@
{ {
"metrics": { "metrics": {
"age": 10, "age": 10,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4765,7 +4765,7 @@
{ {
"metrics": { "metrics": {
"age": 15, "age": 15,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4779,7 +4779,7 @@
{ {
"metrics": { "metrics": {
"age": 20, "age": 20,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4793,7 +4793,7 @@
{ {
"metrics": { "metrics": {
"age": 25, "age": 25,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4807,7 +4807,7 @@
{ {
"metrics": { "metrics": {
"age": 30, "age": 30,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4821,7 +4821,7 @@
{ {
"metrics": { "metrics": {
"age": 35, "age": 35,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4835,7 +4835,7 @@
{ {
"metrics": { "metrics": {
"age": 40, "age": 40,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4849,7 +4849,7 @@
{ {
"metrics": { "metrics": {
"age": 45, "age": 45,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4863,7 +4863,7 @@
{ {
"metrics": { "metrics": {
"age": 50, "age": 50,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4877,7 +4877,7 @@
{ {
"metrics": { "metrics": {
"age": 55, "age": 55,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4891,7 +4891,7 @@
{ {
"metrics": { "metrics": {
"age": 60, "age": 60,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4905,7 +4905,7 @@
{ {
"metrics": { "metrics": {
"age": 65, "age": 65,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4919,7 +4919,7 @@
{ {
"metrics": { "metrics": {
"age": 70, "age": 70,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4933,7 +4933,7 @@
{ {
"metrics": { "metrics": {
"age": 75, "age": 75,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4947,7 +4947,7 @@
{ {
"metrics": { "metrics": {
"age": 80, "age": 80,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4961,7 +4961,7 @@
{ {
"metrics": { "metrics": {
"age": 85, "age": 85,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4975,7 +4975,7 @@
{ {
"metrics": { "metrics": {
"age": 90, "age": 90,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -4989,7 +4989,7 @@
{ {
"metrics": { "metrics": {
"age": 10, "age": 10,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5003,7 +5003,7 @@
{ {
"metrics": { "metrics": {
"age": 15, "age": 15,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5017,7 +5017,7 @@
{ {
"metrics": { "metrics": {
"age": 20, "age": 20,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5031,7 +5031,7 @@
{ {
"metrics": { "metrics": {
"age": 25, "age": 25,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5045,7 +5045,7 @@
{ {
"metrics": { "metrics": {
"age": 30, "age": 30,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5059,7 +5059,7 @@
{ {
"metrics": { "metrics": {
"age": 35, "age": 35,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5073,7 +5073,7 @@
{ {
"metrics": { "metrics": {
"age": 40, "age": 40,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5087,7 +5087,7 @@
{ {
"metrics": { "metrics": {
"age": 45, "age": 45,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5101,7 +5101,7 @@
{ {
"metrics": { "metrics": {
"age": 50, "age": 50,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5115,7 +5115,7 @@
{ {
"metrics": { "metrics": {
"age": 55, "age": 55,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5129,7 +5129,7 @@
{ {
"metrics": { "metrics": {
"age": 60, "age": 60,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5143,7 +5143,7 @@
{ {
"metrics": { "metrics": {
"age": 65, "age": 65,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5157,7 +5157,7 @@
{ {
"metrics": { "metrics": {
"age": 70, "age": 70,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5171,7 +5171,7 @@
{ {
"metrics": { "metrics": {
"age": 75, "age": 75,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5185,7 +5185,7 @@
{ {
"metrics": { "metrics": {
"age": 80, "age": 80,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5199,7 +5199,7 @@
{ {
"metrics": { "metrics": {
"age": 85, "age": 85,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5213,7 +5213,7 @@
{ {
"metrics": { "metrics": {
"age": 90, "age": 90,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5230,6 +5230,12 @@
"metadata": { "metadata": {
"attribute": "Power", "attribute": "Power",
"generators": [ "generators": [
{
"metric": "age",
"spread": 4,
"step": 10,
"ratio": "inverse"
},
{ {
"metric": "weight", "metric": "weight",
"spread": 3, "spread": 3,
@@ -5243,8 +5249,8 @@
"standards": [ "standards": [
{ {
"metrics": { "metrics": {
"age": 18, "age": 20,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -5258,7 +5264,7 @@
{ {
"metrics": { "metrics": {
"age": 18, "age": 18,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
@@ -5289,7 +5295,7 @@
{ {
"metrics": { "metrics": {
"age": 18, "age": 18,
"weight": -1, "weight": 0,
"gender": "Male" "gender": "Male"
}, },
"levels": { "levels": {
@@ -5303,7 +5309,7 @@
{ {
"metrics": { "metrics": {
"age": 18, "age": 18,
"weight": -1, "weight": 0,
"gender": "Female" "gender": "Female"
}, },
"levels": { "levels": {
+86 -12
View File
@@ -1,4 +1,4 @@
import { Activity, Attribute, Gender, getAvgWeight, lbToKg, type ActivityPerformance, type Player, type StandardUnit } from "./util"; import { Activity, Attribute, Gender, getAvgWeight, kgToLb, lbToKg, type ActivityPerformance, type Player, type StandardUnit } from "./util";
// SOURCES // SOURCES
// Squat, Bench, Dead Lift: // Squat, Bench, Dead Lift:
@@ -182,7 +182,7 @@ export class LevelCalculator {
} }
} }
type StandardsConfig = { export type StandardsConfig = {
maxLevel: number maxLevel: number
} }
@@ -210,7 +210,55 @@ export class Standards {
const ageGenerators = allGenerators.filter(g => g.metric === "age"); const ageGenerators = allGenerators.filter(g => g.metric === "age");
for (const ageGenerator of ageGenerators) { for (const ageGenerator of ageGenerators) {
// TODO for (const gender of Object.values(Gender)) {
const referenceAge = 50;
const skewRatio = 0; // 0 = normal, 1 = max skew
const minAge = referenceAge - (ageGenerator.step * ageGenerator.spread * (1 - skewRatio));
const maxAge = referenceAge + (ageGenerator.step * ageGenerator.spread * (1 + skewRatio));
const referenceStandard = this
.byActivity(activity)
.byMetrics({ weight: getAvgWeight(gender, referenceAge), gender, age: referenceAge })
.getOneInterpolated();
let currAge = minAge;
while (currAge <= maxAge) {
const newStandard: Standard = {
metrics: {
weight: getAvgWeight(gender, currAge),
age: currAge,
gender
},
levels: {}
};
// apply allometric scaling to generate levels
for (const lvl in referenceStandard.levels) {
if (!referenceStandard.levels[lvl]) continue;
const scalingExponent = .1;
const coefficient = ageGenerator.ratio === "inverse" ?
referenceAge / currAge :
currAge / referenceAge;
newStandard.levels[lvl] = referenceStandard.levels[lvl] * (coefficient ** scalingExponent);
}
// prefer real data over generated data
const overlappingStandard = this
.byActivity(activity)
.byMetrics(newStandard.metrics)
.getOne();
if (!overlappingStandard)
this.activityStandards[activity].standards.push(newStandard);
currAge += ageGenerator.step;
}
}
// now that we generated metric data, remove the old data
this.activityStandards[activity].standards = this.activityStandards[activity].standards
.filter(s => s.metrics.weight)
} }
const weightGenerators = allGenerators.filter(g => g.metric === "weight"); const weightGenerators = allGenerators.filter(g => g.metric === "weight");
@@ -247,6 +295,7 @@ export class Standards {
const coefficient = weightGenerator.ratio === "inverse" ? const coefficient = weightGenerator.ratio === "inverse" ?
referenceWeight / currWeight : referenceWeight / currWeight :
currWeight / referenceWeight; currWeight / referenceWeight;
newStandard.levels[lvl] = referenceStandard.levels[lvl] * (coefficient ** scalingExponent); newStandard.levels[lvl] = referenceStandard.levels[lvl] * (coefficient ** scalingExponent);
} }
@@ -265,9 +314,19 @@ export class Standards {
// now that we generated metric data, remove the old data // now that we generated metric data, remove the old data
this.activityStandards[activity].standards = this.activityStandards[activity].standards this.activityStandards[activity].standards = this.activityStandards[activity].standards
.filter(s => s.metrics.weight !== -1); .filter(s => s.metrics.weight);
} }
this.activityStandards[activity].standards = this.activityStandards[activity].standards
.sort((a, b) => {
const genderOrder: Record<Gender, number> = { Male: 0, Female: 1 };
const gA = genderOrder[b.metrics.gender] ?? 99;
const gB = genderOrder[b.metrics.gender] ?? 99;
if (gA !== gB) return gA - gB;
if (a.metrics.age !== b.metrics.age)
return a.metrics.age - b.metrics.age;
return a.metrics.weight - b.metrics.weight;
});
} }
} }
@@ -324,6 +383,7 @@ export class Standards {
}; };
const interpolateByAgeAndWeight = (metrics: Metrics): Standard => { const interpolateByAgeAndWeight = (metrics: Metrics): Standard => {
console.log(metrics);
const interpolateByWeight = (targetAge: number): Levels => { const interpolateByWeight = (targetAge: number): Levels => {
const { lower: lowerAgeWeightStandard, upper: upperAgeWeightStandard } = this const { lower: lowerAgeWeightStandard, upper: upperAgeWeightStandard } = this
.byActivity(activity) .byActivity(activity)
@@ -367,10 +427,15 @@ export class Standards {
} }
}; };
const getAllInterpolated = (): Standard[] => { const getAllInterpolated = (opt?: { normalizeForLb?: boolean }): Standard[] => {
const metrics: Metrics = state as Metrics; const metrics: Metrics = state as Metrics;
const interpolatedStandards: Standard[] = this.weightsFor(activity, metrics) let weights = this.weightsFor(activity, metrics.gender, metrics.age);
if (opt?.normalizeForLb) {
weights = weights.map(w => lbToKg(Math.round(kgToLb(w))));
}
const interpolatedStandards: Standard[] = weights
.map(weight => interpolateByAgeAndWeight({ ...metrics, weight })); .map(weight => interpolateByAgeAndWeight({ ...metrics, weight }));
return interpolatedStandards; return interpolatedStandards;
@@ -420,17 +485,26 @@ export class Standards {
return activities; return activities;
} }
public weightsFor(activity: Activity, metrics: Metrics) { // TODO: turn these into queries?
const { lower } = this public weightsFor(activity: Activity, gender: Gender, age: number) {
const { lower, upper } = this
.byActivity(activity) .byActivity(activity)
.byGender(metrics.gender) .byGender(gender)
.getNearest("age", metrics.age); .getNearest("age", age);
const lowerDiff = Math.abs(lower.metrics.age - age);
const upperDiff = Math.abs(upper.metrics.age - age);
const closestAge = lowerDiff < upperDiff ?
lower.metrics.age :
upper.metrics.age;
const weights = this const weights = this
.byActivity(activity) .byActivity(activity)
.byGender(metrics.gender) .byGender(gender)
.byAge(lower.metrics.age) .byAge(closestAge)
.getAll().map((s) => s.metrics.weight); .getAll().map((s) => s.metrics.weight);
console.log({ age, closestAge, weights });
return weights; return weights;
} }
+5 -6
View File
@@ -1,4 +1,6 @@
import { Activity, feetToCm, Gender, getAvgWeight, inchesToCm, kgToLb, lbToKg, minToMs, secToMs, type ActivityPerformance, type Player } from "$lib/services/calculator/util"; import { Standards, type ActivityStandards } from "$lib/services/calculator/main";
import { Activity, ftToCm, Gender, getAvgWeight, inToCm, kgToLb, lbToKg, minToMs, secToMs, type ActivityPerformance, type Player } from "$lib/services/calculator/util";
import allStandardsRaw from "$lib/data/standards.json" assert { type: "json" };
const player: Player = { const player: Player = {
metrics: { metrics: {
@@ -25,7 +27,7 @@ const computedPerformances: ActivityPerformance[] = [
// POWER // POWER
{ {
activity: Activity.BroadJump, activity: Activity.BroadJump,
performance: feetToCm(105) + inchesToCm(5), performance: ftToCm(105) + inToCm(5),
}, },
// ENDURANCE // ENDURANCE
{ {
@@ -39,10 +41,7 @@ const computedPerformances: ActivityPerformance[] = [
}, },
]; ];
for (let age = 15; age < 80; ++age) { const standards = new Standards(allStandardsRaw as ActivityStandards, { maxLevel: 10 });
const avgWeightKG = getAvgWeight(Gender.Female, age);
console.log(`${age}: ${kgToLb(avgWeightKG).toFixed(1)}`);
}
// const newStandards: Standards = { // const newStandards: Standards = {
// "Back Squat": [], // "Back Squat": [],
+104 -98
View File
@@ -2,7 +2,9 @@
import { import {
Standards, Standards,
type ActivityStandards, type ActivityStandards,
type Metrics,
type Standard, type Standard,
type StandardsConfig,
} from "$lib/services/calculator/main"; } from "$lib/services/calculator/main";
import allStandardsRaw from "$lib/data/standards.json" assert { type: "json" }; import allStandardsRaw from "$lib/data/standards.json" assert { type: "json" };
import { import {
@@ -15,21 +17,39 @@
range, range,
} from "$lib/services/calculator/util"; } from "$lib/services/calculator/util";
const selected = $state({ const input = $state({
activity: Activity.BenchPress, activity: Activity.BenchPress,
gender: Gender.Male, metrics: {
age: 18, gender: Gender.Male,
weight: 0, age: "",
maxLevel: 5, weight: "",
},
cfg: {
maxLevel: "5",
},
});
const selected = $derived({
activity: input.activity,
metrics: {
gender: input.metrics.gender,
age: +input.metrics.age,
weight: lbToKg(+input.metrics.weight),
} as Metrics,
cfg: {
maxLevel: +input.cfg.maxLevel,
} as StandardsConfig,
});
$effect(() => {
if (!input.metrics.age) input.metrics.weight = "";
}); });
const allStandards = $derived( const allStandards = $derived(
new Standards(allStandardsRaw as ActivityStandards, { new Standards(allStandardsRaw as ActivityStandards, selected.cfg),
maxLevel: selected.maxLevel,
}),
); );
const getFormattedLevelValue = ( const getLvlValue = (
activity: Activity, activity: Activity,
standard: Standard, standard: Standard,
lvl: number, lvl: number,
@@ -55,13 +75,12 @@
<div class="toolbar"> <div class="toolbar">
<fieldset class="group"> <fieldset class="group">
<legend>Metrics</legend> <legend>Metrics</legend>
<label class="field"> <label class="field">
<span>Activity</span> <span>Activity</span>
<select <select
class="select" class="select"
name="activities" name="activities"
bind:value={selected.activity} bind:value={input.activity}
> >
{#each Object.values(Activity) as activity} {#each Object.values(Activity) as activity}
<option value={activity} <option value={activity}
@@ -78,7 +97,7 @@
<select <select
class="select" class="select"
name="genders" name="genders"
bind:value={selected.gender} bind:value={input.metrics.gender}
> >
{#each Object.values(Gender) as gender} {#each Object.values(Gender) as gender}
<option value={gender}>{gender}</option> <option value={gender}>{gender}</option>
@@ -93,19 +112,23 @@
min="0" min="0"
max="100" max="100"
placeholder="18" placeholder="18"
bind:value={selected.age} bind:value={input.metrics.age}
/> />
</label> </label>
<label class="field w-24"> <label class="field w-24">
<span>Weight (lb)</span> <span>Weight (lb)</span>
<input {#if selected.metrics.age}
type="number" <input
min="0" type="number"
max="600" min="0"
placeholder="170" max="600"
bind:value={selected.weight} placeholder="170"
/> bind:value={input.metrics.weight}
/>
{:else}
<p style="margin: 0;">Requires age</p>
{/if}
</label> </label>
</fieldset> </fieldset>
@@ -119,94 +142,77 @@
min="1" min="1"
max="100" max="100"
placeholder="5" placeholder="5"
bind:value={selected.maxLevel} bind:value={input.cfg.maxLevel}
/> />
</label> </label>
</fieldset> </fieldset>
</div> </div>
{#if !selected.gender || !selected.age} <h1>{allStandards.byActivity(selected.activity).getMetadata().name}</h1>
<p>Not enough info entered</p> <section class="gender-section">
{:else} <h2>{selected.metrics.gender}</h2>
{@render standardsTable( {#if !selected.metrics.age}
selected.gender, {#each allStandards.agesFor(selected.activity, selected.metrics.gender) as age}
selected.age, {@render standardsTable(selected.activity, {
selected.weight, ...selected.metrics,
)} age,
{/if} })}
{/each}
{:else}
{@render standardsTable(selected.activity, selected.metrics)}
{/if}
</section>
</div> </div>
</div> </div>
{#snippet standardsTable(gender: Gender, age: number, weight: number)} {#snippet standardsTable(activity: Activity, metrics: Metrics)}
<section class="gender-section"> {#snippet standardsTableRow(standard: Standard)}
<div class="age-section"> <tr>
<div class="standards-table"> <th scope="row"
<div class="scroll"> >{parseFloat(kgToLb(standard.metrics.weight).toFixed(2))}</th
<table> >
<thead> {#each range(selected.cfg.maxLevel) as lvl}
<tr> <td>{getLvlValue(activity, standard, lvl)}</td>
<th class="levels-th">Body weight</th> {/each}
{#each range(selected.maxLevel) as lvl} </tr>
<th class="levels-th">{lvl}</th> {/snippet}
{/each} <div class="age-section">
</tr> <h3>{metrics.age}</h3>
</thead> <div class="standards-table">
<tbody> <div class="scroll">
{#if weight} <table>
{@const standard = allStandards <thead>
.byActivity(selected.activity) <tr>
.byGender(gender) <th class="levels-th">Body weight</th>
.byAge(age) {#each range(selected.cfg.maxLevel) as lvl}
.byWeight(lbToKg(weight)) <th class="levels-th">{lvl}</th>
.getOneInterpolated()} {/each}
</tr>
<tr> </thead>
<th scope="row" <tbody>
>{Math.round( {#if metrics.weight}
kgToLb(standard.metrics.weight), {@const standard = allStandards
)}</th .byActivity(activity)
> .byGender(metrics.gender)
{#each range(selected.maxLevel) as lvl} .byAge(metrics.age)
<td .byWeight(metrics.weight)
>{getFormattedLevelValue( .getOneInterpolated()}
selected.activity, {@render standardsTableRow(standard)}
standard, {:else}
lvl, {@const standards = allStandards
)}</td .byActivity(activity)
> .byGender(metrics.gender)
{/each} .byAge(metrics.age)
</tr> .getAllInterpolated({ normalizeForLb: true })}
{:else} {#each standards as standard}
{@const standards = allStandards {@render standardsTableRow(standard)}
.byActivity(selected.activity) {/each}
.byGender(gender) {/if}
.byAge(age) </tbody>
.getAllInterpolated()} </table>
{#each standards as standard}
<tr>
<th scope="row"
>{Math.round(
kgToLb(standard.metrics.weight),
)}</th
>
{#each range(allStandards.cfg.maxLevel) as lvl}
<td
>{getFormattedLevelValue(
selected.activity,
standard,
lvl,
)}</td
>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</section> </div>
{/snippet} {/snippet}
<style> <style>