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

166
src/diagram/sets.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { useId } from 'react';
import type { MappingSpec, TreeNode, TreeSpec, VennSpec } from './types';
import { Arrowheads, COLORS, INK, Label, SERIES, arrowId, mathText } from './svg';
const W = 320;
/* ─── نمودارِ ون ──────────────────────────────────────────────────────────
* ناحیه‌ها با نامِ مجموعه‌هایی که در آن‌اند: «A» فقط A، «AB» اشتراکِ A و B (و نه
* C)، «ABC»، و «out» بیرونِ همه. سایه با برشِ تو در تو (clipPath) و ماسکِ
* «نه-این-مجموعه» ساخته می‌شود، پس ناحیه‌ی سایه‌خورده دقیقاً همان ناحیه است. */
export function Venn({ spec }: { spec: VennSpec }) {
const uid = useId().replace(/:/g, '');
const sets = spec.sets.slice(0, 3);
if (sets.length < 1) return null;
const H = sets.length === 3 ? 250 : 190;
const R = sets.length === 3 ? 62 : 70;
const centers: [number, number][] = sets.length === 1 ? [[W / 2, 95]]
: sets.length === 2 ? [[W / 2 - 45, 95], [W / 2 + 45, 95]]
: [[W / 2 - 40, 95], [W / 2 + 40, 95], [W / 2, 160]];
const ids = sets.map((_, i) => `v${uid}-${i}`);
const keyOf = (inside: number[]) => inside.map((i) => sets[i]).join('');
// مرکزِ تقریبیِ هر ناحیه برای نوشتنِ اعضا
const regionCenter = (code: string): [number, number] => {
if (code === 'out') return [22, H - 16];
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
const all = centers.map((_, i) => i);
const cxIn = inside.reduce((a, i) => a + centers[i][0], 0) / inside.length, cyIn = inside.reduce((a, i) => a + centers[i][1], 0) / inside.length;
const outside = all.filter((i) => !inside.includes(i));
if (!outside.length || inside.length > 1) {
if (inside.length === sets.length) return [cxIn, cyIn];
const o = outside.length ? centers[outside[0]] : [cxIn, cyIn];
return [cxIn + (cxIn - o[0]) * 0.18, cyIn + (cyIn - o[1]) * 0.18];
}
const ox = outside.reduce((a, i) => a + centers[i][0], 0) / outside.length, oy = outside.reduce((a, i) => a + centers[i][1], 0) / outside.length;
const dx = cxIn - ox, dy = cyIn - oy, n = Math.hypot(dx, dy) || 1;
return [cxIn + (dx / n) * R * 0.42, cyIn + (dy / n) * R * 0.42];
};
const shaded = (spec.shade ?? []).filter((c) => c === 'out' || [...c].every((ch) => sets.includes(ch)));
const shadeRegion = (code: string, idx: number) => {
const fill = `${COLORS.orange}66`;
if (code === 'out') {
return (
<g key={`s${idx}`}>
<mask id={`${ids[0]}-out`}><rect x={0} y={0} width={W} height={H} fill="#fff" />{centers.map((c, i) => <circle key={i} cx={c[0]} cy={c[1]} r={R} fill="#000" />)}</mask>
<rect x={4} y={4} width={W - 8} height={H - 8} fill={fill} mask={`url(#${ids[0]}-out)`} />
</g>
);
}
const inside = sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0);
const outside = sets.map((_, i) => i).filter((i) => !inside.includes(i));
const mid = `${ids[0]}-m${idx}`;
let node = (
<g>
{outside.length > 0 && <mask id={mid}><rect x={0} y={0} width={W} height={H} fill="#fff" />{outside.map((i) => <circle key={i} cx={centers[i][0]} cy={centers[i][1]} r={R} fill="#000" />)}</mask>}
<rect x={0} y={0} width={W} height={H} fill={fill} mask={outside.length ? `url(#${mid})` : undefined} />
</g>
);
for (const i of inside) node = <g clipPath={`url(#${ids[i]})`}>{node}</g>;
return <g key={`s${idx}`}>{node}</g>;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
<defs>{centers.map((c, i) => <clipPath key={i} id={ids[i]}><circle cx={c[0]} cy={c[1]} r={R} /></clipPath>)}</defs>
<rect x={4} y={4} width={W - 8} height={H - 8} rx={10} fill="none" stroke={INK} strokeWidth={1.5} />
<text x={W - 12} y={20} fontSize={12} fontWeight={800} fill={INK} textAnchor="end" fontStyle="italic">U</text>
{shaded.map(shadeRegion)}
{centers.map((c, i) => {
const col = COLORS[SERIES[i]];
return (
<g key={i}>
<circle cx={c[0]} cy={c[1]} r={R} fill={`${col}14`} stroke={col} strokeWidth={2.4} />
<Label x={c[0] + (i === 1 ? R * 0.72 : i === 0 ? -R * 0.72 : 0)} y={i === 2 ? c[1] + R + 14 : c[1] - R * 0.78} color={col} size={spec.names?.[i] ? 11.5 : 13} weight={900}>{spec.names?.[i] ?? sets[i]}</Label>
</g>
);
})}
{Object.entries(spec.regions).map(([code, members]) => {
const valid = code === 'out' || [...code].every((ch) => sets.includes(ch));
if (!valid || !members.length) return null;
const [x, y] = regionCenter(code === 'out' ? 'out' : keyOf(sets.map((s, i) => (code.includes(s) ? i : -1)).filter((i) => i >= 0)));
const lines = chunk(members.slice(0, 12), 3);
return lines.map((line, li) => (
<Label key={`${code}${li}`} x={x} y={y + (li - (lines.length - 1) / 2) * 14 + 4} color={INK} size={11} anchor={code === 'out' ? 'start' : 'middle'}>
{mathText(line.join('، '))}
</Label>
));
})}
</svg>
);
}
const chunk = <T,>(xs: T[], n: number): T[][] => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, i * n + n));
/* ─── نمودارِ پیکانی (تابع از A به B) ─────────────────────────────────────── */
export function Mapping({ spec }: { spec: MappingSpec }) {
const L = spec.left.slice(0, 10), Rt = spec.right.slice(0, 10);
const rows = Math.max(L.length, Rt.length, 1);
const H = 56 + rows * 28;
const yOf = (i: number, n: number) => 40 + ((i + 0.5) * (rows * 28)) / n;
// جهتِ نگاشت چپ به راست است، مثلِ کتاب (دامنه سمتِ چپ)
const lx = 78, rx = W - 78;
const col = COLORS.red;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
<Arrowheads ids={[col]} />
{[[lx, L, spec.leftLabel, COLORS.blue], [rx, Rt, spec.rightLabel, COLORS.green]].map(([x, items, label, c]) => (
<g key={String(x)}>
<ellipse cx={x as number} cy={40 + (rows * 28) / 2} rx={42} ry={(rows * 28) / 2 + 14} fill={`${c}14`} stroke={c as string} strokeWidth={2.2} />
{label && <Label x={x as number} y={18} color={c as string} size={12} weight={900}>{label as string}</Label>}
{(items as string[]).map((it, i) => (
<Label key={i} x={x as number} y={yOf(i, (items as string[]).length) + 4} color={INK} size={12}>{mathText(it)}</Label>
))}
</g>
))}
{spec.arrows.filter(([a, b]) => a < L.length && b < Rt.length).map(([a, b], i) => (
<line key={i} x1={lx + 18} y1={yOf(a, L.length)} x2={rx - 18} y2={yOf(b, Rt.length)} stroke={col} strokeWidth={1.8} markerEnd={`url(#${arrowId(col)})`} />
))}
</svg>
);
}
/* ─── نمودارِ درختی (احتمال و شمارش) ──────────────────────────────────────── */
export function Tree({ spec }: { spec: TreeSpec }) {
interface Laid { node: TreeNode; x: number; y: number; depth: number; parent?: Laid }
const laid: Laid[] = [];
let leaf = 0;
const depthOf = (n: TreeNode): number => 1 + Math.max(0, ...(n.children ?? []).map(depthOf));
const depth = Math.min(4, depthOf(spec.root));
const levelH = 62;
const place = (n: TreeNode, d: number, parent?: Laid): Laid => {
const kids = d < 3 ? (n.children ?? []).slice(0, 6) : [];
const me: Laid = { node: n, x: 0, y: 24 + d * levelH, depth: d, parent };
if (!kids.length) { me.x = leaf++; laid.push(me); return me; }
const placed = kids.map((c) => place(c, d + 1, me));
me.x = (placed[0].x + placed[placed.length - 1].x) / 2;
laid.push(me);
return me;
};
place(spec.root, 0);
const leaves = Math.max(1, leaf);
const H = 24 + (depth - 1) * levelH + 34;
const X = (x: number) => 24 + ((x + 0.5) / leaves) * (W - 48);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img">
{laid.filter((n) => n.parent).map((n, i) => {
const p = n.parent!;
return (
<g key={`e${i}`}>
<line x1={X(p.x)} y1={p.y + 9} x2={X(n.x)} y2={n.y - 11} stroke={COLORS.gray} strokeWidth={1.8} />
{n.node.p && <Label x={(X(p.x) + X(n.x)) / 2 + (n.x < p.x ? -10 : n.x > p.x ? 10 : 12)} y={(p.y + n.y) / 2 + 3} color={COLORS.red} size={10}>{mathText(n.node.p)}</Label>}
</g>
);
})}
{laid.map((n, i) => (
<g key={`n${i}`}>
<rect x={X(n.x) - 26} y={n.y - 11} width={52} height={21} rx={10} fill={n.depth === 0 ? `${COLORS.blue}22` : '#fff'} stroke={COLORS.blue} strokeWidth={1.6} />
<text x={X(n.x)} y={n.y + 4} fontSize={10.5} fontWeight={800} fill={INK} textAnchor="middle" style={{ unicodeBidi: 'plaintext' }}>{mathText(n.node.label).slice(0, 10)}</text>
</g>
))}
</svg>
);
}