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>
This commit is contained in:
2026-09-28 23:24:50 +03:30
parent 355bae7035
commit ec4c844531
18 changed files with 2165 additions and 419 deletions

View File

@@ -1,392 +0,0 @@
import { memo, useMemo, type ReactNode } from 'react';
import type { Diagram as DiagramSpec, DiagramItem, DiagramPointDef, DiagramRef } from '@/api';
import { parseExpr } from '@/lib/mathExpr';
import { toFa } from '@/lib/format';
/**
* شکلِ پاسخِ دستیار، از روی توصیفِ JSONی که بک‌اند اعتبارسنجی کرده
* (rag/services/diagram.py). مختصات همین‌جا حساب می‌شوند — مدل فقط می‌گوید
* «نقطه روی دایره با زاویه‌ی ۳۰°» — و همه‌چیز عنصرِ SVGِ React است، بدونِ
* innerHTML؛ پس متنِ مدل هیچ راهی به HTMLِ صفحه ندارد.
*
* هر ایرادی (نقطه‌ی ناشناخته، عبارتِ نامعتبر) فقط همان تکه را حذف می‌کند؛ اگر
* چیزی برای کشیدن نماند، هیچ شکلی نشان داده نمی‌شود.
*/
const INK = '#2b2b45';
const MAIN = '#4C5FD5';
const ACCENT = '#ED6956';
const SOFT = '#9e9ec0';
const W = 300;
const PAD = 22;
type XY = [number, number];
function DiagramBase({ spec }: { spec: DiagramSpec }) {
const body = useMemo(() => {
try {
if (spec.kind === 'numberline') return <NumberLine spec={spec} />;
if (spec.kind === 'fraction') return <Fraction spec={spec} />;
if (spec.kind === 'plane') return <Plane spec={spec} />;
} catch {
/* شکلِ خراب نباید پاسخ را خراب کند */
}
return null;
}, [spec]);
if (!body) return null;
return (
// dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت.
// برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند.
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line bg-[#fbfbff] p-2">
{body}
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted">{spec.title}</figcaption>}
</figure>
);
}
export const Diagram = memo(DiagramBase);
/* ─── صفحه‌ی مختصات / هندسه ───────────────────────────────────────────────── */
function Plane({ spec }: { spec: Extract<DiagramSpec, { kind: 'plane' }> }) {
// نقطه‌ها: polar نسبت به مبدأ یا نقطه‌ی from، با حلِ ارجاعِ زنجیره‌ای.
const named = new Map<string, XY>();
const resolving = new Set<string>();
const resolveName = (name: string): XY | null => {
if (named.has(name)) return named.get(name)!;
const def: DiagramPointDef | undefined = spec.points[name];
if (!def || resolving.has(name)) return null;
resolving.add(name);
let xy: XY | null = null;
if (Array.isArray(def)) xy = [def[0], def[1]];
else {
const origin = def.from !== undefined ? ref(def.from) : [0, 0];
if (origin) {
const [r, deg] = def.polar;
const t = (deg * Math.PI) / 180;
xy = [origin[0] + r * Math.cos(t), origin[1] + r * Math.sin(t)];
}
}
resolving.delete(name);
if (xy) named.set(name, xy);
return xy;
};
const ref = (r: DiagramRef): XY | null => (typeof r === 'string' ? resolveName(r) : [r[0], r[1]]);
Object.keys(spec.points).forEach(resolveName);
// نمودارِ تابع‌ها پیش از کادر، چون کادر را تعیین می‌کنند.
const curves: { item: Extract<DiagramItem, { type: 'function' }>; pts: XY[] }[] = [];
for (const item of spec.items) {
if (item.type !== 'function') continue;
try {
const f = parseExpr(item.expr);
const pts: XY[] = [];
for (let i = 0; i <= 160; i++) {
const x = item.from + ((item.to - item.from) * i) / 160;
const y = f(x);
if (Number.isFinite(y)) pts.push([x, y]);
}
if (pts.length > 1) curves.push({ item, pts });
} catch {
/* عبارتِ نامعتبر: همین نمودار کشیده نمی‌شود */
}
}
// کادر
const xs: number[] = [], ys: number[] = [];
const add = ([x, y]: XY) => { xs.push(x); ys.push(y); };
named.forEach(add);
for (const item of spec.items) {
if (item.type === 'circle') {
const c = ref(item.center);
if (c) { add([c[0] - item.r, c[1] - item.r]); add([c[0] + item.r, c[1] + item.r]); }
} else if (item.type === 'text') add(item.at);
else if (item.type === 'segment' || item.type === 'arrow') {
const a = ref(item.from), b = ref(item.to);
if (a) add(a);
if (b) add(b);
} else if (item.type === 'polygon') item.points.forEach((p) => { const xy = ref(p); if (xy) add(xy); });
else if (item.type === 'point') { const xy = ref(item.point); if (xy) add(xy); }
}
for (const { item, pts } of curves) {
xs.push(item.from, item.to);
const cy = pts.map((p) => p[1]).sort((a, b) => a - b);
// دُمِ نمودار (مثلاً tan) نباید کلِ کادر را بکشد: ۵ تا ۹۵ درصد.
ys.push(cy[Math.floor(cy.length * 0.05)], cy[Math.ceil(cy.length * 0.95) - 1]);
}
if (!xs.length) return null;
if (spec.axes) { xs.push(0); ys.push(0); }
let [x0, x1] = [Math.min(...xs), Math.max(...xs)];
let [y0, y1] = [Math.min(...ys), Math.max(...ys)];
const span = Math.max(x1 - x0, y1 - y0, 1e-6);
const margin = span * 0.14 + 0.2;
x0 -= margin; x1 += margin; y0 -= margin; y1 += margin;
const scale = (W - 2 * PAD) / (x1 - x0);
const height = Math.min(Math.max((y1 - y0) * scale + 2 * PAD, 150), 340);
// اگر ارتفاع سقف خورد، مقیاس را از روی ارتفاع بگیر تا دایره بیضی نشود.
const s = Math.min(scale, (height - 2 * PAD) / (y1 - y0));
const ox = (W - (x1 - x0) * s) / 2, oy = (height - (y1 - y0) * s) / 2;
const X = (x: number) => ox + (x - x0) * s;
const Y = (y: number) => oy + (y1 - y) * s;
const P = ([x, y]: XY): XY => [X(x), Y(y)];
const els: ReactNode[] = [];
let key = 0;
const k = () => key++;
if (spec.axes) {
const ticks = niceTicks(x0, x1), yticks = niceTicks(y0, y1);
els.push(
<g key={k()} stroke="#d9d7ea" strokeWidth={1}>
{ticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} strokeDasharray="2 4" />)}
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} strokeDasharray="2 4" />)}
</g>,
<g key={k()} stroke={SOFT} strokeWidth={1.4} markerEnd="url(#dg-arrow-soft)">
<line x1={X(x0)} y1={Y(0)} x2={X(x1)} y2={Y(0)} />
<line x1={X(0)} y1={Y(y0)} x2={X(0)} y2={Y(y1)} />
</g>,
<g key={k()} fill={SOFT} fontSize={9} textAnchor="middle">
{ticks.filter((t) => t !== 0).map((t) => <text key={`tx${t}`} x={X(t)} y={Y(0) + 11}>{fa(t)}</text>)}
{yticks.filter((t) => t !== 0).map((t) => <text key={`ty${t}`} x={X(0) - 8} y={Y(t) + 3}>{fa(t)}</text>)}
</g>,
);
}
const centroid: XY = (() => {
const all = [...named.values()];
if (!all.length) return [0, 0];
return [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length];
})();
for (const item of spec.items) {
if (item.type === 'circle') {
const c = ref(item.center);
if (!c) continue;
const [cx, cy] = P(c);
els.push(<circle key={k()} cx={cx} cy={cy} r={item.r * s} fill="none" stroke={MAIN} strokeWidth={2.2} />);
} else if (item.type === 'segment' || item.type === 'arrow') {
const a = ref(item.from), b = ref(item.to);
if (!a || !b) continue;
const [ax, ay] = P(a), [bx, by] = P(b);
els.push(
<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={item.type === 'arrow' ? ACCENT : INK} strokeWidth={2.2}
strokeLinecap="round" markerEnd={item.type === 'arrow' ? 'url(#dg-arrow)' : undefined} />,
);
if (item.label) els.push(sideLabel(k(), [ax, ay], [bx, by], P(centroid), item.label));
} else if (item.type === 'line') {
const a = ref(item.through[0]), b = ref(item.through[1]);
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) continue;
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = (x1 - x0 + y1 - y0) * 2 / Math.hypot(dx, dy);
const [px, py] = P([a[0] - dx * t, a[1] - dy * t]), [qx, qy] = P([a[0] + dx * t, a[1] + dy * t]);
els.push(<line key={k()} x1={px} y1={py} x2={qx} y2={qy} stroke={MAIN} strokeWidth={1.8} />);
} else if (item.type === 'polygon') {
const pts = item.points.map(ref).filter((p): p is XY => p !== null).map(P);
if (pts.length < 3) continue;
els.push(<polygon key={k()} points={pts.map((p) => p.join(',')).join(' ')} fill="#4C5FD514" stroke={INK} strokeWidth={2.2} strokeLinejoin="round" />);
} else if (item.type === 'angle' || item.type === 'rightAngle') {
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
if (!v || !a || !b) continue;
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
let d = tb - ta;
while (d <= -Math.PI) d += 2 * Math.PI;
while (d > Math.PI) d -= 2 * Math.PI;
const r = Math.min(24, Math.hypot(ax - vx, ay - vy) * 0.4, Math.hypot(bx - vx, by - vy) * 0.4);
if (item.type === 'rightAngle') {
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
const q = Math.min(r, 12);
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
els.push(<polyline key={k()} points={[p1, p2, p3].map((p) => p.join(',')).join(' ')} fill="none" stroke={ACCENT} strokeWidth={1.8} />);
} else {
const end: XY = [vx + r * Math.cos(ta + d), vy + r * Math.sin(ta + d)];
const start: XY = [vx + r * Math.cos(ta), vy + r * Math.sin(ta)];
els.push(
<path key={k()} d={`M${vx},${vy} L${start[0]},${start[1]} A${r},${r} 0 0 ${d > 0 ? 1 : 0} ${end[0]},${end[1]} Z`}
fill="#ED695626" stroke={ACCENT} strokeWidth={1.8} />,
);
}
if (item.label) {
const mid = ta + d / 2, lr = r + 13;
els.push(<Label key={k()} x={vx + lr * Math.cos(mid)} y={vy + lr * Math.sin(mid) + 4} color={ACCENT}>{item.label}</Label>);
}
} else if (item.type === 'text') {
const [tx, ty] = P(item.at);
els.push(<Label key={k()} x={tx} y={ty} color={INK}>{item.text}</Label>);
}
}
for (const { item, pts } of curves) {
const clipped = pts.filter(([, y]) => y >= y0 - (y1 - y0) && y <= y1 + (y1 - y0));
const d = clipped.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(' ');
els.push(<path key={k()} d={d} fill="none" stroke={MAIN} strokeWidth={2.4} strokeLinejoin="round" />);
if (item.label) {
const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1];
els.push(<Label key={k()} x={X(last[0])} y={Y(last[1]) - 8} color={MAIN}>{item.label}</Label>);
}
}
// نقطه‌ها آخر از همه، روی بقیه
const pointLabels = new Map<string, string>();
const extraPoints: { xy: XY; label: string }[] = [];
for (const item of spec.items) {
if (item.type !== 'point') continue;
if (typeof item.point === 'string') pointLabels.set(item.point, item.label ?? item.point);
else extraPoints.push({ xy: item.point, label: item.label ?? '' });
}
const drawPoint = (xy: XY, label: string) => {
const [px, py] = P(xy);
const [cx, cy] = P(centroid);
let [dx, dy] = [px - cx, py - cy];
const n = Math.hypot(dx, dy) || 1;
[dx, dy] = [(dx / n) * 13, (dy / n) * 13 || -13];
els.push(<circle key={k()} cx={px} cy={py} r={3.6} fill={INK} />);
if (label) els.push(<Label key={k()} x={px + dx} y={py + dy + 4} color={INK} bold>{label}</Label>);
};
named.forEach((xy, name) => {
if (name.startsWith('_')) return;
const isOriginOnAxes = spec.axes && xy[0] === 0 && xy[1] === 0 && !pointLabels.has(name);
drawPoint(xy, isOriginOnAxes ? '' : pointLabels.get(name) ?? name);
});
extraPoints.forEach((p) => drawPoint(p.xy, p.label));
return (
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'شکل'}>
<Markers />
{els}
</svg>
);
}
/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */
function NumberLine({ spec }: { spec: Extract<DiagramSpec, { kind: 'numberline' }> }) {
const { min, max } = spec;
const height = spec.jumps.length ? 120 : 80;
const base = height - 38;
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
const step = niceStep(max - min, 12);
const ticks: number[] = [];
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) ticks.push(Math.round(t * 1e6) / 1e6);
const labelled = new Set(spec.points.map((p) => p.at));
return (
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
<Markers />
<line x1={PAD - 12} y1={base} x2={W - PAD + 12} y2={base} stroke={INK} strokeWidth={2} markerEnd="url(#dg-arrow-ink)" markerStart="url(#dg-arrow-ink-start)" />
{ticks.map((t) => (
<g key={t}>
<line x1={X(t)} x2={X(t)} y1={base - 5} y2={base + 5} stroke={INK} strokeWidth={t === 0 ? 2 : 1.3} />
<text x={X(t)} y={base + 19} fontSize={labelled.has(t) ? 11 : 10} fontWeight={labelled.has(t) ? 800 : 400} fill={labelled.has(t) ? INK : SOFT} textAnchor="middle">
{fa(t)}
</text>
</g>
))}
{spec.jumps.map((j, i) => {
const a = X(j.from), b = X(j.to);
const lift = Math.min(46, Math.abs(b - a) * 0.5 + 14);
return (
<g key={`j${i}`}>
<path d={`M${a},${base - 6} Q${(a + b) / 2},${base - 6 - lift * 1.4} ${b},${base - 6}`} fill="none" stroke={ACCENT} strokeWidth={2.2} markerEnd="url(#dg-arrow)" />
{j.label && <Label x={(a + b) / 2} y={base - 10 - lift * 0.75} color={ACCENT}>{j.label}</Label>}
</g>
);
})}
{spec.points.map((p, i) => (
<g key={`p${i}`}>
<circle cx={X(p.at)} cy={base} r={5.5} fill={i === spec.points.length - 1 && spec.points.length > 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} />
{p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && <Label x={X(p.at)} y={base + 33} color={INK}>{p.label}</Label>}
</g>
))}
</svg>
);
}
/* ─── کسر ─────────────────────────────────────────────────────────────────── */
function Fraction({ spec }: { spec: Extract<DiagramSpec, { kind: 'fraction' }> }) {
const { parts, shaded } = spec;
const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff');
if (spec.shape === 'rect') {
const w = (W - 2 * PAD) / parts;
return (
<svg viewBox={`0 0 ${W} 90`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'کسر'}>
{Array.from({ length: parts }, (_, i) => (
<rect key={i} x={W - PAD - (i + 1) * w} y={18} width={w} height={46} fill={fill(i)} stroke={INK} strokeWidth={2} />
))}
<text x={W / 2} y={84} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)}</text>
</svg>
);
}
const cx = W / 2, cy = 82, r = 64;
return (
<svg viewBox={`0 0 ${W} 170`} className="mx-auto block w-full max-w-[260px]" role="img" aria-label={spec.title || 'کسر'}>
{parts === 1 ? (
<circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} />
) : (
Array.from({ length: parts }, (_, i) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
const large = a1 - a0 > Math.PI ? 1 : 0;
return (
<path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`}
fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />
);
})
)}
<text x={cx} y={164} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)} تکه</text>
</svg>
);
}
/* ─── کمکی‌ها ─────────────────────────────────────────────────────────────── */
function Markers() {
const head = (id: string, color: string, start = false) => (
<marker id={id} viewBox="0 0 10 10" refX={start ? 2 : 8} refY="5" markerWidth="7" markerHeight="7" orient={start ? 'auto-start-reverse' : 'auto'}>
<path d="M0,0 L10,5 L0,10 z" fill={color} />
</marker>
);
return (
<defs>
{head('dg-arrow', ACCENT)}
{head('dg-arrow-soft', SOFT)}
{head('dg-arrow-ink', INK)}
{head('dg-arrow-ink-start', INK, true)}
</defs>
);
}
function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) {
return (
<text x={x} y={y} fontSize={11} fontWeight={bold ? 800 : 700} fill={color} textAnchor="middle"
stroke="#fbfbff" strokeWidth={3} paintOrder="stroke" style={{ unicodeBidi: 'plaintext' }}>
{children}
</text>
);
}
/** برچسبِ ضلع، وسطِ پاره‌خط و به سمتِ بیرونِ شکل. */
function sideLabel(key: number, a: XY, b: XY, center: XY, text: string) {
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
const n = Math.hypot(nx, ny) || 1;
nx /= n; ny /= n;
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
return <Label key={key} x={mx + nx * 13} y={my + ny * 13 + 4} color={MAIN}>{text}</Label>;
}
function niceStep(span: number, maxTicks: number): number {
const raw = span / maxTicks;
const pow = 10 ** Math.floor(Math.log10(raw || 1));
const m = raw / pow;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * pow;
}
function niceTicks(lo: number, hi: number): number[] {
const step = niceStep(hi - lo, 8);
const out: number[] = [];
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) out.push(Math.round(t * 1e6) / 1e6);
return out;
}
const fa = (n: number) => toFa(String(n).replace('-', '−'));