Files
MathApp_frontend/src/diagram/plane.tsx
AliReza ec4c844531 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>
2026-09-28 23:24:50 +03:30

694 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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];
}