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

693
src/diagram/plane.tsx Normal file
View File

@@ -0,0 +1,693 @@
import { useId, type ReactNode } from 'react';
import type { ColorName, FunctionMark, PlaneItem, PlaneSpec, PointDef, Ref, Transform } from './types';
import { derivative, extrema, parseExpr, roots } from './expr';
import { Arrowheads, COLORS, GRID, INK, Label, PAPER, PERSIAN_LETTER, bidi, SERIES, SOFT, arrowId, color, fa, ltr, mathText, niceStep, piLabel, ticksBetween } from './svg';
/**
* صفحه‌ی مختصات و هندسه. همه‌ی مختصات اینجا حساب می‌شوند:
* نقطه‌ها polar، وسطِ پاره‌خط، پای عمود، محلِ برخوردِ دو خط، نقطه‌ی روی نمودار
* تابع‌ها ریشه، رأس/اکسترمم، عرض از مبدأ، محورِ تقارن، شیبِ مماس
* تبدیل‌ها دوران، قرینه، انتقال، تجانس
* مدل فقط می‌گوید کدام را می‌خواهد. هر تکه‌ای که حل نشود (نقطه‌ی ناشناخته، فرمولِ
* نامعتبر) بی‌صدا حذف می‌شود و بقیه‌ی شکل کشیده می‌شود.
*/
type XY = [number, number];
const W = 320;
const PAD = 24;
const GEOMETRY = new Set(['circle', 'arc', 'sector', 'angle', 'rightAngle', 'polygon', 'image']);
/* ─── هندسه‌ی نقطه‌ها ─────────────────────────────────────────────────────── */
function footOf(p: XY, a: XY, b: XY): XY {
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1);
return [a[0] + t * dx, a[1] + t * dy];
}
function intersect(a: XY, b: XY, c: XY, d: XY): XY | null {
const den = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
if (Math.abs(den) < 1e-12) return null;
const t = ((a[0] - c[0]) * (c[1] - d[1]) - (a[1] - c[1]) * (c[0] - d[0])) / den;
return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
}
function transformPoint(p: XY, tr: Transform, ref: (r: Ref) => XY | null): XY | null {
if ('rotate' in tr) {
const c = ref(tr.rotate.center);
if (!c) return null;
// دوران مثبت = پادساعتگرد، مثلِ کتاب؛ «در جهتِ عقربه‌ها» یعنی زاویه‌ی منفی.
const t = (tr.rotate.deg * Math.PI) / 180, [x, y] = [p[0] - c[0], p[1] - c[1]];
return [c[0] + x * Math.cos(t) - y * Math.sin(t), c[1] + x * Math.sin(t) + y * Math.cos(t)];
}
if ('translate' in tr) return [p[0] + tr.translate[0], p[1] + tr.translate[1]];
if ('scale' in tr) {
const c = ref(tr.scale.center);
return c ? [c[0] + tr.scale.k * (p[0] - c[0]), c[1] + tr.scale.k * (p[1] - c[1])] : null;
}
const r = tr.reflect;
if (r === 'x') return [p[0], -p[1]];
if (r === 'y') return [-p[0], p[1]];
if (r === 'y=x') return [p[1], p[0]];
const a = ref(r[0]), b = ref(r[1]);
if (!a || !b) return null;
const f = footOf(p, a, b);
return [2 * f[0] - p[0], 2 * f[1] - p[1]];
}
/** نقطه‌های نام‌دار، با حلِ ارجاعِ زنجیره‌ای و بدونِ حلقه‌ی بی‌پایان. */
function resolvePoints(spec: PlaneSpec) {
const named = new Map<string, XY>();
const resolving = new Set<string>();
const ref = (r: Ref): XY | null => (typeof r === 'string' ? byName(r) : [r[0], r[1]]);
function byName(name: string): XY | null {
if (named.has(name)) return named.get(name)!;
const def: PointDef | undefined = spec.points[name];
if (def === undefined || resolving.has(name)) return null;
resolving.add(name);
const xy = build(def);
resolving.delete(name);
if (xy && Number.isFinite(xy[0]) && Number.isFinite(xy[1])) named.set(name, xy);
return named.get(name) ?? null;
}
function build(def: PointDef): XY | null {
if (Array.isArray(def)) return [def[0], def[1]];
if ('polar' in def) {
const o = def.from !== undefined ? ref(def.from) : [0, 0];
if (!o) return null;
const t = (def.polar[1] * Math.PI) / 180;
return [o[0] + def.polar[0] * Math.cos(t), o[1] + def.polar[0] * Math.sin(t)];
}
if ('mid' in def) {
const a = ref(def.mid[0]), b = ref(def.mid[1]);
return a && b ? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] : null;
}
if ('foot' in def) {
const p = ref(def.foot[0]), a = ref(def.foot[1]), b = ref(def.foot[2]);
return p && a && b ? footOf(p, a, b) : null;
}
if ('intersect' in def) {
const [[a, b], [c, d]] = def.intersect;
const pa = ref(a), pb = ref(b), pc = ref(c), pd = ref(d);
return pa && pb && pc && pd ? intersect(pa, pb, pc, pd) : null;
}
if ('along' in def) {
const a = ref(def.along[0]), b = ref(def.along[1]), t = def.along[2];
return a && b ? [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])] : null;
}
if ('fx' in def) {
try {
const x = def.fx[1];
return [x, parseExpr(def.fx[0])(x)];
} catch {
return null;
}
}
return null;
}
Object.keys(spec.points).forEach(byName);
return { named, ref };
}
/* ─── نمونه‌برداری از نمودار، با شکستن در مجانب ──────────────────────────── */
function sample(f: (x: number) => number, from: number, to: number, n = 360): XY[][] {
const runs: XY[][] = [];
let run: XY[] = [];
let prev: number | null = null;
for (let i = 0; i <= n; i++) {
const x = from + ((to - from) * i) / n, y = f(x);
const jump = prev !== null && Number.isFinite(y) && Math.abs(y - prev) > 50 * (Math.abs(prev) + Math.abs(y) + 1) / Math.max(1, n / 60);
if (!Number.isFinite(y) || jump) {
if (run.length > 1) runs.push(run);
run = Number.isFinite(y) ? [[x, y]] : [];
} else run.push([x, y]);
prev = Number.isFinite(y) ? y : null;
}
if (run.length > 1) runs.push(run);
return runs;
}
interface Curve { f: (x: number) => number; from: number; to: number; runs: XY[][] }
function compile(expr: string, from: number, to: number): Curve | null {
try {
const f = parseExpr(expr);
const runs = sample(f, from, to);
return runs.length ? { f, from, to, runs } : null;
} catch {
return null;
}
}
/* ─── برچسبِ ترکیبی: «رأس (۱، −۱)» دو سطر می‌شود تا جهتِ متن به هم نریزد ── */
export function splitLabel(text: string): [string, string] | null {
if (!PERSIAN_LETTER.test(text)) return null;
const m = /^([^()\d۰-۹=]*?[؀-ۿ][^()\d۰-۹=]*?)\s*([(\d۰-۹−\-+].*)$/.exec(text);
return m && /[\d۰-۹]/.test(m[2]) ? [m[1].trim(), m[2].trim()] : null;
}
/** رقمِ لاتین به فارسی و منفیِ درست، در متنِ ریاضیِ برچسب‌ها. */
export { mathText };
function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: number; y: number; text: string; fill: string; size?: number; anchor?: 'start' | 'middle' | 'end' }) {
const parts = splitLabel(text);
if (!parts) return <Label x={x} y={y} color={fill} size={size} anchor={anchor}>{mathText(text)}</Label>;
return (
<g>
<Label x={x} y={y - size * 0.55} color={fill} size={size} anchor={anchor}>{parts[0]}</Label>
<text x={x} y={y + size * 0.75} fontSize={size - 0.5} fontWeight={700} fill={fill} textAnchor={anchor} {...bidi(mathText(parts[1]))}
stroke={PAPER} strokeWidth={3} paintOrder="stroke">{mathText(parts[1])}</text>
</g>
);
}
/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */
export function Plane({ spec }: { spec: PlaneSpec }) {
// شناسه‌ی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند.
const clipId = `dg-plot-${useId().replace(/:/g, '')}`;
const { named, ref } = resolvePoints(spec);
// تصویرِ تبدیل‌ها پیش از همه، تا A′ مثلِ هر نقطه‌ی دیگر قابلِ ارجاع باشد.
const images: { item: Extract<PlaneItem, { type: 'image' }>; pts: XY[]; names: string[] }[] = [];
for (const item of spec.items) {
if (item.type !== 'image') continue;
const pts: XY[] = [], names: string[] = [];
item.of.forEach((r, i) => {
const p = ref(r);
const q = p && transformPoint(p, item.transform, ref);
if (!q) return;
const name = item.names?.[i] ?? (typeof r === 'string' ? `${r}′` : '');
pts.push(q); names.push(name);
if (name && !named.has(name)) named.set(name, q);
});
if (pts.length) images.push({ item, pts, names });
}
// نمودارها، برای کادر و برای رسم.
let series = 0;
const nextColor = (c?: ColorName) => COLORS[c ?? SERIES[series++ % SERIES.length]];
const curves = new Map<PlaneItem, Curve[]>();
for (const item of spec.items) {
if (item.type === 'function' || item.type === 'area' || item.type === 'region') {
const range = item.type === 'region' ? [item.from ?? -50, item.to ?? 50] : [item.from, item.to];
const c = compile(item.expr, range[0], range[1]);
if (c) curves.set(item, [c]);
if (item.type === 'area' && item.expr2) {
const c2 = compile(item.expr2, item.from, item.to);
if (c && c2) curves.set(item, [c, c2]);
}
} else if (item.type === 'piecewise') {
const cs = item.pieces.map((p) => compile(p.expr, p.from, p.to)).filter((c): c is Curve => c !== null);
if (cs.length) curves.set(item, cs);
} else if (item.type === 'tangent') {
try {
const f = parseExpr(item.expr);
if (Number.isFinite(f(item.at))) curves.set(item, [{ f, from: item.at, to: item.at, runs: [] }]);
} catch { /* فرمولِ نامعتبر */ }
}
}
// نقطه‌های ویژه‌ی نمودار (ریشه، رأس، …) که کد حساب می‌کند.
const markPts: { xy: XY; kind: FunctionMark | 'max' | 'min'; color: string }[] = [];
const axisLines: number[] = [];
for (const item of spec.items) {
if (item.type !== 'function' || !item.marks?.length) continue;
const c = curves.get(item)?.[0];
if (!c) continue;
const want = new Set(item.marks);
if (want.has('roots')) for (const r of roots(c.f, c.from, c.to).slice(0, 6)) markPts.push({ xy: [r, 0], kind: 'roots', color: COLORS.green });
if (want.has('vertex') || want.has('extrema') || want.has('axis')) {
const ex = extrema(c.f, c.from, c.to).slice(0, 6);
if (want.has('vertex') || want.has('extrema')) for (const e of ex) markPts.push({ xy: [e.x, e.y], kind: want.has('vertex') ? 'vertex' : e.kind, color: COLORS.red });
if (want.has('axis') && ex.length === 1) axisLines.push(ex[0].x);
}
if (want.has('yIntercept') && c.from <= 0 && c.to >= 0) {
const y = c.f(0);
if (Number.isFinite(y)) markPts.push({ xy: [0, y], kind: 'yIntercept', color: COLORS.orange });
}
}
/* ── کادر ── */
const xs: number[] = [], ys: number[] = [];
const add = (p: XY | null) => { if (p) { xs.push(p[0]); ys.push(p[1]); } };
named.forEach((p) => add(p));
images.forEach((im) => im.pts.forEach(add));
markPts.forEach((m) => add(m.xy));
for (const item of spec.items) {
switch (item.type) {
case 'circle': case 'arc': case 'sector': {
const c = ref(item.center);
const through = item.type === 'circle' && item.through ? ref(item.through) : null;
const r = item.type === 'circle' ? (through && c ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0) : item.r;
if (c) { add([c[0] - r, c[1] - r]); add([c[0] + r, c[1] + r]); }
break;
}
case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break;
case 'ray': add(ref(item.from)); add(ref(item.through)); break;
case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq) { add(a); add(b); } else if (a) add(a); break; }
case 'polygon': item.points.forEach((p) => add(ref(p))); break;
case 'point': add(ref(item.point)); break;
case 'text': add(item.at); break;
case 'vline': xs.push(item.x); break;
case 'hline': ys.push(item.y); break;
case 'tangent': { const c = curves.get(item)?.[0]; if (c) add([item.at, c.f(item.at)]); break; }
default: break;
}
}
for (const [item, cs] of curves) {
if (item.type === 'region' || item.type === 'tangent') continue;
for (const c of cs) {
xs.push(c.from, c.to);
const all = c.runs.flat().map((p) => p[1]).sort((a, b) => a - b);
if (all.length) ys.push(all[Math.floor(all.length * 0.04)], all[Math.ceil(all.length * 0.96) - 1]);
}
}
if (spec.axes) { xs.push(0); ys.push(0); }
if (!xs.length || !ys.length) return null;
let [x0, x1] = spec.xRange ?? [Math.min(...xs), Math.max(...xs)];
let [y0, y1] = spec.yRange ?? [Math.min(...ys), Math.max(...ys)];
if (x1 - x0 < 1e-9) { x0 -= 1; x1 += 1; }
if (y1 - y0 < 1e-9) { y0 -= 1; y1 += 1; }
if (!spec.xRange) { const m = (x1 - x0) * 0.1 + 0.15; x0 -= m; x1 += m; }
if (!spec.yRange) { const m = (y1 - y0) * 0.1 + 0.15; y0 -= m; y1 += m; }
// مقیاسِ یکسان برای هندسه (دایره نباید بیضی شود)؛ برای نمودارِ تنها، ارتفاع سقف دارد.
const geometry = spec.items.some((i) => GEOMETRY.has(i.type));
const plotW = W - 2 * PAD;
let sx = plotW / (x1 - x0), sy = sx;
let H = (y1 - y0) * sy + 2 * PAD;
const [minH, maxH] = [170, 380];
if (H > maxH) {
if (geometry) { sy = sx = (maxH - 2 * PAD) / (y1 - y0); } else sy = (maxH - 2 * PAD) / (y1 - y0);
H = maxH;
} else if (H < minH && !geometry) {
sy = (minH - 2 * PAD) / (y1 - y0);
H = minH;
} else H = Math.max(H, 120);
const ox = (W - (x1 - x0) * sx) / 2, oy = (H - (y1 - y0) * sy) / 2;
const X = (x: number) => ox + (x - x0) * sx;
const Y = (y: number) => oy + (y1 - y) * sy;
const P = (p: XY): XY => [X(p[0]), Y(p[1])];
const path = (pts: XY[]) => pts.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(2)},${Y(p[1]).toFixed(2)}`).join(' ');
const layers = { back: [] as ReactNode[], fill: [] as ReactNode[], mid: [] as ReactNode[], curves: [] as ReactNode[], marks: [] as ReactNode[], points: [] as ReactNode[], labels: [] as ReactNode[] };
const arrowColors = new Set<string>([SOFT]);
let key = 0;
const k = () => key++;
const clip = `url(#${clipId})`;
const centroid: XY = (() => {
const all = [...named.values()];
return all.length ? [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length] : [(x0 + x1) / 2, (y0 + y1) / 2];
})();
const away = (p: XY, dist = 13): XY => {
const [px, py] = P(p), [cx, cy] = P(centroid);
let [dx, dy] = [px - cx, py - cy];
const n = Math.hypot(dx, dy);
if (n < 1e-6) { dx = 0.6; dy = -0.8; } else { dx /= n; dy /= n; }
return [px + dx * dist, py + dy * dist + 4];
};
/* ── شبکه و محور ── */
const trig = spec.trig ?? false;
const xstep = trig ? (x1 - x0 > 5 * Math.PI ? Math.PI : Math.PI / 2) : spec.grid ? Math.max(1, niceStep(x1 - x0, 12)) : niceStep(x1 - x0, 8);
const ystep = spec.grid ? Math.max(1, niceStep(y1 - y0, 12)) : niceStep(y1 - y0, 7);
const xticks = ticksBetween(x0, x1, xstep), yticks = ticksBetween(y0, y1, ystep);
if (spec.axes || spec.grid) {
layers.back.push(
<g key={k()} stroke={GRID} strokeWidth={1}>
{xticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} />)}
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} />)}
</g>,
);
}
if (spec.axes) {
const ax = x0 <= 0 && x1 >= 0 ? X(0) : X(x0), ay = y0 <= 0 && y1 >= 0 ? Y(0) : Y(y0);
layers.back.push(
<g key={k()} stroke={SOFT} strokeWidth={1.4}>
<line x1={X(x0)} y1={ay} x2={X(x1) + 6} y2={ay} markerEnd={`url(#${arrowId(SOFT)})`} />
<line x1={ax} y1={Y(y0)} x2={ax} y2={Y(y1) - 6} markerEnd={`url(#${arrowId(SOFT)})`} />
</g>,
<g key={k()} fill={SOFT} fontSize={9.5} fontWeight={600}>
{xticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
<g key={`tx${t}`}>
<line x1={X(t)} x2={X(t)} y1={ay - 3} y2={ay + 3} stroke={SOFT} />
<text x={X(t)} y={ay + 13} textAnchor="middle">{trig ? piLabel(t) : fa(t)}</text>
</g>
))}
{yticks.filter((t) => Math.abs(t) > 1e-9).map((t) => (
<g key={`ty${t}`}>
<line x1={ax - 3} x2={ax + 3} y1={Y(t)} y2={Y(t)} stroke={SOFT} />
<text x={ax - 6} y={Y(t) + 3.5} textAnchor="end">{fa(t)}</text>
</g>
))}
{x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0 && <text x={ax - 6} y={ay + 13} textAnchor="end">۰</text>}
<text x={X(x1) + 2} y={ay - 7} textAnchor="end" fontStyle="italic">x</text>
<text x={ax + 7} y={Y(y1) + 4} fontStyle="italic">y</text>
</g>,
);
}
/* ── تکه‌ها ── */
for (const item of spec.items) {
const itemColor = (fallback: string) => color(item.color, fallback);
switch (item.type) {
case 'circle': {
const c = ref(item.center);
if (!c) break;
const through = item.through ? ref(item.through) : null;
const r = through ? Math.hypot(through[0] - c[0], through[1] - c[1]) : item.r ?? 0;
if (r <= 0) break;
const col = itemColor(COLORS.blue);
layers.mid.push(<ellipse key={k()} cx={X(c[0])} cy={Y(c[1])} rx={r * sx} ry={r * sy} fill={item.fill ? `${col}1f` : 'none'} stroke={col} strokeWidth={2.2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(c[0] + r * 0.72)} y={Y(c[1] + r * 0.72) - 4} text={item.label} fill={col} />);
break;
}
case 'arc': case 'sector': {
const c = ref(item.center);
if (!c) break;
const col = itemColor(item.type === 'sector' ? COLORS.orange : COLORS.red);
const a0 = (item.start * Math.PI) / 180, a1 = (item.end * Math.PI) / 180;
const pts: XY[] = [];
for (let i = 0; i <= 64; i++) { const t = a0 + ((a1 - a0) * i) / 64; pts.push([c[0] + item.r * Math.cos(t), c[1] + item.r * Math.sin(t)]); }
const d = item.type === 'sector' ? `${path([c, ...pts])} Z` : path(pts);
layers.mid.push(<path key={k()} d={d} fill={item.type === 'sector' ? `${col}33` : 'none'} stroke={col} strokeWidth={item.type === 'arc' ? 3 : 2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const m = (a0 + a1) / 2, rr = item.type === 'arc' ? item.r * 1.16 : item.r * 0.6;
layers.labels.push(<RichLabel key={k()} x={X(c[0] + rr * Math.cos(m))} y={Y(c[1] + rr * Math.sin(m)) + 4} text={item.label} fill={col} />);
}
break;
}
case 'polygon': {
const pts = item.points.map(ref).filter((p): p is XY => p !== null);
if (pts.length < 3) break;
const col = itemColor(COLORS.blue);
layers.fill.push(<path key={k()} d={`${path(pts)} Z`} fill={item.fill === false ? 'none' : `${col}1a`} stroke={item.color ? col : INK} strokeWidth={2.2} strokeLinejoin="round" strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const cx = pts.reduce((a, p) => a + p[0], 0) / pts.length, cy = pts.reduce((a, p) => a + p[1], 0) / pts.length;
layers.labels.push(<RichLabel key={k()} x={X(cx)} y={Y(cy) + 4} text={item.label} fill={col} />);
}
break;
}
case 'segment': case 'arrow': case 'vector': {
const a = ref(item.from), b = ref(item.to);
if (!a || !b) break;
const isArrow = item.type !== 'segment';
const col = itemColor(isArrow ? (item.type === 'vector' ? COLORS.purple : COLORS.red) : INK);
if (isArrow) arrowColors.add(col);
const [ax, ay] = P(a), [bx, by] = P(b);
layers.mid.push(<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={col} strokeWidth={isArrow ? 2.4 : 2.2} strokeLinecap="round"
strokeDasharray={item.dashed ? '6 5' : undefined} markerEnd={isArrow ? `url(#${arrowId(col)})` : undefined} />);
if (item.type === 'segment' && item.ticks) layers.mid.push(<Ticks key={k()} a={[ax, ay]} b={[bx, by]} n={item.ticks} col={col} />);
if (item.label) {
const [lx, ly, anchor] = sideOffset([ax, ay], [bx, by], P(centroid));
layers.labels.push(<RichLabel key={k()} x={lx} y={ly} anchor={anchor} text={item.label} fill={item.color ? col : COLORS.blue} />);
}
break;
}
case 'line': case 'ray': {
const [a, b] = item.type === 'line' ? lineEnds(item, ref) : [ref(item.from), ref(item.through)];
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) break;
const col = itemColor(COLORS.blue);
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = (Math.abs(x1 - x0) + Math.abs(y1 - y0)) * 3 / Math.hypot(dx, dy);
const s: XY = item.type === 'line' ? [a[0] - dx * t, a[1] - dy * t] : a;
const e: XY = [a[0] + dx * t, a[1] + dy * t];
layers.mid.push(<line key={k()} clipPath={clip} x1={X(s[0])} y1={Y(s[1])} x2={X(e[0])} y2={Y(e[1])} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
if (item.label) {
const lp = edgePoint(a, b, x0, x1, y0, y1);
layers.labels.push(<RichLabel key={k()} x={X(lp[0])} y={Y(lp[1]) - 6} text={item.label} fill={col} />);
}
break;
}
case 'vline': case 'hline': {
const col = itemColor(COLORS.gray);
const dash = item.dashed === false ? undefined : '5 4';
if (item.type === 'vline') {
layers.mid.push(<line key={k()} x1={X(item.x)} x2={X(item.x)} y1={Y(y0)} y2={Y(y1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(item.x) + 4} y={Y(y1) + 12} text={item.label} fill={col} />);
} else {
layers.mid.push(<line key={k()} y1={Y(item.y)} y2={Y(item.y)} x1={X(x0)} x2={X(x1)} stroke={col} strokeWidth={1.8} strokeDasharray={dash} />);
if (item.label) layers.labels.push(<RichLabel key={k()} x={X(x0) + 30} y={Y(item.y) - 7} text={item.label} fill={col} />);
}
break;
}
case 'function': case 'piecewise': {
const cs = curves.get(item);
if (!cs) break;
const col = nextColor(item.color);
cs.forEach((c) => c.runs.forEach((run) => layers.curves.push(
<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.6} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={item.dashed ? '7 5' : undefined} />,
)));
if (item.type === 'piecewise') {
item.pieces.forEach((p) => {
let f: (x: number) => number;
try { f = parseExpr(p.expr); } catch { return; }
([[p.from, p.closedFrom ?? true], [p.to, p.closedTo ?? true]] as [number, boolean][]).forEach(([x, closed]) => {
const y = f(x);
if (Number.isFinite(y) && Math.abs(x) < 1e3) layers.points.push(<circle key={k()} cx={X(x)} cy={Y(y)} r={4} fill={closed ? col : '#fff'} stroke={col} strokeWidth={2} />);
});
});
}
if (item.label) {
// سرِ راستِ بخشِ دیده‌شده‌ی نمودار، کمی دورتر از خودِ خط.
const vis = cs.flatMap((c) => c.runs.flat()).filter(([x, y]) => x >= x0 && x <= x1 && y >= y0 + (y1 - y0) * 0.04 && y <= y1 - (y1 - y0) * 0.04);
const at = vis[vis.length - 1];
if (at) {
const slope = vis.length > 2 ? (Y(at[1]) - Y(vis[vis.length - 3][1])) : 0;
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, Math.max(44, X(at[0]) - 30))} y={Math.max(14, Math.min(H - 8, Y(at[1]) + (slope < 0 ? 16 : -10)))} text={item.label} fill={col} />);
}
}
break;
}
case 'tangent': {
const c = curves.get(item)?.[0];
if (!c) break;
const col = itemColor(COLORS.red);
const y = c.f(item.at), m = derivative(c.f, item.at);
const span = (x1 - x0) * 2;
layers.curves.push(<line key={k()} clipPath={clip} x1={X(item.at - span)} y1={Y(y - m * span)} x2={X(item.at + span)} y2={Y(y + m * span)} stroke={col} strokeWidth={2} strokeDasharray={item.dashed ? '6 5' : undefined} />);
layers.points.push(<circle key={k()} cx={X(item.at)} cy={Y(y)} r={4.2} fill={col} stroke="#fff" strokeWidth={1.5} />);
const text = item.label ?? `شیب = ${fa(m)}`;
layers.labels.push(<RichLabel key={k()} x={Math.min(W - 44, X(item.at) + 36)} y={Y(y) + (m > 0 ? 18 : -10)} text={text} fill={col} />);
break;
}
case 'area': {
const cs = curves.get(item);
if (!cs) break;
const col = nextColor(item.color);
const top = cs[0].runs.flat();
const bottom: XY[] = cs[1] ? cs[1].runs.flat().slice().reverse() : [[item.to, 0], [item.from, 0]];
layers.fill.push(<path key={k()} clipPath={clip} d={`${path([...top, ...bottom])} Z`} fill={`${col}38`} stroke="none" />);
if (item.label) {
const mid = top[Math.floor(top.length / 2)];
layers.labels.push(<RichLabel key={k()} x={X(mid[0])} y={(Y(mid[1]) + Y(cs[1] ? cs[1].f(mid[0]) : 0)) / 2 + 4} text={item.label} fill={col} />);
}
break;
}
case 'region': {
const c = curves.get(item)?.[0];
if (!c) break;
const col = itemColor(COLORS.blue);
const edge = item.side === 'above' ? y1 + (y1 - y0) : y0 - (y1 - y0);
c.runs.forEach((run) => {
const poly: XY[] = [...run, [run[run.length - 1][0], edge], [run[0][0], edge]];
layers.fill.push(<path key={k()} clipPath={clip} d={`${path(poly)} Z`} fill={`${col}26`} stroke="none" />);
layers.curves.push(<path key={k()} clipPath={clip} d={path(run)} fill="none" stroke={col} strokeWidth={2.2} strokeDasharray={item.strict ? '6 5' : undefined} />);
});
if (item.label) {
// جایی داخلِ ناحیه: بینِ نمودار و لبه‌ی کادر، در یک‌چهارمِ عرض از سمتی که ناحیه پهن‌تر است.
const candidates = [0.2, 0.5, 0.8].map((t) => x0 + (x1 - x0) * t);
let best = candidates[0], room = -Infinity;
for (const x of candidates) {
const y = c.f(x);
const r = item.side === 'above' ? y1 - y : y - y0;
if (Number.isFinite(r) && r > room) { room = r; best = x; }
}
const yc = c.f(best);
const yl = item.side === 'above' ? (Math.min(yc, y1) + y1) / 2 : (Math.max(yc, y0) + y0) / 2;
layers.labels.push(<RichLabel key={k()} x={X(best)} y={Y(yl)} text={item.label} fill={col} />);
}
break;
}
case 'angle': case 'rightAngle': {
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
if (!v || !a || !b) break;
const col = itemColor(COLORS.red);
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.max(12, Math.min(26, Math.hypot(ax - vx, ay - vy) * 0.35, Math.hypot(bx - vx, by - vy) * 0.35));
if (item.type === 'rightAngle') {
const q = Math.min(r, 12);
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
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];
layers.marks.push(<path key={k()} d={`M${p1.join(',')} L${p2.join(',')} L${p3.join(',')}`} fill={`${col}22`} stroke={col} strokeWidth={1.8} />);
} else {
const arcs = Math.max(1, Math.min(3, item.arcs ?? 1));
for (let i = 0; i < arcs; i++) {
const rr = r + i * 4;
const s: XY = [vx + rr * Math.cos(ta), vy + rr * Math.sin(ta)], e: XY = [vx + rr * Math.cos(ta + d), vy + rr * Math.sin(ta + d)];
layers.marks.push(
<path key={k()} d={i === 0 ? `M${vx},${vy} L${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')} Z` : `M${s.join(',')} A${rr},${rr} 0 0 ${d > 0 ? 1 : 0} ${e.join(',')}`}
fill={i === 0 ? `${col}2a` : 'none'} stroke={col} strokeWidth={1.7} />,
);
}
}
if (item.label) {
const m = ta + d / 2, lr = r + 15 + ((item.type === 'angle' ? item.arcs ?? 1 : 1) - 1) * 4;
layers.labels.push(<RichLabel key={k()} x={vx + lr * Math.cos(m)} y={vy + lr * Math.sin(m) + 4} text={item.label} fill={col} />);
}
break;
}
case 'text': {
layers.labels.push(<RichLabel key={k()} x={X(item.at[0])} y={Y(item.at[1])} text={item.text} fill={color(item.color, INK)} />);
break;
}
default: break;
}
}
/* ── تصویرِ تبدیل‌ها ── */
for (const im of images) {
const col = color(im.item.color, COLORS.purple);
if (im.pts.length >= 3) layers.fill.push(<path key={k()} d={`${path(im.pts)} Z`} fill={`${col}14`} stroke={col} strokeWidth={2.2} strokeDasharray={im.item.dashed === false ? undefined : '6 4'} strokeLinejoin="round" />);
else if (im.pts.length === 2) layers.mid.push(<line key={k()} x1={X(im.pts[0][0])} y1={Y(im.pts[0][1])} x2={X(im.pts[1][0])} y2={Y(im.pts[1][1])} stroke={col} strokeWidth={2.2} strokeDasharray="6 4" />);
im.pts.forEach((p, i) => {
layers.points.push(<circle key={k()} cx={X(p[0])} cy={Y(p[1])} r={3.4} fill={col} />);
if (im.names[i]) { const [lx, ly] = away(p); layers.labels.push(<Label key={k()} x={lx} y={ly} color={col} weight={800}>{im.names[i]}</Label>); }
});
}
/* ── نقطه‌های ویژه‌ی نمودار ── */
for (const x of axisLines) {
layers.mid.push(<line key={k()} x1={X(x)} x2={X(x)} y1={Y(y0)} y2={Y(y1)} stroke={COLORS.gray} strokeWidth={1.6} strokeDasharray="5 4" />);
layers.labels.push(<Label key={k()} x={X(x)} y={Y(y1) + 12} color={COLORS.gray} size={10}>{`x = ${fa(x)}`}</Label>);
}
const markName: Record<string, string> = { vertex: 'رأس', max: 'ماکزیمم', min: 'مینیمم', yIntercept: '', roots: '' };
// عددِ نزدیک به مضربِ π/2 در نمودارِ مثلثاتی به صورتِ π نوشته می‌شود (۳٫۱۴ → π).
const fx = (x: number) => (trig && Math.abs(x / (Math.PI / 2) - Math.round(x / (Math.PI / 2))) < 1e-6 ? piLabel(x) : fa(x));
// بیش از چهار نقطه از یک نوع (ریشه‌های سینوس) فقط نقطه می‌گیرند، نه برچسب؛ وگرنه روی هم می‌افتند.
const kindCount = new Map<string, number>();
markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1));
for (const m of markPts) {
const [px, py] = P(m.xy);
if (px < 0 || px > W || py < 0 || py > H) continue;
layers.points.push(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />);
const crowded = (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) > 4;
if (crowded) continue;
const coord = ltr(`(${fx(m.xy[0])}, ${fa(m.xy[1])})`);
const below = m.kind === 'min' || m.kind === 'vertex' ? m.xy[1] <= (y0 + y1) / 2 : m.kind === 'roots';
const ly = below ? py + 16 : py - 9;
const name = markName[m.kind];
layers.labels.push(
<g key={k()}>
{name && <Label x={px} y={below ? ly + 12 : ly - 12} color={m.color} size={10}>{name}</Label>}
<text x={px} y={ly} fontSize={10} fontWeight={800} fill={m.color} textAnchor="middle" {...bidi(coord)} stroke={PAPER} strokeWidth={3} paintOrder="stroke">{coord}</text>
</g>,
);
}
/* ── نقطه‌ها ── */
const explicit = new Map<string, Extract<PlaneItem, { type: 'point' }>>();
const loosePoints: Extract<PlaneItem, { type: 'point' }>[] = [];
for (const item of spec.items) {
if (item.type !== 'point') continue;
if (typeof item.point === 'string') explicit.set(item.point, item); else loosePoints.push(item);
}
const drawPoint = (xy: XY, label: string, col: string, open = false) => {
const [px, py] = P(xy);
layers.points.push(<circle key={k()} cx={px} cy={py} r={open ? 4 : 3.8} fill={open ? '#fff' : col} stroke={open ? col : '#fff'} strokeWidth={open ? 2 : 1.4} />);
if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); }
};
const imageNames = new Set(images.flatMap((im) => im.names));
named.forEach((xy, name) => {
if (name.startsWith('_') || imageNames.has(name)) return;
const it = explicit.get(name);
// مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطه‌اش را خواسته باشد برچسب می‌گیرد.
if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return;
drawPoint(xy, it?.label ?? name, color(it?.color, INK), it?.open);
});
loosePoints.forEach((it) => drawPoint(it.point as XY, it.label ?? '', color(it.color, INK), it.open));
return (
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'شکل'}>
<defs>
<clipPath id={clipId}><rect x={X(x0)} y={Y(y1)} width={(x1 - x0) * sx} height={(y1 - y0) * sy} /></clipPath>
</defs>
<Arrowheads ids={[...arrowColors]} />
{layers.back}{layers.fill}{layers.mid}{layers.curves}{layers.marks}{layers.points}{layers.labels}
</svg>
);
}
/**
* دو نقطه از خط: یا through، یا از معادله‌ی ax + by = c (ضریب‌ها از صورتِ مسئله؛
* شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود).
*/
function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, ref: (r: Ref) => XY | null): [XY | null, XY | null] {
if (item.eq) {
const [a, b, c] = item.eq;
if (Math.abs(b) > 1e-12) return [[0, c / b], [1, (c - a) / b]];
if (Math.abs(a) > 1e-12) return [[c / a, 0], [c / a, 1]];
return [null, null];
}
return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null];
}
/** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پاره‌خط. */
function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) {
const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
const [ux, uy] = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
const [nx, ny] = [-uy, ux];
const count = Math.max(1, Math.min(3, n));
return (
<g stroke={col} strokeWidth={2}>
{Array.from({ length: count }, (_, i) => {
const o = (i - (count - 1) / 2) * 4.5;
const cx = mx + ux * o, cy = my + uy * o;
return <line key={i} x1={cx - nx * 6} y1={cy - ny * 6} x2={cx + nx * 6} y2={cy + ny * 6} />;
})}
</g>
);
}
/**
* جای برچسبِ ضلع: وسطِ پاره‌خط، به سمتِ بیرونِ شکل. برای خطِ تقریباً عمودی برچسب
* از کنارِ خط شروع می‌شود (نه وسط‌چین)، وگرنه متنِ بلند روی خودِ خط می‌افتد.
*/
function sideOffset(a: XY, b: XY, center: XY): [number, number, 'start' | 'middle' | 'end'] {
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; }
const anchor = nx > 0.6 ? 'start' : nx < -0.6 ? 'end' : 'middle';
const d = anchor === 'middle' ? 14 : 8;
return [mx + nx * d, my + ny * d + 4, anchor];
}
/** نقطه‌ای از خط که داخلِ کادر و نزدیکِ لبه‌ی راستِ آن است، برای برچسبِ خط. */
function edgePoint(a: XY, b: XY, x0: number, x1: number, y0: number, y1: number): XY {
let [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const n = Math.hypot(dx, dy) || 1;
[dx, dy] = [dx / n, dy / n];
if (dx < 0 || (dx === 0 && dy < 0)) [dx, dy] = [-dx, -dy];
const inside = (p: XY) => p[0] > x0 + (x1 - x0) * 0.1 && p[0] < x1 - (x1 - x0) * 0.1 && p[1] > y0 + (y1 - y0) * 0.1 && p[1] < y1 - (y1 - y0) * 0.1;
const span = Math.hypot(x1 - x0, y1 - y0);
let best: XY | null = null;
for (let t = -span * 2; t <= span * 2; t += span / 200) {
const p: XY = [a[0] + dx * t, a[1] + dy * t];
if (inside(p)) best = p; // آخرین نقطه‌ی داخلِ کادر در جهتِ راست
}
return best ?? [(x0 + x1) / 2, (y0 + y1) / 2];
}