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

99
src/diagram/charts.tsx Normal file
View File

@@ -0,0 +1,99 @@
import type { ChartSpec } from './types';
import { COLORS, GRID, INK, Label, SERIES, SOFT, bidi, fa, mathText, niceStep, ticksBetween } from './svg';
const W = 330;
/** نمودارِ ستونی، خط‌شکسته و دایره‌ای. درصدِ دایره‌ای را کد از روی مقدارها حساب می‌کند. */
export function Chart({ spec }: { spec: ChartSpec }) {
const n = Math.min(spec.labels.length, spec.values.length);
if (!n) return null;
const labels = spec.labels.slice(0, n), values = spec.values.slice(0, n);
return spec.kind === 'pie' ? <Pie labels={labels} values={values} /> : <Bars kind={spec.kind} labels={labels} values={values} unit={spec.unit} />;
}
function Bars({ kind, labels, values, unit }: { kind: 'bar' | 'linechart'; labels: string[]; values: number[]; unit?: string }) {
const H = 230, left = 38, right = 12, top = 22, bottom = 44;
const lo = Math.min(0, ...values), hi = Math.max(0, ...values);
const step = niceStep(hi - lo || 1, 5);
const y0 = Math.floor(lo / step) * step, y1 = Math.ceil(hi / step) * step || step;
const plotW = W - left - right, plotH = H - top - bottom;
const Y = (v: number) => top + ((y1 - v) / (y1 - y0)) * plotH;
const slot = plotW / labels.length;
const X = (i: number) => left + slot * (i + 0.5);
const barW = Math.min(38, slot * 0.62);
const long = labels.some((l) => l.length > 6);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
{ticksBetween(y0, y1, step).map((t) => (
<g key={t}>
<line x1={left} x2={W - right} y1={Y(t)} y2={Y(t)} stroke={Math.abs(t) < 1e-9 ? SOFT : GRID} strokeWidth={Math.abs(t) < 1e-9 ? 1.5 : 1} />
<text x={left - 6} y={Y(t) + 3.5} fontSize={9.5} fill={SOFT} textAnchor="end">{fa(t)}</text>
</g>
))}
{unit && <text x={left - 4} y={12} fontSize={9.5} fill={SOFT} textAnchor="end" {...bidi(mathText(unit))}>{mathText(unit)}</text>}
{kind === 'bar'
? values.map((v, i) => {
const c = COLORS[SERIES[i % SERIES.length]];
return (
<g key={i}>
<rect x={X(i) - barW / 2} y={Math.min(Y(v), Y(0))} width={barW} height={Math.abs(Y(v) - Y(0))} rx={4} fill={c} />
<Label x={X(i)} y={v >= 0 ? Y(v) - 5 : Y(v) + 13} color={INK} size={10.5}>{fa(v)}</Label>
</g>
);
})
: (
<>
<path d={values.map((v, i) => `${i ? 'L' : 'M'}${X(i)},${Y(v)}`).join(' ')} fill="none" stroke={COLORS.blue} strokeWidth={2.6} strokeLinejoin="round" />
{values.map((v, i) => (
<g key={i}>
<circle cx={X(i)} cy={Y(v)} r={4.2} fill={COLORS.blue} stroke="#fff" strokeWidth={1.5} />
<Label x={X(i)} y={Y(v) - 8} color={INK} size={10}>{fa(v)}</Label>
</g>
))}
</>
)}
{labels.map((l, i) => (
<text key={i} x={X(i)} y={H - bottom + 16} fontSize={long ? 9 : 10.5} fontWeight={700} fill={INK} textAnchor={long ? 'end' : 'middle'}
transform={long ? `rotate(-30 ${X(i)} ${H - bottom + 16})` : undefined} {...bidi(mathText(l))}>{mathText(l)}</text>
))}
</svg>
);
}
function Pie({ labels, values }: { labels: string[]; values: number[] }) {
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
if (total <= 0) return null;
const cx = 105, cy = 105, r = 88;
let a = -Math.PI / 2;
const slices = values.map((v, i) => {
const frac = Math.max(0, v) / total, a0 = a, a1 = a + frac * 2 * Math.PI;
a = a1;
return { i, frac, a0, a1, c: COLORS[SERIES[i % SERIES.length]] };
});
const H = Math.max(215, 24 + labels.length * 22);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[360px]" role="img">
{slices.map((s) => {
if (s.frac >= 0.9999) return <circle key={s.i} cx={cx} cy={cy} r={r} fill={s.c} stroke="#fff" strokeWidth={2} />;
const large = s.a1 - s.a0 > Math.PI ? 1 : 0;
const p0 = [cx + r * Math.cos(s.a0), cy + r * Math.sin(s.a0)], p1 = [cx + r * Math.cos(s.a1), cy + r * Math.sin(s.a1)];
return <path key={s.i} d={`M${cx},${cy} L${p0.join(',')} A${r},${r} 0 ${large} 1 ${p1.join(',')} Z`} fill={s.c} stroke="#fff" strokeWidth={2} />;
})}
{slices.filter((s) => s.frac >= 0.06).map((s) => {
const m = (s.a0 + s.a1) / 2;
return (
<text key={`t${s.i}`} x={cx + r * 0.62 * Math.cos(m)} y={cy + r * 0.62 * Math.sin(m) + 4} fontSize={11} fontWeight={800} fill="#fff" textAnchor="middle">
{fa(s.frac * 100, 1)}٪
</text>
);
})}
{labels.map((l, i) => (
<g key={`l${i}`}>
<rect x={214} y={18 + i * 22} width={12} height={12} rx={3} fill={slices[i].c} />
<text x={232} y={28 + i * 22} fontSize={10.5} fontWeight={700} fill={INK} style={{ unicodeBidi: 'plaintext' }}>{`${l} (${fa(values[i])})`}</text>
</g>
))}
</svg>
);
}