Files
MathApp_frontend/src/diagram/expr.ts
AliReza ec4c844531 draw every kind of textbook figure, lock math text left-to-right, student-first family screen
- src/diagram: plane (constructed points, graphs with computed roots, vertex,
  extrema, tangent, asymptotes, piecewise limits, regions, rotations and
  reflections, line from ax + by = c), number line with intervals and stacked
  fractions, solids with hidden edges, bar/line/pie charts, sign tables, Venn,
  arrow mappings, probability trees, fraction bars and mixed numbers, grids,
  groups, clock. Pure-math text is bidi-overridden: Persian digits used to turn
  3 × 4 = 12 into 12 = 4 × 3 even with dir=ltr.
- MathText locks math runs inside Persian sentences in chat bubbles and figure
  captions, so -1 no longer shows as 1-.
- Family screen: a student sees only the invite-code form; the parent form is
  behind a link.
- Dev-only diagram gallery at ?gallery (not in the production build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:24:50 +03:30

180 lines
7.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, '/').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);
}
/* ─── تحلیلِ عددی: ریشه، اکسترمم، شیب ───────────────────────────────────────
* همه با دو برابر کردنِ دقت (bisection) تا ۱e-10؛ برای نمایش به دو رقمِ اعشار گرد
* می‌شوند. ریشه‌ی مضاعف (مثلِ x² در صفر) علامت عوض نمی‌کند، پس جدا از روی
* اکسترممی که مقدارش صفر است پیدا می‌شود.
*/
type F = (x: number) => number;
function bisect(g: F, a: number, b: number): number {
let ga = g(a);
for (let i = 0; i < 80; i++) {
const m = (a + b) / 2, gm = g(m);
if (!Number.isFinite(gm)) return m;
if (Math.sign(gm) === Math.sign(ga)) { a = m; ga = gm; } else b = m;
if (b - a < 1e-11) break;
}
return (a + b) / 2;
}
/** مشتقِ عددی (تفاضلِ مرکزی). */
export function derivative(f: F, x: number): number {
const h = 1e-5 * Math.max(1, Math.abs(x));
return (f(x + h) - f(x - h)) / (2 * h);
}
const snap = (x: number) => {
const r = Math.round(x);
if (Math.abs(x - r) < 1e-7) return r;
const r2 = Math.round(x * 1e6) / 1e6;
return Math.abs(x - r2) < 1e-9 ? r2 : x;
};
export function extrema(f: F, from: number, to: number): { x: number; y: number; kind: 'max' | 'min' }[] {
const n = 600, out: { x: number; y: number; kind: 'max' | 'min' }[] = [];
const d = (x: number) => derivative(f, x);
let px = from, pd = d(px);
for (let i = 1; i <= n; i++) {
const x = from + ((to - from) * i) / n, dx = d(x);
if (Number.isFinite(pd) && Number.isFinite(dx) && pd !== 0 && Math.sign(dx) !== Math.sign(pd)) {
const xe = snap(bisect(d, px, x));
const y = f(xe);
// لبه‌ی مجانب (1/x) علامتِ مشتق را بی‌اکسترمم عوض نمی‌کند: تابع باید پیوسته باشد.
if (Number.isFinite(y) && Math.abs(f(px) - f(x)) < 1e3 * (Math.abs(y) + 1)) out.push({ x: xe, y: snap(y), kind: pd > 0 ? 'max' : 'min' });
}
px = x; pd = dx;
}
return out;
}
export function roots(f: F, from: number, to: number): number[] {
const n = 800, out: number[] = [];
let px = from, py = f(px);
if (Math.abs(py) < 1e-12) out.push(from);
for (let i = 1; i <= n; i++) {
const x = from + ((to - from) * i) / n, y = f(x);
if (Math.abs(y) < 1e-12) out.push(x);
else if (Number.isFinite(py) && Number.isFinite(y) && py !== 0 && Math.sign(y) !== Math.sign(py)) {
const r = bisect(f, px, x);
// پرشِ علامت بدونِ گذر از صفر (مجانب) ریشه نیست.
if (Math.abs(f(r)) < 1e-6) out.push(r);
}
px = x; py = y;
}
for (const e of extrema(f, from, to)) if (Math.abs(e.y) < 1e-9) out.push(e.x);
const uniq: number[] = [];
for (const r of out.map(snap).sort((a, b) => a - b)) if (!uniq.length || Math.abs(r - uniq[uniq.length - 1]) > 1e-6) uniq.push(r);
return uniq;
}