Files
MathApp_frontend/src/lib/mathExpr.ts
AliReza 355bae7035 play whole islands, make home the path itself, draw tutor diagrams, add tedi
- Home is now the island path of every unit with one clear Start button on the
  current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
  an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
  fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
  and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:45:57 +03:30

111 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* پارسرِ کوچکِ عبارتِ ریاضی برای نمودارِ دستیار (`x^2-2*x`، `sin(x)+1`).
*
* عمداً eval یا new Function نیست: عبارت از مدل می‌آید و مدل متنِ کاربر را
* خوانده است. فقط عدد، x، pi، e، + − * / ^، پرانتز و چند تابعِ شناخته‌شده
* پذیرفته می‌شود؛ هر چیزِ دیگر خطا است و نمودار کشیده نمی‌شود. ضربِ ضمنی
* (2x، 3(x+1)، x sin(x)) هم پشتیبانی می‌شود چون مدل گاهی می‌نویسدش.
*/
type Node =
| { k: 'num'; v: number }
| { k: 'x' }
| { k: 'neg'; a: Node }
| { k: 'bin'; op: '+' | '-' | '*' | '/' | '^'; a: Node; b: Node }
| { k: 'fn'; f: string; a: Node };
const FUNCS: Record<string, (v: number) => number> = {
sin: (v) => Math.sin(v), cos: (v) => Math.cos(v), tan: (v) => Math.tan(v),
sqrt: (v) => Math.sqrt(v), abs: (v) => Math.abs(v), log: (v) => Math.log10(v), ln: (v) => Math.log(v),
};
function tokenize(src: string): string[] {
const s = src.replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi');
const out: string[] = [];
let i = 0;
while (i < s.length) {
const rest = s.slice(i);
const num = /^(\d+\.?\d*|\.\d+)/.exec(rest);
if (num) { out.push(num[0]); i += num[0].length; continue; }
const word = /^[a-z]+/.exec(rest);
if (word) {
const w = word[0];
if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; }
// «xsin» یا «pix»: نامِ چسبیده را تکه‌تکه کن
const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k));
if (!known) throw new Error(`نامِ ناشناخته: ${w}`);
out.push(known); i += known.length; continue;
}
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
throw new Error(`نویسه‌ی ناشناخته: ${s[i]}`);
}
return out;
}
export function parseExpr(src: string): (x: number) => number {
if (src.length > 120) throw new Error('عبارت بلند است');
const t = tokenize(src);
let p = 0;
const peek = () => t[p];
const eat = (tok?: string) => {
const v = t[p++];
if (tok !== undefined && v !== tok) throw new Error(`انتظارِ ${tok}`);
return v;
};
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);
function expr(): Node {
let a = term();
while (peek() === '+' || peek() === '-') { const op = eat() as '+' | '-'; a = { k: 'bin', op, a, b: term() }; }
return a;
}
function term(): Node {
let a = unary();
for (;;) {
if (peek() === '*' || peek() === '/') { const op = eat() as '*' | '/'; a = { k: 'bin', op, a, b: unary() }; }
else if (startsAtom(peek())) a = { k: 'bin', op: '*', a, b: power() }; // ضربِ ضمنی
else return a;
}
}
function unary(): Node {
if (peek() === '-') { eat(); return { k: 'neg', a: unary() }; }
if (peek() === '+') { eat(); return unary(); }
return power();
}
function power(): Node {
const base = atom();
if (peek() === '^') { eat(); return { k: 'bin', op: '^', a: base, b: unary() }; }
return base;
}
function atom(): Node {
const tok = eat();
if (tok === undefined) throw new Error('عبارت ناتمام است');
if (/^[\d.]/.test(tok)) return { k: 'num', v: Number(tok) };
if (tok === 'x') return { k: 'x' };
if (tok === 'pi') return { k: 'num', v: Math.PI };
if (tok === 'e') return { k: 'num', v: Math.E };
if (tok in FUNCS) {
if (peek() === '(') { eat('('); const a = expr(); eat(')'); return { k: 'fn', f: tok, a }; }
return { k: 'fn', f: tok, a: power() };
}
if (tok === '(') { const a = expr(); eat(')'); return a; }
throw new Error(`غیرمنتظره: ${tok}`);
}
const tree = expr();
if (p !== t.length) throw new Error('عبارت اضافه دارد');
const ev = (n: Node, x: number): number => {
switch (n.k) {
case 'num': return n.v;
case 'x': return x;
case 'neg': return -ev(n.a, x);
case 'fn': return FUNCS[n.f](ev(n.a, x));
case 'bin': {
const a = ev(n.a, x), b = ev(n.b, x);
return n.op === '+' ? a + b : n.op === '-' ? a - b : n.op === '*' ? a * b : n.op === '/' ? a / b : Math.pow(a, b);
}
}
};
return (x) => ev(tree, x);
}