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

143
src/diagram/svg.tsx Normal file
View File

@@ -0,0 +1,143 @@
import type { ReactNode } from 'react';
import type { ColorName } from './types';
import { toFa } from '@/lib/format';
/**
* رنگ‌ها و قطعه‌های مشترکِ همه‌ی شکل‌ها.
*
* رنگ‌ها معنا دارند و در همه‌ی شکل‌ها یکی‌اند: آبی شکلِ اصلی، قرمز زاویه و چیزی که
* باید دیده شود، سبز جواب، نارنجی/بنفش سری‌های دوم و سوم. مدل فقط نامِ رنگ را
* می‌دهد، نه کدِ hex، تا شکل‌ها همیشه با رنگ‌های اپ بخوانند.
*/
export const COLORS: Record<ColorName, string> = {
blue: '#3F5BD8',
red: '#E5484D',
green: '#1F9D55',
orange: '#EE8A12',
purple: '#8B5CF6',
teal: '#0E9F9F',
pink: '#D9468F',
gray: '#8A8FA3',
black: '#26263D',
yellow: '#F5B400',
};
/** ترتیبِ رنگِ سری‌ها وقتی مدل رنگ نداده (نمودارِ دوم، ستونِ سوم، …). */
export const SERIES: ColorName[] = ['blue', 'red', 'green', 'orange', 'purple', 'teal', 'pink', 'yellow'];
export const INK = COLORS.black;
export const SOFT = '#9EA3B8';
export const GRID = '#E4E6F0';
export const PAPER = '#FBFBFF';
export const color = (name: ColorName | undefined, fallback: string) => (name ? COLORS[name] : fallback);
/** عدد با رقمِ فارسی، منفیِ درست (−) و بدونِ صفرهای بی‌معنای اعشار. */
export function fa(n: number, digits = 2): string {
if (!Number.isFinite(n)) return '';
const r = Math.round(n * 10 ** digits) / 10 ** digits;
const s = (Object.is(r, -0) ? 0 : r).toString();
return toFa(s.replace('-', '−').replace('.', '٫'));
}
/** حرفِ فارسی/عربی — نه رقم: ۰–۹ِ فارسی (U+06F0…) عمداً بیرون است. */
export const PERSIAN_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF]/;
/**
* متنِ ریاضی را چپ‌به‌راست قفل می‌کند. رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ
* عربی» است و علامت‌های بینِ دو رقم را راست‌به‌چپ می‌کند: «۳ × ۴ = ۱۲» به شکلِ
* «۱۲ = ۴ × ۳» دیده می‌شد، حتی با dir=ltr. نشانه‌ی نامرئیِ LRM بینِ نویسه‌ها
* همه‌ی علامت‌ها را چپ‌به‌راست نگه می‌دارد. فقط برای متنی که حرفِ فارسی ندارد.
*/
export function ltr(s: string): string {
if (!s || PERSIAN_LETTER.test(s)) return s;
return '\u200E' + [...s].join('\u200E') + '\u200E';
}
/** رقمِ لاتین به فارسی، منفیِ درست (−)، و قفلِ چپ‌به‌راست برای متنِ ریاضیِ خالص. */
export function mathText(s: string): string {
const t = toFa(s.replace(/-/g, '−'));
return ltr(t);
}
/**
* جهتِ متنِ SVG: متنِ ریاضیِ خالص با bidi-override چپ‌به‌راست (در Chrome نه LRM و نه
* direction=ltr رقمِ فارسی را از برعکس شدن نجات نداد: «۳ × ۴ = ۱۲» → «۱۲ = ۴ × ۳»)،
* و متنِ فارسی جهتش را از خودش می‌گیرد.
*/
export function bidi(children: ReactNode): { direction?: 'ltr'; style: { unicodeBidi: 'plaintext' | 'bidi-override'; direction?: 'ltr' } } {
const math = typeof children === 'string' && !PERSIAN_LETTER.test(children);
// bidi-override: نویسه‌ها دقیقاً به ترتیبِ نوشتن، چپ به راست. برای ریاضیِ خالص درست
// همان چیزی است که می‌خواهیم؛ برای متنِ فارسی هرگز، چون حروف را برعکس می‌کند.
return math ? { direction: 'ltr', style: { unicodeBidi: 'bidi-override', direction: 'ltr' } } : { style: { unicodeBidi: 'plaintext' } };
}
/** برچسب با هاله‌ی سفید تا روی خط‌ها خوانا بماند؛ جهتش را از متنِ خودش می‌گیرد. */
export function Label({
x, y, color: c = INK, size = 11, weight = 700, anchor = 'middle', children,
}: { x: number; y: number; color?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; children: ReactNode }) {
return (
<text x={x} y={y} fontSize={size} fontWeight={weight} fill={c} textAnchor={anchor}
stroke={PAPER} strokeWidth={3.2} strokeLinejoin="round" paintOrder="stroke" {...bidi(children)}>
{children}
</text>
);
}
/** سرِ فلش؛ هر رنگ یک marker (SVG رنگِ marker را از خط به ارث نمی‌برد). */
export function Arrowheads({ ids }: { ids: string[] }) {
return (
<defs>
{[...new Set(ids)].map((c) => (
<marker key={c} id={arrowId(c)} viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill={c} />
</marker>
))}
</defs>
);
}
export const arrowId = (hex: string) => `dg-ah-${hex.replace('#', '')}`;
/** کسرِ دوطبقه (صورت روی مخرج)؛ در کتابِ دبستان «/» ممیز است، پس کسر همیشه طبقه‌ای کشیده می‌شود. */
export function StackedFraction({ x, y, num, den, color: c = INK, size = 10 }: { x: number; y: number; num: string; den: string; color?: string; size?: number }) {
const w = Math.max(num.length, den.length) * size * 0.62 + 4;
return (
<g>
<text x={x} y={y - 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{num}</text>
<line x1={x - w / 2} x2={x + w / 2} y1={y + 1} y2={y + 1} stroke={c} strokeWidth={1} />
<text x={x} y={y + size + 2} fontSize={size} fontWeight={700} fill={c} textAnchor="middle">{den}</text>
</g>
);
}
export function niceStep(span: number, maxTicks: number): number {
const raw = span / Math.max(1, maxTicks);
const pow = 10 ** Math.floor(Math.log10(raw || 1));
const m = raw / pow;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * pow;
}
export function ticksBetween(lo: number, hi: number, step: number): number[] {
const out: number[] = [];
for (let t = Math.ceil(lo / step - 1e-9) * step; t <= hi + 1e-9; t += step) out.push(Math.round(t * 1e9) / 1e9);
return out;
}
/** بزرگ‌ترین مقسوم‌علیهِ مشترک، برای ساده کردنِ کسرِ برچسب‌ها. */
export function gcd(a: number, b: number): number {
a = Math.abs(a); b = Math.abs(b);
while (b) [a, b] = [b, a % b];
return a || 1;
}
/** برچسبِ مضربِ π برای محورِ مثلثاتی: π/2، π، 3π/2، −π، … */
export function piLabel(t: number): string {
const k = Math.round(t / (Math.PI / 2));
if (k === 0) return '۰';
const g = gcd(k, 2);
const num = k / g, den = 2 / g;
const sign = num < 0 ? '−' : '';
const n = Math.abs(num);
const head = n === 1 ? 'π' : `${toFa(n)}π`;
return den === 1 ? `${sign}${head}` : `${sign}${head}/${toFa(den)}`;
}