Skip to content

Commit 6721d3f

Browse files
jsollycursoragent
andauthored
fix(pie): weight mastery slices by question count (#49)
Equal wedges made Basics (~25% of the deck) look like 1/8 of the wheel, so hub % captured looked higher than the filled area. Angle each slice by its question count and derive the hub from the same cleared/total math so filled area matches the center percentage. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: John Solly <jsolly@users.noreply.github.com>
1 parent abc799a commit 6721d3f

5 files changed

Lines changed: 180 additions & 19 deletions

File tree

scripts/verify-pie-progress.mjs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Verifies hub % matches weighted slice fill for representative progress mixes.
3+
* Run: node scripts/verify-pie-progress.mjs
4+
*/
5+
import {
6+
capturedFillRatio,
7+
hubCapturedPercent,
8+
weightedCapturedRatio,
9+
} from "../src/lib/pie-progress.ts";
10+
11+
/** Real deck sizes (Basics is larger than the other lifecycle slices). */
12+
const DECK = [
13+
{ id: "basics", total: 50 },
14+
{ id: "find", total: 22 },
15+
{ id: "shape", total: 22 },
16+
{ id: "bid", total: 22 },
17+
{ id: "vehicle", total: 22 },
18+
{ id: "team", total: 22 },
19+
{ id: "propose", total: 22 },
20+
{ id: "win", total: 22 },
21+
];
22+
23+
function slice(total, cleared, { mastered = cleared, learning = 0 } = {}) {
24+
const ratio = total === 0 ? 0 : cleared / total;
25+
return {
26+
cleared,
27+
total,
28+
ratio,
29+
masteredRatio: total === 0 ? 0 : mastered / total,
30+
learningRatio: total === 0 ? 0 : learning / total,
31+
};
32+
}
33+
34+
function scenario(name, clearedById) {
35+
const slices = DECK.map(({ id, total }) => {
36+
const cleared = clearedById[id] ?? 0;
37+
// Split: first clear = learning, second+ = mastered (approx for fill layers).
38+
const learning = Math.min(cleared, Math.ceil(cleared * 0.3));
39+
const mastered = cleared - learning;
40+
return slice(total, cleared, { mastered, learning });
41+
});
42+
43+
const hub = hubCapturedPercent(slices);
44+
const weighted = weightedCapturedRatio(slices);
45+
const weightedPct = Math.round(weighted * 100);
46+
const fillMatchesRatio = slices.every(
47+
(s) => Math.abs(capturedFillRatio(s) - s.ratio) < 1e-9,
48+
);
49+
50+
const ok = hub === weightedPct && fillMatchesRatio;
51+
const detail = DECK.map(({ id, total }) => {
52+
const c = clearedById[id] ?? 0;
53+
const pct = total ? Math.round((c / total) * 100) : 0;
54+
const angle = Math.round((total / DECK.reduce((n, d) => n + d.total, 0)) * 100);
55+
return `${id} ${pct}% of slice (angle ~${angle}% of wheel, ${c}/${total})`;
56+
}).join("; ");
57+
58+
console.log(`${ok ? "PASS" : "FAIL"} ${name}`);
59+
console.log(` hub=${hub}% weightedArea=${weightedPct}% fill≡ratio=${fillMatchesRatio}`);
60+
console.log(` ${detail}`);
61+
if (!ok) process.exitCode = 1;
62+
return ok;
63+
}
64+
65+
scenario("empty", {});
66+
scenario("user-like ~20% (Basics heavy)", { basics: 32, vehicle: 8 });
67+
scenario("Basics 80% only", { basics: 40 });
68+
scenario("Vehicle 100% only", { vehicle: 22 });
69+
scenario("every slice 50%", Object.fromEntries(DECK.map((d) => [d.id, Math.floor(d.total / 2)])));
70+
scenario("full capture", Object.fromEntries(DECK.map((d) => [d.id, d.total])));
71+
scenario("thin progress across many slices", {
72+
basics: 5,
73+
find: 5,
74+
shape: 5,
75+
bid: 5,
76+
vehicle: 5,
77+
team: 5,
78+
propose: 5,
79+
win: 5,
80+
});
81+
scenario("one slice fully mastered look (learning+mastered layers)", {
82+
basics: 50, // all cleared — fill uses mastered+learning ratios in UI
83+
});
84+
85+
if (!process.exitCode) console.log("\nAll pie-progress scenarios passed.");

src/components/app/PieHome.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
</header>
6262

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

6767
<div

src/components/app/PieWheel.svelte

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
import { SVGRenderer } from "echarts/renderers";
66
import type { Attachment } from "svelte/attachments";
77
8+
import {
9+
capturedFillRatio,
10+
hubCapturedPercent,
11+
sliceAngleValue,
12+
} from "$lib/pie-progress";
813
import type { UnitStats } from "$lib/types";
914
1015
type Props = {
1116
stats: UnitStats[];
12-
/** Overall mastery shown in the ALEKS-style center hub. */
13-
hubPercent: number;
1417
};
1518
1619
type WheelOption = ComposeOption<PieSeriesOption>;
@@ -23,7 +26,10 @@
2326
2427
use([PieChart, LabelLayout, SVGRenderer]);
2528
26-
let { stats, hubPercent }: Props = $props();
29+
let { stats }: Props = $props();
30+
31+
/** Same cleared/total math as the engine — derived from the slices on screen. */
32+
let hubPercent = $derived(hubCapturedPercent(stats));
2733
2834
function clampRatio(ratio: number): number {
2935
return Math.min(1, Math.max(0, ratio));
@@ -42,7 +48,22 @@
4248
const masteredPct = Math.round(clampRatio(stat.masteredRatio) * 100);
4349
const learningPct = Math.round(clampRatio(stat.learningRatio) * 100);
4450
const capturedPct = Math.round(clampRatio(stat.ratio) * 100);
45-
return `${stat.unit.label}: ${capturedPct}% captured (${masteredPct}% mastered, ${learningPct}% learning)`;
51+
return `${stat.unit.label}: ${capturedPct}% of slice (${stat.cleared}/${stat.total} questions; ${masteredPct}% mastered, ${learningPct}% learning)`;
52+
}
53+
54+
function sliceData(
55+
currentStats: UnitStats[],
56+
progressIndex: number | null,
57+
color: string,
58+
): NonNullable<PieSeriesOption["data"]> {
59+
return currentStats.map((slice, sliceIndex) => ({
60+
// Angle ∝ question count so filled area matches hub % captured.
61+
value: sliceAngleValue(slice.total),
62+
name: slice.unit.label,
63+
itemStyle: {
64+
color: progressIndex === null || sliceIndex === progressIndex ? color : "transparent",
65+
},
66+
}));
4667
}
4768
4869
function sliceFill(
@@ -53,7 +74,7 @@
5374
): PieSeriesOption | null {
5475
const masteredRatio = clampRatio(stat.masteredRatio);
5576
const learningRatio = clampRatio(stat.learningRatio);
56-
const capturedRatio = clampRatio(masteredRatio + learningRatio);
77+
const capturedRatio = capturedFillRatio(stat);
5778
if (kind === "learning" && learningRatio === 0) return null;
5879
if (kind === "mastered" && masteredRatio === 0) return null;
5980
@@ -79,13 +100,7 @@
79100
label: { show: false },
80101
labelLine: { show: false },
81102
emphasis: { disabled: true },
82-
data: currentStats.map((slice, sliceIndex) => ({
83-
value: 1,
84-
name: slice.unit.label,
85-
itemStyle: {
86-
color: sliceIndex === progressIndex ? color : "transparent",
87-
},
88-
})),
103+
data: sliceData(currentStats, progressIndex, color),
89104
};
90105
}
91106
@@ -110,7 +125,7 @@
110125
labelLine: { show: false },
111126
emphasis: { disabled: true },
112127
data: currentStats.map((stat) => ({
113-
value: 1,
128+
value: sliceAngleValue(stat.total),
114129
name: stat.unit.label,
115130
itemStyle: {
116131
color: `hsla(${stat.unit.hue}, 45%, 50%, 0.14)`,
@@ -151,7 +166,7 @@
151166
labelLayout: { hideOverlap: false },
152167
labelLine: { show: false },
153168
data: currentStats.map((stat) => ({
154-
value: 1,
169+
value: sliceAngleValue(stat.total),
155170
name: stat.unit.label,
156171
itemStyle: { color: "transparent" },
157172
})),

src/lib/pie-progress.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Mastery-wheel math.
3+
*
4+
* The hub "% captured" is cleared questions / all questions.
5+
* Slice angles are weighted by each unit's question count so filled area
6+
* matches that same ratio (equal wedges would over/under-state units with
7+
* different deck sizes — Basics has ~2× the questions of other slices).
8+
*/
9+
10+
export type ProgressSlice = {
11+
cleared: number;
12+
total: number;
13+
/** 0..1 cleared/total — used for radial fill height. */
14+
ratio: number;
15+
masteredRatio: number;
16+
learningRatio: number;
17+
};
18+
19+
/** Overall hub percent (0–100), rounded. */
20+
export function hubCapturedPercent(slices: readonly ProgressSlice[]): number {
21+
let cleared = 0;
22+
let total = 0;
23+
for (const slice of slices) {
24+
cleared += slice.cleared;
25+
total += slice.total;
26+
}
27+
if (total === 0) return 0;
28+
return Math.round((cleared / total) * 100);
29+
}
30+
31+
/** ECharts pie `value` — angle share proportional to question count. */
32+
export function sliceAngleValue(total: number): number {
33+
return Math.max(total, 1);
34+
}
35+
36+
/**
37+
* Exact (unrounded) fraction of the wheel's area that should appear filled
38+
* when each slice's radial fill height is `ratio` and angles are weighted
39+
* by `total`. Equals cleared/total when ratio === cleared/total per slice.
40+
*/
41+
export function weightedCapturedRatio(slices: readonly ProgressSlice[]): number {
42+
let cleared = 0;
43+
let total = 0;
44+
for (const slice of slices) {
45+
cleared += slice.total * clamp01(slice.ratio);
46+
total += slice.total;
47+
}
48+
if (total === 0) return 0;
49+
return cleared / total;
50+
}
51+
52+
/**
53+
* Captured ratio from mastered + learning layers (radial fill).
54+
* Should match `ratio` (cleared/total) for normal progress records.
55+
*/
56+
export function capturedFillRatio(slice: ProgressSlice): number {
57+
return clamp01(slice.masteredRatio + slice.learningRatio);
58+
}
59+
60+
function clamp01(n: number): number {
61+
return Math.min(1, Math.max(0, n));
62+
}

src/lib/quiz-state.svelte.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
parseLearnPath,
4545
type LearnRoute,
4646
} from "$lib/learn-routes";
47+
import { hubCapturedPercent } from "$lib/pie-progress";
4748
import type {
4849
QuestionRecord,
4950
QuizProgress,
@@ -365,10 +366,8 @@ export class QuizGame {
365366
}
366367

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

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

0 commit comments

Comments
 (0)