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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions scripts/verify-pie-progress.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Verifies hub % matches weighted slice fill for representative progress mixes.
* Run: node scripts/verify-pie-progress.mjs
*/
import {
capturedFillRatio,
hubCapturedPercent,
weightedCapturedRatio,
} from "../src/lib/pie-progress.ts";

/** Real deck sizes (Basics is larger than the other lifecycle slices). */
const DECK = [
{ id: "basics", total: 50 },
{ id: "find", total: 22 },
{ id: "shape", total: 22 },
{ id: "bid", total: 22 },
{ id: "vehicle", total: 22 },
{ id: "team", total: 22 },
{ id: "propose", total: 22 },
{ id: "win", total: 22 },
];

function slice(total, cleared, { mastered = cleared, learning = 0 } = {}) {
const ratio = total === 0 ? 0 : cleared / total;
return {
cleared,
total,
ratio,
masteredRatio: total === 0 ? 0 : mastered / total,
learningRatio: total === 0 ? 0 : learning / total,
};
}

function scenario(name, clearedById) {
const slices = DECK.map(({ id, total }) => {
const cleared = clearedById[id] ?? 0;
// Split: first clear = learning, second+ = mastered (approx for fill layers).
const learning = Math.min(cleared, Math.ceil(cleared * 0.3));
const mastered = cleared - learning;
return slice(total, cleared, { mastered, learning });
});

const hub = hubCapturedPercent(slices);
const weighted = weightedCapturedRatio(slices);
const weightedPct = Math.round(weighted * 100);
const fillMatchesRatio = slices.every(
(s) => Math.abs(capturedFillRatio(s) - s.ratio) < 1e-9,
);

const ok = hub === weightedPct && fillMatchesRatio;
const detail = DECK.map(({ id, total }) => {
const c = clearedById[id] ?? 0;
const pct = total ? Math.round((c / total) * 100) : 0;
const angle = Math.round((total / DECK.reduce((n, d) => n + d.total, 0)) * 100);
return `${id} ${pct}% of slice (angle ~${angle}% of wheel, ${c}/${total})`;
}).join("; ");

console.log(`${ok ? "PASS" : "FAIL"} ${name}`);
console.log(` hub=${hub}% weightedArea=${weightedPct}% fill≡ratio=${fillMatchesRatio}`);
console.log(` ${detail}`);
if (!ok) process.exitCode = 1;
return ok;
}

scenario("empty", {});
scenario("user-like ~20% (Basics heavy)", { basics: 32, vehicle: 8 });
scenario("Basics 80% only", { basics: 40 });
scenario("Vehicle 100% only", { vehicle: 22 });
scenario("every slice 50%", Object.fromEntries(DECK.map((d) => [d.id, Math.floor(d.total / 2)])));
scenario("full capture", Object.fromEntries(DECK.map((d) => [d.id, d.total])));
scenario("thin progress across many slices", {
basics: 5,
find: 5,
shape: 5,
bid: 5,
vehicle: 5,
team: 5,
propose: 5,
win: 5,
});
scenario("one slice fully mastered look (learning+mastered layers)", {
basics: 50, // all cleared — fill uses mastered+learning ratios in UI
});

if (!process.exitCode) console.log("\nAll pie-progress scenarios passed.");
2 changes: 1 addition & 1 deletion src/components/app/PieHome.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
</header>

<div class="mt-4 sm:mt-6">
<PieWheel {stats} hubPercent={game.masteryPercent} />
<PieWheel {stats} />
</div>

<div
Expand Down
43 changes: 29 additions & 14 deletions src/components/app/PieWheel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import { SVGRenderer } from "echarts/renderers";
import type { Attachment } from "svelte/attachments";

import {
capturedFillRatio,
hubCapturedPercent,
sliceAngleValue,
} from "$lib/pie-progress";
import type { UnitStats } from "$lib/types";

type Props = {
stats: UnitStats[];
/** Overall mastery shown in the ALEKS-style center hub. */
hubPercent: number;
};

type WheelOption = ComposeOption<PieSeriesOption>;
Expand All @@ -23,7 +26,10 @@

use([PieChart, LabelLayout, SVGRenderer]);

let { stats, hubPercent }: Props = $props();
let { stats }: Props = $props();

/** Same cleared/total math as the engine — derived from the slices on screen. */
let hubPercent = $derived(hubCapturedPercent(stats));

function clampRatio(ratio: number): number {
return Math.min(1, Math.max(0, ratio));
Expand All @@ -42,7 +48,22 @@
const masteredPct = Math.round(clampRatio(stat.masteredRatio) * 100);
const learningPct = Math.round(clampRatio(stat.learningRatio) * 100);
const capturedPct = Math.round(clampRatio(stat.ratio) * 100);
return `${stat.unit.label}: ${capturedPct}% captured (${masteredPct}% mastered, ${learningPct}% learning)`;
return `${stat.unit.label}: ${capturedPct}% of slice (${stat.cleared}/${stat.total} questions; ${masteredPct}% mastered, ${learningPct}% learning)`;
}

function sliceData(
currentStats: UnitStats[],
progressIndex: number | null,
color: string,
): NonNullable<PieSeriesOption["data"]> {
return currentStats.map((slice, sliceIndex) => ({
// Angle ∝ question count so filled area matches hub % captured.
value: sliceAngleValue(slice.total),
name: slice.unit.label,
itemStyle: {
color: progressIndex === null || sliceIndex === progressIndex ? color : "transparent",
},
}));
}

function sliceFill(
Expand All @@ -53,7 +74,7 @@
): PieSeriesOption | null {
const masteredRatio = clampRatio(stat.masteredRatio);
const learningRatio = clampRatio(stat.learningRatio);
const capturedRatio = clampRatio(masteredRatio + learningRatio);
const capturedRatio = capturedFillRatio(stat);
if (kind === "learning" && learningRatio === 0) return null;
if (kind === "mastered" && masteredRatio === 0) return null;

Expand All @@ -79,13 +100,7 @@
label: { show: false },
labelLine: { show: false },
emphasis: { disabled: true },
data: currentStats.map((slice, sliceIndex) => ({
value: 1,
name: slice.unit.label,
itemStyle: {
color: sliceIndex === progressIndex ? color : "transparent",
},
})),
data: sliceData(currentStats, progressIndex, color),
};
}

Expand All @@ -110,7 +125,7 @@
labelLine: { show: false },
emphasis: { disabled: true },
data: currentStats.map((stat) => ({
value: 1,
value: sliceAngleValue(stat.total),
name: stat.unit.label,
itemStyle: {
color: `hsla(${stat.unit.hue}, 45%, 50%, 0.14)`,
Expand Down Expand Up @@ -151,7 +166,7 @@
labelLayout: { hideOverlap: false },
labelLine: { show: false },
data: currentStats.map((stat) => ({
value: 1,
value: sliceAngleValue(stat.total),
name: stat.unit.label,
itemStyle: { color: "transparent" },
})),
Expand Down
62 changes: 62 additions & 0 deletions src/lib/pie-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Mastery-wheel math.
*
* The hub "% captured" is cleared questions / all questions.
* Slice angles are weighted by each unit's question count so filled area
* matches that same ratio (equal wedges would over/under-state units with
* different deck sizes — Basics has ~2× the questions of other slices).
*/

export type ProgressSlice = {
cleared: number;
total: number;
/** 0..1 cleared/total — used for radial fill height. */
ratio: number;
masteredRatio: number;
learningRatio: number;
};

/** Overall hub percent (0–100), rounded. */
export function hubCapturedPercent(slices: readonly ProgressSlice[]): number {
let cleared = 0;
let total = 0;
for (const slice of slices) {
cleared += slice.cleared;
total += slice.total;
}
if (total === 0) return 0;
return Math.round((cleared / total) * 100);
}

/** ECharts pie `value` — angle share proportional to question count. */
export function sliceAngleValue(total: number): number {
return Math.max(total, 1);
}

/**
* Exact (unrounded) fraction of the wheel's area that should appear filled
* when each slice's radial fill height is `ratio` and angles are weighted
* by `total`. Equals cleared/total when ratio === cleared/total per slice.
*/
export function weightedCapturedRatio(slices: readonly ProgressSlice[]): number {
let cleared = 0;
let total = 0;
for (const slice of slices) {
cleared += slice.total * clamp01(slice.ratio);
total += slice.total;
}
if (total === 0) return 0;
return cleared / total;
}

/**
* Captured ratio from mastered + learning layers (radial fill).
* Should match `ratio` (cleared/total) for normal progress records.
*/
export function capturedFillRatio(slice: ProgressSlice): number {
return clamp01(slice.masteredRatio + slice.learningRatio);
}

function clamp01(n: number): number {
return Math.min(1, Math.max(0, n));
}
7 changes: 3 additions & 4 deletions src/lib/quiz-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
parseLearnPath,
type LearnRoute,
} from "$lib/learn-routes";
import { hubCapturedPercent } from "$lib/pie-progress";
import type {
QuestionRecord,
QuizProgress,
Expand Down Expand Up @@ -360,10 +361,8 @@ export class QuizGame {
}

get masteryPercent(): number {
const total = QUESTIONS.length;
if (total === 0) return 0;
const cleared = QUESTIONS.filter((q) => this.isCleared(q.id)).length;
return Math.round((cleared / total) * 100);
// Same cleared/total as the pie hub — weighted by each unit's deck size.
return hubCapturedPercent(this.allStats);
}

/** Whether the learner has any persisted progress worth resetting. */
Expand Down