288 lines
9.2 KiB
TypeScript
288 lines
9.2 KiB
TypeScript
import type { MathProblem, Difficulty } from "../types";
|
||
import { randomInt, randomChoice, shuffle } from "@/lib/utils";
|
||
import { roundToDecimalPlaces, roundToSignificantFigures, toStandardForm, standardFormToKatex } from "@/lib/math/decimals";
|
||
|
||
let counter = 0;
|
||
function nextId() {
|
||
return `dp-${++counter}-${Date.now()}`;
|
||
}
|
||
|
||
export function generateDecimalCompareOrder(difficulty: Difficulty): MathProblem {
|
||
const count = difficulty === 1 ? 3 : difficulty === 2 ? 4 : 5;
|
||
const values: number[] = [];
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const intPart = randomInt(0, difficulty === 1 ? 2 : 10);
|
||
const decPlaces = randomInt(1, difficulty === 1 ? 2 : 3);
|
||
const decPart = randomInt(1, Math.pow(10, decPlaces) - 1);
|
||
const val = intPart + decPart / Math.pow(10, decPlaces);
|
||
values.push(Math.round(val * 1000) / 1000);
|
||
}
|
||
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const shuffled = shuffle(values);
|
||
|
||
return {
|
||
id: nextId(),
|
||
prompt: `\\text{Order from smallest to largest: } ${shuffled.join(", ")}`,
|
||
answer: { kind: "decimal", value: 0 },
|
||
hints: [
|
||
"Compare the whole number part first",
|
||
"If the whole parts are equal, compare tenths, then hundredths",
|
||
"Writing them with the same number of decimal places can help",
|
||
],
|
||
steps: sorted.map((v, i) => ({
|
||
explanation: `Position ${i + 1}: ${v}`,
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function generateDecimalRounding(difficulty: Difficulty): MathProblem {
|
||
const value = randomInt(100, 99999) / (difficulty === 1 ? 100 : 1000);
|
||
const roundType = randomChoice(
|
||
difficulty === 1
|
||
? ["whole", "1dp"] as const
|
||
: difficulty === 2
|
||
? ["1dp", "2dp", "whole"] as const
|
||
: ["1sf", "2sf", "3sf"] as const,
|
||
);
|
||
|
||
let answer: number;
|
||
let instruction: string;
|
||
|
||
switch (roundType) {
|
||
case "whole":
|
||
answer = Math.round(value);
|
||
instruction = "the nearest whole number";
|
||
break;
|
||
case "1dp":
|
||
answer = roundToDecimalPlaces(value, 1);
|
||
instruction = "1 decimal place";
|
||
break;
|
||
case "2dp":
|
||
answer = roundToDecimalPlaces(value, 2);
|
||
instruction = "2 decimal places";
|
||
break;
|
||
case "1sf":
|
||
answer = roundToSignificantFigures(value, 1);
|
||
instruction = "1 significant figure";
|
||
break;
|
||
case "2sf":
|
||
answer = roundToSignificantFigures(value, 2);
|
||
instruction = "2 significant figures";
|
||
break;
|
||
case "3sf":
|
||
answer = roundToSignificantFigures(value, 3);
|
||
instruction = "3 significant figures";
|
||
break;
|
||
}
|
||
|
||
return {
|
||
id: nextId(),
|
||
prompt: `\\text{Round } ${value} \\text{ to ${instruction}}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
"Look at the digit to the right of where you're rounding",
|
||
"If it's 5 or more, round up. If it's less than 5, round down",
|
||
],
|
||
steps: [
|
||
{ explanation: `Rounding ${value} to ${instruction}` },
|
||
{ explanation: `Answer: ${answer}` },
|
||
],
|
||
};
|
||
}
|
||
|
||
export function generateStandardForm(difficulty: Difficulty): MathProblem {
|
||
let value: number;
|
||
if (difficulty === 1) {
|
||
value = randomChoice([7438, 1578, 62, 93000, 5200, 340]);
|
||
} else if (difficulty === 2) {
|
||
value = randomChoice([0.086, 0.748, 0.0055, 0.34, 0.007]);
|
||
} else {
|
||
value = randomChoice([12436.3, 0.00092, 504000, 0.0000067, 83100]);
|
||
}
|
||
|
||
const sf = toStandardForm(value);
|
||
|
||
return {
|
||
id: nextId(),
|
||
prompt: `\\text{Express } ${value} \\text{ in standard form}`,
|
||
answer: { kind: "standardForm", coefficient: sf.coefficient, exponent: sf.exponent },
|
||
hints: [
|
||
"Move the decimal point so there's one non-zero digit before it",
|
||
"Count how many places you moved the decimal",
|
||
value >= 1
|
||
? "Number is ≥ 1, so the power is positive"
|
||
: "Number is < 1, so the power is negative",
|
||
],
|
||
steps: [
|
||
{ explanation: `Move the decimal point to get a number between 1 and 10` },
|
||
{ explanation: `Coefficient: ${sf.coefficient}` },
|
||
{ explanation: `The decimal moved ${Math.abs(sf.exponent)} places, power = ${sf.exponent}` },
|
||
{ explanation: "Answer", math: standardFormToKatex(sf.coefficient, sf.exponent) },
|
||
],
|
||
};
|
||
}
|
||
|
||
export function generateDecimalAddSubtract(difficulty: Difficulty): MathProblem {
|
||
const operation = randomChoice(["add", "subtract"] as const);
|
||
const op = operation === "add" ? "+" : "-";
|
||
const places = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3;
|
||
|
||
const a = randomInt(100, 9999) / Math.pow(10, places);
|
||
const b = randomInt(100, 9999) / Math.pow(10, places);
|
||
const [first, second] = operation === "subtract" ? [Math.max(a, b), Math.min(a, b)] : [a, b];
|
||
|
||
const answer = roundToDecimalPlaces(
|
||
operation === "add" ? first + second : first - second,
|
||
places,
|
||
);
|
||
|
||
return {
|
||
id: nextId(),
|
||
prompt: `${first} ${op} ${second}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
"Line up the decimal points",
|
||
"Add zeros to fill empty places",
|
||
operation === "add" ? "Add column by column from right to left" : "Subtract column by column, borrow if needed",
|
||
],
|
||
steps: [
|
||
{ explanation: `Line up: ${first} ${op} ${second}` },
|
||
{ explanation: `= ${answer}` },
|
||
],
|
||
};
|
||
}
|
||
|
||
export function generateDecimalMultiplyDivide(difficulty: Difficulty): MathProblem {
|
||
const operation = randomChoice(["multiply", "divide"] as const);
|
||
|
||
if (difficulty === 1) {
|
||
// By powers of 10
|
||
const power = randomChoice([10, 100, 1000]);
|
||
const base = randomInt(1, 999) / 100;
|
||
|
||
if (operation === "multiply") {
|
||
const answer = roundToDecimalPlaces(base * power, 10);
|
||
return {
|
||
id: nextId(),
|
||
prompt: `${base} \\times ${power}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
`When multiplying by ${power}, move decimal ${Math.log10(power)} place(s) right`,
|
||
],
|
||
steps: [
|
||
{ explanation: `Move decimal point ${Math.log10(power)} place(s) to the right` },
|
||
{ explanation: `= ${answer}` },
|
||
],
|
||
};
|
||
} else {
|
||
const answer = roundToDecimalPlaces(base / power, 10);
|
||
return {
|
||
id: nextId(),
|
||
prompt: `${base} \\div ${power}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
`When dividing by ${power}, move decimal ${Math.log10(power)} place(s) left`,
|
||
],
|
||
steps: [
|
||
{ explanation: `Move decimal point ${Math.log10(power)} place(s) to the left` },
|
||
{ explanation: `= ${answer}` },
|
||
],
|
||
};
|
||
}
|
||
}
|
||
|
||
// By whole numbers or decimals
|
||
const a = randomInt(10, 999) / 10;
|
||
const b = difficulty === 2 ? randomInt(2, 12) : randomInt(10, 99) / 10;
|
||
|
||
if (operation === "multiply") {
|
||
const answer = roundToDecimalPlaces(a * b, 4);
|
||
return {
|
||
id: nextId(),
|
||
prompt: `${a} \\times ${b}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
"Multiply as if there are no decimal points",
|
||
"Count total decimal places in both numbers",
|
||
"Place the decimal point in your answer",
|
||
],
|
||
steps: [
|
||
{ explanation: `${a} × ${b}` },
|
||
{ explanation: `= ${answer}` },
|
||
],
|
||
};
|
||
} else {
|
||
const answer = roundToDecimalPlaces(a / b, 4);
|
||
return {
|
||
id: nextId(),
|
||
prompt: `${a} \\div ${b}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
"Place decimal point in answer directly above the one in the dividend",
|
||
"Divide as normal",
|
||
],
|
||
steps: [
|
||
{ explanation: `${a} ÷ ${b}` },
|
||
{ explanation: `= ${answer}` },
|
||
],
|
||
};
|
||
}
|
||
}
|
||
|
||
export function generateDecimalConversion(difficulty: Difficulty): MathProblem {
|
||
const direction = randomChoice(["toFraction", "toDecimal"] as const);
|
||
|
||
if (direction === "toFraction") {
|
||
const decimal = randomChoice(
|
||
difficulty === 1
|
||
? [0.5, 0.25, 0.75, 0.1, 0.2, 0.4]
|
||
: [0.125, 0.375, 0.625, 0.875, 0.15, 0.35, 0.45],
|
||
);
|
||
const den = decimal === 0.5 ? 2 : decimal === 0.25 || decimal === 0.75 ? 4 : [0.125, 0.375, 0.625, 0.875].includes(decimal) ? 8 : 100;
|
||
const num = Math.round(decimal * den);
|
||
const g = gcdSimple(num, den);
|
||
|
||
return {
|
||
id: nextId(),
|
||
prompt: `\\text{Convert } ${decimal} \\text{ to a fraction}`,
|
||
answer: { kind: "fraction", numerator: num / g, denominator: den / g },
|
||
hints: [
|
||
`Write as ${num}/${den}`,
|
||
"Simplify by dividing numerator and denominator by their GCD",
|
||
],
|
||
steps: [
|
||
{ explanation: `${decimal} = ${num}/${den}` },
|
||
{ explanation: `Simplified: ${num / g}/${den / g}` },
|
||
],
|
||
};
|
||
} else {
|
||
const den = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 8, 20, 25, 40, 50]);
|
||
const num = randomInt(1, den - 1);
|
||
const answer = roundToDecimalPlaces(num / den, 6);
|
||
|
||
return {
|
||
id: nextId(),
|
||
prompt: `\\text{Convert } \\frac{${num}}{${den}} \\text{ to a decimal}`,
|
||
answer: { kind: "decimal", value: answer },
|
||
hints: [
|
||
`Divide ${num} by ${den}`,
|
||
"Or find an equivalent fraction with denominator 10, 100, etc.",
|
||
],
|
||
steps: [
|
||
{ explanation: `${num} ÷ ${den} = ${answer}` },
|
||
],
|
||
};
|
||
}
|
||
}
|
||
|
||
function gcdSimple(a: number, b: number): number {
|
||
a = Math.abs(a);
|
||
b = Math.abs(b);
|
||
while (b) {
|
||
[a, b] = [b, a % b];
|
||
}
|
||
return a;
|
||
}
|