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

106
src/diagram/numberline.tsx Normal file
View File

@@ -0,0 +1,106 @@
import type { NumberLineSpec } from './types';
import { Arrowheads, COLORS, INK, Label, SERIES, SOFT, StackedFraction, arrowId, color, fa, gcd, mathText, niceStep, ticksBetween } from './svg';
import { toFa } from '@/lib/format';
const W = 330;
const PAD = 26;
/**
* محورِ اعداد: نقطه (توپُر یا توخالی)، پرش (فلشِ خمیده)، و بازه (پاره‌خطِ رنگی با
* سرِ باز/بسته، یا تا بی‌نهایت). با `fractions` تیک‌ها کسرِ دوطبقه‌اند (۱/۴، ۲/۴)،
* چون در کتابِ دبستان «/» ممیز است.
*/
export function NumberLine({ spec }: { spec: NumberLineSpec }) {
const { min, max } = spec;
const intervals = spec.intervals ?? [];
const lanes = intervals.length;
const jumpH = spec.jumps.length ? 52 : 0;
const LANE = intervals.some((iv) => iv.label) ? 24 : 14;
const base = 22 + jumpH + lanes * LANE + 10;
const H = base + (spec.fractions ? 58 : 44);
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
// محورِ عددهای صحیح گامِ صحیحِ «گرد» می‌گیرد (۱، ۲، ۵، ۱۰، …)، نه ۲٫۵
const intStep = (span: number) => [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000].find((c) => span / c <= 12) ?? niceStep(span, 12);
const integers = Number.isInteger(min) && Number.isInteger(max);
const step = spec.step && spec.step > 0 && (max - min) / spec.step <= 40 ? spec.step : integers ? intStep(max - min) : niceStep(max - min, 12);
const ticks = ticksBetween(min, max, step);
const labelled = new Set(spec.points.map((p) => p.at));
// بیش از ۱۴ تیک: فقط بعضی عدد می‌گیرند (صفر، مضرب‌های ۵ یا ۱۰، نقطه‌های خواسته‌شده)،
// وگرنه عددها روی هم می‌افتند.
const every = ticks.length > 14 ? Math.ceil(ticks.length / 10) : 1;
const showLabel = (t: number, i: number) => every === 1 || labelled.has(t) || Math.abs(t) < 1e-9 || i % every === 0;
const arrows = new Set<string>([INK, COLORS.red]);
const tickLabel = (t: number) => {
if (spec.fractions && step < 1) {
const den = Math.round(1 / step);
const num = Math.round(t * den);
if (num % den === 0) return null; // عددِ صحیح با رقمِ معمولی
const g = gcd(num, den);
return { num: toFa(Math.abs(num / g)), den: toFa(den / g), neg: num < 0 };
}
return null;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
<Arrowheads ids={[...arrows, ...intervals.map((iv, i) => color(iv.color, COLORS[SERIES[i % SERIES.length]]))]} />
<line x1={PAD - 16} y1={base} x2={W - PAD + 16} y2={base} stroke={INK} strokeWidth={2}
markerEnd={`url(#${arrowId(INK)})`} markerStart={`url(#${arrowId(INK)})`} />
{ticks.map((t, ti) => {
const fr = showLabel(t, ti) ? tickLabel(t) : null;
const hideNumber = !showLabel(t, ti);
const on = labelled.has(t);
const whole = Math.abs(t - Math.round(t)) < 1e-9;
return (
<g key={t}>
<line x1={X(t)} x2={X(t)} y1={base - (whole ? 6 : 4)} y2={base + (whole ? 6 : 4)} stroke={INK} strokeWidth={Math.abs(t) < 1e-9 ? 2.2 : 1.3} />
{fr ? (
<StackedFraction x={X(t)} y={base + 16} num={`${fr.neg ? '−' : ''}${fr.num}`} den={fr.den} color={on ? INK : SOFT} size={9} />
) : hideNumber ? null : (
<text x={X(t)} y={base + 20} fontSize={on ? 11.5 : 10} fontWeight={on ? 800 : 500} fill={on ? INK : SOFT} textAnchor="middle">{fa(t)}</text>
)}
</g>
);
})}
{intervals.map((iv, i) => {
const col = color(iv.color, COLORS[SERIES[i % SERIES.length]]);
const y = base - 10 - i * LANE;
const a = iv.from === null ? PAD - 12 : X(iv.from), b = iv.to === null ? W - PAD + 12 : X(iv.to);
return (
<g key={`iv${i}`}>
<line x1={a} x2={b} y1={y} y2={y} stroke={col} strokeWidth={4} strokeLinecap="round"
markerStart={iv.from === null ? `url(#${arrowId(col)})` : undefined} markerEnd={iv.to === null ? `url(#${arrowId(col)})` : undefined} />
{iv.from !== null && <circle cx={a} cy={y} r={4.6} fill={iv.closedFrom ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
{iv.to !== null && <circle cx={b} cy={y} r={4.6} fill={iv.closedTo ? col : '#fff'} stroke={col} strokeWidth={2.2} />}
{iv.label && <Label x={(Math.max(a, PAD) + Math.min(b, W - PAD)) / 2} y={y - 7} color={col} size={10.5}>{mathText(iv.label)}</Label>}
</g>
);
})}
{spec.jumps.map((j, i) => {
const a = X(j.from), b = X(j.to);
const lift = Math.min(42, Math.abs(b - a) * 0.45 + 12);
const y = base - 6 - lanes * LANE;
return (
<g key={`j${i}`}>
<path d={`M${a},${y} Q${(a + b) / 2},${y - lift * 1.5} ${b},${y}`} fill="none" stroke={COLORS.red} strokeWidth={2.3} markerEnd={`url(#${arrowId(COLORS.red)})`} />
{j.label && <Label x={(a + b) / 2} y={y - lift * 0.8 - 4} color={COLORS.red} size={10.5}>{mathText(j.label)}</Label>}
</g>
);
})}
{spec.points.map((p, i) => {
const col = color(p.color, i === spec.points.length - 1 && spec.points.length > 1 ? COLORS.red : COLORS.blue);
const numeric = /^[-−+]?[\d۰-۹.,٫/]+$/.test(p.label.trim());
return (
<g key={`p${i}`}>
<circle cx={X(p.at)} cy={base} r={5.6} fill={p.open ? '#fff' : col} stroke={p.open ? col : '#fff'} strokeWidth={p.open ? 2.4 : 1.6} />
{p.label && !numeric && <Label x={X(p.at)} y={base + (spec.fractions ? 50 : 36)} color={col} size={10.5}>{p.label}</Label>}
</g>
);
})}
</svg>
);
}